diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml
new file mode 100644
index 00000000..d43f6752
--- /dev/null
+++ b/.github/workflows/release-smoke.yml
@@ -0,0 +1,49 @@
+name: Release smoke
+
+on:
+  workflow_dispatch:
+  workflow_call:
+
+permissions:
+  contents: read
+
+jobs:
+  runtime-linux:
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4.7"
+          bundler-cache: true
+      - run: gem install tebako -v 0.14.0 --no-document
+      - name: Build and execute the Tebako payload without Ruby on PATH
+        run: |
+          chmod +x scripts/*
+          export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)
+          DIST_DIR=$PWD/dist scripts/build-release
+          mkdir extracted
+          tar -xzf dist/hive-linux-x86_64.tar.gz -C extracted
+          env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent \
+            XDG_DATA_HOME="$RUNNER_TEMP/data" extracted/hive/hive --version
+
+  runtime-macos:
+    runs-on: macos-14
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4.7"
+          bundler-cache: true
+      - run: |
+          gem install tebako -v 0.14.0 --no-document
+          brew install gnu-tar
+      - name: Build and execute the Tebako payload without Ruby on PATH
+        run: |
+          chmod +x scripts/*
+          export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)
+          TAR=gtar DIST_DIR=$PWD/dist scripts/build-release
+          mkdir extracted
+          tar -xzf dist/hive-macos-arm64.tar.gz -C extracted
+          env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent \
+            XDG_DATA_HOME="$RUNNER_TEMP/data" extracted/hive/hive --version
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..d6282c3f
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,260 @@
+name: Release
+
+on:
+  push:
+    tags:
+      - "v*.*.*"
+
+permissions:
+  contents: write
+  id-token: write
+  attestations: write
+
+jobs:
+  test:
+    uses: ./.github/workflows/tier1-e2e.yml
+
+  build-linux:
+    name: Build Tebako linux-x86_64
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4.7"
+          bundler-cache: true
+      - run: gem install tebako -v 0.14.0 --no-document
+      - name: Build deterministic artifact
+        run: |
+          chmod +x scripts/*
+          export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)
+          DIST_DIR=$PWD/dist scripts/build-release
+      - uses: actions/upload-artifact@v4
+        with:
+          name: dist-linux
+          path: dist/hive-linux-x86_64.tar.gz
+
+  build-macos:
+    name: Build Tebako macos-arm64
+    runs-on: macos-14
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4.7"
+          bundler-cache: true
+      - run: |
+          gem install tebako -v 0.14.0 --no-document
+          brew install gnu-tar
+      - name: Build deterministic artifact
+        run: |
+          chmod +x scripts/*
+          export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)
+          TAR=gtar DIST_DIR=$PWD/dist scripts/build-release
+      - uses: actions/upload-artifact@v4
+        with:
+          name: dist-macos
+          path: dist/hive-macos-arm64.tar.gz
+
+  assemble:
+    name: Assemble and verify immutable release identity
+    needs: [build-linux, build-macos]
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4.7"
+      - uses: actions/download-artifact@v4
+        with:
+          path: downloads
+      - name: Generate manifest, checksums, installer, and downstream metadata
+        run: |
+          mkdir dist
+          cp downloads/dist-linux/hive-linux-x86_64.tar.gz dist/
+          cp downloads/dist-macos/hive-macos-arm64.tar.gz dist/
+          export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)
+          scripts/generate-release-manifest dist "${{ github.ref_name }}" "${{ github.sha }}"
+          scripts/verify-release dist
+          scripts/render-package-metadata dist/manifest.json package-metadata
+      - uses: actions/upload-artifact@v4
+        with:
+          name: release-dist
+          path: dist/*
+      - uses: actions/upload-artifact@v4
+        with:
+          name: downstream-metadata
+          include-hidden-files: true
+          path: package-metadata/**
+
+  runtime-linux:
+    name: Runtime closure on Ubuntu 22.04
+    needs: assemble
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/download-artifact@v4
+        with:
+          name: release-dist
+          path: dist
+      - run: |
+          mkdir extracted
+          tar -xzf dist/hive-linux-x86_64.tar.gz -C extracted
+          env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent \
+            XDG_DATA_HOME="$RUNNER_TEMP/data" extracted/hive/hive --version
+
+  runtime-macos:
+    name: Runtime closure on macOS 14 arm64
+    needs: assemble
+    runs-on: macos-14
+    steps:
+      - uses: actions/download-artifact@v4
+        with:
+          name: release-dist
+          path: dist
+      - run: |
+          mkdir extracted
+          tar -xzf dist/hive-macos-arm64.tar.gz -C extracted
+          env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent \
+            XDG_DATA_HOME="$RUNNER_TEMP/data" extracted/hive/hive --version
+
+  install-ubuntu:
+    name: Clean Ubuntu Bash install lifecycle
+    needs: assemble
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/download-artifact@v4
+        with:
+          name: release-dist
+          path: dist
+      - name: Install both aliases from the assembled release
+        run: |
+          HOME="$RUNNER_TEMP/home" XDG_DATA_HOME="$RUNNER_TEMP/data" XDG_BIN_HOME="$RUNNER_TEMP/bin" \
+            bash dist/install.sh --version "${GITHUB_REF_NAME#v}" --base-url "file://$PWD/dist" --skip-provenance
+          env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent "$RUNNER_TEMP/bin/hive" --version
+          env -i HOME="$RUNNER_TEMP/home" PATH=/nonexistent "$RUNNER_TEMP/bin/hv" --version
+
+  install-macos:
+    name: Clean macOS Homebrew install lifecycle
+    needs: assemble
+    runs-on: macos-14
+    steps:
+      - uses: actions/download-artifact@v4
+        with:
+          name: release-dist
+          path: dist
+      - uses: actions/download-artifact@v4
+        with:
+          name: downstream-metadata
+          path: package-metadata
+      - name: Install the exact artifact through Homebrew
+        run: |
+          formula=package-metadata/homebrew-tap/Formula/hive.rb
+          ruby -pi -e 'sub(%r{https://github.com/ivankuznetsov/hive/releases/download/[^\"]+}, "file://#{File.expand_path("dist/hive-macos-arm64.tar.gz")}")' "$formula"
+          brew install --formula "$formula"
+          hive --version
+          hv --version
+          brew uninstall --formula hive
+
+  install-arch:
+    name: Clean Arch hive-bin lifecycle
+    needs: assemble
+    runs-on: ubuntu-22.04
+    container: archlinux:base
+    steps:
+      - name: Prepare clean Arch host
+        run: pacman -Syu --noconfirm base-devel git sudo
+      - uses: actions/download-artifact@v4
+        with:
+          name: release-dist
+          path: dist
+      - uses: actions/download-artifact@v4
+        with:
+          name: downstream-metadata
+          path: package-metadata
+      - name: Build and install exact AUR metadata
+        run: |
+          useradd -m builder
+          cp -a package-metadata/aur/hive-bin /home/builder/pkg
+          cp dist/hive-linux-x86_64.tar.gz /home/builder/pkg/
+          sed -i "s#source=(.*#source=('file://$PWD/dist/hive-linux-x86_64.tar.gz')#" /home/builder/pkg/PKGBUILD
+          chown -R builder:builder /home/builder/pkg
+          runuser -u builder -- bash -lc 'cd ~/pkg && makepkg --noconfirm'
+          pacman -U --noconfirm /home/builder/pkg/hive-bin-*.pkg.tar.zst
+          hive --version
+          hv --version
+          pacman -R --noconfirm hive-bin
+
+  publish:
+    name: Publish GitHub Release
+    needs: [test, assemble, runtime-linux, runtime-macos, install-ubuntu, install-macos, install-arch]
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/download-artifact@v4
+        with:
+          name: release-dist
+          path: dist
+      - name: Attest every published release input
+        uses: actions/attest-build-provenance@v2
+        with:
+          subject-path: |
+            dist/*.tar.gz
+            dist/install.sh
+            dist/manifest.json
+            dist/SHA256SUMS
+      - name: Create immutable release
+        uses: softprops/action-gh-release@v2
+        with:
+          tag_name: ${{ github.ref_name }}
+          files: |
+            dist/*.tar.gz
+            dist/install.sh
+            dist/manifest.json
+            dist/SHA256SUMS
+          fail_on_unmatched_files: true
+          generate_release_notes: true
+
+  promote-homebrew:
+    name: Promote Homebrew tap metadata
+    needs: publish
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          repository: ivankuznetsov/homebrew-hive
+          token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+          path: tap
+      - uses: actions/download-artifact@v4
+        with:
+          name: downstream-metadata
+          path: package-metadata
+      - run: |
+          cp package-metadata/homebrew-tap/Formula/hive.rb tap/Formula/hive.rb
+          cd tap
+          git config user.name hive-release-bot
+          git config user.email hive-release-bot@users.noreply.github.com
+          git add Formula/hive.rb
+          git commit -m "hive ${{ github.ref_name }}"
+          git push
+
+  promote-aur:
+    name: Promote AUR hive-bin metadata
+    needs: publish
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: webfactory/ssh-agent@v0.9.1
+        with:
+          ssh-private-key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
+      - uses: actions/download-artifact@v4
+        with:
+          name: downstream-metadata
+          path: package-metadata
+      - run: |
+          git clone ssh://aur@aur.archlinux.org/hive-bin.git aur
+          cp package-metadata/aur/hive-bin/PKGBUILD aur/PKGBUILD
+          cp package-metadata/aur/hive-bin/.SRCINFO aur/.SRCINFO
+          cd aur
+          git config user.name hive-release-bot
+          git config user.email hive-release-bot@users.noreply.github.com
+          git add PKGBUILD .SRCINFO
+          git commit -m "hive-bin ${{ github.ref_name }}"
+          git push
diff --git a/.github/workflows/tier1-e2e.yml b/.github/workflows/tier1-e2e.yml
new file mode 100644
index 00000000..5edcd61e
--- /dev/null
+++ b/.github/workflows/tier1-e2e.yml
@@ -0,0 +1,47 @@
+name: Tier-1 install e2e
+
+on:
+  workflow_dispatch:
+  workflow_call:
+
+permissions:
+  contents: read
+
+jobs:
+  ubuntu-2204:
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4.7"
+          bundler-cache: true
+      - run: bundle exec rake test
+      - run: bundle exec ruby -Ilib:test -e 'Dir["test/acceptance/**/*_test.rb"].sort.each { |file| require "./#{file}" }'
+      - run: bash scripts/test-installer-fixtures
+
+  macos-14-arm64:
+    runs-on: macos-14
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4.7"
+          bundler-cache: true
+      - run: bundle exec rake test
+      - run: bundle exec ruby -Ilib:test -e 'Dir["test/acceptance/**/*_test.rb"].sort.each { |file| require "./#{file}" }'
+      - run: bash scripts/test-installer-fixtures
+
+  arch-x86_64:
+    runs-on: ubuntu-22.04
+    container: archlinux:base
+    steps:
+      - name: Prepare clean Arch host
+        run: pacman -Syu --noconfirm git ruby base-devel curl
+      - uses: actions/checkout@v6
+      - name: Run acceptance and installer contracts
+        run: |
+          bundle install
+          bundle exec rake test
+          bundle exec ruby -Ilib:test -e 'Dir["test/acceptance/**/*_test.rb"].sort.each { |file| require "./#{file}" }'
+          bash scripts/test-installer-fixtures
diff --git a/.tebako.yml b/.tebako.yml
new file mode 100644
index 00000000..d6bd1866
--- /dev/null
+++ b/.tebako.yml
@@ -0,0 +1,18 @@
+# Tebako bundle configuration for runtime-free Hive releases (KTD1).
+# Build with the release scripts under scripts/; pin the exact Ruby patch
+# that matches .ruby-version.
+---
+entry_point: bin/hive
+tebafile: hive
+ruby_version: "3.4.7"
+package_mode: bundle
+# Portable glibc Linux builds target the oldest supported baseline so the
+# artifact runs on Ubuntu 22.04+ and current Arch (R4, R19).
+forward_portability: true
+include:
+  - lib/
+  - bin/hive
+  - templates/
+  - schemas/
+  - LICENSE
+  - config.example.yml
diff --git a/INSTALL_PROMPT.md b/INSTALL_PROMPT.md
new file mode 100644
index 00000000..6cb77007
--- /dev/null
+++ b/INSTALL_PROMPT.md
@@ -0,0 +1,83 @@
+# Install Hive (agent prompt)
+
+Paste this into Claude Code, Codex, or Pi. The agent must stop at every hard boundary below and must not invent credentials or edit unrelated files.
+
+## Goal
+
+Install the self-contained Hive CLI on this host, verify it, optionally run `hive init` in the current project, and optionally install the separate `hive-skills` package through the host agent's native mechanism.
+
+## Hard stop conditions
+
+- If the OS/arch is not tier-1 (`macOS 14+ arm64`, `Ubuntu 22.04+ x86_64`, or current Arch x86_64), explain the support boundary from `docs/install.md` and stop.
+- If an existing `hive` on `PATH` is **not** this product (e.g. Apache Hive), do **not** overwrite it. Install with the Bash channel and `--command-name hv`, then use `hv` for every later step.
+- Never install package managers, agent CLIs, or provider credentials without explicit user consent.
+- Never write Claude/Codex/Pi skills from `hive init` or the core installer.
+- Never enable or start the daemon without asking (interactive) or an explicit flag.
+
+## Channel selection
+
+1. Detect OS and architecture (`uname -s`, `uname -m`).
+2. Detect an unrelated `hive` (`command -v hive` + version/help probe).
+3. Choose exactly one channel:
+   - **Unrelated `hive` present** → Bash installer with `--command-name hv`.
+   - **macOS arm64 + Homebrew available** → `brew install ivankuznetsov/hive/hive` (fully qualified tap).
+   - **Arch + AUR helper (`yay`/`paru`)** → `yay -S hive-bin` (or paru).
+   - **Otherwise (including Ubuntu)** → verified Bash installer.
+4. After install, run `hive --version` or `hv --version` and refuse to continue if verification fails.
+
+## Bash installer (fallback / collision)
+
+Safer two-step flow (preferred over piping curl to bash when the user can confirm):
+
+```bash
+curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh -o /tmp/hive-install.sh
+curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/SHA256SUMS -o /tmp/hive-SHA256SUMS
+expected=$(awk '$2 == "install.sh" { print $1 }' /tmp/hive-SHA256SUMS)
+if command -v sha256sum >/dev/null 2>&1; then
+  actual=$(sha256sum /tmp/hive-install.sh | awk '{ print $1 }')
+else
+  actual=$(shasum -a 256 /tmp/hive-install.sh | awk '{ print $1 }')
+fi
+test -n "$expected" && test "$actual" = "$expected"
+bash /tmp/hive-install.sh --version 0.1.0 --command-name hv   # or hive
+```
+
+One-liner (checksum still verifies the archive payload):
+
+```bash
+curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash -s -- --version 0.1.0
+```
+
+## After core install
+
+1. Ask before initialization, then collect provider, model, and the name of an
+   environment variable containing the credential. Run, for example,
+   `hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY`.
+   Never request or store the credential value.
+2. `init` registers a user service but must not autostart non-interactively.
+3. Offer skills install only through a native mechanism:
+
+### Claude Code
+
+```bash
+claude plugin marketplace add ivankuznetsov/hive-skills@v0.1.0
+claude plugin install hive@hive-skills
+```
+
+### Codex
+
+```bash
+codex plugin marketplace add ivankuznetsov/hive-skills@v0.1.0
+codex plugin add hive@hive-skills
+```
+
+### Pi
+
+```bash
+pi install npm:@ivankuznetsov/hive-skills@0.1.0
+```
+
+If marketplace/package policy blocks the install, print the manual command and stop without copying files into dot-directories.
+
+4. Recommend a fresh agent session after skills install.
+5. Report the final command name (`hive` or `hv`), version, channel used, and next steps.
diff --git a/README.md b/README.md
index 1fd7a0ff..c08e49c9 100644
--- a/README.md
+++ b/README.md
@@ -51,26 +51,14 @@ Hive's other primary surface is a coding agent — Claude Code, Codex, Gemini, P
 Paste this into Claude Code, Codex, or another agent CLI when you want it to install Hive for you. The block has explicit stop-conditions so the agent halts before clobbering existing state.
 
 ```text
-Install Hive from the canonical GitHub source into ~/Dev/hive and put the hive binary on PATH.
-
-Before changing anything:
-- If ~/Dev/hive already exists, stop and ask whether to reuse it, pull it, or choose another directory.
-- If `claude` is missing or `claude --version` is older than 2.1.118, stop and report the missing prerequisite.
-- If `codex` is missing or `codex --version` is older than 0.125.0, stop and report that the default execute agent will not work until Codex is installed.
-- If `gh auth status` fails, stop and ask the user to authenticate GitHub CLI.
-- If ~/.local/bin is not on PATH, stop and ask which PATH directory should receive the symlink, then substitute that directory for ~/.local/bin in the link command below.
-- If <bin-dir>/hive already exists (file, symlink, or another checkout's binary), stop and ask whether to overwrite it or pick a different bin directory before running the link command below.
-
-Run these commands in order (replace <bin-dir> with the chosen PATH directory; default is ~/.local/bin):
-
-git clone https://github.com/ivankuznetsov/hive ~/Dev/hive
-cd ~/Dev/hive
-bundle install
-mkdir -p <bin-dir>
-ln -sf ~/Dev/hive/bin/hive <bin-dir>/hive
-hive --version
-
-Report the installed version and the path returned by `command -v hive`.
+Follow INSTALL_PROMPT.md from this repository exactly. Install the self-contained
+v0.1.0 release through the supported Homebrew, AUR, or verified Bash channel;
+do not clone the source tree or require a system Ruby. Preserve an unrelated
+`hive` command by selecting the collision-safe `hv` Bash install. Verify the
+installed command's exact version before continuing. If I approve `init`, ask
+for the provider, model, and credential environment-variable name, and never
+ask for or persist the credential value. Report the chosen channel, command
+path, and verified version.
 ```
 
 ### Operate Hive day-to-day via an agent
@@ -86,7 +74,24 @@ The `--json` envelope is stable across versions (schemas live under [schemas/](s
 
 ## Install
 
-Requires Ruby 3.4, git ≥ 2.40, an authenticated `claude` ≥ 2.1.118, `codex` ≥ 0.125.0 for the default execute agent, and an authenticated `gh`. See [docs/getting-started.md#prerequisites](docs/getting-started.md#prerequisites) for the full prerequisite list and the first-run walkthrough.
+Hive is distributed as a **runtime-free** CLI from immutable GitHub Releases — no system Ruby is required for normal use. See [docs/install.md](docs/install.md) for the full support matrix, collision-safe `hv` install, and agent prompt.
+
+```bash
+# macOS arm64 (Homebrew tap)
+brew install ivankuznetsov/hive/hive
+
+# Arch x86_64
+yay -S hive-bin
+
+# Ubuntu / fallback (verified Bash installer)
+curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash
+```
+
+If an unrelated `hive` (e.g. Apache Hive) is already on `PATH`, install with `--command-name hv` and use `hv` thereafter. Agent-driven install: paste [INSTALL_PROMPT.md](INSTALL_PROMPT.md). Skills are a **separate** package — see [packaging/hive-skills](packaging/hive-skills/README.md).
+
+### Development install (contributors)
+
+Requires Ruby 3.4, git ≥ 2.40, an authenticated `claude` ≥ 2.1.118, `codex` ≥ 0.125.0 for the default execute agent, and an authenticated `gh`. See [docs/getting-started.md#prerequisites](docs/getting-started.md#prerequisites).
 
 ```bash
 git clone https://github.com/ivankuznetsov/hive ~/Dev/hive
@@ -96,8 +101,6 @@ mkdir -p ~/.local/bin
 ln -sf ~/Dev/hive/bin/hive ~/.local/bin/hive
 ```
 
-If `~/.local/bin` is not on `PATH`, put the symlink in a directory that is. The `-sf` form overwrites an existing `hive` at the target path; run `command -v hive` first if you already have one installed and don't want to clobber it. Verify the install with `hive --version` and `hive doctor`.
-
 ## Power-User / Scripting CLI
 
 The TUI is the recommended human interface and an agent-driven CLI is the recommended automation surface, but every workflow verb is also available directly on `bin/hive` for scripting, debugging, and recovery. Each verb supports `--json` and returns a typed envelope, so wrapper scripts get structured output.
@@ -121,4 +124,7 @@ Full per-command reference, every flag, every envelope field, and every exit cod
 - **[docs/cli.md](docs/cli.md)** — The full command surface exposed by `bin/hive`: every verb, every flag, every `--json` envelope contract, and every exit code. Read this when you're scripting Hive or wiring it into an agent that needs the full CLI map.
 - **[docs/recipes.md](docs/recipes.md)** — Concrete end-to-end workflows, including the xbookmark dogfood replay (linked to the real PR and a committed transcript of the run). Read this when you want to see what a complete idea-to-PR run looks like before trying it yourself.
 - **[docs/faq.md](docs/faq.md)** — Troubleshooting and design-rationale answers: why folders instead of a database, why per-stage subprocesses instead of a long-running orchestrator, why commit `.hive-state/` to an orphan branch, why opt-in daemon, why no built-in web UI. Read this when you hit a surprise or want to know "why is it like this?".
+- **[docs/install.md](docs/install.md)** — Distribution channels (Homebrew, AUR, Bash), XDG layout, update/uninstall, and skills package.
+- **[docs/daemon.md](docs/daemon.md)** — Per-user LaunchAgent / systemd registration and lifecycle.
+- **[docs/update-uninstall.md](docs/update-uninstall.md)** — Channel-aware update and non-destructive uninstall.
 - **[wiki/index.md](wiki/index.md)** — The catalog of the LLM-maintained engineering wiki under `wiki/`, which is the deepest source of reference material for every command, module, and stage. Read this when the user-facing docs above don't have the depth you need.
diff --git a/Rakefile b/Rakefile
index 9f5959cd..6e41cec7 100644
--- a/Rakefile
+++ b/Rakefile
@@ -5,7 +5,7 @@ require "rake/testtask"
 Rake::TestTask.new do |t|
   t.libs << "test"
   t.libs << "lib"
-  t.test_files = FileList["test/{unit,integration}/**/*_test.rb"]
+  t.test_files = FileList["test/{unit,integration,acceptance}/**/*_test.rb"]
   t.warning = false
 end
 
diff --git a/bin/hive b/bin/hive
index bfd53884..53dd2d8c 100755
--- a/bin/hive
+++ b/bin/hive
@@ -1,12 +1,34 @@
 #!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Invocation-name-neutral entry point: works as `hive` or `hv` (R6, R20).
+# Packaged builds (Tebako) may replace this with a self-contained binary that
+# still dispatches through Hive::CLI.
 
 $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
 
 require "hive"
 require "hive/cli"
+require "hive/paths"
+require "hive/assets"
+require "hive/dependency_check"
+
+# Materialize versioned assets on first run so packaged installs share one
+# XDG data lookup path regardless of install channel.
+begin
+  Hive::Assets.materialize!
+rescue StandardError
+  # Non-fatal: development checkouts already resolve templates from source.
+  nil
+end
 
-if ARGV == [ "--version" ] || ARGV == [ "-v" ]
-  puts Hive::VERSION
+if ARGV == ["--version"] || ARGV == ["-v"]
+  build = ENV["HIVE_BUILD_ID"]
+  if build && !build.empty?
+    puts "#{Hive::VERSION}+#{build}"
+  else
+    puts Hive::VERSION
+  end
   exit 0
 end
 
diff --git a/bin/hv b/bin/hv
new file mode 120000
index 00000000..0994cb5f
--- /dev/null
+++ b/bin/hv
@@ -0,0 +1 @@
+hive
\ No newline at end of file
diff --git a/docs/architecture.md b/docs/architecture.md
index b7de5922..f69ad325 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -16,10 +16,8 @@ Hive is a Ruby CLI around filesystem state, agent subprocesses, and git worktree
 `-- <slug>/                         # feature worktree created by 4-execute
     `-- app files...
 
-~/Dev/hive/
-|-- bin/hive
-|-- lib/hive/
-`-- config.yml                      # global registry
+$XDG_CONFIG_HOME/hive/
+`-- config.yml                      # global registry + provider references
 ```
 
 The project checkout holds code. `.hive-state/` holds durable Hive state on the separate `hive/state` branch. The feature worktree holds code changes for one task branch.
@@ -76,7 +74,9 @@ hive doctor --json
 
 ## Config Schema
 
-Global registry lives at `~/Dev/hive/config.yml`:
+Global registry and credential references live at
+`$XDG_CONFIG_HOME/hive/config.yml` (or `HIVE_HOME/config.yml` when explicitly
+overridden):
 
 ```yaml
 registered_projects:
diff --git a/docs/cli.md b/docs/cli.md
index 082ad50f..777b2214 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -54,7 +54,7 @@ The daemon is optional and per-project. It polls `hive status --json`, dispatche
 ```bash
 hive daemon enable <project>
 hive daemon enable --all
-hive daemon start --dry-run --detach
+hive daemon start
 hive daemon status
 hive daemon tail
 hive daemon stop
@@ -88,7 +88,7 @@ Workflow verbs (`brainstorm`, `plan`, `develop`, `open-pr`, `review`, `finalize`
 |---:|---|
 | 0 | Success. |
 | 1 | Generic failure. |
-| 2 | Already initialized. |
+| 2 | Reserved compatibility code; rerunning `hive init` repairs existing state. |
 | 3 | Task is in an error marker state. |
 | 4 | Wrong stage. |
 | 64 | Usage error. |
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 00000000..5b9e5339
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,48 @@
+# Configuration
+
+## XDG layout
+
+Hive honors the XDG Base Directory Specification:
+
+| Variable | Default | Contents |
+|----------|---------|----------|
+| `XDG_CONFIG_HOME` | `~/.config` | `hive/config.yml` user provider/model config |
+| `XDG_DATA_HOME` | `~/.local/share` | versioned assets, install receipt |
+| `XDG_STATE_HOME` | `~/.local/state` | durable state, daemon logs |
+| `XDG_CACHE_HOME` | `~/.cache` | caches |
+| `XDG_RUNTIME_DIR` | platform temp private dir | sockets/locks |
+
+Override the combined registry/daemon home with `HIVE_HOME` (tests and advanced operators).
+
+## User config
+
+`$XDG_CONFIG_HOME/hive/config.yml` (mode `0600`) stores **references**, never plaintext secrets:
+
+```yaml
+provider: openai
+model: gpt-5
+api_key_env: OPENAI_API_KEY
+```
+
+`hive init` collects these values on its first interactive run. For automation,
+provide all three explicitly:
+
+```bash
+hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY
+```
+
+The named environment variable must contain the credential before Hive invokes
+that provider. Hive stores the variable name, never its value. A non-interactive
+first run without complete choices fails with the exact flags to supply.
+
+## Project config
+
+Per-project pipeline settings live in `<project>/.hive-state/config.yml` (created by `hive init`). Ownership metadata for generated scaffold files lives in `<project>/.hive/manifest.yml`.
+
+## Global registry
+
+Registered projects are listed in the global config (`HIVE_HOME/config.yml` or `$XDG_CONFIG_HOME/hive/config.yml`).
+
+On first use, a legacy `~/Dev/hive/config.yml` is merged non-destructively into
+the active XDG config: existing XDG values win and previously registered project
+rows are retained. The legacy file remains in place.
diff --git a/docs/daemon.md b/docs/daemon.md
new file mode 100644
index 00000000..7c0561c2
--- /dev/null
+++ b/docs/daemon.md
@@ -0,0 +1,41 @@
+# Daemon and user services
+
+## Commands
+
+```bash
+hive daemon start        # start the registered user service
+hive daemon run          # internal service-manager entry (foreground)
+hive daemon stop
+hive daemon status [--json]
+hive daemon reload
+hive daemon tail
+hive daemon enable PROJECT|--all
+hive daemon disable PROJECT|--all
+```
+
+## Per-user service registration
+
+`hive init` registers a user service definition but **does not** enable/start it non-interactively:
+
+- **macOS:** `~/Library/LaunchAgents/dev.hive.daemon.plist`
+- **Linux:** `$XDG_CONFIG_HOME/systemd/user/hive.service`
+
+On a TTY, init asks once before starting. On non-TTY, it prints:
+
+```text
+hive: service registered (not started; non-interactive). Start later with: hive daemon start
+```
+
+Service definitions use the **stable** command path (`hive` / `hv` symlink), not a versioned Cellar realpath, so upgrades do not break the unit.
+
+Public `start`, `stop`, and `status` commands control the registered launchd or
+`systemd --user` service. If no service is registered, they fall back to direct
+process control for compatibility. Service-manager availability and lifecycle
+failures are reported as failures; Hive does not claim that a daemon started
+when the user bus or launch service rejected it.
+
+## Enablement vs running
+
+- **Enabled** is service-manager state (systemd enable / LaunchAgent loaded).
+- **Running** is whether the process is up.
+- `hive update` refreshes the definition and preserves both.
diff --git a/docs/faq.md b/docs/faq.md
index be4353a4..73d18f56 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -20,7 +20,9 @@ Hive state changes often and should not pollute the project's code history or tr
 
 ### Why opt-in daemon, not always-on?
 
-The daemon can spend real agent time and move many tasks. Per-project enrollment is the durable consent signal, and `--dry-run` lets you inspect dispatches before live mode.
+The daemon can spend real agent time and move many tasks. Per-project enrollment
+is the durable consent signal, and the registered user service does not start
+non-interactively without explicit operator action.
 
 ### Why no built-in web UI?
 
@@ -32,9 +34,10 @@ Hive treats agent CLIs as profiles. Planning, implementation, review, and browse
 
 ## Troubleshooting
 
-### `already initialized`
+### A previous `hive init` stopped partway through
 
-Cause: the project already has a `hive/state` branch. Fix: skip `hive init` or use the existing `.hive-state/`. Exit code: 2.
+Rerun the same `hive init` command. Existing branch/worktree state is reused and
+missing config, scaffold, registry, migration, or service steps are repaired.
 
 ### `not a git repository`
 
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 162eb713..8a3c8b59 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -6,15 +6,19 @@ The example is xbookmark, a real Hive dogfood task that finished as [xbookmark P
 
 ## Prerequisites
 
-You need Ruby 3.4, git >= 2.40, `claude` authenticated, `codex` installed for the default execute agent, `gh` authenticated, and a git checkout you can modify. The commands below use `~/Dev/xbookmark`; substitute your own project path and project name when running against another repo.
+You need git >= 2.40, `claude` authenticated, `codex` installed for the default execute agent, `gh` authenticated, and a git checkout you can modify. Normal installs do not require Ruby. The commands below use `~/Dev/xbookmark`; substitute your own project path and project name when running against another repo.
 
 ## Step 1 - Install
 
 ```bash
-git clone https://github.com/ivankuznetsov/hive ~/Dev/hive && cd ~/Dev/hive && bundle install && mkdir -p ~/.local/bin && ln -sf ~/Dev/hive/bin/hive ~/.local/bin/hive
+brew install ivankuznetsov/hive/hive                    # macOS 14+ arm64
+# yay -S hive-bin                                       # Arch x86_64
+# curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash  # Ubuntu
 ```
 
-If `~/.local/bin` is not on your `PATH`, put the symlink in a directory that is before running the next step. The `-sf` form will overwrite an existing `hive` at the target path, so check `command -v hive` first if you already have one installed.
+Use the channel for your platform from [docs/install.md](install.md). If an
+unrelated `hive` already exists, use the verified Bash installer with
+`--command-name hv` and substitute `hv` below.
 
 ```bash
 hive --version
@@ -24,10 +28,14 @@ hive --version
 
 ```bash
 cd ~/Dev/xbookmark
-hive init .
+hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY
 ```
 
-`hive init` creates `.hive-state/` as a worktree of the orphan `hive/state` branch, registers the project in `~/Dev/hive/config.yml`, and scaffolds the stage folders. Read the storage details in [docs/architecture.md#storage-layout](architecture.md#storage-layout).
+`hive init` creates `.hive-state/` as a worktree of the orphan `hive/state`
+branch, registers the project in `$XDG_CONFIG_HOME/hive/config.yml`, stores only
+the credential environment-variable reference, and scaffolds the stage folders.
+It is safe to rerun after a partial initialization; missing pieces are repaired.
+Read the storage details in [docs/architecture.md#storage-layout](architecture.md#storage-layout).
 
 ## Step 3 - Capture The Idea
 
diff --git a/docs/install.md b/docs/install.md
new file mode 100644
index 00000000..591f1565
--- /dev/null
+++ b/docs/install.md
@@ -0,0 +1,100 @@
+# Installing Hive
+
+Hive ships as a runtime-free CLI (Tebako-packaged Ruby) from immutable GitHub Releases. You do **not** need a system Ruby.
+
+## Support matrix
+
+| Tier | Host | Channel |
+|------|------|---------|
+| 1 | macOS 14+ arm64 | Homebrew tap |
+| 1 | Ubuntu 22.04+ x86_64 | Bash installer |
+| 1 | Arch Linux x86_64 | AUR `hive-bin` |
+| 2 | Other macOS/Linux | Best-effort Bash |
+| 3 | Windows (native), others | Unsupported — use WSL2 tier-1 Linux |
+
+## Homebrew (macOS arm64)
+
+```bash
+brew install ivankuznetsov/hive/hive
+hive --version
+```
+
+Exposes both `hive` and `hv`. If Apache Hive already owns `hive` on your PATH, use the Bash installer with `--command-name hv` instead of overwriting it.
+
+## AUR (Arch x86_64)
+
+```bash
+yay -S hive-bin
+# or: paru -S hive-bin
+hive --version
+```
+
+## Bash installer (Ubuntu and fallback)
+
+Safer two-step verification:
+
+```bash
+TAG=v0.1.0
+BASE=https://github.com/ivankuznetsov/hive/releases/download/$TAG
+curl -fsSL "$BASE/install.sh" -o /tmp/hive-install.sh
+curl -fsSL "$BASE/SHA256SUMS" -o /tmp/hive-SHA256SUMS
+expected=$(awk '$2 == "install.sh" { print $1 }' /tmp/hive-SHA256SUMS)
+if command -v sha256sum >/dev/null 2>&1; then
+  actual=$(sha256sum /tmp/hive-install.sh | awk '{ print $1 }')
+else
+  actual=$(shasum -a 256 /tmp/hive-install.sh | awk '{ print $1 }')
+fi
+test -n "$expected" && test "$actual" = "$expected"
+bash /tmp/hive-install.sh --version 0.1.0
+```
+
+Collision-safe alias:
+
+```bash
+bash /tmp/hive-install.sh --version 0.1.0 --command-name hv
+hv --version
+```
+
+One-liner (archive still SHA-256 verified):
+
+```bash
+curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash
+```
+
+Installs under `$XDG_DATA_HOME/hive/versions/<version>` and links both `hive`
+and `hv` into `~/.local/bin`. A collision-safe `--command-name hv` install
+creates only `hv`. Custom-prefix installs still write their ownership receipt to
+`$XDG_DATA_HOME/hive/install-receipt.yml`, where update and uninstall can find
+it. The installer never edits your shell profile; it prints a PATH hint when
+needed.
+
+## Agent-driven install
+
+Paste [INSTALL_PROMPT.md](../INSTALL_PROMPT.md) into Claude Code, Codex, or Pi. The prompt selects the safest channel, verifies `--version`, offers `init`, and optionally installs `hive-skills` through native marketplace/package commands.
+
+## Skills (separate package)
+
+Core Hive never installs agent skills. Use the [hive-skills](../packaging/hive-skills/README.md) package:
+
+```bash
+# Claude
+claude plugin marketplace add ivankuznetsov/hive-skills@v0.1.0
+claude plugin install hive@hive-skills
+
+# Codex
+codex plugin marketplace add ivankuznetsov/hive-skills@v0.1.0
+codex plugin add hive@hive-skills
+
+# Pi
+pi install npm:@ivankuznetsov/hive-skills@0.1.0
+```
+
+## Next steps
+
+```bash
+cd your-project
+hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY
+hive tui
+```
+
+See also: [configuration](configuration.md), [daemon](daemon.md), [update & uninstall](update-uninstall.md), [migration](migration.md), [troubleshooting](troubleshooting.md).
diff --git a/docs/migration.md b/docs/migration.md
new file mode 100644
index 00000000..6a639dc4
--- /dev/null
+++ b/docs/migration.md
@@ -0,0 +1,21 @@
+# Migration from legacy layouts
+
+## Legacy locations
+
+| Legacy | Role |
+|--------|------|
+| `~/Dev/hive` | Previous global home (registry, daemon pid/logs) |
+| `~/.hive-state` | Possible legacy global state |
+
+## What Hive does
+
+On `hive init`, if legacy trees exist, Hive **copies and verifies** them into
+`$XDG_STATE_HOME/hive/legacy-import/` and writes `.migration.yml`. It also
+merges the legacy registry and daemon consent into
+`$XDG_CONFIG_HOME/hive/config.yml`: current XDG values win, while project rows
+missing from the active registry are adopted. The legacy directories are
+**retained** until you remove them yourself.
+
+## Project state
+
+Per-project `.hive-state/` git worktrees are unchanged by the XDG migration. The new `.hive/` directory only tracks install/uninstall ownership for generated scaffold files.
diff --git a/docs/recipes.md b/docs/recipes.md
index 0d3ee3f3..993de189 100644
--- a/docs/recipes.md
+++ b/docs/recipes.md
@@ -49,19 +49,18 @@ hive status
 hive status --json
 ```
 
-For automation, enroll projects and do a dry run before live dispatch:
+For automation, enroll projects and inspect their status before live dispatch:
 
 ```bash
 hive daemon enable --all
-hive daemon start --dry-run --detach
-hive daemon tail
+hive daemon status
 ```
 
-When the dry-run dispatches look right:
+When enrollment looks right, start the registered user service:
 
 ```bash
-hive daemon stop
-hive daemon start --detach
+hive daemon start
+hive daemon tail
 ```
 
 ## Recover From REVIEW_STALE
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 00000000..2bddd3a2
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,39 @@
+# Troubleshooting installs
+
+## `hive` is Apache Hive / wrong binary
+
+```bash
+command -v hive
+hive --version
+```
+
+If it is not this product, install with:
+
+```bash
+bash install.sh --command-name hv
+hv --version
+```
+
+## `~/.local/bin` not on PATH
+
+The Bash installer prints the exact export line. It never edits your profile.
+
+## Daemon won't register on Linux
+
+Headless sessions without a user systemd bus cannot register or start the user
+service. Hive reports that failure. Establish a user session with a working
+`systemctl --user` bus, then rerun `hive init` to repair registration before
+running `hive daemon start`.
+
+## Update says ownership unknown
+
+Reinstall through the channel you prefer (brew / AUR / install.sh) so provenance is unambiguous. Bash installs write `$XDG_DATA_HOME/hive/install-receipt.yml`.
+
+## Checksum / attestation failures
+
+- SHA-256 is mandatory; a mismatch aborts before extraction.
+- `gh attestation verify` is optional; when `gh` is missing you get a warning after a successful checksum.
+
+## Unsupported platform
+
+Tier-3 hosts receive a clear message and no partial install. Use a tier-1 environment or WSL2 Ubuntu 22.04+.
diff --git a/docs/update-uninstall.md b/docs/update-uninstall.md
new file mode 100644
index 00000000..e06cefe2
--- /dev/null
+++ b/docs/update-uninstall.md
@@ -0,0 +1,42 @@
+# Update and uninstall
+
+## Update
+
+```bash
+hive update
+# or
+hv update
+```
+
+Hive detects ownership and delegates:
+
+| Channel | Command |
+|---------|---------|
+| Homebrew | `brew upgrade ivankuznetsov/hive/hive` |
+| AUR | `yay -Syu hive-bin` (or paru / pacman) |
+| Bash | download and verify a version-pinned `install.sh`, then run it |
+
+Ambiguous or unknown ownership stops with recovery instructions — Hive never guesses or in-place swaps package-manager files.
+
+## Uninstall
+
+```bash
+hive uninstall           # preview + confirm
+hive uninstall --yes     # non-interactive
+hive uninstall --yes --clean-project
+```
+
+What is removed:
+
+- Per-user service registration
+- Binary via the owning channel (`brew uninstall ivankuznetsov/hive/hive`, AUR `-R`, or verified Bash symlinks + receipt)
+
+What is **preserved by default**:
+
+- `$XDG_CONFIG_HOME/hive`
+- `$XDG_STATE_HOME/hive` (and legacy `~/Dev/hive` / `~/.hive-state` if present)
+- Completed pipeline outputs
+- Modified project files
+- Agent-installed skills
+
+`--clean-project` only deletes **unmodified** files listed in the current project's `.hive/manifest.yml` (hash-verified). Modified or unknown files are always kept.
diff --git a/hive.gemspec b/hive.gemspec
new file mode 100644
index 00000000..9294c7a2
--- /dev/null
+++ b/hive.gemspec
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require_relative "lib/hive"
+
+Gem::Specification.new do |spec|
+  spec.name          = "hive"
+  spec.version       = Hive::VERSION
+  spec.authors       = ["Ivan Kuznetsov"]
+  spec.email         = []
+
+  spec.summary       = "Multi-agent folder-as-state software pipeline"
+  spec.description   = "Hive turns a rough software idea into a merge-ready pull request " \
+                       "through a multi-agent pipeline with a TUI and daemon."
+  spec.homepage      = "https://github.com/ivankuznetsov/hive"
+  spec.license       = "MIT"
+  spec.required_ruby_version = ">= 3.4.0"
+
+  spec.files = Dir.chdir(__dir__) do
+    `git ls-files -z`.split("\x0").reject do |f|
+      f.start_with?("test/", "docs/plans/", "docs/brainstorms/", "vendor/", ".git/")
+    end
+  end
+  spec.bindir        = "bin"
+  spec.executables   = %w[hive]
+  spec.require_paths = ["lib"]
+
+  spec.add_dependency "thor", "~> 1.3"
+  spec.add_dependency "telegram-bot-ruby", "~> 2.7"
+  spec.add_dependency "bubbletea", "= 0.1.4"
+  spec.add_dependency "lipgloss", "~> 0.2.2"
+
+  spec.metadata["source_code_uri"] = "https://github.com/ivankuznetsov/hive"
+  spec.metadata["changelog_uri"]   = "https://github.com/ivankuznetsov/hive/blob/main/CHANGELOG.md"
+end
diff --git a/install.sh b/install.sh
new file mode 100755
index 00000000..2a7d2ee8
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,342 @@
+#!/usr/bin/env bash
+# Hive verified Bash installer (R5-R7, U7).
+# Downloads a pinned GitHub Release archive, verifies SHA-256, installs
+# under $XDG_DATA_HOME/hive/versions/<version>, and links into ~/.local/bin.
+#
+# Usage:
+#   curl -fsSL https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh | bash
+#   bash install.sh --version 0.1.0 --command-name hv
+set -euo pipefail
+
+HIVE_VERSION="${HIVE_VERSION:-0.1.0}"
+COMMAND_NAME="hive"
+REPO="${HIVE_REPO:-ivankuznetsov/hive}"
+BASE_URL="${HIVE_RELEASE_BASE:-https://github.com/${REPO}/releases/download/v${HIVE_VERSION}}"
+PREFIX=""
+DRY_RUN=0
+SKIP_PROVENANCE=0
+
+usage() {
+  cat <<EOF
+Usage: install.sh [options]
+  --version VER       Install version (default: ${HIVE_VERSION})
+  --command-name NAME Install as hive or hv (default: hive)
+  --prefix DIR        Override XDG data prefix
+  --base-url URL      Override release asset base URL
+  --dry-run           Print actions only
+  --skip-provenance   Skip gh attestation verify (checksum still required)
+  -h, --help          Show help
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --version) HIVE_VERSION="$2"; shift 2
+      BASE_URL="${HIVE_RELEASE_BASE:-https://github.com/${REPO}/releases/download/v${HIVE_VERSION}}"
+      ;;
+    --command-name) COMMAND_NAME="$2"; shift 2 ;;
+    --prefix) PREFIX="$2"; shift 2 ;;
+    --base-url) BASE_URL="$2"; shift 2 ;;
+    --dry-run) DRY_RUN=1; shift ;;
+    --skip-provenance) SKIP_PROVENANCE=1; shift ;;
+    -h|--help) usage; exit 0 ;;
+    *) echo "unknown option: $1" >&2; usage; exit 64 ;;
+  esac
+done
+
+if [[ ! "$HIVE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+  echo "error: --version must be MAJOR.MINOR.PATCH, got: ${HIVE_VERSION}" >&2
+  exit 64
+fi
+
+if [[ "$COMMAND_NAME" != "hive" && "$COMMAND_NAME" != "hv" ]]; then
+  echo "error: --command-name must be hive or hv" >&2
+  exit 64
+fi
+
+need() {
+  if ! command -v "$1" >/dev/null 2>&1; then
+    echo "error: missing required tool '$1' ($2)" >&2
+    exit 1
+  fi
+}
+
+need curl "install curl to download release assets"
+need tar "install tar to extract archives"
+if command -v sha256sum >/dev/null 2>&1; then
+  sha256_file() { sha256sum "$1" | awk '{print $1}'; }
+elif command -v shasum >/dev/null 2>&1; then
+  sha256_file() { shasum -a 256 "$1" | awk '{print $1}'; }
+else
+  echo "error: need sha256sum or shasum for integrity verification" >&2
+  exit 1
+fi
+
+os=$(uname -s | tr '[:upper:]' '[:lower:]')
+arch=$(uname -m)
+case "$os" in
+  darwin) platform=macos ;;
+  linux) platform=linux ;;
+  *) echo "error: unsupported OS '$os' (tier-3). See docs/install.md" >&2; exit 1 ;;
+esac
+case "$arch" in
+  arm64|aarch64) arch=arm64 ;;
+  x86_64|amd64) arch=x86_64 ;;
+  *) echo "error: unsupported architecture '$arch'" >&2; exit 1 ;;
+esac
+
+if [[ "$platform" == "macos" && "$arch" != "arm64" ]]; then
+  echo "warn: macOS $arch is tier-2 best-effort" >&2
+elif [[ "$platform" == "macos" ]]; then
+  macos_major=$(sw_vers -productVersion 2>/dev/null | cut -d. -f1 || true)
+  if [[ ! "$macos_major" =~ ^[0-9]+$ ]] || (( macos_major < 14 )); then
+    echo "warn: macOS ${macos_major:-unknown} arm64 is tier-2 best-effort; tier-1 starts at macOS 14" >&2
+  else
+    echo "hive install: host classification macOS ${macos_major} arm64 (tier-1)"
+  fi
+fi
+if [[ "$platform" == "linux" && "$arch" != "x86_64" ]]; then
+  echo "warn: Linux $arch is tier-2 best-effort" >&2
+elif [[ "$platform" == "linux" ]]; then
+  distro=unknown
+  distro_version=unknown
+  if [[ -r /etc/os-release ]]; then
+    distro=$(awk -F= '$1=="ID" {gsub(/["\047]/, "", $2); print $2}' /etc/os-release)
+    distro_version=$(awk -F= '$1=="VERSION_ID" {gsub(/["\047]/, "", $2); print $2}' /etc/os-release)
+  fi
+  ubuntu_tier1=0
+  if [[ "$distro" == "ubuntu" ]] && awk -v actual="$distro_version" 'BEGIN { split(actual,a,"."); exit !((a[1]+0)>22 || ((a[1]+0)==22 && (a[2]+0)>=4)) }'; then
+    ubuntu_tier1=1
+  fi
+  if [[ "$distro" == "arch" || "$ubuntu_tier1" -eq 1 ]]; then
+    echo "hive install: host classification Linux ${distro} ${distro_version} x86_64 (tier-1)"
+  else
+    echo "warn: Linux ${distro} ${distro_version} x86_64 is tier-2 best-effort; tier-1 is Ubuntu 22.04+ or current Arch" >&2
+  fi
+fi
+
+XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
+XDG_BIN_HOME="${XDG_BIN_HOME:-$HOME/.local/bin}"
+if [[ -n "$PREFIX" ]]; then
+  if [[ "$PREFIX" != /* ]]; then
+    echo "error: --prefix must be an absolute path" >&2
+    exit 64
+  fi
+  DATA_ROOT="$PREFIX"
+else
+  DATA_ROOT="${XDG_DATA_HOME}/hive"
+fi
+for path_value in "$XDG_DATA_HOME" "$XDG_BIN_HOME" "$DATA_ROOT"; do
+  if [[ "$path_value" != /* || "$path_value" == *$'\n'* || "$path_value" == *'"'* || "$path_value" == *'\'* ]]; then
+    echo "error: install paths must be absolute and cannot contain quotes, backslashes, or newlines: ${path_value}" >&2
+    exit 64
+  fi
+done
+VERSION_DIR="${DATA_ROOT}/versions/${HIVE_VERSION}"
+ARCHIVE="hive-${platform}-${arch}.tar.gz"
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+
+echo "hive install: version=${HIVE_VERSION} platform=${platform}-${arch} command=${COMMAND_NAME}"
+
+if [[ "$DRY_RUN" -eq 1 ]]; then
+  echo "dry-run: would fetch ${BASE_URL}/${ARCHIVE}"
+  echo "dry-run: would install into ${VERSION_DIR}"
+  echo "dry-run: would link ${XDG_BIN_HOME}/${COMMAND_NAME}"
+  [[ "$COMMAND_NAME" == "hive" ]] && echo "dry-run: would link ${XDG_BIN_HOME}/hv"
+  exit 0
+fi
+
+# Refuse to overwrite any unrelated command. A prior Bash-channel symlink
+# into this install's versions root is the only command we replace.
+preflight_names=("$COMMAND_NAME")
+[[ "$COMMAND_NAME" == "hive" ]] && preflight_names=("hive" "hv")
+for name in "${preflight_names[@]}"; do
+  if [[ -e "${XDG_BIN_HOME}/${name}" || -L "${XDG_BIN_HOME}/${name}" ]]; then
+    existing="${XDG_BIN_HOME}/${name}"
+  else
+    existing=$(command -v "$name" 2>/dev/null || true)
+  fi
+  if [[ -n "$existing" ]]; then
+    target=$(readlink "$existing" 2>/dev/null || true)
+    if [[ ! -L "$existing" || ( "$target" != "${DATA_ROOT}/versions/"* && "$target" != "${XDG_DATA_HOME}/hive/versions/"* ) ]]; then
+      echo "error: '$existing' already exists and is not owned by this Bash install." >&2
+      if [[ "$name" == "hive" ]]; then
+        echo "       Re-run with --command-name hv to preserve it and install the collision-safe alias." >&2
+      fi
+      exit 1
+    fi
+  fi
+done
+
+cd "$TMP"
+echo "hive install: downloading manifest + archive"
+curl -fsSL "${BASE_URL}/manifest.json" -o manifest.json
+curl -fsSL "${BASE_URL}/SHA256SUMS" -o SHA256SUMS
+curl -fsSL "${BASE_URL}/${ARCHIVE}" -o "${ARCHIVE}"
+
+# Verify digest before extraction.
+expected=$(awk -v f="$ARCHIVE" '$2==f {print $1}' SHA256SUMS)
+if [[ -z "$expected" ]]; then
+  # Try basenames with */
+  expected=$(awk -v f="$ARCHIVE" '$2 ~ f"$" {print $1}' SHA256SUMS)
+fi
+actual=$(sha256_file "$ARCHIVE")
+if [[ -z "$expected" || "$expected" != "$actual" ]]; then
+  echo "error: SHA-256 mismatch for ${ARCHIVE}" >&2
+  echo "  expected: ${expected:-<missing>}" >&2
+  echo "  actual:   ${actual}" >&2
+  exit 1
+fi
+
+# Cross-check manifest entry when ruby is available (checksum already verified).
+if command -v ruby >/dev/null 2>&1; then
+  ARCHIVE="$ARCHIVE" ACTUAL="$actual" ruby -e '
+    require "json"
+    archive = ENV.fetch("ARCHIVE")
+    actual = ENV.fetch("ACTUAL")
+    m = JSON.parse(File.read("manifest.json"))
+    a = m.fetch("assets").find { |x| x["filename"] == archive }
+    abort "archive not listed in manifest" unless a
+    abort "manifest sha256 mismatch" unless a["sha256"] == actual
+    abort "refusing mutable latest URL" if a["url"].to_s.include?("/latest/")
+    puts "manifest ok"
+  '
+fi
+
+if [[ "$SKIP_PROVENANCE" -eq 0 ]] && command -v gh >/dev/null 2>&1; then
+  if ! gh attestation verify "${ARCHIVE}" --repo "$REPO" 2>/dev/null; then
+    echo "warn: gh attestation verify failed or unavailable; checksum verification passed" >&2
+  fi
+elif [[ "$SKIP_PROVENANCE" -eq 0 ]]; then
+  echo "warn: gh not installed; skipped provenance attestation (checksum verified)" >&2
+fi
+
+# Validate every member before extraction. Release archives contain only
+# regular files/directories beneath hive/; links and special files are not
+# needed and are rejected rather than trying to normalize attacker targets.
+mkdir -p extract
+tar -tzf "${ARCHIVE}" >members.txt
+while IFS= read -r member; do
+  case "$member" in
+    hive|hive/|hive/*) ;;
+    *) echo "error: unsafe archive member outside hive/: ${member}" >&2; exit 1 ;;
+  esac
+  if [[ "$member" == /* || "/$member/" == *"/../"* || "/$member/" == *"/./"* ]]; then
+    echo "error: unsafe archive member path: ${member}" >&2
+    exit 1
+  fi
+done <members.txt
+if ! tar -tvzf "${ARCHIVE}" | awk 'substr($1,1,1) !~ /^[-d]$/ { bad=1 } END { exit bad }'; then
+  echo "error: archive contains a link or special file; only regular files and directories are allowed" >&2
+  exit 1
+fi
+tar -xzf "${ARCHIVE}" -C extract
+if find extract -type l | grep -q .; then
+  echo "error: archive extraction produced an unexpected symlink" >&2
+  exit 1
+fi
+
+# Stage and validate the complete payload before touching a previous install.
+stage="${VERSION_DIR}.staging.$$"
+rm -rf "$stage"
+mkdir -p "$stage"
+cp -a extract/hive/. "$stage/" 2>/dev/null || cp -a extract/. "$stage/"
+staged_bin="${stage}/hive"
+if [[ ! -x "$staged_bin" ]]; then
+  staged_bin="${stage}/bin/hive"
+fi
+if [[ ! -x "$staged_bin" ]]; then
+  echo "error: packaged binary not found in archive" >&2
+  exit 1
+fi
+staged_version=$("$staged_bin" --version)
+if [[ "$staged_version" != "$HIVE_VERSION" ]]; then
+  echo "error: packaged binary version ${staged_version:-<empty>} does not match requested ${HIVE_VERSION}" >&2
+  exit 1
+fi
+
+backup="${VERSION_DIR}.rollback.$$"
+had_previous=0
+rm -rf "$backup"
+mkdir -p "${XDG_BIN_HOME}"
+if [[ -e "$VERSION_DIR" ]]; then
+  mv "$VERSION_DIR" "$backup"
+  had_previous=1
+fi
+rollback_install() {
+  rm -rf "$VERSION_DIR"
+  if [[ "$had_previous" -eq 1 && -e "$backup" ]]; then
+    mv "$backup" "$VERSION_DIR"
+  else
+    # Remove only links this invocation was authorized to create. A
+    # collision-safe hv install must never remove an unrelated `hive`.
+    for rollback_name in "${preflight_names[@]}"; do
+      rm -f "${XDG_BIN_HOME}/${rollback_name}"
+    done
+  fi
+}
+if ! mv "$stage" "$VERSION_DIR"; then
+  rollback_install
+  exit 1
+fi
+
+bin_src="${VERSION_DIR}/hive"
+[[ -x "$bin_src" ]] || bin_src="${VERSION_DIR}/bin/hive"
+command_names=("$COMMAND_NAME")
+if [[ "$COMMAND_NAME" == "hive" ]]; then
+  command_names=("hive" "hv")
+fi
+for name in "${command_names[@]}"; do
+  if ! ln -sfn "$bin_src" "${XDG_BIN_HOME}/${name}"; then
+    rollback_install
+    exit 1
+  fi
+done
+
+for name in "${command_names[@]}"; do
+  if ! verified_version=$("${XDG_BIN_HOME}/${name}" --version) || [[ "$verified_version" != "$HIVE_VERSION" ]]; then
+    echo "error: post-install verification failed for ${XDG_BIN_HOME}/${name}" >&2
+    rollback_install
+    exit 1
+  fi
+done
+rm -rf "$backup"
+
+# The receipt always lives in the canonical XDG data root so lifecycle
+# commands can discover custom-prefix installs. It binds every command link
+# to the exact payload path and digest installed above.
+RECEIPT_ROOT="${XDG_DATA_HOME}/hive"
+RECEIPT_PATH="${RECEIPT_ROOT}/install-receipt.yml"
+mkdir -p "$RECEIPT_ROOT"
+binary_sha256=$(sha256_file "$bin_src")
+cat >"$RECEIPT_PATH" <<EOF
+channel: bash
+version: "${HIVE_VERSION}"
+command_name: "${COMMAND_NAME}"
+command_names:
+$(for name in "${command_names[@]}"; do printf '  - "%s"\n' "$name"; done)
+command_paths:
+$(for name in "${command_names[@]}"; do printf '  - "%s/%s"\n' "$XDG_BIN_HOME" "$name"; done)
+bin_dir: "${XDG_BIN_HOME}"
+prefix: "${DATA_ROOT}"
+binary_path: "${bin_src}"
+binary_sha256: "${binary_sha256}"
+installed_at: "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+EOF
+chmod 600 "$RECEIPT_PATH" 2>/dev/null || true
+
+# PATH hint without editing shell profiles.
+case ":$PATH:" in
+  *":${XDG_BIN_HOME}:"*) ;;
+  *)
+    echo "hive install: ${XDG_BIN_HOME} is not on PATH."
+    echo "  Add this line to your shell profile:"
+    echo "    export PATH=\"${XDG_BIN_HOME}:\$PATH\""
+    ;;
+esac
+
+export PATH="${XDG_BIN_HOME}:$PATH"
+echo "hive install: verified ${COMMAND_NAME} ${HIVE_VERSION}"
+[[ "$COMMAND_NAME" == "hive" ]] && echo "hive install: verified hv ${HIVE_VERSION}"
+echo "hive install: complete"
diff --git a/lib/hive/assets.rb b/lib/hive/assets.rb
new file mode 100644
index 00000000..c309ec53
--- /dev/null
+++ b/lib/hive/assets.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "digest"
+require "yaml"
+require "time"
+require "hive/paths"
+
+module Hive
+  # Materializes immutable package-owned assets into the versioned XDG
+  # data root so Homebrew, AUR, and Bash installs share one lookup path
+  # (KTD3). Source seeds live next to the packaged binary or in the
+  # development checkout under templates/, schemas/, and lib/hive/scripts/.
+  module Assets
+    SEED_GLOBS = [
+      "templates/**/*",
+      "schemas/**/*",
+      "lib/hive/scripts/**/*"
+    ].freeze
+
+    module_function
+
+    def package_root
+      # When packaged (Tebako / installed), HIVE_PACKAGE_ROOT points at the
+      # immutable seed tree. In development, fall back to the repository root.
+      env = ENV["HIVE_PACKAGE_ROOT"]
+      return File.expand_path(env) if env && !env.empty? && File.directory?(env)
+
+      File.expand_path("../..", __dir__)
+    end
+
+    def active_version
+      Hive::VERSION
+    end
+
+    def active_dir
+      Hive::Paths.version_dir(active_version)
+    end
+
+    def materialize!(version: active_version, source_root: package_root)
+      dest = Hive::Paths.version_dir(version)
+      marker = File.join(dest, ".materialized")
+      return dest if File.exist?(marker) && verified?(dest)
+
+      FileUtils.mkdir_p(dest)
+      assets = {}
+      SEED_GLOBS.each do |glob|
+        Dir.glob(File.join(source_root, glob), File::FNM_DOTMATCH).each do |src|
+          next unless File.file?(src)
+
+          rel = src.delete_prefix(source_root.to_s).sub(%r{\A/}, "")
+          target = File.join(dest, rel)
+          FileUtils.mkdir_p(File.dirname(target))
+          FileUtils.rm_f(target)
+          FileUtils.cp(src, target)
+          assets[rel] = {
+            "sha256" => Digest::SHA256.file(src).hexdigest,
+            "bytes" => File.size(src)
+          }
+        end
+      end
+
+      meta = {
+        "version" => version,
+        "source_root" => source_root,
+        "materialized_at" => Time.now.utc.iso8601,
+        "assets" => assets.sort.to_h
+      }
+      Hive::Paths.write_private_file(marker, meta.to_yaml)
+      dest
+    end
+
+    def resolve(*parts)
+      root = materialize!
+      candidate = File.join(root, *parts)
+      return candidate if File.exist?(candidate)
+
+      # Development fallback: read from the package/source root directly.
+      File.join(package_root, *parts)
+    end
+
+    def verified?(dir)
+      return false unless File.directory?(dir)
+
+      marker = File.join(dir, ".materialized")
+      return false unless File.file?(marker) && !File.symlink?(marker)
+
+      metadata = YAML.safe_load(File.read(marker)) || {}
+      assets = metadata["assets"]
+      return false unless assets.is_a?(Hash) && !assets.empty?
+
+      assets.all? do |rel, expected|
+        next false unless expected.is_a?(Hash)
+
+        path = File.join(dir, rel)
+        File.file?(path) && !File.symlink?(path) &&
+          File.size(path) == expected["bytes"] &&
+          Digest::SHA256.file(path).hexdigest == expected["sha256"]
+      end
+    rescue Psych::Exception, Errno::ENOENT, Errno::EACCES
+      false
+    end
+  end
+end
diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb
index 9bc60cb8..c09d8333 100644
--- a/lib/hive/cli.rb
+++ b/lib/hive/cli.rb
@@ -25,15 +25,59 @@ module Hive
     end
     map "--version" => :version
 
-    desc "init [PROJECT_PATH]", "Bootstrap .hive-state (orphan hive/state branch); TTY-prompts for agents + limits"
+    desc "update", "Update Hive via the owning install channel (brew / AUR / Bash)"
+    long_desc <<~DESC
+      Detects whether this `hive`/`hv` binary is owned by Homebrew, AUR
+      (hive-bin), or the Bash installer, then delegates to that channel.
+      Never swaps package-manager files in place. Refreshes the user
+      service definition after a successful upgrade without changing
+      autostart state.
+
+      Exit codes: 0 success; 1 ownership unknown/ambiguous or updater failed.
+    DESC
+    option :dry_run, type: :boolean, default: false, desc: "print the selected updater without running it"
+    def update
+      require "hive/commands/update"
+      Hive::Commands::Update.new(dry_run: options[:dry_run]).call
+    end
+
+    desc "uninstall", "Unregister the daemon and remove the owning binary; preserve user work"
+    long_desc <<~DESC
+      Stops and unregisters the per-user service, then delegates binary
+      removal to Homebrew, AUR, or the Bash installer. Config, XDG state,
+      completed pipeline outputs, and agent skills are preserved.
+
+      With --clean-project, only unmodified hash-matching files from the
+      current project's `.hive/` manifest are offered for removal.
+
+      Exit codes: 0 success / cancelled preview; 1 package-manager failure.
+    DESC
+    option :yes, type: :boolean, default: false, aliases: "-y", desc: "skip confirmation prompt"
+    option :clean_project, type: :boolean, default: false, desc: "remove unmodified manifest-owned scaffold in the current project"
+    option :dry_run, type: :boolean, default: false, desc: "preview only"
+    def uninstall
+      require "hive/commands/uninstall"
+      Hive::Commands::Uninstall.new(
+        yes: options[:yes],
+        clean_project: options[:clean_project],
+        dry_run: options[:dry_run]
+      ).call
+    end
+
+    desc "init [PROJECT_PATH]", "Create or repair .hive-state and first-run user configuration"
     long_desc <<~DESC
       Initialises hive in PROJECT_PATH (defaults to the current directory):
       creates the orphan `hive/state` branch, attaches it as a worktree at
       `<project>/.hive-state/`, scaffolds stage folders, ignores
       `.hive-state/` on master, and registers the project globally.
 
-      On a TTY, init asks the operator four questions before writing
-      anything to disk:
+      Existing partial initialization is repaired in place: the branch,
+      worktree, config, scaffold, registry, migration, and service registration
+      steps are all idempotent.
+
+      On a TTY, first-time user configuration asks for provider, model, and the
+      name of the environment variable containing the credential. It never asks
+      for or writes the credential value. Project setup then asks four questions:
 
         1. Planning agent (drives 2-brainstorm + 3-plan)         — default claude
         2. Development agent (drives 4-execute)                  — default codex
@@ -44,33 +88,41 @@ module Hive
       OR a 1-based index. Blank input takes the default. Answer `n` at
       the final confirmation to abort with no disk side effects.
 
-      On non-TTY (CI, pipes, scripted callers) the prompts are skipped
-      and a one-line summary is emitted to stdout so the caller can see
-      which defaults landed:
+      On non-TTY (CI, pipes, scripted callers), pass the user configuration
+      explicitly (or through HIVE_PROVIDER, HIVE_MODEL, and HIVE_API_KEY_ENV):
 
-        hive: using defaults — planning=claude, dev=codex, reviewers=all3, limits=defaults
+        hive init . --provider openai --model gpt-5 --api-key-env OPENAI_API_KEY
 
-      To set non-default values from automation, run init and then
-      hand-edit `.hive-state/config.yml` (see `wiki/modules/config.md`
-      for the schema). Piped STDIN is intentionally NOT consumed.
+      Missing non-interactive first-run values are a CONFIG error. Agent and
+      limit prompts continue to use project defaults when input is not a TTY.
 
       Exit codes:
         0  — initialised successfully
         1  — generic / unexpected error
-        2  — already initialised (`hive/state` branch already exists)
+        2  — reserved for compatibility with older releases
         64 — user aborted at the confirmation prompt
 
       See `wiki/commands/init.md` for the full prompt flow and ADR-023.
     DESC
     option :force, type: :boolean, default: false, desc: "skip clean-tree check"
+    option :provider, type: :string, desc: "first-run provider name (or HIVE_PROVIDER)"
+    option :model, type: :string, desc: "first-run model name (or HIVE_MODEL)"
+    option :api_key_env, type: :string,
+                         desc: "credential environment-variable name; never the credential value"
     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],
+        api_key_env: options[:api_key_env]
+      ).call
     end
 
     desc "forget NAME", "Remove a project from the global registry (inverse of `hive init`)"
     long_desc <<~DESC
-      Drops the entry whose `name` matches NAME from ~/Dev/hive/config.yml.
+      Drops the entry whose `name` matches NAME from $XDG_CONFIG_HOME/hive/config.yml.
       The project's .hive-state directory on disk (if any) is left alone.
 
       An unknown name is a USAGE error (64), mirroring `hive metrics
@@ -94,7 +146,7 @@ module Hive
 
     desc "prune", "Drop registry entries whose project path no longer exists"
     long_desc <<~DESC
-      Walks ~/Dev/hive/config.yml and removes every `registered_projects`
+      Walks $XDG_CONFIG_HOME/hive/config.yml and removes every `registered_projects`
       entry whose `path` is not a directory on disk OR whose row shape is
       invalid (non-Hash, missing `path`, etc. — hand-edit accidents).
       Useful after running `hive init` against `mktemp -d` directories
@@ -481,11 +533,11 @@ module Hive
     desc "daemon SUBCOMMAND [PROJECT]", "Manage the hive daemon (start / stop / status / reload / tail / enable / disable)"
     long_desc <<~DESC
       Subcommands:
-        start [--detach] [--dry-run]      Run the dispatcher loop. Without
-                                          --detach, runs in the foreground.
-        stop [--json]                     Send SIGTERM to the running daemon.
+        start                             Start the registered per-user service.
+        run [--dry-run]                   Internal foreground service-manager dispatcher entry.
+        stop [--json]                     Stop the registered per-user service.
                                           --json emits hive-daemon-stop.v1.
-        status [--json]                   Show running / not-running.
+        status [--json]                   Show process and service-manager state.
         reload [--json]                   Send SIGHUP to reload config.
                                           --json emits hive-daemon-reload.v1.
         tail                              Stream daemon.log.
@@ -505,6 +557,10 @@ module Hive
       projects; for projects that pre-date the daemon, run
       `hive daemon enable <project>` (or `--all`).
 
+      Public start, stop, and status route through launchd or `systemd --user`
+      when registered, with direct process control only as an unregistered
+      compatibility fallback.
+
       Exit codes: 0 success; 1 daemon-not-running — `reload` exits 1 when
       no daemon is up (caller MUST start one first); `stop` is idempotent
       and exits 0 in the same condition (re-running stop is always safe);
@@ -595,7 +651,7 @@ module Hive
                                           --json emits hive-bot-reload.v1.
         tail                              Stream bot.log.
 
-      The bot reads the global `bot:` block from ~/Dev/hive/config.yml.
+      The bot reads the global `bot:` block from $XDG_CONFIG_HOME/hive/config.yml.
       Its Telegram token comes only from HIVE_TELEGRAM_BOT_TOKEN. Incoming
       updates from chat IDs outside bot.chat_id_allowlist are ignored.
 
diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb
index f88bca6f..b59a6906 100644
--- a/lib/hive/commands/daemon.rb
+++ b/lib/hive/commands/daemon.rb
@@ -27,7 +27,9 @@ module Hive
     class Daemon
       include Hive::Schemas::EnvelopeEmitter
 
-      VALID_SUBCOMMANDS = %w[start stop status reload tail enable disable].freeze
+      # `run` is the service-manager entry point (LaunchAgent / systemd):
+      # foreground, non-detached, identical to `start` without --detach.
+      VALID_SUBCOMMANDS = %w[start run stop status reload tail enable disable].freeze
 
       # USAGE-class error specific to enable/disable. Carries an
       # error_kind drawn from Hive::Schemas::EnrollErrorKind so the
@@ -44,7 +46,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,
+                     service_adapter: :auto)
         @subcommand = subcommand
         @target = target
         @detach = detach
@@ -52,6 +55,7 @@ module Hive
         @all = all
         @json = json
         @hive_home = hive_home
+        @service_adapter = service_adapter
       end
 
       def call
@@ -62,8 +66,9 @@ module Hive
         end
 
         case @subcommand
-        when "start"            then start_daemon
-        when "stop"             then stop_daemon
+        when "start"            then start_managed_daemon
+        when "run"              then start_daemon_foreground
+        when "stop"             then stop_managed_daemon
         when "status"           then status_daemon
         when "reload"           then reload_daemon
         when "tail"             then tail_daemon
@@ -81,6 +86,25 @@ module Hive
 
       private
 
+      def start_daemon_foreground
+        @detach = false
+        start_daemon
+      end
+
+      def start_managed_daemon
+        adapter = registered_service_adapter
+        return start_daemon unless adapter
+
+        if @detach || @dry_run
+          raise Hive::InvalidTaskPath,
+                "hive daemon start is managed by the registered user service; " \
+                "--detach/--dry-run apply only to the internal `daemon run` entry point"
+        end
+        raise Hive::Error, "hive: service manager did not start the daemon" unless adapter.start!
+
+        puts "hive: daemon started via #{adapter.status['manager']}"
+      end
+
       def start_daemon
         warn_unsupported_json_flag if @json
         FileUtils.mkdir_p(@hive_home)
@@ -123,7 +147,7 @@ module Hive
         # process that happens to have the same PID. PR-40 review P2 #3.
         File.write(pid_file, pid_file_payload(Process.pid, own_start_time).to_yaml)
 
-        # Load the daemon block from ~/Dev/hive/config.yml so operator
+        # Load the daemon block from the active XDG global config so operator
         # overrides (max_concurrent_runs, poll_interval_sec, log paths,
         # etc.) actually take effect. PR-40 review P1 #2: this used to
         # call merge_defaults({}) which discarded the global config.
@@ -260,6 +284,20 @@ module Hive
         end
       end
 
+      def stop_managed_daemon
+        adapter = registered_service_adapter
+        return stop_daemon unless adapter
+
+        before = adapter.status
+        raise Hive::Error, "hive: service manager did not stop the daemon" unless adapter.stop!
+
+        if @json
+          puts JSON.generate(stop_envelope(running: false, was_running: !!before["running"]))
+        else
+          puts "hive: daemon stopped via #{adapter.status['manager']}"
+        end
+      end
+
       def status_daemon
         running = false
         pid = nil
@@ -275,8 +313,17 @@ module Hive
           end
         end
 
+        service = service_manager_status
+        if service && service["registered"]
+          running = !!service["running"]
+          unless running
+            pid = nil
+            uptime_sec = nil
+          end
+        end
+
         if @json
-          puts JSON.generate(
+          payload = {
             "schema" => "hive-daemon-status",
             "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"),
             "ok" => true,
@@ -284,17 +331,45 @@ module Hive
             "pid" => running ? pid : nil,
             "uptime_sec" => uptime_sec,
             "pid_file" => pid_file,
-            "log_file" => log_file
-          )
+            "log_file" => log_file,
+            "service" => service
+          }
+          puts JSON.generate(payload)
         elsif running
           puts "hive daemon: running (pid #{pid}, uptime #{uptime_sec}s)"
+          puts "hive daemon service: #{service.inspect}" if service
         else
           puts "hive daemon: not running"
+          puts "hive daemon service: #{service.inspect}" if service
         end
         # Exit code: 0 for running, 1 for not running (per plan U8)
         raise Hive::Error, "daemon not running" unless running
       end
 
+      def service_manager_status
+        adapter = service_adapter
+        adapter&.status
+      rescue LoadError, StandardError
+        nil
+      end
+
+      def registered_service_adapter
+        adapter = service_adapter
+        return nil unless adapter
+
+        status = adapter.status
+        status["registered"] ? adapter : nil
+      end
+
+      def service_adapter
+        return @service_adapter unless @service_adapter == :auto
+
+        require "hive/service/adapter"
+        @service_adapter = Hive::Service::Adapter.for_host
+      rescue LoadError
+        @service_adapter = nil
+      end
+
       def reload_daemon
         result = compute_reload_outcome
         if @json
@@ -450,7 +525,7 @@ module Hive
           if @subcommand == "enable" && !@all && any_changed
             puts ""
             puts "  next: hive daemon reload   # if the daemon is already running"
-            puts "        hive daemon start --dry-run --detach   # if it's not"
+            puts "        hive daemon start    # if it's not"
           end
         end
       end
diff --git a/lib/hive/commands/forget.rb b/lib/hive/commands/forget.rb
index d2fbd014..e07145eb 100644
--- a/lib/hive/commands/forget.rb
+++ b/lib/hive/commands/forget.rb
@@ -4,7 +4,7 @@ require "hive/config"
 module Hive
   module Commands
     # `hive forget NAME [--json]` — remove the entry whose `name` matches
-    # NAME from the global registry (~/Dev/hive/config.yml). The
+    # NAME from the active XDG global registry. The
     # project's `.hive-state` directory on disk is not touched; the
     # registry and the on-disk state are independent.
     #
diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb
index b86e8d61..83379a89 100644
--- a/lib/hive/commands/init.rb
+++ b/lib/hive/commands/init.rb
@@ -5,11 +5,16 @@ require "hive/config"
 require "hive/git_ops"
 require "hive/commands/init/prompts"
 require "hive/commands/doctor"
+require "hive/project/scaffold"
+require "hive/state_migration"
+require "hive/user_config"
+require "hive/paths"
 
 module Hive
   module Commands
     class Init
-      def initialize(project_path, force: false, prompts: nil)
+      def initialize(project_path, force: false, prompts: nil, skip_service: false,
+                     provider: nil, model: nil, api_key_env: nil)
         @project_path = File.expand_path(project_path)
         @force = force
         # Optional Prompts instance for testability. Tests inject a
@@ -19,35 +24,91 @@ module Hive
         # summary_io: $stdout)` runs (UI on stderr, machine-parseable
         # summary on stdout — see #collect_prompt_answers below).
         @prompts = prompts
+        # When true, skip LaunchAgent/systemd registration (U3 hooks this).
+        # Kept so unit tests of the scaffold path stay isolated from the
+        # service manager.
+        @skip_service = skip_service
+        @provider = provider
+        @model = model
+        @api_key_env = api_key_env
       end
 
       def call
         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}"
-        end
+        validate_clean_tree! unless @force || ops.hive_state_branch_exists?
 
-        # Prompt placement is load-bearing (per ADR-023): runs AFTER the
-        # already-initialized guard above, BEFORE any disk writes below.
+        # Prompt placement is load-bearing (per ADR-023): runs BEFORE any
+        # project disk writes below. A repair run only prompts when the
+        # per-project config is actually missing.
         # 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
+        answers = collect_prompt_answers unless project_config_exists?(ops)
+
+        ensure_user_config!
+        maybe_migrate_legacy_state!
 
         ops.hive_state_init
         write_per_project_config(ops, answers: answers)
         ops.add_hive_state_to_master_gitignore!
+        scaffold_project!
 
         entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path)
 
+        register_user_service_if_needed!
+
         print_summary(entry: entry, ops: ops)
         run_init_preflight!
       end
 
+      def scaffold_project!
+        Hive::Project::Scaffold.new(@project_path).call(force: @force)
+      end
+
+      def ensure_user_config!
+        Hive::UserConfig.ensure_configured!(
+          provider: @provider,
+          model: @model,
+          api_key_env: @api_key_env
+        )
+      rescue Hive::ConfigError => e
+        warn "hive: user config: #{e.message}"
+        raise
+      end
+
+      def maybe_migrate_legacy_state!
+        migration = Hive::StateMigration.new
+        return unless migration.legacy_present?
+
+        result = migration.call(copy: true)
+        return unless result.migrated
+
+        warn "hive: migrated #{result.files.size} file(s) from legacy state " \
+             "into #{result.destination} (source retained at #{result.source})"
+      end
+
+      def register_user_service_if_needed!
+        return if @skip_service
+
+        # U3 wires LaunchAgent / systemd --user here. Until the adapters
+        # load, init remains functional without native service registration.
+        begin
+          require "hive/service/adapter"
+        rescue LoadError
+          return
+        end
+        return unless defined?(Hive::Service::Adapter)
+
+        adapter = Hive::Service::Adapter.for_host
+        return unless adapter
+
+        adapter.register!
+        adapter.prompt_start_if_interactive!
+      rescue NotImplementedError
+        nil
+      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
@@ -166,10 +227,16 @@ module Hive
         cfg_path = File.join(ops.hive_state_path, "config.yml")
         return if File.exist?(cfg_path)
 
+        raise Hive::ConfigError, "project config is missing and no initialization answers were collected" unless answers
+
         content = render_project_config(ops, answers: answers)
         File.write(cfg_path, content)
       end
 
+      def project_config_exists?(ops)
+        File.exist?(File.join(ops.hive_state_path, "config.yml"))
+      end
+
       def render_project_config(ops, answers:)
         require "erb"
         template = File.read(File.expand_path("../../../templates/project_config.yml.erb", __dir__))
diff --git a/lib/hive/commands/prune.rb b/lib/hive/commands/prune.rb
index c91f02d7..5fc0ca52 100644
--- a/lib/hive/commands/prune.rb
+++ b/lib/hive/commands/prune.rb
@@ -4,7 +4,7 @@ require "hive/config"
 module Hive
   module Commands
     # `hive prune [--dry-run] [--json]` — drop every registry entry in
-    # ~/Dev/hive/config.yml whose `path` no longer points at a directory.
+    # active XDG global config whose `path` no longer points at a directory.
     # Also drops malformed entries (non-Hash rows, rows missing `path`,
     # rows whose `path` isn't a String) — these are hand-edit accidents
     # and have always been undisplayable in `hive status`. The
diff --git a/lib/hive/commands/uninstall.rb b/lib/hive/commands/uninstall.rb
new file mode 100644
index 00000000..8ecc7dc3
--- /dev/null
+++ b/lib/hive/commands/uninstall.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require "hive/install_provenance"
+require "hive/project/cleanup"
+require "hive/service/adapter"
+require "fileutils"
+
+module Hive
+  module Commands
+    # Safe uninstall (R15 / F4 / AE4): stop+unregister service, delegate
+    # binary removal to the owning channel, optionally clean hash-matching
+    # generated files in the *current* project only. Config, XDG state,
+    # completed outputs, and agent skills are preserved by default.
+    class Uninstall
+      def initialize(
+        project_path: Dir.pwd,
+        yes: false,
+        clean_project: false,
+        dry_run: false,
+        command_path: nil,
+        exec: method(:system),
+        service_adapter: :auto,
+        input: $stdin,
+        output: $stdout
+      )
+        @project_path = File.expand_path(project_path)
+        @yes = yes
+        @clean_project = clean_project
+        @dry_run = dry_run
+        @command_path = command_path
+        @exec = exec
+        @service_adapter = service_adapter
+        @input = input
+        @output = output
+      end
+
+      def call
+        owner = InstallProvenance.detect(command_path: @command_path)
+        cleanup = Hive::Project::Cleanup.new(@project_path)
+        plan = cleanup.preview
+
+        print_preview(owner, plan)
+        return preview_only(owner, plan) if @dry_run
+        return preview_only(owner, plan) unless confirm?
+
+        ensure_owned!(owner)
+
+        stop_and_unregister_service!
+        remove_binary!(owner)
+
+        if @clean_project
+          result = cleanup.call!(confirm: true)
+          @output.puts "hive uninstall: removed #{result.removed.size} generated file(s); " \
+                       "preserved #{result.preserved.size} modified/unknown"
+        else
+          @output.puts "hive uninstall: left project files intact " \
+                       "(pass --clean-project to remove unmodified generated scaffold only)"
+        end
+
+        @output.puts "hive uninstall: preserved user config, XDG state, and agent skills"
+        owner
+      end
+
+      private
+
+      def print_preview(owner, plan)
+        @output.puts "hive uninstall preview:"
+        @output.puts "  channel: #{owner.channel}"
+        @output.puts "  binary action: #{owner.uninstaller.inspect || 'manual'}"
+        @output.puts "  service: stop + unregister"
+        @output.puts "  project removable (hash-match): #{plan.removed.inspect}"
+        @output.puts "  project preserved (modified):   #{plan.preserved.inspect}"
+        @output.puts "  always preserved: $XDG_CONFIG_HOME/hive, $XDG_STATE_HOME/hive, skills"
+      end
+
+      def preview_only(owner, _plan)
+        @output.puts "hive uninstall: no changes made (preview/cancelled)"
+        owner
+      end
+
+      def confirm?
+        return true if @yes
+        return false unless @input.respond_to?(:tty?) && @input.tty?
+
+        @output.print "hive uninstall: proceed? [y/N] "
+        %w[y yes].include?(@input.gets.to_s.strip.downcase)
+      end
+
+      def stop_and_unregister_service!
+        adapter = service_adapter
+        return unless adapter
+
+        raise Hive::Error, "hive uninstall: service manager failed to stop the daemon" unless adapter.stop!
+        raise Hive::Error, "hive uninstall: service manager failed to unregister the daemon" unless adapter.unregister!
+      rescue StandardError => e
+        raise e if e.is_a?(Hive::Error)
+
+        raise Hive::Error, "hive uninstall: service cleanup failed: #{e.message}"
+      end
+
+      def remove_binary!(owner)
+        case owner.channel
+        when :bash
+          unlink_bash(owner)
+        else
+          cmd = owner.uninstaller
+          return unless cmd.is_a?(Array)
+
+          ok = @exec.call(*cmd)
+          unless ok
+            raise Hive::Error,
+                  "hive uninstall: package manager failed (#{cmd.inspect}); " \
+                  "not continuing into project cleanup"
+          end
+        end
+      end
+
+      def unlink_bash(owner)
+        receipt = owner.details
+        links = Array(receipt["command_paths"])
+        raise Hive::Error, "hive uninstall: Bash receipt has no owned command paths" if links.empty?
+
+        links.each do |link|
+          unless InstallProvenance.bash_receipt_owns_path?(receipt, link)
+            raise Hive::Error,
+                  "hive uninstall: #{link} no longer matches the installed Bash payload; " \
+                  "refusing to remove a replacement command"
+          end
+        end
+        links.each { |link| FileUtils.rm_f(link) }
+        FileUtils.rm_f(InstallProvenance.receipt_path)
+        @output.puts "hive uninstall: removed #{links.join(', ')}"
+      end
+
+      def ensure_owned!(owner)
+        return unless %i[unknown ambiguous].include?(owner.channel)
+
+        raise Hive::Error,
+              "hive uninstall: #{owner.details['hint']} No service, project, or binary changes were made."
+      end
+
+      def service_adapter
+        return @service_adapter unless @service_adapter == :auto
+
+        @service_adapter = Hive::Service::Adapter.for_host
+      end
+    end
+  end
+end
diff --git a/lib/hive/commands/update.rb b/lib/hive/commands/update.rb
new file mode 100644
index 00000000..49400a8d
--- /dev/null
+++ b/lib/hive/commands/update.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require "open3"
+require "hive/install_provenance"
+require "hive/service/adapter"
+
+module Hive
+  module Commands
+    # `hive update` delegates to the owning install channel (R8 / F3).
+    class Update
+      def initialize(command_path: nil, dry_run: false, exec: method(:system),
+                     service_adapter: :auto, version_probe: nil)
+        @command_path = command_path
+        @dry_run = dry_run
+        @exec = exec
+        @service_adapter = service_adapter
+        @version_probe = version_probe || method(:probe_version)
+      end
+
+      def call
+        owner = InstallProvenance.detect(command_path: @command_path)
+        case owner.channel
+        when :unknown, :ambiguous
+          warn "hive update: #{owner.details['hint']}"
+          raise Hive::Error, "cannot update: install ownership is #{owner.channel}"
+        end
+
+        unless owner.updater
+          raise Hive::Error, "hive update: no updater for channel #{owner.channel}"
+        end
+
+        puts "hive update: channel=#{owner.channel} via #{owner.updater.inspect}"
+        if @dry_run
+          puts "hive update: dry-run; not executing"
+          return owner
+        end
+
+        # Capture service state so we can refresh without changing
+        # autostart after the package manager swaps the binary.
+        adapter, was_registered, was_enabled, was_running = service_state
+
+        ok = @exec.call(*owner.updater)
+        unless ok
+          raise Hive::Error,
+                "hive update: package manager failed (#{owner.updater.inspect}); " \
+                "not modifying service registration or project files"
+        end
+
+        refresh_service(adapter, was_registered, was_enabled, was_running)
+        resulting_version = @version_probe.call(owner.command_path)
+        unless resulting_version.to_s.match?(/\A[0-9]+\.[0-9]+\.[0-9]+\z/)
+          raise Hive::Error,
+                "hive update: installed command failed version verification at #{owner.command_path}"
+        end
+        puts "hive update: done; version=#{resulting_version}"
+        owner
+      end
+
+      private
+
+      def service_state
+        adapter = service_adapter
+        return [ nil, false, false, false ] unless adapter
+
+        st = adapter.status
+        [ adapter, !!st["registered"], !!st["enabled"], !!st["running"] ]
+      end
+
+      def refresh_service(adapter, was_registered, was_enabled, was_running)
+        return unless adapter && was_registered
+
+        adapter.refresh!(was_enabled: was_enabled, was_running: was_running)
+        refreshed = adapter.status
+        return if !!refreshed["enabled"] == was_enabled && !!refreshed["running"] == was_running
+
+        raise Hive::Error,
+              "hive update: service refresh did not preserve state " \
+              "(enabled #{was_enabled}->#{refreshed['enabled']}, " \
+              "running #{was_running}->#{refreshed['running']})"
+      rescue StandardError => e
+        raise e if e.is_a?(Hive::Error)
+
+        raise Hive::Error, "hive update: service refresh failed: #{e.message}"
+      end
+
+      def service_adapter
+        return @service_adapter unless @service_adapter == :auto
+
+        @service_adapter = Hive::Service::Adapter.for_host
+      end
+
+      def probe_version(path)
+        out, err, status = Open3.capture3(path, "--version")
+        raise Hive::Error, "hive update: #{path} --version failed: #{err.strip}" unless status.success?
+
+        out.strip
+      end
+    end
+  end
+end
diff --git a/lib/hive/config.rb b/lib/hive/config.rb
index a67097d8..ca8bbcf6 100644
--- a/lib/hive/config.rb
+++ b/lib/hive/config.rb
@@ -1,6 +1,7 @@
 require "yaml"
 require "fileutils"
 require "hive/agent_profiles"
+require "hive/paths"
 
 module Hive
   module Config
@@ -158,7 +159,7 @@ module Hive
       },
       # Global Telegram bot settings. The bot is an operator surface
       # across every registered project, so runtime code loads these
-      # from ~/Dev/hive/config.yml via load_global_bot. The token lives
+      # from the XDG global config via load_global_bot. The token lives
       # only in HIVE_TELEGRAM_BOT_TOKEN and is never persisted.
       "bot" => {
         "enabled" => false,
@@ -170,11 +171,11 @@ module Hive
         "codex_budget_usd" => 1,
         "codex_timeout_sec" => 120,
         "shutdown_grace_sec" => 60,
-        "pid_file" => "~/Dev/hive/.bot.pid",
-        "log_file" => "~/Dev/hive/logs/bot.log",
+        "pid_file" => "~/.local/state/hive/.bot.pid",
+        "log_file" => "~/.local/state/hive/logs/bot.log",
         "log_max_bytes" => 10_485_760,
         "log_max_files" => 5,
-        "last_seen_state_file" => "~/Dev/hive/.bot.last_seen_update_id"
+        "last_seen_state_file" => "~/.local/state/hive/.bot.last_seen_update_id"
       },
       # Auto-rebase pre-step for `hive run` (plan
       # docs/plans/2026-05-14-001-feat-hive-auto-rebase-stale-worktree-plan.md).
@@ -209,11 +210,13 @@ module Hive
     module_function
 
     def hive_home
-      ENV["HIVE_HOME"] || File.expand_path("~/Dev/hive")
+      # HIVE_HOME override → XDG state home. Legacy data is adopted separately.
+      # See Hive::Paths (KTD3 / R9).
+      Hive::Paths.hive_home
     end
 
     def global_config_path
-      File.join(hive_home, "config.yml")
+      Hive::Paths.global_registry_path
     end
 
     def hive_state_dir(project_root, hive_state_name = ".hive-state")
@@ -299,7 +302,7 @@ module Hive
     # same `error_kind: "config"` envelope the TUI's narrow rescue catches.
     #
     # EACCES specifically is the most user-facing of the IO group:
-    # `chmod 000 ~/Dev/hive/config.yml` (or running as a different
+    # `chmod 000 $XDG_CONFIG_HOME/hive/config.yml` (or running as a different
     # user) used to surface as `internal error: Errno::EACCES: ...`
     # at exit 70. The root cause is configuration access, not a Hive
     # bug — exit 78 is the right shape.
@@ -311,7 +314,7 @@ module Hive
       raise ConfigError, "global config at #{path} is not readable: #{e.message}"
     end
 
-    # Atomic + EACCES-aware writer for ~/Dev/hive/config.yml. Mirrors
+    # Atomic + EACCES-aware writer for the active XDG global config. Mirrors
     # the shape of `Hive::Markers.write_atomic` so a future flock
     # upgrade (Issue #31) can swap in here without rewriting every
     # call site. Permission errors on write surface as ConfigError
@@ -327,7 +330,7 @@ module Hive
     end
 
     # Load and validate the global `daemon` block from
-    # `~/Dev/hive/config.yml`. Returns the merged Hash (operator
+    # `$XDG_CONFIG_HOME/hive/config.yml`. Returns the merged Hash (operator
     # overrides on top of `Config::DEFAULTS["daemon"]`). Used by
     # `hive daemon start` / `reload` so operator knobs in the global
     # config (max_concurrent_runs, poll_interval_sec, log_*, etc.)
@@ -355,7 +358,7 @@ module Hive
       merged
     end
 
-    # Load and validate the global `bot` block from ~/Dev/hive/config.yml.
+    # Load and validate the global `bot` block from the active XDG config.
     # `require_runtime: true` is used by `hive bot start` so the opt-in
     # credentials fail loudly there without making read-only commands like
     # `hive status` require a Telegram token.
diff --git a/lib/hive/daemon/child_supervisor.rb b/lib/hive/daemon/child_supervisor.rb
index fd87d15a..bb7e53b2 100644
--- a/lib/hive/daemon/child_supervisor.rb
+++ b/lib/hive/daemon/child_supervisor.rb
@@ -73,7 +73,7 @@ module Hive
         FileUtils.mkdir_p(File.dirname(log_path))
         # Open with truncation ("w") so each child run starts a fresh
         # per-task log; logs from prior runs are not preserved here —
-        # the daemon's own log file at ~/Dev/hive/logs/daemon.log
+        # the daemon's own log file under $XDG_STATE_HOME/hive/logs
         # carries the cross-run history.
         log_io = File.open(log_path, "w")
         log_io.puts("[hive-daemon] #{Time.now.utc.iso8601} spawn argv=#{argv.inspect}")
diff --git a/lib/hive/daemon/dispatcher.rb b/lib/hive/daemon/dispatcher.rb
index 4f6d3450..e82bdb54 100644
--- a/lib/hive/daemon/dispatcher.rb
+++ b/lib/hive/daemon/dispatcher.rb
@@ -513,7 +513,7 @@ module Hive
       end
 
       def reload_config!
-        # PR-40 review P1 #2: rebase on the global ~/Dev/hive/config.yml's
+        # PR-40 review P1 #2: rebase on the active XDG global config's
         # daemon block, not bare DEFAULTS.
         @daemon_cfg = Hive::Config.load_global_daemon
         @config = { "daemon" => @daemon_cfg }
diff --git a/lib/hive/dependency_check.rb b/lib/hive/dependency_check.rb
new file mode 100644
index 00000000..36fea04b
--- /dev/null
+++ b/lib/hive/dependency_check.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require "open3"
+require "rbconfig"
+
+module Hive
+  # Classifies host tools as hard, channel-specific, or optional and
+  # reports missing tools with actionable install hints (R7). Never
+  # silently installs anything.
+  module DependencyCheck
+    Check = Struct.new(:name, :kind, :capability, :present, :path, :hint, keyword_init: true)
+
+    HARD = [
+      { name: "git", capability: "project_workflow", hint: "install git via your package manager (e.g. apt install git / brew install git)" },
+      { name: "bash", capability: "project_workflow", hint: "install bash (required for shell hooks and installers)" }
+    ].freeze
+
+    CHANNEL = [
+      { name: "curl", capability: "bash_install", hint: "install curl to use the Bash installer channel" },
+      { name: "tar", capability: "bash_install", hint: "install tar to extract release archives" },
+      { name: "sha256sum", capability: "bash_install", hint: "install coreutils (sha256sum) or use shasum -a 256 on macOS",
+        alternatives: %w[shasum] }
+    ].freeze
+
+    OPTIONAL = [
+      { name: "gh", capability: "github_integration", hint: "install GitHub CLI (https://cli.github.com) for PR and attestation features" },
+      { name: "jq", capability: "json_tooling", hint: "install jq for shell-side JSON processing of hive --json output" },
+      { name: "claude", capability: "agent_claude", hint: "install Claude Code CLI for planning/review agents" },
+      { name: "codex", capability: "agent_codex", hint: "install Codex CLI for the recommended execute agent" },
+      { name: "pi", capability: "agent_pi", hint: "install Pi CLI for the pi agent profile" },
+      { name: "brew", capability: "channel_homebrew", hint: "install Homebrew on macOS for the native package channel" },
+      { name: "systemctl", capability: "daemon_systemd", hint: "systemd --user is required for native Linux daemon registration" },
+      { name: "launchctl", capability: "daemon_launchd", hint: "launchctl is required for macOS LaunchAgent registration" }
+    ].freeze
+
+    module_function
+
+    def check_all(path_env: ENV["PATH"])
+      (HARD + CHANNEL + OPTIONAL).map { |spec| check_one(spec, path_env: path_env) }
+    end
+
+    def hard_missing(path_env: ENV["PATH"])
+      check_all(path_env: path_env).select { |c| c.kind == :hard && !c.present }
+    end
+
+    def report(io: $stderr, path_env: ENV["PATH"])
+      results = check_all(path_env: path_env)
+      missing = results.reject(&:present)
+      return if missing.empty?
+
+      io.puts "hive: dependency diagnostics"
+      missing.each do |c|
+        io.puts "  [#{c.kind}/#{c.capability}] missing #{c.name}: #{c.hint}"
+      end
+    end
+
+    def which(cmd, path_env: ENV["PATH"])
+      path_env.to_s.split(File::PATH_SEPARATOR).each do |dir|
+        candidate = File.join(dir, cmd)
+        return candidate if File.executable?(candidate)
+      end
+      nil
+    end
+
+    def check_one(spec, path_env: ENV["PATH"])
+      names = [spec[:name]] + Array(spec[:alternatives])
+      found = nil
+      names.each do |n|
+        found = which(n, path_env: path_env)
+        break if found
+      end
+
+      kind = if HARD.any? { |h| h[:name] == spec[:name] }
+               :hard
+      elsif CHANNEL.any? { |h| h[:name] == spec[:name] }
+               :channel
+      else
+               :optional
+      end
+
+      Check.new(
+        name: spec[:name],
+        kind: kind,
+        capability: spec[:capability],
+        present: !found.nil?,
+        path: found,
+        hint: spec[:hint]
+      )
+    end
+    private_class_method :check_one
+  end
+end
diff --git a/lib/hive/install_provenance.rb b/lib/hive/install_provenance.rb
new file mode 100644
index 00000000..fd9868b3
--- /dev/null
+++ b/lib/hive/install_provenance.rb
@@ -0,0 +1,210 @@
+# frozen_string_literal: true
+
+require "open3"
+require "json"
+require "yaml"
+require "digest"
+require "hive/paths"
+require "hive/assets"
+
+module Hive
+  # Detect which install channel owns the running binary (KTD5 / R8).
+  # Never mutates package-manager files; returns a structured owner for
+  # `hive update` / `hive uninstall` to delegate to.
+  module InstallProvenance
+    Owner = Struct.new(:channel, :command_path, :details, :updater, :uninstaller, keyword_init: true)
+
+    RECEIPT_NAME = "install-receipt.yml"
+    HOMEBREW_FORMULA = "ivankuznetsov/hive/hive"
+    RELEASE_REPOSITORY = "ivankuznetsov/hive"
+
+    module_function
+
+    def detect(command_path: nil)
+      path = command_path || resolve_self_path
+      path = File.expand_path(path) if path
+
+      owners = []
+      owners << detect_homebrew(path) if path
+      owners << detect_aur(path) if path
+      owners << detect_bash(path)
+      owners.compact!
+
+      case owners.size
+      when 0
+        Owner.new(
+          channel: :unknown,
+          command_path: path,
+          details: { "hint" => "Could not determine install channel. " \
+                               "Reinstall via brew, AUR hive-bin, or the Bash installer." },
+          updater: nil,
+          uninstaller: nil
+        )
+      when 1
+        owners.first
+      else
+        Owner.new(
+          channel: :ambiguous,
+          command_path: path,
+          details: {
+            "candidates" => owners.map(&:channel),
+            "hint" => "Multiple install channels claim this binary. " \
+                      "Use the channel you installed with: brew upgrade, " \
+                      "yay -Syu hive-bin, or re-run install.sh."
+          },
+          updater: nil,
+          uninstaller: nil
+        )
+      end
+    end
+
+    def receipt_path
+      File.join(Hive::Paths.data_home, RECEIPT_NAME)
+    end
+
+    def write_bash_receipt!(version:, command_name:, prefix:, binary_path:, command_names: nil, bin_dir: Hive::Paths.bin_dir)
+      names = Array(command_names || [ command_name ]).map(&:to_s).uniq
+      paths = names.map { |name| File.join(bin_dir, name) }
+      raise ArgumentError, "bash receipt binary must exist: #{binary_path}" unless File.file?(binary_path)
+
+      data = {
+        "channel" => "bash",
+        "version" => version,
+        "command_name" => command_name,
+        "command_names" => names,
+        "command_paths" => paths,
+        "bin_dir" => File.expand_path(bin_dir),
+        "prefix" => prefix,
+        "binary_path" => File.expand_path(binary_path),
+        "binary_sha256" => Digest::SHA256.file(binary_path).hexdigest,
+        "installed_at" => Time.now.utc.iso8601
+      }
+      Hive::Paths.write_private_file(receipt_path, data.to_yaml)
+      receipt_path
+    end
+
+    def read_bash_receipt
+      return nil unless File.exist?(receipt_path)
+
+      data = YAML.safe_load(File.read(receipt_path)) || {}
+      return nil unless data.is_a?(Hash) && data["channel"] == "bash"
+
+      data
+    end
+
+    def resolve_self_path
+      # $0 may be bin/hive or a symlink in PATH.
+      return File.expand_path($PROGRAM_NAME) if $PROGRAM_NAME && File.exist?($PROGRAM_NAME)
+
+      which = `command -v hive 2>/dev/null`.strip
+      which.empty? ? nil : which
+    end
+    private_class_method :resolve_self_path
+
+    def detect_homebrew(path)
+      brew = `command -v brew 2>/dev/null`.strip
+      return nil if brew.empty?
+
+      return nil unless path_owned_by_brew_formula?(path, HOMEBREW_FORMULA)
+
+      Owner.new(
+        channel: :homebrew,
+        command_path: path,
+        details: { "formula" => HOMEBREW_FORMULA },
+        updater: [ "brew", "upgrade", HOMEBREW_FORMULA ],
+        uninstaller: [ "brew", "uninstall", HOMEBREW_FORMULA ]
+      )
+    end
+    private_class_method :detect_homebrew
+
+    def path_owned_by_brew_formula?(path, formula)
+      out, _err, status = Open3.capture3("brew", "--prefix", formula)
+      return false unless status.success?
+
+      prefix = out.strip
+      return false if prefix.empty?
+
+      resolved_path = File.realpath(path)
+      resolved_prefix = File.realpath(prefix)
+      resolved_path == resolved_prefix || resolved_path.start_with?(resolved_prefix + File::SEPARATOR)
+    rescue StandardError
+      false
+    end
+    private_class_method :path_owned_by_brew_formula?
+
+    def detect_aur(path)
+      # pacman -Qo /usr/bin/hive
+      return nil unless File.exist?("/usr/bin/pacman")
+
+      out, _err, status = Open3.capture3("pacman", "-Qo", path)
+      return nil unless status.success?
+      return nil unless out.match?(/is owned by hive-bin\b/)
+
+      helper = %w[yay paru pamac].map { |h| `command -v #{h} 2>/dev/null`.strip }.find { |h| !h.empty? }
+      updater = if helper
+                  [ File.basename(helper), "-Syu", "hive-bin" ]
+      else
+                  %w[pacman -Syu hive-bin]
+      end
+      Owner.new(
+        channel: :aur,
+        command_path: path,
+        details: { "package" => "hive-bin", "helper" => helper },
+        updater: updater,
+        uninstaller: helper ? [ File.basename(helper), "-R", "hive-bin" ] : %w[pacman -R hive-bin]
+      )
+    rescue StandardError
+      nil
+    end
+    private_class_method :detect_aur
+
+    def detect_bash(path)
+      receipt = read_bash_receipt
+      return nil unless receipt
+      return nil unless path && bash_receipt_owns_path?(receipt, path)
+
+      command_name = receipt["command_name"] || "hive"
+      prefix = receipt.fetch("prefix")
+      updater = [
+        Hive::Assets.resolve("lib", "hive", "scripts", "bash_update.sh"),
+        "--repo", RELEASE_REPOSITORY,
+        "--command-name", command_name,
+        "--prefix", prefix
+      ]
+      Owner.new(
+        channel: :bash,
+        command_path: File.expand_path(path),
+        details: receipt,
+        updater: updater,
+        uninstaller: [ :bash_unlink, *Array(receipt["command_paths"]) ]
+      )
+    end
+    private_class_method :detect_bash
+
+    def bash_receipt_owns_path?(receipt, path)
+      return false unless receipt.is_a?(Hash) && receipt["channel"] == "bash"
+
+      version = receipt["version"].to_s
+      return false unless version.match?(/\A[0-9]+\.[0-9]+\.[0-9]+\z/)
+
+      prefix = receipt["prefix"].to_s
+      binary = receipt["binary_path"].to_s
+      digest = receipt["binary_sha256"].to_s
+      command_paths = Array(receipt["command_paths"]).map { |candidate| File.expand_path(candidate.to_s) }
+      requested = File.expand_path(path)
+      return false unless Hive::Paths.absolute_path?(prefix) && Hive::Paths.absolute_path?(binary)
+      return false unless command_paths.include?(requested)
+      return false unless File.file?(binary) && !File.symlink?(binary)
+      return false unless digest.match?(/\A[a-f0-9]{64}\z/)
+      return false unless Digest::SHA256.file(binary).hexdigest == digest
+
+      versions_root = File.join(File.expand_path(prefix), "versions", version)
+      return false unless binary == versions_root || binary.start_with?(versions_root + File::SEPARATOR)
+      return false unless File.exist?(requested) || File.symlink?(requested)
+
+      File.realpath(requested) == File.realpath(binary)
+    rescue Errno::ENOENT, Errno::EACCES, ArgumentError
+      false
+    end
+  end
+end
diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb
new file mode 100644
index 00000000..f536c95a
--- /dev/null
+++ b/lib/hive/paths.rb
@@ -0,0 +1,170 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "tmpdir"
+
+module Hive
+  # XDG Base Directory resolution for Hive runtime paths (KTD3 / R9).
+  #
+  # Order of preference for each base:
+  #   1. Absolute path in the matching XDG_* environment variable
+  #   2. Documented default under $HOME
+  #
+  # Relative XDG values are rejected: they would write relative to the
+  # current working directory and violate the standards contract.
+  #
+  # HIVE_HOME remains a supported override for the combined state/config
+  # home used by the daemon and global registry, so existing tests and
+  # operators keep working while the XDG layout is adopted.
+  module Paths
+    APP_NAME = "hive"
+    DIR_MODE = 0o700
+    FILE_MODE = 0o600
+
+    module_function
+
+    def config_home
+      ensure_dir(join_base(xdg_or_default("XDG_CONFIG_HOME", File.join(home, ".config")), APP_NAME))
+    end
+
+    def data_home
+      ensure_dir(join_base(xdg_or_default("XDG_DATA_HOME", File.join(home, ".local", "share")), APP_NAME))
+    end
+
+    def state_home
+      ensure_dir(join_base(xdg_or_default("XDG_STATE_HOME", File.join(home, ".local", "state")), APP_NAME))
+    end
+
+    def cache_home
+      ensure_dir(join_base(xdg_or_default("XDG_CACHE_HOME", File.join(home, ".cache")), APP_NAME))
+    end
+
+    def runtime_dir
+      ensure_dir(resolve_runtime_dir)
+    end
+
+    def versions_dir
+      ensure_dir(File.join(data_home, "versions"))
+    end
+
+    def version_dir(version = Hive::VERSION)
+      ensure_dir(File.join(versions_dir, version.to_s))
+    end
+
+    def user_config_path
+      # Keep test/operator HIVE_HOME isolation for user config too so
+      # `ensure_defaults!` during `hive init` never writes into the real
+      # home when a temporary HIVE_HOME is set.
+      if hive_home_override
+        File.join(hive_home_override, "user-config.yml")
+      else
+        File.join(config_home, "config.yml")
+      end
+    end
+
+    def global_registry_path
+      # Prefer HIVE_HOME when set (test/compat override); otherwise XDG config.
+      if hive_home_override
+        File.join(hive_home_override, "config.yml")
+      else
+        File.join(config_home, "config.yml")
+      end
+    end
+
+    # Combined "hive home" used by daemon PID/log paths. Prefer explicit
+    # HIVE_HOME, otherwise use the active XDG state root. Legacy ~/Dev/hive
+    # data is adopted by StateMigration rather than becoming the live path.
+    def hive_home
+      return hive_home_override if hive_home_override
+
+      state_home
+    end
+
+    def legacy_global_home
+      File.expand_path("~/Dev/hive")
+    end
+
+    def legacy_state_home
+      File.expand_path("~/.hive-state")
+    end
+
+    def bin_dir
+      ensure_dir(xdg_or_default("XDG_BIN_HOME", File.join(home, ".local", "bin")))
+    end
+
+    def ensure_dir(path, mode: DIR_MODE)
+      FileUtils.mkdir_p(path)
+      File.chmod(mode, path) if File.directory?(path)
+      path
+    end
+
+    def write_private_file(path, content)
+      dir = File.dirname(path)
+      ensure_dir(dir)
+      tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.tmp")
+      File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, FILE_MODE) do |f|
+        f.write(content)
+        f.fsync
+      end
+      File.rename(tmp, path)
+      File.chmod(FILE_MODE, path)
+      path
+    ensure
+      FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
+    end
+
+    def absolute_env_path(name)
+      raw = ENV[name]
+      return nil if raw.nil? || raw.strip.empty?
+      return File.expand_path(raw) if absolute_path?(raw)
+
+      raise Hive::ConfigError,
+            "#{name}=#{raw.inspect} must be an absolute path " \
+            "(XDG Base Directory Specification); refusing to write relative to the project"
+    end
+
+    def absolute_path?(path)
+      path.start_with?("/") || (path.match?(/\A[A-Za-z]:[\\\/]/) && Gem.win_platform?)
+    end
+
+    def home
+      File.expand_path("~")
+    end
+
+    def hive_home_override
+      raw = ENV["HIVE_HOME"]
+      return nil if raw.nil? || raw.strip.empty?
+
+      File.expand_path(raw)
+    end
+    private_class_method :hive_home_override
+
+    def xdg_or_default(env_name, default)
+      absolute_env_path(env_name) || default
+    end
+    private_class_method :xdg_or_default
+
+    def join_base(base, name)
+      File.join(base, name)
+    end
+    private_class_method :join_base
+
+    def resolve_runtime_dir
+      if (from_env = absolute_env_path("XDG_RUNTIME_DIR"))
+        return File.join(from_env, APP_NAME)
+      end
+
+      # macOS and other hosts without XDG_RUNTIME_DIR: private 0700 dir
+      # under the platform temp root (KTD3).
+      base = File.join(Dir.tmpdir, "hive-runtime-#{Process.uid}")
+      FileUtils.mkdir_p(base)
+      File.chmod(0o700, base)
+      unless File.stat(base).uid == Process.uid
+        warn "hive: runtime directory #{base} is not owned by the current user; " \
+             "sockets/locks may be less isolated than XDG_RUNTIME_DIR"
+      end
+      File.join(base, APP_NAME)
+    end
+    private_class_method :resolve_runtime_dir
+  end
+end
diff --git a/lib/hive/platform.rb b/lib/hive/platform.rb
new file mode 100644
index 00000000..9950cb99
--- /dev/null
+++ b/lib/hive/platform.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+require "rbconfig"
+
+module Hive
+  # Host capability and tier classification (R19).
+  #
+  # Tier-1: macOS 14+ arm64, Ubuntu 22.04+ x86_64, current Arch x86_64
+  # Tier-2: best-effort (other Linux/macOS combinations)
+  # Tier-3: rejected with a clear support message
+  module Platform
+    Tier = Struct.new(:level, :os, :arch, :message, keyword_init: true)
+
+    module_function
+
+    def uname_s
+      `uname -s 2>/dev/null`.strip
+    rescue StandardError
+      RbConfig::CONFIG["host_os"].to_s
+    end
+
+    def uname_m
+      `uname -m 2>/dev/null`.strip
+    rescue StandardError
+      RbConfig::CONFIG["host_cpu"].to_s
+    end
+
+    def os_family
+      s = uname_s.downcase
+      return :macos if s.include?("darwin")
+      return :linux if s.include?("linux")
+      return :windows if s.include?("mingw") || s.include?("msys") || s.include?("cygwin")
+
+      :unknown
+    end
+
+    def arch
+      m = uname_m.downcase
+      case m
+      when "arm64", "aarch64" then :arm64
+      when "x86_64", "amd64" then :x86_64
+      else m.empty? ? :unknown : m.to_sym
+      end
+    end
+
+    def macos?
+      os_family == :macos
+    end
+
+    def linux?
+      os_family == :linux
+    end
+
+    def linux_distro
+      return nil unless linux?
+
+      if File.exist?("/etc/os-release")
+        data = File.read("/etc/os-release")
+        id = data[/^ID=(.+)$/, 1]&.gsub(/["']/, "")
+        return id
+      end
+      nil
+    end
+
+    def linux_version
+      return nil unless linux?
+      return nil unless File.exist?("/etc/os-release")
+
+      data = File.read("/etc/os-release")
+      data[/^VERSION_ID=(.+)$/, 1]&.gsub(/["']/, "")
+    rescue StandardError
+      nil
+    end
+
+    def macos_major
+      return nil unless macos?
+
+      ver = `sw_vers -productVersion 2>/dev/null`.strip
+      ver.split(".").first.to_i
+    rescue StandardError
+      nil
+    end
+
+    def classify
+      case os_family
+      when :macos
+        if arch == :arm64 && macos_major && macos_major >= 14
+          Tier.new(level: 1, os: :macos, arch: :arm64, message: "macOS arm64 (tier-1)")
+        elsif arch == :arm64 || arch == :x86_64
+          Tier.new(level: 2, os: :macos, arch: arch, message: "macOS #{arch} (tier-2 best-effort)")
+        else
+          Tier.new(level: 3, os: :macos, arch: arch, message: "unsupported macOS architecture: #{arch}")
+        end
+      when :linux
+        distro = linux_distro
+        ubuntu_supported = distro == "ubuntu" && version_at_least?(linux_version, "22.04")
+        if arch == :x86_64 && (distro == "arch" || ubuntu_supported)
+          Tier.new(level: 1, os: :linux, arch: :x86_64, message: "Linux #{distro} x86_64 (tier-1)")
+        elsif arch == :x86_64 || arch == :arm64
+          Tier.new(level: 2, os: :linux, arch: arch, message: "Linux #{distro || 'unknown'} #{arch} (tier-2 best-effort)")
+        else
+          Tier.new(level: 3, os: :linux, arch: arch, message: "unsupported Linux architecture: #{arch}")
+        end
+      when :windows
+        Tier.new(level: 3, os: :windows, arch: arch, message: "Windows is not supported in v1; use WSL2 with a tier-1 Linux distro")
+      else
+        Tier.new(level: 3, os: os_family, arch: arch, message: "unsupported operating system: #{uname_s.inspect}")
+      end
+    end
+
+    def tier1?
+      classify.level == 1
+    end
+
+    def supported?
+      classify.level <= 2
+    end
+
+    def reject_if_unsupported!
+      tier = classify
+      return tier if tier.level <= 2
+
+      raise Hive::Error, "hive: #{tier.message}. See docs/install.md for the support matrix."
+    end
+
+    def release_asset_triplet
+      case [ os_family, arch ]
+      when [ :macos, :arm64 ] then "macos-arm64"
+      when [ :linux, :x86_64 ] then "linux-x86_64"
+      else
+        "#{os_family}-#{arch}"
+      end
+    end
+
+    def version_at_least?(actual, minimum)
+      return false if actual.nil? || actual.empty?
+
+      require "rubygems/version"
+      Gem::Version.new(actual) >= Gem::Version.new(minimum)
+    rescue ArgumentError
+      false
+    end
+    private_class_method :version_at_least?
+  end
+end
diff --git a/lib/hive/project/cleanup.rb b/lib/hive/project/cleanup.rb
new file mode 100644
index 00000000..58083e9b
--- /dev/null
+++ b/lib/hive/project/cleanup.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "hive/project/manifest"
+
+module Hive
+  module Project
+    # Hash-verified cleanup of manifest-owned files in the *current*
+    # project only (R15, KTD8). Never walks the filesystem for other
+    # projects; never deletes modified or unknown content.
+    class Cleanup
+      Result = Struct.new(:removed, :preserved, :skipped_unknown, :preview, keyword_init: true)
+
+      def initialize(project_root)
+        @project_root = File.expand_path(project_root)
+        @manifest = Manifest.load(@project_root)
+      end
+
+      def preview
+        return empty_result(preview: true) unless @manifest
+
+        Result.new(
+          removed: @manifest.removable_paths,
+          preserved: @manifest.preserved_paths,
+          skipped_unknown: unknown_under_hive,
+          preview: true
+        )
+      end
+
+      def call!(confirm: false)
+        plan = preview
+        return plan unless confirm
+
+        removed = []
+        plan.removed.each do |rel|
+          abs = File.join(@project_root, rel)
+          next unless safe_path?(abs)
+
+          FileUtils.rm_f(abs)
+          removed << rel
+        end
+
+        # Remove empty .hive/ if only the manifest remains and it was ours.
+        hive_dir = File.join(@project_root, ".hive")
+        if File.directory?(hive_dir)
+          leftovers = Dir.children(hive_dir) - %w[. ..]
+          if leftovers.empty? || leftovers == ["manifest.yml"]
+            FileUtils.rm_rf(hive_dir) if plan.preserved.empty?
+          end
+        end
+
+        Result.new(
+          removed: removed,
+          preserved: plan.preserved,
+          skipped_unknown: plan.skipped_unknown,
+          preview: false
+        )
+      end
+
+      private
+
+      def empty_result(preview:)
+        Result.new(removed: [], preserved: [], skipped_unknown: [], preview: preview)
+      end
+
+      def unknown_under_hive
+        hive = File.join(@project_root, ".hive")
+        return [] unless File.directory?(hive)
+        return [] unless @manifest
+
+        known = @manifest.generated.keys.map { |r| File.join(@project_root, r) }
+        known << @manifest.path
+        found = []
+        Dir.glob(File.join(hive, "**", "*"), File::FNM_DOTMATCH).each do |abs|
+          next if File.directory?(abs)
+          next if known.include?(abs)
+
+          found << abs.delete_prefix(@project_root + "/")
+        end
+        found
+      end
+
+      def safe_path?(abs)
+        return false if File.symlink?(abs)
+
+        real = begin
+          File.realpath(File.dirname(abs))
+        rescue Errno::ENOENT
+          return false
+        end
+        root = File.realpath(@project_root)
+        real == root || real.start_with?(root + File::SEPARATOR)
+      end
+    end
+  end
+end
diff --git a/lib/hive/project/manifest.rb b/lib/hive/project/manifest.rb
new file mode 100644
index 00000000..7eb521be
--- /dev/null
+++ b/lib/hive/project/manifest.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require "digest"
+require "yaml"
+require "fileutils"
+require "hive/paths"
+
+module Hive
+  module Project
+    # Ownership manifest for generated project files (KTD8 / R10 / R15).
+    # Uninstall offers deletion only for unmodified manifest-owned paths.
+    class Manifest
+      FILENAME = "manifest.yml"
+      SCHEMA_VERSION = 1
+      SHIPPED_GENERATED_PATHS = %w[
+        .hive/README.md
+        .hive/config.example.yml
+      ].freeze
+
+      attr_reader :root, :data
+
+      def self.path_for(project_root)
+        File.join(project_root, ".hive", FILENAME)
+      end
+
+      def self.load(project_root)
+        path = path_for(project_root)
+        return nil unless File.exist?(path)
+
+        data = YAML.safe_load(File.read(path)) || {}
+        raise Hive::ConfigError, "manifest at #{path} must be a hash" unless data.is_a?(Hash)
+
+        new(project_root, data)
+      end
+
+      def self.exists?(project_root)
+        File.exist?(path_for(project_root))
+      end
+
+      def initialize(project_root, data = nil)
+        @root = File.expand_path(project_root)
+        @data = data || {
+          "schema_version" => SCHEMA_VERSION,
+          "template_version" => Hive::VERSION,
+          "generated" => {},
+          "created_at" => Time.now.utc.iso8601,
+          "updated_at" => Time.now.utc.iso8601
+        }
+      end
+
+      def path
+        self.class.path_for(@root)
+      end
+
+      def template_version
+        @data["template_version"]
+      end
+
+      def generated
+        @data["generated"] ||= {}
+      end
+
+      def record!(relative_path, content)
+        rel = relative_path.to_s.sub(%r{\A\./}, "")
+        digest = Digest::SHA256.hexdigest(content)
+        generated[rel] = {
+          "sha256" => digest,
+          "bytes" => content.bytesize
+        }
+        @data["updated_at"] = Time.now.utc.iso8601
+        digest
+      end
+
+      def save!
+        Hive::Paths.ensure_dir(File.dirname(path))
+        Hive::Paths.write_private_file(path, @data.to_yaml)
+        path
+      end
+
+      def unmodified?(relative_path)
+        rel = relative_path.to_s.sub(%r{\A\./}, "")
+        entry = generated[rel]
+        return false unless entry
+
+        abs = File.join(@root, rel)
+        return false unless File.file?(abs)
+        return false if File.symlink?(abs)
+
+        Digest::SHA256.file(abs).hexdigest == entry["sha256"]
+      end
+
+      def removable_paths
+        generated.keys.select { |rel| shipped_generated_path?(rel) && unmodified?(rel) }
+      end
+
+      def preserved_paths
+        generated.keys.reject { |rel| shipped_generated_path?(rel) && unmodified?(rel) }
+      end
+
+      def shipped_generated_path?(relative_path)
+        SHIPPED_GENERATED_PATHS.include?(relative_path.to_s.sub(%r{\A\./}, ""))
+      end
+    end
+  end
+end
diff --git a/lib/hive/project/scaffold.rb b/lib/hive/project/scaffold.rb
new file mode 100644
index 00000000..0003ead9
--- /dev/null
+++ b/lib/hive/project/scaffold.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "erb"
+require "hive/project/manifest"
+require "hive/paths"
+require "hive/assets"
+
+module Hive
+  module Project
+    # Writes the versioned `.hive/` scaffold used for ownership tracking and
+    # package-safe cleanup. Does NOT replace `.hive-state/` (the git worktree
+    # state machine) — both coexist: `.hive-state` is runtime state; `.hive`
+    # is the install/uninstall ownership boundary (R10, KTD8).
+    class Scaffold
+      TEMPLATE_FILES = {
+        "README.md" => <<~MD,
+          # Hive project scaffold
+
+          This directory is managed by `hive init`. It records ownership
+          metadata for files Hive generated so `hive uninstall` can remove
+          only unmodified generated content.
+
+          Project pipeline state lives in `.hive-state/` (git worktree).
+          Do not store secrets here.
+        MD
+        "config.example.yml" => <<~YAML
+          # Example project-local overrides. Real credentials must stay in
+          # environment variables — never paste secrets into YAML.
+          #
+          # provider: openai
+          # model: gpt-5
+          # api_key_env: OPENAI_API_KEY
+        YAML
+      }.freeze
+
+      unless TEMPLATE_FILES.keys.map { |rel| File.join(".hive", rel) }.sort == Manifest::SHIPPED_GENERATED_PATHS.sort
+        raise "Hive project scaffold and cleanup allowlist are out of sync"
+      end
+
+      Result = Struct.new(:created, :skipped, :conflicts, :manifest, keyword_init: true)
+
+      def initialize(project_root)
+        @project_root = File.expand_path(project_root)
+        @hive_dir = File.join(@project_root, ".hive")
+      end
+
+      def call(force: false)
+        if File.exist?(@hive_dir) && !Manifest.exists?(@project_root) && !force
+          raise Hive::Error,
+                "hive: #{@hive_dir} exists without a Hive manifest; " \
+                "refusing to claim ownership. Move or remove it, or re-run with --force"
+        end
+
+        manifest = Manifest.load(@project_root) || Manifest.new(@project_root)
+        created = []
+        skipped = []
+        conflicts = []
+
+        TEMPLATE_FILES.each do |rel, content|
+          target_rel = File.join(".hive", rel)
+          abs = File.join(@project_root, target_rel)
+          if File.exist?(abs)
+            if manifest.generated[target_rel] && !manifest.unmodified?(target_rel)
+              conflicts << target_rel
+              skipped << target_rel
+              next
+            end
+            if File.read(abs) == content
+              skipped << target_rel
+              manifest.record!(target_rel, content) unless manifest.generated[target_rel]
+              next
+            end
+            # Existing file differs and is not tracked as ours → preserve.
+            if manifest.generated[target_rel]
+              conflicts << target_rel
+            end
+            skipped << target_rel
+            next
+          end
+
+          FileUtils.mkdir_p(File.dirname(abs))
+          File.write(abs, content)
+          File.chmod(0o644, abs)
+          manifest.record!(target_rel, content)
+          created << target_rel
+        end
+
+        # Always refresh manifest metadata even when no files changed.
+        manifest.data["template_version"] ||= Hive::VERSION
+        manifest.save!
+
+        Result.new(created: created, skipped: skipped, conflicts: conflicts, manifest: manifest)
+      end
+    end
+  end
+end
diff --git a/lib/hive/scripts/bash_update.sh b/lib/hive/scripts/bash_update.sh
new file mode 100755
index 00000000..39b7c3f5
--- /dev/null
+++ b/lib/hive/scripts/bash_update.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# Resolve the latest semver tag, then download, checksum, and execute that
+# immutable release's installer. The mutable /latest endpoint is used only
+# to discover a tag; no bytes from it are executed.
+set -euo pipefail
+
+repo="ivankuznetsov/hive"
+command_name="hive"
+prefix=""
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --repo) repo=${2:?missing repository}; shift 2 ;;
+    --command-name) command_name=${2:?missing command name}; shift 2 ;;
+    --prefix) prefix=${2:?missing prefix}; shift 2 ;;
+    *) echo "error: unknown bash updater option: $1" >&2; exit 64 ;;
+  esac
+done
+
+case "$command_name" in hive|hv) ;; *) echo "error: invalid command name" >&2; exit 64 ;; esac
+
+for tool in curl bash; do
+  command -v "$tool" >/dev/null 2>&1 || { echo "error: missing required tool '$tool'" >&2; exit 1; }
+done
+
+if command -v sha256sum >/dev/null 2>&1; then
+  sha256_file() { sha256sum "$1" | awk '{print $1}'; }
+elif command -v shasum >/dev/null 2>&1; then
+  sha256_file() { shasum -a 256 "$1" | awk '{print $1}'; }
+else
+  echo "error: need sha256sum or shasum for installer verification" >&2
+  exit 1
+fi
+
+if [[ -n "${HIVE_UPDATE_VERSION:-}" ]]; then
+  version=$HIVE_UPDATE_VERSION
+else
+  effective=$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${repo}/releases/latest")
+  tag=${effective##*/tag/}
+  version=${tag#v}
+fi
+if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+  echo "error: latest release did not resolve to a semver tag: ${version}" >&2
+  exit 1
+fi
+
+base="https://github.com/${repo}/releases/download/v${version}"
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+curl -fsSL "${base}/install.sh" -o "${tmp}/install.sh"
+curl -fsSL "${base}/SHA256SUMS" -o "${tmp}/SHA256SUMS"
+expected=$(awk '$2=="install.sh" {print $1}' "${tmp}/SHA256SUMS")
+actual=$(sha256_file "${tmp}/install.sh")
+if [[ -z "$expected" || "$expected" != "$actual" ]]; then
+  echo "error: SHA-256 mismatch for pinned installer v${version}" >&2
+  exit 1
+fi
+
+if command -v gh >/dev/null 2>&1; then
+  gh attestation verify "${tmp}/install.sh" --repo "$repo" >/dev/null
+fi
+
+args=(--version "$version" --command-name "$command_name")
+[[ -n "$prefix" ]] && args+=(--prefix "$prefix")
+exec bash "${tmp}/install.sh" "${args[@]}"
diff --git a/lib/hive/service/adapter.rb b/lib/hive/service/adapter.rb
new file mode 100644
index 00000000..f72aeaa9
--- /dev/null
+++ b/lib/hive/service/adapter.rb
@@ -0,0 +1,165 @@
+# frozen_string_literal: true
+
+require "erb"
+require "fileutils"
+require "open3"
+require "hive/paths"
+require "hive/platform"
+
+module Hive
+  module Service
+    # Normalized per-user service contract (KTD7 / R13-R14).
+    # Platform adapters render and register definitions; enable/start is
+    # never implicit on non-interactive sessions.
+    class Adapter
+      LABEL = "dev.hive.daemon"
+      SYSTEMD_UNIT = "hive.service"
+
+      Contract = Struct.new(
+        :command_path, :state_dir, :runtime_dir, :log_path, :env,
+        keyword_init: true
+      )
+
+      attr_reader :contract
+
+      def self.for_host(contract: nil)
+        c = contract || default_contract
+        case Hive::Platform.os_family
+        when :macos then require "hive/service/launchd"; Launchd.new(c)
+        when :linux then require "hive/service/systemd_user"; SystemdUser.new(c)
+        end
+      end
+
+      def self.default_contract
+        command = resolve_command_path
+        state = Hive::Paths.state_home
+        Contract.new(
+          command_path: command,
+          state_dir: state,
+          runtime_dir: Hive::Paths.runtime_dir,
+          log_path: File.join(state, "daemon.log"),
+          env: build_env
+        )
+      end
+
+      def self.resolve_command_path
+        # Prefer the stable invoked path (symlink), never realpath of a
+        # versioned cellar path (risk: service breaks after upgrade).
+        from_env = ENV["HIVE_COMMAND_PATH"]
+        return File.expand_path(from_env) if from_env && !from_env.empty?
+
+        candidates = [
+          ($PROGRAM_NAME if $PROGRAM_NAME && File.executable?($PROGRAM_NAME)),
+          `command -v hv 2>/dev/null`.strip,
+          `command -v hive 2>/dev/null`.strip,
+          File.expand_path("../../../bin/hive", __dir__)
+        ].compact.uniq
+        candidates.find { |candidate| hive_command?(candidate) } ||
+          raise(Hive::Error, "hive: could not resolve a verified hive/hv command for service registration")
+      end
+
+      def self.hive_command?(candidate)
+        return false if candidate.empty? || !File.executable?(candidate)
+
+        out, _err, status = Open3.capture3(candidate, "--version")
+        status.success? && out.strip == Hive::VERSION
+      rescue StandardError
+        false
+      end
+
+      def self.build_env
+        env = {}
+        env["HIVE_HOME"] = Hive::Paths.hive_home
+        %w[XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME XDG_RUNTIME_DIR].each do |k|
+          env[k] = ENV[k] if ENV[k] && !ENV[k].empty?
+        end
+        env
+      end
+
+      def initialize(contract)
+        @contract = contract
+      end
+
+      def register!
+        raise NotImplementedError
+      end
+
+      def unregister!
+        raise NotImplementedError
+      end
+
+      def start!
+        raise NotImplementedError
+      end
+
+      def stop!
+        raise NotImplementedError
+      end
+
+      def status
+        raise NotImplementedError
+      end
+
+      def enabled?
+        raise NotImplementedError
+      end
+
+      def definition_path
+        raise NotImplementedError
+      end
+
+      def render
+        raise NotImplementedError
+      end
+
+      def prompt_start_if_interactive!(input: $stdin, output: $stderr)
+        if input.respond_to?(:tty?) && input.tty?
+          output.print "hive: start the background daemon now? [y/N] "
+          answer = input.gets.to_s.strip.downcase
+          if %w[y yes].include?(answer)
+            started = start!
+            raise Hive::Error, "hive: service manager did not start the daemon" unless started
+
+            output.puts "hive: daemon started"
+            return :started
+          end
+          output.puts "hive: daemon registered but not started " \
+                      "(run `hive daemon start` when ready)"
+          :declined
+        else
+          output.puts "hive: service registered (not started; non-interactive). " \
+                      "Start later with: hive daemon start"
+          :non_interactive
+        end
+      end
+
+      def refresh!(was_enabled:, was_running:)
+        register!
+        raise Hive::Error, "hive: service manager did not restart the daemon" if was_running && !start!
+        # Preserve enablement separately from running state.
+        if was_enabled && respond_to?(:enable!) && !enable!
+          raise Hive::Error, "hive: service manager did not restore daemon enablement"
+        end
+        true
+      end
+
+      protected
+
+      def render_template(name, binding_obj)
+        path = File.expand_path("../../../templates/service/#{name}", __dir__)
+        ERB.new(File.read(path), trim_mode: "-").result(binding_obj)
+      end
+
+      def write_definition(path, content)
+        FileUtils.mkdir_p(File.dirname(path))
+        tmp = "#{path}.tmp.#{Process.pid}"
+        File.write(tmp, content)
+        File.chmod(0o644, tmp)
+        File.rename(tmp, path)
+        path
+      ensure
+        FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
+      end
+    end
+  end
+end
diff --git a/lib/hive/service/launchd.rb b/lib/hive/service/launchd.rb
new file mode 100644
index 00000000..d09eb741
--- /dev/null
+++ b/lib/hive/service/launchd.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require "open3"
+require "hive/service/adapter"
+
+module Hive
+  module Service
+    class Launchd < Adapter
+      def definition_path
+        File.join(File.expand_path("~/Library/LaunchAgents"), "#{LABEL}.plist")
+      end
+
+      def render
+        label = LABEL
+        command_path = contract.command_path
+        state_dir = contract.state_dir
+        log_path = contract.log_path
+        env = contract.env
+        render_template("dev.hive.daemon.plist.erb", binding)
+      end
+
+      def register!
+        write_definition(definition_path, render)
+        # bootout first so re-register is idempotent; ignore failures.
+        system("launchctl", "bootout", "gui/#{Process.uid}", definition_path, out: File::NULL, err: File::NULL)
+        ok = system("launchctl", "bootstrap", "gui/#{Process.uid}", definition_path, out: File::NULL, err: File::NULL)
+        raise Hive::Error, "hive: launchctl bootstrap failed for #{definition_path}" unless ok || definition_loaded?
+
+        definition_path
+      end
+
+      def unregister!
+        if File.exist?(definition_path) || definition_loaded?
+          ok = system("launchctl", "bootout", "gui/#{Process.uid}", definition_path,
+                      out: File::NULL, err: File::NULL)
+          if !ok && definition_loaded?
+            raise Hive::Error, "hive: launchctl bootout failed for #{definition_path}"
+          end
+        end
+        FileUtils.rm_f(definition_path)
+        true
+      end
+
+      def start!
+        ok = system("launchctl", "kickstart", "-k", "gui/#{Process.uid}/#{LABEL}",
+                    out: File::NULL, err: File::NULL)
+        raise Hive::Error, "hive: launchctl kickstart failed for #{LABEL}" unless ok
+
+        true
+      end
+
+      def stop!
+        return true unless definition_loaded?
+
+        ok = system("launchctl", "kill", "SIGTERM", "gui/#{Process.uid}/#{LABEL}",
+                    out: File::NULL, err: File::NULL)
+        raise Hive::Error, "hive: launchctl stop failed for #{LABEL}" unless ok
+
+        true
+      end
+
+      def enable!
+        # RunAtLoad stays false in the plist; enablement is "registered".
+        true
+      end
+
+      def enabled?
+        File.exist?(definition_path)
+      end
+
+      def status
+        out, _err, status = Open3.capture3("launchctl", "print", "gui/#{Process.uid}/#{LABEL}")
+        running = status.success? && out.match?(/state = running/i)
+        {
+          "manager" => "launchd",
+          "registered" => File.exist?(definition_path),
+          "enabled" => enabled?,
+          "running" => running,
+          "definition" => definition_path,
+          "label" => LABEL
+        }
+      end
+
+      private
+
+      def definition_loaded?
+        _out, _err, status = Open3.capture3("launchctl", "print", "gui/#{Process.uid}/#{LABEL}")
+        status.success?
+      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..4a90f110
--- /dev/null
+++ b/lib/hive/service/systemd_user.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require "open3"
+require "hive/service/adapter"
+
+module Hive
+  module Service
+    class SystemdUser < Adapter
+      def definition_path
+        unit_dir = ENV["XDG_CONFIG_HOME"]
+        unit_dir = if unit_dir && !unit_dir.empty? && unit_dir.start_with?("/")
+                     File.join(unit_dir, "systemd", "user")
+        else
+                     File.expand_path("~/.config/systemd/user")
+        end
+        File.join(unit_dir, SYSTEMD_UNIT)
+      end
+
+      def render
+        command_path = contract.command_path
+        state_dir = contract.state_dir
+        log_path = contract.log_path
+        env = contract.env
+        render_template("hive.service.erb", binding)
+      end
+
+      def systemd_escape(value)
+        value.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub("%", "%%")
+      end
+
+      def register!
+        write_definition(definition_path, render)
+        unless systemd_available?
+          warn "hive: systemctl --user is unavailable; wrote unit to #{definition_path}. " \
+               "Start on demand with: hive daemon start"
+          return definition_path
+        end
+
+        run_systemctl!("daemon-reload")
+        definition_path
+      end
+
+      def unregister!
+        return true unless File.exist?(definition_path)
+
+        if systemd_available?
+          run_systemctl!("disable", "--now", SYSTEMD_UNIT)
+        end
+        FileUtils.rm_f(definition_path)
+        run_systemctl!("daemon-reload") if systemd_available?
+        true
+      end
+
+      def start!
+        unless systemd_available?
+          raise Hive::Error,
+                "hive: systemctl --user is unavailable (no reachable user bus); " \
+                "the unit remains registered at #{definition_path}"
+        end
+        run_systemctl!("enable", SYSTEMD_UNIT)
+        run_systemctl!("start", SYSTEMD_UNIT)
+        true
+      end
+
+      def stop!
+        return true unless File.exist?(definition_path)
+        return true unless systemd_available?
+
+        run_systemctl!("stop", SYSTEMD_UNIT)
+      end
+
+      def enable!
+        return false unless systemd_available?
+
+        run_systemctl!("enable", SYSTEMD_UNIT)
+      end
+
+      def enabled?
+        return File.exist?(definition_path) unless systemd_available?
+
+        out, _err, status = Open3.capture3("systemctl", "--user", "is-enabled", SYSTEMD_UNIT)
+        status.success? && out.strip == "enabled"
+      end
+
+      def status
+        running = false
+        if systemd_available?
+          _out, _err, st = Open3.capture3("systemctl", "--user", "is-active", SYSTEMD_UNIT)
+          running = st.success?
+        end
+        {
+          "manager" => "systemd-user",
+          "registered" => File.exist?(definition_path),
+          "enabled" => enabled?,
+          "running" => running,
+          "definition" => definition_path,
+          "unit" => SYSTEMD_UNIT,
+          "systemd_available" => systemd_available?
+        }
+      end
+
+      private
+
+      def systemd_available?
+        return @systemd_available if defined?(@systemd_available)
+
+        @systemd_available = begin
+          _out, _err, status = Open3.capture3("systemctl", "--user", "show-environment")
+          status.success?
+        rescue StandardError
+          false
+        end
+      end
+
+      def run_systemctl!(*args)
+        ok = system("systemctl", "--user", *args, out: File::NULL, err: File::NULL)
+        raise Hive::Error, "hive: systemctl --user #{args.join(' ')} failed" unless ok
+
+        true
+      end
+    end
+  end
+end
diff --git a/lib/hive/state_migration.rb b/lib/hive/state_migration.rb
new file mode 100644
index 00000000..4e7a149a
--- /dev/null
+++ b/lib/hive/state_migration.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "digest"
+require "yaml"
+require "hive/paths"
+
+module Hive
+  # Non-destructive migration of legacy global state into the XDG layout
+  # (R12, KTD3). Copies and verifies; never deletes the legacy tree.
+  class StateMigration
+    Result = Struct.new(:migrated, :source, :destination, :files, :skipped_reason, keyword_init: true)
+
+    def initialize(
+      legacy_global: Hive::Paths.legacy_global_home,
+      legacy_state: Hive::Paths.legacy_state_home,
+      destination: nil,
+      config_destination: nil
+    )
+      @legacy_global = legacy_global
+      @legacy_state = legacy_state
+      @destination = destination || Hive::Paths.state_home
+      @config_destination = config_destination || Hive::Paths.global_registry_path
+    end
+
+    def legacy_present?
+      File.directory?(@legacy_global) || File.directory?(@legacy_state)
+    end
+
+    def call(copy: true)
+      sources = []
+      sources << @legacy_global if File.directory?(@legacy_global)
+      sources << @legacy_state if File.directory?(@legacy_state)
+
+      if sources.empty?
+        return Result.new(
+          migrated: false,
+          source: nil,
+          destination: @destination,
+          files: [],
+          skipped_reason: "no legacy state found"
+        )
+      end
+
+
+      adopt_legacy_config! if copy
+
+      marker = File.join(@destination, ".migration.yml")
+      if File.exist?(marker)
+        return Result.new(
+          migrated: false,
+          source: sources.first,
+          destination: @destination,
+          files: [],
+          skipped_reason: "migration already recorded at #{marker}"
+        )
+      end
+
+      return dry_result(sources) unless copy
+
+      copied = []
+      sources.each do |src|
+        label = File.basename(src)
+        dest_root = File.join(@destination, "legacy-import", label)
+        FileUtils.mkdir_p(dest_root)
+        Dir.chdir(src) do
+          Dir.glob("**/*", File::FNM_DOTMATCH).each do |rel|
+            next if rel == "." || rel == ".."
+            next if File.directory?(rel)
+
+            target = File.join(dest_root, rel)
+            FileUtils.mkdir_p(File.dirname(target))
+            FileUtils.cp(rel, target)
+            unless File.size(target) == File.size(rel) &&
+                   Digest::SHA256.file(target).hexdigest == Digest::SHA256.file(rel).hexdigest
+              raise Hive::Error, "hive: migration verify failed for #{rel}"
+            end
+            copied << File.join("legacy-import", label, rel)
+          end
+        end
+      end
+
+      record = {
+        "migrated_at" => Time.now.utc.iso8601,
+        "sources" => sources,
+        "files" => copied,
+        "legacy_retained" => true
+      }
+      Hive::Paths.write_private_file(marker, record.to_yaml)
+
+      Result.new(
+        migrated: true,
+        source: sources.join(","),
+        destination: @destination,
+        files: copied,
+        skipped_reason: nil
+      )
+    end
+
+    private
+
+    def adopt_legacy_config!
+      source = File.join(@legacy_global, "config.yml")
+      return unless File.file?(source)
+      return if File.expand_path(source) == File.expand_path(@config_destination)
+
+      legacy = YAML.safe_load(File.read(source)) || {}
+      current = if File.exist?(@config_destination)
+                  YAML.safe_load(File.read(@config_destination)) || {}
+      else
+                  {}
+      end
+      unless legacy.is_a?(Hash) && current.is_a?(Hash)
+        raise Hive::ConfigError, "legacy and active global config must both be hashes"
+      end
+
+      merged = legacy.merge(current)
+      legacy_projects = Array(legacy["registered_projects"])
+      current_projects = Array(current["registered_projects"])
+      merged["registered_projects"] = (current_projects + legacy_projects).uniq do |entry|
+        entry.is_a?(Hash) ? [ entry["name"], entry["path"] ] : entry
+      end
+      Hive::Paths.write_private_file(@config_destination, merged.to_yaml)
+    rescue Psych::Exception => e
+      raise Hive::ConfigError, "legacy config at #{source} is not valid YAML: #{e.message}"
+    end
+
+    def dry_result(sources)
+      Result.new(
+        migrated: false,
+        source: sources.join(","),
+        destination: @destination,
+        files: [],
+        skipped_reason: "copy disabled"
+      )
+    end
+  end
+end
diff --git a/lib/hive/user_config.rb b/lib/hive/user_config.rb
new file mode 100644
index 00000000..45c61af2
--- /dev/null
+++ b/lib/hive/user_config.rb
@@ -0,0 +1,151 @@
+# frozen_string_literal: true
+
+require "yaml"
+require "stringio"
+require "hive/paths"
+
+module Hive
+  # User-scoped provider/model configuration at $XDG_CONFIG_HOME/hive/config.yml
+  # (R10, R11, KTD6). Stores credential *references* (env var names), never
+  # plaintext secrets.
+  module UserConfig
+    SECRET_VALUE_PATTERN = /
+      \b(sk-[A-Za-z0-9_-]{10,}|
+         xox[baprs]-[A-Za-z0-9-]{10,}|
+         gh[pousr]_[A-Za-z0-9]{20,})\b
+    /x
+
+    module_function
+
+    def path
+      Hive::Paths.user_config_path
+    end
+
+    def load
+      return {} unless File.exist?(path)
+
+      data = YAML.safe_load(File.read(path)) || {}
+      raise Hive::ConfigError, "user config at #{path} must be a hash" unless data.is_a?(Hash)
+
+      data
+    rescue Psych::Exception => e
+      raise Hive::ConfigError, "user config at #{path} is not valid YAML: #{e.message}"
+    end
+
+    def write!(data)
+      raise ArgumentError, "user config must be a Hash" unless data.is_a?(Hash)
+
+      # Reject secrets before sanitize/redact so a caller that tries to
+      # persist a raw key fails loudly rather than writing [REDACTED].
+      if SECRET_VALUE_PATTERN.match?(data.to_yaml) || data.keys.map(&:to_s).intersect?(%w[api_key token secret password])
+        raise Hive::ConfigError,
+              "refusing to write user config: payload appears to contain a plaintext secret; " \
+              "store an environment-variable reference (e.g. api_key_env: OPENAI_API_KEY) instead"
+      end
+      sanitized = sanitize(data)
+      payload = sanitized.to_yaml
+      Hive::Paths.write_private_file(path, payload)
+      path
+    end
+
+    ENV_REFERENCE_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
+
+    def ensure_configured!(provider: nil, model: nil, api_key_env: nil, input: $stdin, output: $stderr)
+      data = load
+      return path if %w[provider model api_key_env].all? { |key| present?(data[key]) }
+
+      values = {
+        "provider" => provider || ENV["HIVE_PROVIDER"] || data["provider"],
+        "model" => model || ENV["HIVE_MODEL"] || data["model"],
+        "api_key_env" => api_key_env || ENV["HIVE_API_KEY_ENV"] || data["api_key_env"]
+      }
+
+      if values.values.any? { |value| !present?(value) }
+        if input.respond_to?(:tty?) && input.tty?
+          values = prompt_for_missing(values, input: input, output: output)
+        else
+          raise Hive::ConfigError,
+                "first-run provider configuration is required; pass --provider NAME " \
+                "--model NAME --api-key-env ENV_VAR (or set HIVE_PROVIDER, HIVE_MODEL, " \
+                "and HIVE_API_KEY_ENV), then re-run hive init"
+        end
+      end
+
+      unless ENV_REFERENCE_PATTERN.match?(values.fetch("api_key_env"))
+        raise Hive::ConfigError,
+              "api_key_env must be an environment-variable name such as OPENAI_API_KEY; " \
+              "plaintext credentials are never stored"
+      end
+
+      %w[provider model api_key_env].each do |key|
+        data[key] = values.fetch(key) unless present?(data[key])
+      end
+      data["created_at"] ||= Time.now.utc.iso8601
+      write!(data)
+    end
+
+    # Backward-compatible API for callers that already provide explicit
+    # values. It no longer invents provider/model/credential defaults.
+    def ensure_defaults!(provider: nil, model: nil, api_key_env: nil)
+      ensure_configured!(
+        provider: provider,
+        model: model,
+        api_key_env: api_key_env,
+        input: StringIO.new
+      )
+    end
+
+    def redact(text)
+      text.to_s.gsub(SECRET_VALUE_PATTERN, "[REDACTED]")
+    end
+
+    def sanitize(data)
+      data.each_with_object({}) do |(k, v), out|
+        key = k.to_s
+        if %w[api_key token secret password].include?(key)
+          # Drop raw secret keys entirely — only *_env references are allowed.
+          next
+        end
+
+        out[key] = case v
+        when Hash then sanitize(v)
+        when String then redact(v)
+        else v
+        end
+      end
+    end
+    private_class_method :sanitize
+
+    def present?(value)
+      value.is_a?(String) && !value.strip.empty?
+    end
+    private_class_method :present?
+
+    def prompt_for_missing(values, input:, output:)
+      prompts = {
+        "provider" => "Provider (for example openai or anthropic): ",
+        "model" => "Model (for example gpt-5): ",
+        "api_key_env" => "Credential environment variable (for example OPENAI_API_KEY): "
+      }
+      output.puts "Hive provider setup stores references only; do not paste an API key."
+      prompts.each do |key, label|
+        next if present?(values[key])
+
+        loop do
+          output.print label
+          output.flush
+          answer = input.gets
+          raise Hive::ConfigError, "provider setup ended before #{key} was supplied; no config was written" if answer.nil?
+
+          answer = answer.strip
+          next if answer.empty?
+
+          values[key] = answer
+          break
+        end
+      end
+      values
+    end
+    private_class_method :prompt_for_missing
+  end
+end
diff --git a/packaging/aur/PKGBUILD.erb b/packaging/aur/PKGBUILD.erb
new file mode 100644
index 00000000..d3c72324
--- /dev/null
+++ b/packaging/aur/PKGBUILD.erb
@@ -0,0 +1,24 @@
+# Maintainer: Hive contributors
+pkgname=hive-bin
+pkgver=<%= version %>
+pkgrel=1
+pkgdesc="Multi-agent folder-as-state software pipeline (prebuilt)"
+arch=('x86_64')
+url="https://github.com/ivankuznetsov/hive"
+license=('MIT')
+provides=('hive')
+conflicts=('hive')
+depends=()
+source=("<%= url %>")
+sha256sums=('<%= sha256 %>')
+
+package() {
+  cd "$srcdir/hive"
+  install -Dm755 hive "$pkgdir/usr/bin/hive"
+  ln -s hive "$pkgdir/usr/bin/hv"
+  if [ -d share ]; then
+    mkdir -p "$pkgdir/usr/share/hive"
+    cp -a share/. "$pkgdir/usr/share/hive/"
+  fi
+  install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" 2>/dev/null || true
+}
diff --git a/packaging/aur/hive.install b/packaging/aur/hive.install
new file mode 100644
index 00000000..f963b234
--- /dev/null
+++ b/packaging/aur/hive.install
@@ -0,0 +1,13 @@
+# No per-user state mutations in package hooks (KTD5).
+# Service registration and XDG materialization happen inside `hive init`.
+post_install() {
+  echo "hive-bin installed. Run 'hive init' in a project, then 'hive daemon start' if desired."
+}
+
+post_upgrade() {
+  echo "hive-bin upgraded. Existing daemon enable/start state is preserved; run 'hive daemon status' to inspect."
+}
+
+pre_remove() {
+  echo "hive-bin: package files will be removed. User config, state, and project .hive-state are left intact."
+}
diff --git a/packaging/hive-skills/.agents/plugins/marketplace.json b/packaging/hive-skills/.agents/plugins/marketplace.json
new file mode 100644
index 00000000..1edfc616
--- /dev/null
+++ b/packaging/hive-skills/.agents/plugins/marketplace.json
@@ -0,0 +1,17 @@
+{
+  "name": "hive-skills",
+  "interface": {
+    "displayName": "Hive Skills"
+  },
+  "plugins": [
+    {
+      "name": "hive",
+      "source": {
+        "source": "local",
+        "path": "./plugins/hive"
+      },
+      "description": "Drive Hive from a coding agent",
+      "version": "0.1.0"
+    }
+  ]
+}
diff --git a/packaging/hive-skills/.claude-plugin/marketplace.json b/packaging/hive-skills/.claude-plugin/marketplace.json
new file mode 100644
index 00000000..40534f61
--- /dev/null
+++ b/packaging/hive-skills/.claude-plugin/marketplace.json
@@ -0,0 +1,17 @@
+{
+  "name": "hive-skills",
+  "owner": {
+    "name": "ivankuznetsov"
+  },
+  "metadata": {
+    "description": "Hive multi-agent pipeline skills for Claude Code",
+    "version": "0.1.0"
+  },
+  "plugins": [
+    {
+      "name": "hive",
+      "source": "./plugins/hive",
+      "description": "Drive Hive from a coding agent: init, stage verbs, status, findings"
+    }
+  ]
+}
diff --git a/packaging/hive-skills/.github/workflows/publish.yml b/packaging/hive-skills/.github/workflows/publish.yml
new file mode 100644
index 00000000..701b76c2
--- /dev/null
+++ b/packaging/hive-skills/.github/workflows/publish.yml
@@ -0,0 +1,34 @@
+name: Publish hive-skills
+
+on:
+  push:
+    tags:
+      - "v*.*.*"
+
+permissions:
+  contents: read
+  id-token: write
+
+jobs:
+  validate:
+    uses: ./.github/workflows/validate.yml
+
+  publish-npm:
+    needs: validate
+    runs-on: ubuntu-latest
+    environment: npm
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/setup-node@v4
+        with:
+          node-version: "22"
+          registry-url: https://registry.npmjs.org
+      - name: Verify tag and package version agree
+        working-directory: plugins/hive
+        run: test "v$(node -p 'require("./package.json").version')" = "$GITHUB_REF_NAME"
+      - run: npm pack --dry-run
+        working-directory: plugins/hive
+      - run: npm publish --access public --provenance
+        working-directory: plugins/hive
+        env:
+          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/packaging/hive-skills/.github/workflows/validate.yml b/packaging/hive-skills/.github/workflows/validate.yml
new file mode 100644
index 00000000..98c6c59b
--- /dev/null
+++ b/packaging/hive-skills/.github/workflows/validate.yml
@@ -0,0 +1,24 @@
+name: Validate hive-skills
+
+on:
+  push:
+  pull_request:
+  workflow_call:
+
+jobs:
+  validate:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4"
+      - run: ruby test/validate_manifests.rb
+      - uses: actions/setup-node@v4
+        with:
+          node-version: "22"
+      - run: ruby test/validate_package.rb
+      - name: Install native agent CLIs
+        run: npm install --global @anthropic-ai/claude-code @openai/codex @mariozechner/pi-coding-agent
+      - name: Isolated native install and discovery
+        run: bash test/native_discovery.sh
diff --git a/packaging/hive-skills/README.md b/packaging/hive-skills/README.md
new file mode 100644
index 00000000..a378a38c
--- /dev/null
+++ b/packaging/hive-skills/README.md
@@ -0,0 +1,37 @@
+# hive-skills
+
+Separate multi-agent skills package for Hive. Distributed independently of the core binary (R17).
+
+## Install
+
+**Claude Code**
+
+```bash
+claude plugin marketplace add ivankuznetsov/hive-skills@v0.1.0
+claude plugin install hive@hive-skills
+```
+
+**Codex**
+
+```bash
+codex plugin marketplace add ivankuznetsov/hive-skills@v0.1.0
+codex plugin add hive@hive-skills
+```
+
+**Pi**
+
+```bash
+pi install npm:@ivankuznetsov/hive-skills@0.1.0
+```
+
+## Layout
+
+```
+plugins/hive/
+  skills/hive/SKILL.md
+  .claude-plugin/plugin.json
+  .codex-plugin/plugin.json
+  package.json
+```
+
+The shared skill content is host-neutral; only the manifests differ per agent.
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..2e8a8557
--- /dev/null
+++ b/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json
@@ -0,0 +1,8 @@
+{
+  "name": "hive",
+  "version": "0.1.0",
+  "description": "Hive multi-agent folder-as-state pipeline skill",
+  "author": {
+    "name": "Hive contributors"
+  }
+}
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..4df90f74
--- /dev/null
+++ b/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json
@@ -0,0 +1,6 @@
+{
+  "name": "hive",
+  "version": "0.1.0",
+  "description": "Hive multi-agent folder-as-state pipeline skill",
+  "skills": ["skills/hive"]
+}
diff --git a/packaging/hive-skills/plugins/hive/package.json b/packaging/hive-skills/plugins/hive/package.json
new file mode 100644
index 00000000..4e0c603e
--- /dev/null
+++ b/packaging/hive-skills/plugins/hive/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "@ivankuznetsov/hive-skills",
+  "version": "0.1.0",
+  "description": "Hive shared agent skill for Pi and npm distribution",
+  "license": "MIT",
+  "files": [
+    "skills/**",
+    ".claude-plugin/**",
+    ".codex-plugin/**"
+  ],
+  "pi": {
+    "skills": ["skills/hive"]
+  },
+  "keywords": ["hive", "agent", "skills", "pi"]
+}
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..0bd96374
--- /dev/null
+++ b/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md
@@ -0,0 +1,43 @@
+---
+name: hive
+description: Drive the Hive multi-agent pipeline — install verification, init, stage verbs, status, findings, and daemon lifecycle.
+---
+
+# Hive skill
+
+Use only a command whose version and help output identify this Hive product.
+An unrelated `hive` (notably Apache Hive) must never win over a verified `hv`.
+
+## Discover the command
+
+```bash
+is_hive_pipeline() {
+  command -v "$1" >/dev/null 2>&1 || return 1
+  "$1" --version 2>/dev/null | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$' || return 1
+  "$1" help 2>/dev/null | grep -Eq 'daemon|folder-as-state|Multi-agent' || return 1
+}
+
+if is_hive_pipeline hive; then HIVE=hive
+elif is_hive_pipeline hv; then HIVE=hv
+else echo "Hive is not installed, or only an unrelated hive command was found"; exit 1
+fi
+"$HIVE" --version
+```
+
+## Common operations
+
+| Intent | Command |
+|--------|---------|
+| Attach project | `$HIVE init .` |
+| New idea | `$HIVE new <project> "<text>"` |
+| Dashboard | `$HIVE tui` |
+| Status JSON | `$HIVE status --json` |
+| Advance stage | `$HIVE brainstorm\|plan\|develop\|open-pr\|review\|finalize <slug>` |
+| Findings | `$HIVE findings <slug>` |
+| Daemon | `$HIVE daemon status` / `start` / `stop` |
+| Update | `$HIVE update` |
+| Uninstall | `$HIVE uninstall` |
+
+Never write provider API secrets into YAML. Use environment-variable references only.
+
+See [references/commands.md](references/commands.md) for the full command surface.
diff --git a/packaging/hive-skills/plugins/hive/skills/hive/references/commands.md b/packaging/hive-skills/plugins/hive/skills/hive/references/commands.md
new file mode 100644
index 00000000..458f7b79
--- /dev/null
+++ b/packaging/hive-skills/plugins/hive/skills/hive/references/commands.md
@@ -0,0 +1,33 @@
+# Hive command reference (skill)
+
+Resolve `HIVE` as `hive` or `hv` (whichever is installed).
+
+## Lifecycle
+
+- `$HIVE --version`
+- `$HIVE init [PATH]`
+- `$HIVE update`
+- `$HIVE uninstall [--clean-project] [--yes]`
+- `$HIVE doctor`
+
+## Workflow verbs
+
+- `$HIVE new PROJECT TEXT`
+- `$HIVE brainstorm|plan|develop|open-pr|review|finalize|archive TARGET`
+- `$HIVE run TARGET`
+- `$HIVE status [--json] [--diagnose SLUG]`
+- `$HIVE findings TARGET`
+- `$HIVE accept-finding` / `$HIVE reject-finding`
+
+## Daemon
+
+- `$HIVE daemon start [--detach]`
+- `$HIVE daemon run` (service entry)
+- `$HIVE daemon stop|status|reload|tail`
+- `$HIVE daemon enable|disable PROJECT|--all`
+
+## Safety
+
+- Do not overwrite an unrelated `hive` binary.
+- Do not autostart the daemon non-interactively.
+- Do not delete `$XDG_STATE_HOME/hive` or agent skills during uninstall.
diff --git a/packaging/hive-skills/test/native_discovery.sh b/packaging/hive-skills/test/native_discovery.sh
new file mode 100755
index 00000000..77a4befe
--- /dev/null
+++ b/packaging/hive-skills/test/native_discovery.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Exercise each agent's own package lifecycle in isolated config roots.
+set -euo pipefail
+
+root=$(cd "$(dirname "$0")/.." && pwd)
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+export CLAUDE_CONFIG_DIR="$tmp/claude"
+claude plugin validate "$root"
+claude plugin marketplace add "$root"
+claude plugin install hive@hive-skills
+claude plugin list | grep -q 'hive'
+
+export CODEX_HOME="$tmp/codex"
+codex plugin marketplace add "$root"
+codex plugin add hive@hive-skills
+codex plugin list | grep -q 'hive'
+
+tarball=$(cd "$root/plugins/hive" && npm pack --silent)
+tarball="$root/plugins/hive/$tarball"
+export PI_CODING_AGENT_DIR="$tmp/pi"
+pi install "$tarball"
+pi list | grep -q 'hive-skills'
+
+printf 'native_discovery: claude, codex, and pi discovered hive\n'
diff --git a/packaging/hive-skills/test/validate_manifests.rb b/packaging/hive-skills/test/validate_manifests.rb
new file mode 100755
index 00000000..4010d492
--- /dev/null
+++ b/packaging/hive-skills/test/validate_manifests.rb
@@ -0,0 +1,50 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "json"
+require "pathname"
+
+root = Pathname.new(__dir__).parent
+errors = []
+
+def load_json(path)
+  JSON.parse(File.read(path))
+rescue JSON::ParserError => e
+  raise "invalid JSON #{path}: #{e.message}"
+end
+
+begin
+  claude_mkt = load_json(root.join(".claude-plugin/marketplace.json"))
+  codex_mkt = load_json(root.join(".agents/plugins/marketplace.json"))
+  claude_plug = load_json(root.join("plugins/hive/.claude-plugin/plugin.json"))
+  codex_plug = load_json(root.join("plugins/hive/.codex-plugin/plugin.json"))
+  pkg = load_json(root.join("plugins/hive/package.json"))
+
+  versions = [
+    claude_mkt.dig("metadata", "version"),
+    codex_mkt.dig("plugins", 0, "version"),
+    claude_plug["version"],
+    codex_plug["version"],
+    pkg["version"]
+  ]
+  unless versions.uniq.size == 1
+    errors << "version mismatch across manifests: #{versions.inspect}"
+  end
+
+  skill = root.join("plugins/hive/skills/hive/SKILL.md")
+  errors << "missing shared skill" unless skill.file?
+  body = skill.read
+  errors << "skill must not hardcode only 'hive' without hv fallback" unless body.include?("hv")
+
+  puts "validate_manifests: version=#{versions.first}"
+rescue StandardError => e
+  errors << e.message
+end
+
+if errors.empty?
+  puts "ok"
+  exit 0
+end
+
+warn errors.map { |e| "error: #{e}" }
+exit 1
diff --git a/packaging/hive-skills/test/validate_package.rb b/packaging/hive-skills/test/validate_package.rb
new file mode 100755
index 00000000..b0a4db3b
--- /dev/null
+++ b/packaging/hive-skills/test/validate_package.rb
@@ -0,0 +1,28 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "json"
+require "open3"
+require "pathname"
+
+root = Pathname.new(__dir__).parent
+package = root.join("plugins/hive")
+out, err, status = Open3.capture3("npm", "pack", "--dry-run", "--json", chdir: package.to_s)
+abort "npm pack --dry-run failed: #{err}" unless status.success?
+
+result = JSON.parse(out).fetch(0)
+files = result.fetch("files").map { |entry| entry.fetch("path") }
+required = %w[
+  package.json
+  skills/hive/SKILL.md
+  skills/hive/references/commands.md
+  .claude-plugin/plugin.json
+  .codex-plugin/plugin.json
+]
+missing = required - files
+abort "npm package missing: #{missing.join(', ')}" unless missing.empty?
+
+forbidden = files.grep(%r{(?:^|/)(?:test|\.github|node_modules)(?:/|$)})
+abort "npm package contains repository-only files: #{forbidden.join(', ')}" unless forbidden.empty?
+
+puts "validate_package: #{result.fetch('filename')} contains #{files.size} files"
diff --git a/packaging/homebrew/hive.rb.erb b/packaging/homebrew/hive.rb.erb
new file mode 100644
index 00000000..6a7ff1d0
--- /dev/null
+++ b/packaging/homebrew/hive.rb.erb
@@ -0,0 +1,19 @@
+class Hive < Formula
+  desc "Multi-agent folder-as-state software pipeline"
+  homepage "https://github.com/ivankuznetsov/hive"
+  url "<%= url %>"
+  version "<%= version %>"
+  sha256 "<%= sha256 %>"
+  license "MIT"
+
+  def install
+    bin.install "hive"
+    bin.install_symlink "hive" => "hv"
+    (share/"hive").install Dir["share/*"] if Dir.exist?("share")
+  end
+
+  test do
+    assert_match version.to_s, shell_output("#{bin}/hive --version")
+    assert_match version.to_s, shell_output("#{bin}/hv --version")
+  end
+end
diff --git a/release/manifest.schema.json b/release/manifest.schema.json
new file mode 100644
index 00000000..07f5498f
--- /dev/null
+++ b/release/manifest.schema.json
@@ -0,0 +1,36 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/blob/main/release/manifest.schema.json",
+  "title": "Hive release manifest",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["schema_version", "tag", "version", "commit", "created_at", "assets"],
+  "properties": {
+    "schema_version": { "const": 1 },
+    "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" },
+    "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
+    "commit": { "type": "string", "minLength": 7 },
+    "created_at": { "type": "string" },
+    "ruby_version": { "type": "string" },
+    "assets": {
+      "type": "array",
+      "minItems": 1,
+      "items": {
+        "type": "object",
+        "additionalProperties": false,
+        "required": ["platform", "arch", "filename", "url", "sha256", "size", "ruby_version"],
+        "properties": {
+          "platform": { "type": "string", "enum": ["macos", "linux"] },
+          "arch": { "type": "string", "enum": ["arm64", "x86_64"] },
+          "filename": { "type": "string" },
+          "url": { "type": "string" },
+          "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
+          "size": { "type": "integer", "minimum": 1 },
+          "ruby_version": { "type": "string" },
+          "min_os": { "type": "string" },
+          "min_glibc": { "type": ["string", "null"] }
+        }
+      }
+    }
+  }
+}
diff --git a/schemas/hive-daemon-status.v1.json b/schemas/hive-daemon-status.v1.json
index 424cc2b7..648eaf0b 100644
--- a/schemas/hive-daemon-status.v1.json
+++ b/schemas/hive-daemon-status.v1.json
@@ -18,7 +18,8 @@
         "pid",
         "uptime_sec",
         "pid_file",
-        "log_file"
+        "log_file",
+        "service"
       ],
       "properties": {
         "schema": { "const": "hive-daemon-status" },
@@ -43,6 +44,11 @@
         "log_file": {
           "type": "string",
           "description": "Absolute path of the daemon's JSON-line log file."
+        },
+        "service": {
+          "type": ["object", "null"],
+          "description": "Per-user service-manager status (launchd / systemd --user), or null when no adapter/status is available.",
+          "additionalProperties": true
         }
       }
     }
diff --git a/scripts/build-release b/scripts/build-release
new file mode 100755
index 00000000..8277b386
--- /dev/null
+++ b/scripts/build-release
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+# Build Tebako-packaged Hive artifacts for tier-1 targets.
+# Release builds require Tebako. Tests may opt into a clearly-labelled
+# source bundle with HIVE_ALLOW_SOURCE_BUNDLE=1; that mode is never used by
+# release automation and is not a publishable artifact.
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+DIST=${DIST_DIR:-"${ROOT}/dist"}
+VERSION=$(ruby -I"${ROOT}/lib" -e 'require "hive"; print Hive::VERSION')
+SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git -C "$ROOT" show -s --format=%ct HEAD)}
+if [[ ! "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]]; then
+  echo "error: SOURCE_DATE_EPOCH must be an integer" >&2
+  exit 1
+fi
+mkdir -p "$DIST"
+
+platform=$(uname -s | tr '[:upper:]' '[:lower:]')
+arch=$(uname -m)
+case "$platform" in
+  darwin) platform=macos ;;
+  linux) platform=linux ;;
+esac
+case "$arch" in
+  aarch64) arch=arm64 ;;
+  amd64) arch=x86_64 ;;
+esac
+
+name="hive-${platform}-${arch}"
+stage=$(mktemp -d)
+trap 'rm -rf "$stage"' EXIT
+
+echo "build-release: staging ${name} (version ${VERSION})"
+mkdir -p "${stage}/hive"
+if command -v tebako >/dev/null 2>&1; then
+  if ! tebako press -e "${ROOT}/bin/hive" -o "${stage}/hive/hive" \
+    -r "${ROOT}" -R "$(tr -d '[:space:]' <"${ROOT}/.ruby-version")"; then
+    echo "error: tebako press failed; refusing to create a source fallback release" >&2
+    exit 1
+  fi
+elif [[ "${HIVE_ALLOW_SOURCE_BUNDLE:-0}" == "1" ]]; then
+  echo "build-release: TEST-ONLY source-closure archive (not publishable)"
+  cp -a "${ROOT}/bin" "${ROOT}/lib" "${ROOT}/templates" "${ROOT}/schemas" "${stage}/hive/"
+  cp "${ROOT}/LICENSE" "${stage}/hive/" 2>/dev/null || true
+  cp "${ROOT}/bin/hive" "${stage}/hive/hive"
+  ruby -pi -e 'sub(%q{File.expand_path("../lib", __dir__)}, %q{File.expand_path("lib", __dir__)})' "${stage}/hive/hive"
+  chmod +x "${stage}/hive/hive"
+  # Seed tree for first-run materialization.
+  mkdir -p "${stage}/hive/share"
+  cp -a "${ROOT}/templates" "${ROOT}/schemas" "${stage}/hive/share/" 2>/dev/null || true
+else
+  echo "error: tebako is required for release builds; install the pinned builder or set HIVE_ALLOW_SOURCE_BUNDLE=1 only for local tests" >&2
+  exit 1
+fi
+
+printf '%s\n' "$VERSION" >"${stage}/hive/VERSION"
+BUILT_AT=$(ruby -rtime -e 'print Time.at(Integer(ARGV.fetch(0))).utc.iso8601' "$SOURCE_DATE_EPOCH")
+cat >"${stage}/hive/build-metadata.json" <<EOF
+{
+  "version": "${VERSION}",
+  "platform": "${platform}",
+  "arch": "${arch}",
+  "ruby_version": "$(cat "${ROOT}/.ruby-version")",
+  "built_at": "${BUILT_AT}"
+}
+EOF
+
+if [[ "$("${stage}/hive/hive" --version)" != "$VERSION" ]]; then
+  echo "error: packaged executable does not report ${VERSION}" >&2
+  exit 1
+fi
+
+TAR_BIN=${TAR:-tar}
+tar_version=$("$TAR_BIN" --version 2>/dev/null || true)
+if [[ "${tar_version%%$'\n'*}" != *"GNU tar"* ]]; then
+  echo "error: deterministic release packaging requires GNU tar (set TAR=gtar on macOS)" >&2
+  exit 1
+fi
+"$TAR_BIN" --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 \
+  --numeric-owner --format=ustar -C "$stage" -cf - hive | gzip -n -9 >"${DIST}/${name}.tar.gz"
+echo "build-release: wrote ${DIST}/${name}.tar.gz"
diff --git a/scripts/generate-release-manifest b/scripts/generate-release-manifest
new file mode 100755
index 00000000..1f289075
--- /dev/null
+++ b/scripts/generate-release-manifest
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+# Generate a machine-readable release manifest from built archives.
+# Usage: scripts/generate-release-manifest <dist-dir> <tag> <commit> [base-url]
+set -euo pipefail
+
+DIST_DIR=${1:?dist dir}
+TAG=${2:?tag e.g. v0.1.0}
+COMMIT=${3:?commit sha}
+BASE_URL=${4:-"https://github.com/ivankuznetsov/hive/releases/download/${TAG}"}
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+RUBY_VERSION=$(tr -d '[:space:]' <"${ROOT}/.ruby-version" 2>/dev/null || echo "3.4.7")
+SOURCE_VERSION=$(ruby -I"${ROOT}/lib" -e 'require "hive"; print Hive::VERSION')
+SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git -C "$ROOT" show -s --format=%ct HEAD)}
+OUT="${DIST_DIR}/manifest.json"
+
+if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+  echo "error: tag must be vMAJOR.MINOR.PATCH, got: $TAG" >&2
+  exit 1
+fi
+if [[ "v${SOURCE_VERSION}" != "$TAG" ]]; then
+  echo "error: tag ${TAG} does not match Hive::VERSION ${SOURCE_VERSION}" >&2
+  exit 1
+fi
+for archive in "${DIST_DIR}"/hive-*.tar.gz; do
+  [[ -f "$archive" ]] || continue
+  packaged_version=$(tar -xOzf "$archive" hive/VERSION 2>/dev/null | tr -d '[:space:]')
+  if [[ "$packaged_version" != "$SOURCE_VERSION" ]]; then
+    echo "error: $(basename "$archive") VERSION ${packaged_version:-<missing>} does not match ${SOURCE_VERSION}" >&2
+    exit 1
+  fi
+done
+cp "${ROOT}/install.sh" "${DIST_DIR}/install.sh"
+
+export DIST_DIR TAG COMMIT BASE_URL RUBY_VERSION OUT SOURCE_DATE_EPOCH
+ruby <<'RUBY'
+require "json"
+require "digest"
+require "time"
+
+dist = ENV.fetch("DIST_DIR")
+tag = ENV.fetch("TAG")
+version = tag.delete_prefix("v")
+commit = ENV.fetch("COMMIT")
+base = ENV.fetch("BASE_URL")
+ruby_ver = ENV.fetch("RUBY_VERSION")
+out = ENV.fetch("OUT")
+
+assets = Dir[File.join(dist, "hive-*.tar.gz")].sort.filter_map do |archive|
+  base_name = File.basename(archive)
+  m = base_name.match(/\Ahive-([a-z]+)-([a-z0-9_]+)\.tar\.gz\z/)
+  unless m
+    warn "warn: skip unrecognized archive name: #{base_name}"
+    next
+  end
+  platform, arch = m[1], m[2]
+  digest = Digest::SHA256.file(archive).hexdigest
+  size = File.size(archive)
+  min_os = platform == "macos" ? "14.0" : "ubuntu-22.04"
+  min_glibc = platform == "linux" ? "2.31" : nil
+  {
+    "platform" => platform,
+    "arch" => arch,
+    "filename" => base_name,
+    "url" => "#{base}/#{base_name}",
+    "sha256" => digest,
+    "size" => size,
+    "ruby_version" => ruby_ver,
+    "min_os" => min_os,
+    "min_glibc" => min_glibc
+  }
+end
+
+if assets.empty?
+  warn "error: no assets found for manifest"
+  exit 1
+end
+
+manifest = {
+  "schema_version" => 1,
+  "tag" => tag,
+  "version" => version,
+  "commit" => commit,
+  "created_at" => Time.at(Integer(ENV.fetch("SOURCE_DATE_EPOCH"))).utc.iso8601,
+  "ruby_version" => ruby_ver,
+  "assets" => assets
+}
+
+blob = JSON.pretty_generate(manifest)
+if blob.include?("/latest/")
+  warn "error: manifest must not reference mutable 'latest' assets"
+  exit 1
+end
+
+File.write(out, blob + "\n")
+
+sums_files = assets.map { |asset| asset["filename"] } + ["install.sh"]
+sums = sums_files.sort.map do |filename|
+  "#{Digest::SHA256.file(File.join(dist, filename)).hexdigest}  #{filename}"
+end.join("\n") + "\n"
+File.write(File.join(dist, "SHA256SUMS"), sums)
+puts "wrote #{out} and #{File.join(dist, "SHA256SUMS")}"
+RUBY
diff --git a/scripts/package-release b/scripts/package-release
new file mode 100755
index 00000000..adf26403
--- /dev/null
+++ b/scripts/package-release
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Orchestrate build → manifest → verify for a release tag.
+# Usage: scripts/package-release <tag> [commit]
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+TAG=${1:?tag e.g. v0.1.0}
+COMMIT=${2:-$(git -C "$ROOT" rev-parse HEAD)}
+export DIST_DIR="${DIST_DIR:-${ROOT}/dist}"
+mkdir -p "$DIST_DIR"
+
+"${ROOT}/scripts/build-release"
+"${ROOT}/scripts/generate-release-manifest" "$DIST_DIR" "$TAG" "$COMMIT"
+"${ROOT}/scripts/verify-release" "$DIST_DIR"
+echo "package-release: ready under ${DIST_DIR}"
diff --git a/scripts/render-package-metadata b/scripts/render-package-metadata
new file mode 100755
index 00000000..85409ea8
--- /dev/null
+++ b/scripts/render-package-metadata
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# Render Homebrew formula and AUR PKGBUILD from a release manifest.
+# Usage: scripts/render-package-metadata <manifest.json> <out-dir>
+set -euo pipefail
+
+MANIFEST=${1:?manifest.json}
+OUT=${2:?out dir}
+mkdir -p "${OUT}/homebrew-tap/Formula" "${OUT}/aur/hive-bin"
+
+export MANIFEST OUT
+ruby <<'RUBY'
+require "json"
+require "fileutils"
+
+manifest = JSON.parse(File.read(ENV.fetch("MANIFEST")))
+out = ENV.fetch("OUT")
+version = manifest["version"]
+
+def find_asset(manifest, platform, arch)
+  manifest["assets"].find { |a| a["platform"] == platform && a["arch"] == arch }
+end
+
+mac = find_asset(manifest, "macos", "arm64")
+lin = find_asset(manifest, "linux", "x86_64")
+
+if mac
+  formula = <<~RUBY
+    class Hive < Formula
+      desc "Multi-agent folder-as-state software pipeline"
+      homepage "https://github.com/ivankuznetsov/hive"
+      url "#{mac["url"]}"
+      version "#{version}"
+      sha256 "#{mac["sha256"]}"
+      license "MIT"
+
+      def install
+        bin.install "hive"
+        bin.install_symlink "hive" => "hv"
+        (share/"hive").install Dir["share/*"] if Dir.exist?("share")
+      end
+
+      test do
+        assert_match version.to_s, shell_output("\#{bin}/hive --version")
+        assert_match version.to_s, shell_output("\#{bin}/hv --version")
+      end
+    end
+  RUBY
+  # The heredoc above escapes #{bin} as \#{bin} so Ruby leaves it for Homebrew.
+  formula = formula.gsub('\\#{', '#{')
+  path = File.join(out, "homebrew-tap", "Formula", "hive.rb")
+  File.write(path, formula)
+  puts path
+end
+
+if lin
+  pkgbuild = <<~PKG
+    # Maintainer: Hive contributors
+    pkgname=hive-bin
+    pkgver=#{version}
+    pkgrel=1
+    pkgdesc="Multi-agent folder-as-state software pipeline (prebuilt)"
+    arch=('x86_64')
+    url="https://github.com/ivankuznetsov/hive"
+    license=('MIT')
+    provides=('hive')
+    conflicts=('hive')
+    depends=()
+    source=("#{lin["url"]}")
+    sha256sums=('#{lin["sha256"]}')
+
+    package() {
+      cd "$srcdir/hive"
+      install -Dm755 hive "$pkgdir/usr/bin/hive"
+      ln -s hive "$pkgdir/usr/bin/hv"
+      if [ -d share ]; then
+        mkdir -p "$pkgdir/usr/share/hive"
+        cp -a share/. "$pkgdir/usr/share/hive/"
+      fi
+      install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" 2>/dev/null || true
+    }
+  PKG
+  path = File.join(out, "aur", "hive-bin", "PKGBUILD")
+  File.write(path, pkgbuild)
+  puts path
+
+  srcinfo = <<~SRCINFO
+    pkgbase = hive-bin
+    \tpkgdesc = Multi-agent folder-as-state software pipeline (prebuilt)
+    \tpkgver = #{version}
+    \tpkgrel = 1
+    \turl = https://github.com/ivankuznetsov/hive
+    \tarch = x86_64
+    \tlicense = MIT
+    \tprovides = hive
+    \tconflicts = hive
+    \tsource = #{lin["url"]}
+    \tsha256sums = #{lin["sha256"]}
+
+    pkgname = hive-bin
+  SRCINFO
+  srcinfo_path = File.join(out, "aur", "hive-bin", ".SRCINFO")
+  File.write(srcinfo_path, srcinfo)
+  puts srcinfo_path
+end
+
+puts "render-package-metadata: done"
+RUBY
diff --git a/scripts/test-installer-fixtures b/scripts/test-installer-fixtures
new file mode 100755
index 00000000..69e3d4f3
--- /dev/null
+++ b/scripts/test-installer-fixtures
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Fixture-based checks for install channel selection and hostile inputs.
+set -euo pipefail
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+FIX="${ROOT}/test/fixtures/install_hosts"
+fail=0
+
+check() {
+  local name=$1
+  shift
+  if "$@"; then
+    echo "ok  - $name"
+  else
+    echo "FAIL- $name"
+    fail=1
+  fi
+}
+
+# install.sh is shellcheck-friendly enough to parse.
+check "install.sh is bash" bash -n "${ROOT}/install.sh"
+
+# Dry-run on this host should not mutate.
+check "install.sh dry-run" bash "${ROOT}/install.sh" --dry-run --version 0.1.0
+
+# Fixture JSON files describe expected channel choices.
+if [[ -d "$FIX" ]]; then
+  for f in "$FIX"/*.json; do
+    [[ -f "$f" ]] || continue
+    ruby -e '
+      require "json"
+      doc = JSON.parse(File.read(ARGV[0]))
+      abort "bad channel" unless %w[homebrew aur bash bash_hv].include?(doc["expected_channel"])
+      abort "missing os/arch" unless doc["os"] && doc["arch"]
+      puts "fixture ok #{ARGV[0]}"
+    ' "$f"
+    check "fixture $(basename "$f")" true
+  done
+fi
+
+# Hostility: generate-release-manifest refuses empty dist.
+tmpdir=$(mktemp -d)
+trap 'rm -rf "$tmpdir"' EXIT
+if "${ROOT}/scripts/generate-release-manifest" "$tmpdir" v0.1.0 deadbeef 2>/dev/null; then
+  echo "FAIL- generate-release-manifest should fail on empty dist"
+  fail=1
+else
+  echo "ok  - empty dist rejected"
+fi
+
+# One-byte mutation fails verify.
+mkdir -p "$tmpdir/dist"
+mkdir -p "$tmpdir/archive/hive"
+printf '0.1.0\n' >"$tmpdir/archive/hive/VERSION"
+printf '#!/bin/sh\necho 0.1.0\n' >"$tmpdir/archive/hive/hive"
+chmod +x "$tmpdir/archive/hive/hive"
+tar -C "$tmpdir/archive" -czf "$tmpdir/dist/hive-linux-x86_64.tar.gz" hive
+"${ROOT}/scripts/generate-release-manifest" "$tmpdir/dist" v0.1.0 abcdef1
+printf x >>"$tmpdir/dist/hive-linux-x86_64.tar.gz"
+if "${ROOT}/scripts/verify-release" "$tmpdir/dist" 2>/dev/null; then
+  echo "FAIL- verify should fail after mutation"
+  fail=1
+else
+  echo "ok  - mutation fails verify"
+fi
+
+exit "$fail"
diff --git a/scripts/verify-release b/scripts/verify-release
new file mode 100755
index 00000000..15802e80
--- /dev/null
+++ b/scripts/verify-release
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Verify release archives against manifest + SHA256SUMS.
+# Usage: scripts/verify-release <dist-dir>
+set -euo pipefail
+
+DIST_DIR=${1:?dist dir}
+MANIFEST="${DIST_DIR}/manifest.json"
+SUMS="${DIST_DIR}/SHA256SUMS"
+
+if [[ ! -f "$MANIFEST" ]]; then
+  echo "error: missing $MANIFEST" >&2
+  exit 1
+fi
+if [[ ! -f "$SUMS" ]]; then
+  echo "error: missing $SUMS" >&2
+  exit 1
+fi
+
+export DIST_DIR MANIFEST SUMS
+ruby <<'RUBY'
+require "json"
+require "digest"
+
+dist = ENV.fetch("DIST_DIR")
+manifest = JSON.parse(File.read(ENV.fetch("MANIFEST")))
+sums_path = ENV.fetch("SUMS")
+
+abort "error: schema_version must be 1" unless manifest["schema_version"] == 1
+abort "error: tag must start with v" unless manifest["tag"].to_s.start_with?("v")
+abort "error: tag/version mismatch" unless manifest["tag"] == "v#{manifest["version"]}"
+
+# Verify SHA256SUMS lines
+sums = {}
+File.readlines(sums_path).each do |line|
+  line = line.strip
+  next if line.empty?
+  expected, file = line.split(/\s+/, 2)
+  file = file.sub(%r{\A\./}, "")
+  path = File.join(dist, file)
+  abort "error: missing #{path}" unless File.file?(path)
+  actual = Digest::SHA256.file(path).hexdigest
+  abort "error: checksum mismatch for #{file}" unless actual == expected
+  sums[file] = expected
+end
+
+required_sums = manifest.fetch("assets").map { |asset| asset.fetch("filename") } + ["install.sh"]
+missing_sums = required_sums - sums.keys
+abort "error: SHA256SUMS missing #{missing_sums.join(', ')}" unless missing_sums.empty?
+
+manifest["assets"].each do |a|
+  abort "error: mutable latest URL in manifest: #{a["url"]}" if a["url"].include?("/latest/")
+  path = File.join(dist, a["filename"])
+  abort "error: missing asset #{path}" unless File.file?(path)
+  digest = Digest::SHA256.file(path).hexdigest
+  abort "error: manifest sha256 mismatch for #{a["filename"]}" unless digest == a["sha256"]
+  abort "error: size mismatch for #{a["filename"]}" unless File.size(path) == a["size"]
+  packaged_version = IO.popen(["tar", "-xOzf", path, "hive/VERSION"], &:read).to_s.strip
+  abort "error: packaged VERSION mismatch for #{a["filename"]}" unless packaged_version == manifest["version"]
+end
+
+puts "verify-release: ok"
+RUBY
diff --git a/templates/project/config.yml b/templates/project/config.yml
new file mode 100644
index 00000000..019e51c7
--- /dev/null
+++ b/templates/project/config.yml
@@ -0,0 +1,5 @@
+# User-scoped example — real file is $XDG_CONFIG_HOME/hive/config.yml
+# Credential values must never appear here; only environment references.
+provider: env
+model: default
+api_key_env: HIVE_API_KEY
diff --git a/templates/project/manifest.yml b/templates/project/manifest.yml
new file mode 100644
index 00000000..892ef139
--- /dev/null
+++ b/templates/project/manifest.yml
@@ -0,0 +1,7 @@
+# Hive project ownership manifest scaffold (schema v1).
+# Written by `hive init` via Hive::Project::Manifest.
+schema_version: 1
+template_version: "0.1.0"
+generated: {}
+created_at: null
+updated_at: null
diff --git a/templates/service/dev.hive.daemon.plist.erb b/templates/service/dev.hive.daemon.plist.erb
new file mode 100644
index 00000000..68d4e26b
--- /dev/null
+++ b/templates/service/dev.hive.daemon.plist.erb
@@ -0,0 +1,33 @@
+<?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><%= command_path %></string>
+    <string>daemon</string>
+    <string>run</string>
+  </array>
+  <key>WorkingDirectory</key>
+  <string><%= state_dir %></string>
+  <key>EnvironmentVariables</key>
+  <dict>
+<% env.each do |k, v| -%>
+    <key><%= k %></key>
+    <string><%= v %></string>
+<% end -%>
+  </dict>
+  <key>RunAtLoad</key>
+  <false/>
+  <key>KeepAlive</key>
+  <false/>
+  <key>StandardOutPath</key>
+  <string><%= log_path %></string>
+  <key>StandardErrorPath</key>
+  <string><%= log_path %></string>
+  <key>ProcessType</key>
+  <string>Background</string>
+</dict>
+</plist>
diff --git a/templates/service/hive.service.erb b/templates/service/hive.service.erb
new file mode 100644
index 00000000..7a35aa6f
--- /dev/null
+++ b/templates/service/hive.service.erb
@@ -0,0 +1,19 @@
+[Unit]
+Description=Hive daemon (per-user)
+Documentation=https://github.com/ivankuznetsov/hive
+After=default.target
+
+[Service]
+Type=simple
+ExecStart=<%= command_path %> daemon run
+WorkingDirectory=<%= state_dir %>
+Restart=on-failure
+RestartSec=5
+<% env.each do |key, value| -%>
+Environment="<%= systemd_escape("#{key}=#{value}") %>"
+<% end -%>
+StandardOutput=append:<%= log_path %>
+StandardError=append:<%= log_path %>
+
+[Install]
+WantedBy=default.target
diff --git a/test/acceptance/install_lifecycle_test.rb b/test/acceptance/install_lifecycle_test.rb
new file mode 100644
index 00000000..39fd42c6
--- /dev/null
+++ b/test/acceptance/install_lifecycle_test.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "json"
+require "open3"
+require "hive/project/scaffold"
+require "hive/install_provenance"
+require "hive/commands/uninstall"
+require "hive/paths"
+require "hive/platform"
+
+# Acceptance-style checks runnable on any host (AE1–AE5 contracted
+# behaviors that do not require a clean macOS/AUR VM).
+class InstallLifecycleAcceptanceTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_version_and_hv_alias
+    out_h = `#{RbConfig.ruby} -Ilib bin/hive --version`.strip
+    out_v = `#{RbConfig.ruby} -Ilib bin/hv --version`.strip
+    assert_equal out_h, out_v
+    assert_equal Hive::VERSION, out_h
+  end
+
+  def test_platform_classification_present
+    tier = Hive::Platform.classify
+    assert_includes [ 1, 2, 3 ], tier.level
+  end
+
+  def test_seeded_uninstall_preserves_work
+    with_tmp_dir do |dir|
+      old = %w[HOME HIVE_HOME XDG_STATE_HOME XDG_DATA_HOME XDG_CONFIG_HOME XDG_BIN_HOME].to_h do |key|
+        [ key, ENV[key] ]
+      end
+      ENV["HOME"] = dir
+      ENV["HIVE_HOME"] = File.join(dir, "hh")
+      ENV["XDG_STATE_HOME"] = File.join(dir, "st")
+      ENV["XDG_DATA_HOME"] = File.join(dir, "da")
+      ENV["XDG_CONFIG_HOME"] = File.join(dir, "cf")
+      ENV["XDG_BIN_HOME"] = File.join(dir, "bin")
+      FileUtils.mkdir_p(ENV["HIVE_HOME"])
+
+      state = File.join(Hive::Paths.state_home, "done.txt")
+      FileUtils.mkdir_p(File.dirname(state))
+      File.write(state, "keep\n")
+
+      proj = File.join(dir, "p")
+      FileUtils.mkdir_p(proj)
+      Hive::Project::Scaffold.new(proj).call
+      File.write(File.join(proj, "app.rb"), "1\n")
+
+      command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ])
+
+      Hive::Commands::Uninstall.new(
+        project_path: proj, yes: true, clean_project: false,
+        command_path: command, service_adapter: nil,
+        output: StringIO.new, input: StringIO.new
+      ).call
+
+      assert_equal "keep\n", File.read(state)
+      assert_equal "1\n", File.read(File.join(proj, "app.rb"))
+    ensure
+      old&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value }
+    end
+  end
+
+  def test_prompt_and_docs_exist
+    root = File.expand_path("../..", __dir__)
+    %w[
+      INSTALL_PROMPT.md
+      install.sh
+      docs/install.md
+      docs/configuration.md
+      docs/daemon.md
+      docs/update-uninstall.md
+      docs/migration.md
+      docs/troubleshooting.md
+    ].each do |rel|
+      assert File.exist?(File.join(root, rel)), "missing #{rel}"
+    end
+  end
+
+  def test_skills_package_validates
+    root = File.expand_path("../../packaging/hive-skills", __dir__)
+    _o, err, st = Open3.capture3("ruby", File.join(root, "test/validate_manifests.rb"))
+    assert st.success?, err
+  end
+end
diff --git a/test/fixtures/install_hosts/arch_yay.json b/test/fixtures/install_hosts/arch_yay.json
new file mode 100644
index 00000000..b056f338
--- /dev/null
+++ b/test/fixtures/install_hosts/arch_yay.json
@@ -0,0 +1,9 @@
+{
+  "os": "linux",
+  "distro": "arch",
+  "arch": "x86_64",
+  "homebrew": false,
+  "aur_helper": "yay",
+  "unrelated_hive": false,
+  "expected_channel": "aur"
+}
diff --git a/test/fixtures/install_hosts/collision_hv.json b/test/fixtures/install_hosts/collision_hv.json
new file mode 100644
index 00000000..cf48932a
--- /dev/null
+++ b/test/fixtures/install_hosts/collision_hv.json
@@ -0,0 +1,11 @@
+{
+  "os": "linux",
+  "distro": "ubuntu",
+  "arch": "x86_64",
+  "homebrew": false,
+  "aur_helper": null,
+  "unrelated_hive": true,
+  "unrelated_hive_identity": "Apache Hive",
+  "expected_channel": "bash_hv",
+  "command_name": "hv"
+}
diff --git a/test/fixtures/install_hosts/macos_arm64_brew.json b/test/fixtures/install_hosts/macos_arm64_brew.json
new file mode 100644
index 00000000..0d15f49a
--- /dev/null
+++ b/test/fixtures/install_hosts/macos_arm64_brew.json
@@ -0,0 +1,8 @@
+{
+  "os": "macos",
+  "arch": "arm64",
+  "homebrew": true,
+  "aur_helper": null,
+  "unrelated_hive": false,
+  "expected_channel": "homebrew"
+}
diff --git a/test/fixtures/install_hosts/ubuntu_bash.json b/test/fixtures/install_hosts/ubuntu_bash.json
new file mode 100644
index 00000000..e745678b
--- /dev/null
+++ b/test/fixtures/install_hosts/ubuntu_bash.json
@@ -0,0 +1,9 @@
+{
+  "os": "linux",
+  "distro": "ubuntu",
+  "arch": "x86_64",
+  "homebrew": false,
+  "aur_helper": null,
+  "unrelated_hive": false,
+  "expected_channel": "bash"
+}
diff --git a/test/integration/daemon_command_test.rb b/test/integration/daemon_command_test.rb
index ea385c4d..b136aa57 100644
--- a/test/integration/daemon_command_test.rb
+++ b/test/integration/daemon_command_test.rb
@@ -14,7 +14,14 @@ 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)
+      # Isolate both process state and service-definition discovery. Without
+      # XDG_CONFIG_HOME, a developer's real registered user unit makes these
+      # direct-PID compatibility fixtures route through the platform adapter.
+      env = ENV.to_h.merge(
+        "HOME" => home,
+        "HIVE_HOME" => home,
+        "XDG_CONFIG_HOME" => File.join(home, "config")
+      )
       block.call(home, env)
     end
   end
@@ -38,6 +45,7 @@ class HiveDaemonCommandTest < Minitest::Test
       assert_equal "hive-daemon-status", doc["schema"]
       assert_equal false, doc["running"]
       assert_equal File.join(home, ".daemon.pid"), doc["pid_file"]
+      assert doc.key?("service")
       assert_equal 1, status.exitstatus
     end
   end
@@ -1164,7 +1172,7 @@ class HiveDaemonCommandTest < Minitest::Test
       assert_equal Hive::ExitCodes::USAGE, status.exitstatus,
                    "bare `hive daemon` must exit 64 (USAGE) with the same shape as `hive daemon frobnicate`"
       assert_match(/missing SUBCOMMAND/, err)
-      assert_match(/start, stop, status, reload, tail, enable, disable/, err)
+      assert_match(/start, run, stop, status, reload, tail, enable, disable/, err)
     end
   end
 
diff --git a/test/integration/daemon_lifecycle_test.rb b/test/integration/daemon_lifecycle_test.rb
new file mode 100644
index 00000000..d8c2b7d8
--- /dev/null
+++ b/test/integration/daemon_lifecycle_test.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/service/adapter"
+require "hive/service/systemd_user"
+
+class DaemonLifecycleTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_default_contract_uses_stable_command_path
+    with_tmp_dir do |dir|
+      old = ENV["HIVE_COMMAND_PATH"]
+      ENV["HIVE_COMMAND_PATH"] = File.join(dir, "hive")
+      begin
+        c = Hive::Service::Adapter.default_contract
+        assert_equal File.join(dir, "hive"), c.command_path
+        refute_empty c.state_dir
+        refute_empty c.log_path
+      ensure
+        old.nil? ? ENV.delete("HIVE_COMMAND_PATH") : ENV["HIVE_COMMAND_PATH"] = old
+      end
+    end
+  end
+
+  def test_refresh_preserves_running_intent
+    with_tmp_dir do |dir|
+      contract = Hive::Service::Adapter::Contract.new(
+        command_path: File.join(dir, "hive"),
+        state_dir: dir, runtime_dir: dir,
+        log_path: File.join(dir, "d.log"), env: {}
+      )
+      adapter = Hive::Service::SystemdUser.new(contract)
+      started = false
+      enabled = false
+      adapter.define_singleton_method(:register!) { true }
+      adapter.define_singleton_method(:start!) { started = true }
+      adapter.define_singleton_method(:enable!) { enabled = true }
+
+      adapter.refresh!(was_enabled: true, was_running: false)
+      refute started
+      assert enabled
+
+      started = false
+      enabled = false
+      adapter.refresh!(was_enabled: false, was_running: true)
+      assert started
+      refute enabled
+    end
+  end
+
+
+  def test_command_resolution_rejects_unrelated_hive_and_uses_verified_hv
+    with_tmp_dir do |dir|
+      hive = File.join(dir, "hive")
+      hv = File.join(dir, "hv")
+      File.write(hive, "#!/bin/sh\necho 'Apache Hive 4.0'\n")
+      File.write(hv, "#!/bin/sh\necho '#{Hive::VERSION}'\n")
+      FileUtils.chmod(0o755, [ hive, hv ])
+      old_path = ENV["PATH"]
+      old_command = ENV.delete("HIVE_COMMAND_PATH")
+      ENV["PATH"] = "#{dir}:#{old_path}"
+      begin
+        assert_equal hv, Hive::Service::Adapter.resolve_command_path
+      ensure
+        ENV["PATH"] = old_path
+        old_command.nil? ? ENV.delete("HIVE_COMMAND_PATH") : ENV["HIVE_COMMAND_PATH"] = old_command
+      end
+    end
+  end
+end
diff --git a/test/integration/fixtures/tui-fake-child b/test/integration/fixtures/tui-fake-child
index 3a9fdbd6..2836a069 100755
--- a/test/integration/fixtures/tui-fake-child
+++ b/test/integration/fixtures/tui-fake-child
@@ -7,6 +7,7 @@
 #   HIVE_TUI_FAKE_STDERR=text    print to stderr
 #   HIVE_TUI_FAKE_TRAP_INT=1     trap SIGINT, print marker, exit 130
 #   HIVE_TUI_FAKE_BLOCK=1        sleep until killed (used for SIGINT/SIGTERM tests)
+#   HIVE_TUI_FAKE_BLOCK_SECONDS  when blocking, exit after this many seconds
 
 if [[ "$HIVE_TUI_FAKE_TRAP_INT" == "1" ]]; then
   trap 'echo "fake-child:caught-SIGINT"; exit 130' INT
@@ -18,6 +19,10 @@ if [[ -n "$HIVE_TUI_FAKE_STDERR" ]]; then
   printf '%s' "$HIVE_TUI_FAKE_STDERR" 1>&2
 fi
 if [[ "$HIVE_TUI_FAKE_BLOCK" == "1" ]]; then
+  if [[ -n "$HIVE_TUI_FAKE_BLOCK_SECONDS" ]]; then
+    sleep "$HIVE_TUI_FAKE_BLOCK_SECONDS"
+    exit "${HIVE_TUI_FAKE_EXIT:-0}"
+  fi
   while true; do sleep 0.1; done
 fi
 exit "${HIVE_TUI_FAKE_EXIT:-0}"
diff --git a/test/integration/init_test.rb b/test/integration/init_test.rb
index 0b423861..f10a543c 100644
--- a/test/integration/init_test.rb
+++ b/test/integration/init_test.rb
@@ -113,14 +113,16 @@ class InitTest < Minitest::Test
     end
   end
 
-  def test_double_init_raises_already_initialized_with_exit_2
+  def test_double_init_is_idempotent_and_repairs_missing_scaffold
     with_tmp_global_config do
       with_tmp_git_repo do |dir|
         capture_io { Hive::Commands::Init.new(dir).call }
-        _, err, status = with_captured_exit { Hive::Commands::Init.new(dir).call }
-        assert_equal Hive::ExitCodes::ALREADY_INITIALIZED, status,
-                     "second init must raise Hive::AlreadyInitialized (exit 2), not bare exit"
-        assert_includes err, "already initialized"
+        FileUtils.rm_f(File.join(dir, ".hive", "config.example.yml"))
+        out, err, status = with_captured_exit { Hive::Commands::Init.new(dir).call }
+        assert_equal 0, status
+        assert_match(/service registered|doctor pre-flight/, err)
+        assert_includes out, "hive: initialized"
+        assert File.exist?(File.join(dir, ".hive", "config.example.yml"))
       end
     end
   end
@@ -287,9 +289,9 @@ 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
+  def test_init_already_initialized_repairs_without_reprompting
+    # On a re-run of `hive init` an existing project config means the
+    # project prompt is not consumed. We feed an
     # input stream that would crash the prompt validator if consumed
     # ('crash'), then assert it's still pristine after the second init.
     with_tmp_global_config do
@@ -304,8 +306,8 @@ class InitTest < Minitest::Test
         _, 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 0, status
+        assert_match(/service registered|doctor pre-flight/, err)
         assert_equal "crash-on-this-input", input.gets&.chomp,
                      "no input should have been consumed by the second init"
       end
diff --git a/test/integration/lifecycle_safety_test.rb b/test/integration/lifecycle_safety_test.rb
new file mode 100644
index 00000000..d1206b44
--- /dev/null
+++ b/test/integration/lifecycle_safety_test.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/project/scaffold"
+require "hive/commands/uninstall"
+require "hive/install_provenance"
+require "hive/paths"
+
+class LifecycleSafetyTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_uninstall_preserves_seeded_work
+    with_tmp_dir do |dir|
+      old = {
+        "HOME" => ENV["HOME"],
+        "HIVE_HOME" => ENV["HIVE_HOME"],
+        "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"],
+        "XDG_STATE_HOME" => ENV["XDG_STATE_HOME"],
+        "XDG_CONFIG_HOME" => ENV["XDG_CONFIG_HOME"]
+      }
+      ENV["HOME"] = dir
+      ENV["HIVE_HOME"] = File.join(dir, "hive-home")
+      ENV["XDG_DATA_HOME"] = File.join(dir, "share")
+      ENV["XDG_STATE_HOME"] = File.join(dir, "state")
+      ENV["XDG_CONFIG_HOME"] = File.join(dir, "config")
+      FileUtils.mkdir_p(ENV["HIVE_HOME"])
+
+      # Seed user work that must survive.
+      state_file = File.join(Hive::Paths.state_home, "completed-output.md")
+      FileUtils.mkdir_p(File.dirname(state_file))
+      File.write(state_file, "done work\n")
+      cfg = File.join(Hive::Paths.config_home, "config.yml")
+      FileUtils.mkdir_p(File.dirname(cfg))
+      File.write(cfg, "provider: env\n")
+
+      project = File.join(dir, "proj")
+      FileUtils.mkdir_p(project)
+      Hive::Project::Scaffold.new(project).call
+      File.write(File.join(project, "user-code.rb"), "puts 1\n")
+
+      command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ])
+
+      out = StringIO.new
+      Hive::Commands::Uninstall.new(
+        project_path: project,
+        yes: true,
+        clean_project: false,
+        command_path: command,
+        service_adapter: nil,
+        output: out,
+        input: StringIO.new
+      ).call
+
+      assert_equal "done work\n", File.read(state_file)
+      assert_equal "provider: env\n", File.read(cfg)
+      assert_equal "puts 1\n", File.read(File.join(project, "user-code.rb"))
+      assert File.exist?(File.join(project, ".hive", "README.md"))
+      refute File.exist?(File.join(Hive::Paths.bin_dir, "hv"))
+    ensure
+      old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
+    end
+  end
+end
diff --git a/test/integration/runtime_closure_test.rb b/test/integration/runtime_closure_test.rb
new file mode 100644
index 00000000..582bb893
--- /dev/null
+++ b/test/integration/runtime_closure_test.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "rbconfig"
+require "hive/assets"
+require "hive/paths"
+
+class RuntimeClosureTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_version_identical_as_hive_and_hv
+    hive_out = run!(RbConfig.ruby, "-Ilib", "bin/hive", "--version")
+    hv_out = run!(RbConfig.ruby, "-Ilib", "bin/hv", "--version")
+    assert_equal hive_out, hv_out
+    assert_equal "#{Hive::VERSION}\n", hive_out
+  end
+
+  def test_assets_materialize_under_xdg_data
+    with_tmp_dir do |home|
+      old = {
+        "HOME" => ENV["HOME"],
+        "HIVE_HOME" => ENV["HIVE_HOME"],
+        "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"],
+        "HIVE_PACKAGE_ROOT" => ENV["HIVE_PACKAGE_ROOT"]
+      }
+      ENV["HOME"] = home
+      ENV.delete("HIVE_HOME")
+      ENV["XDG_DATA_HOME"] = File.join(home, "share")
+      # __dir__ is test/integration → repo root is two levels up
+      ENV["HIVE_PACKAGE_ROOT"] = File.expand_path("../..", __dir__)
+
+      begin
+        dest = Hive::Assets.materialize!
+        assert File.directory?(dest)
+        assert_equal File.join(home, "share", "hive", "versions", Hive::VERSION), dest
+        assert File.exist?(File.join(dest, ".materialized"))
+        # Re-run is idempotent.
+        assert_equal dest, Hive::Assets.materialize!
+
+        template = File.join(dest, "templates", "project", "config.yml")
+        original = File.read(template)
+        File.write(template, "corrupted\n")
+        refute Hive::Assets.verified?(dest)
+        Hive::Assets.materialize!
+        assert_equal original, File.read(template)
+
+        FileUtils.rm_f(template)
+        refute Hive::Assets.verified?(dest)
+        Hive::Assets.materialize!
+        assert_equal original, File.read(template)
+      ensure
+        old.each do |k, v|
+          if v.nil?
+            ENV.delete(k)
+          else
+            ENV[k] = v
+          end
+        end
+      end
+    end
+  end
+
+  def test_help_works_as_hv
+    out = run!(RbConfig.ruby, "-Ilib", "bin/hv", "help", "version")
+    assert_match(/version/i, out)
+  end
+end
diff --git a/test/integration/tui_subprocess_test.rb b/test/integration/tui_subprocess_test.rb
index 34ad39c3..0d751ed2 100644
--- a/test/integration/tui_subprocess_test.rb
+++ b/test/integration/tui_subprocess_test.rb
@@ -24,12 +24,14 @@ class TuiSubprocessTest < Minitest::Test
     # registry populated. Re-zeroing it keeps tests order-independent.
     Hive::Tui::SubprocessRegistry.clear
     %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR
-       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) }
+       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK
+       HIVE_TUI_FAKE_BLOCK_SECONDS].each { |k| ENV.delete(k) }
   end
 
   def teardown
     %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR
-       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) }
+       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK
+       HIVE_TUI_FAKE_BLOCK_SECONDS].each { |k| ENV.delete(k) }
     Hive::Tui::SubprocessRegistry.clear
   end
 
@@ -315,26 +317,17 @@ class TuiSubprocessDispatchBackgroundTest < Minitest::Test
     # Two slow children, dispatched back-to-back. If `dispatch_background`
     # is truly non-blocking, both finish in ~max(t1, t2), not t1 + t2.
     ENV["HIVE_TUI_FAKE_BLOCK"] = "1"
-    pids_killed = []
-    pids_mutex = Mutex.new
+    ENV["HIVE_TUI_FAKE_BLOCK_SECONDS"] = "0.5"
 
     Hive::Tui::Subprocess.dispatch_background([ FAKE_CHILD, "develop" ], dispatch: @dispatch)
     Hive::Tui::Subprocess.dispatch_background([ FAKE_CHILD, "review" ], dispatch: @dispatch)
 
-    # Kill any blocking child after a brief moment so the reapers can fire.
-    killer = Thread.new do
-      sleep 0.3
-      `pgrep -f tui-fake-child`.split.each do |pid|
-        Process.kill("TERM", pid.to_i)
-        pids_mutex.synchronize { pids_killed << pid.to_i }
-      rescue Errno::ESRCH
-        nil
-      end
-    end
-
+    started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
     assert wait_for_messages(2, timeout: 5.0),
       "both reapers must dispatch SubprocessExited; concurrent runs are the whole point"
-    killer.join
+    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
+    assert_operator elapsed, :<, 0.9,
+                    "two 0.5s children should overlap instead of taking ~1.0s"
     verbs = @messages.map(&:verb).sort
     assert_equal %w[develop review], verbs,
       "each dispatch must surface its own verb on completion (no cross-talk)"
diff --git a/test/test_helper.rb b/test/test_helper.rb
index d251d52b..487af9c9 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -68,15 +68,42 @@ module HiveTestHelper
     out
   end
 
+
+  def install_fake_bash_channel(prefix:, version: Hive::VERSION, command_names: [ "hive" ])
+    binary = File.join(prefix, "versions", version, "hive")
+    FileUtils.mkdir_p(File.dirname(binary))
+    File.write(binary, "#!/bin/sh\nprintf '%s\\n' '#{version}'\n")
+    FileUtils.chmod(0o755, binary)
+    FileUtils.mkdir_p(Hive::Paths.bin_dir)
+    command_names.each do |name|
+      FileUtils.ln_sf(binary, File.join(Hive::Paths.bin_dir, name))
+    end
+    Hive::InstallProvenance.write_bash_receipt!(
+      version: version,
+      command_name: command_names.first,
+      command_names: command_names,
+      prefix: prefix,
+      binary_path: binary,
+      bin_dir: Hive::Paths.bin_dir
+    )
+    File.join(Hive::Paths.bin_dir, command_names.first)
+  end
+
   def with_tmp_global_config
     dir = Dir.mktmpdir("hive-global")
-    old = ENV["HIVE_HOME"]
+    old = %w[HIVE_HOME HIVE_PROVIDER HIVE_MODEL HIVE_API_KEY_ENV XDG_CONFIG_HOME].to_h do |key|
+      [ key, ENV[key] ]
+    end
     ENV["HIVE_HOME"] = dir
+    ENV["XDG_CONFIG_HOME"] = File.join(dir, "xdg-config")
+    ENV["HIVE_PROVIDER"] = "openai"
+    ENV["HIVE_MODEL"] = "gpt-5"
+    ENV["HIVE_API_KEY_ENV"] = "OPENAI_API_KEY"
     File.write(File.join(dir, "config.yml"), { "registered_projects" => [] }.to_yaml)
     begin
       yield(dir)
     ensure
-      ENV["HIVE_HOME"] = old
+      old.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value }
       # 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/commands/daemon_envelope_test.rb b/test/unit/commands/daemon_envelope_test.rb
index 4e270298..da7587a2 100644
--- a/test/unit/commands/daemon_envelope_test.rb
+++ b/test/unit/commands/daemon_envelope_test.rb
@@ -13,6 +13,60 @@ require "hive/commands/daemon"
 class HiveCommandsDaemonEnvelopeTest < Minitest::Test
   include HiveTestHelper
 
+  class FakeServiceAdapter
+    attr_reader :starts, :stops
+
+    def initialize(registered: true, running: false)
+      @registered = registered
+      @running = running
+      @starts = 0
+      @stops = 0
+    end
+
+    def status
+      {
+        "manager" => "fake-user-service",
+        "registered" => @registered,
+        "enabled" => @registered,
+        "running" => @running
+      }
+    end
+
+    def start!
+      @starts += 1
+      @running = true
+    end
+
+    def stop!
+      @stops += 1
+      @running = false
+      true
+    end
+  end
+
+  def test_public_start_and_stop_route_through_registered_service_adapter
+    adapter = FakeServiceAdapter.new
+    out, = capture_io do
+      Hive::Commands::Daemon.new("start", service_adapter: adapter).call
+      Hive::Commands::Daemon.new("stop", service_adapter: adapter).call
+    end
+    assert_equal 1, adapter.starts
+    assert_equal 1, adapter.stops
+    assert_match(/started via fake-user-service/, out)
+    assert_match(/stopped via fake-user-service/, out)
+  end
+
+  def test_status_uses_registered_service_state_and_always_emits_service
+    adapter = FakeServiceAdapter.new(running: true)
+    out, = capture_io do
+      Hive::Commands::Daemon.new("status", json: true, service_adapter: adapter).call
+    end
+    payload = JSON.parse(out)
+    assert_equal true, payload["running"]
+    assert_equal "fake-user-service", payload.dig("service", "manager")
+    assert payload.key?("service")
+  end
+
   # Verifies the StandardError → InternalError → envelope chain the
   # EnvelopeEmitter mixin promises in lib/hive.rb. Without this test,
   # a refactor of call_with_envelope could silently lose the envelope
diff --git a/test/unit/commands/uninstall_test.rb b/test/unit/commands/uninstall_test.rb
new file mode 100644
index 00000000..5e0ff88d
--- /dev/null
+++ b/test/unit/commands/uninstall_test.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/commands/uninstall"
+require "hive/project/scaffold"
+require "hive/install_provenance"
+
+class CommandsUninstallTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_preview_preserves_state
+    with_tmp_dir do |dir|
+      old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] }
+      ENV["HOME"] = dir
+      ENV["XDG_DATA_HOME"] = File.join(dir, "share")
+      ENV["HIVE_HOME"] = File.join(dir, "hive-home")
+      FileUtils.mkdir_p(ENV["HIVE_HOME"])
+      state = File.join(dir, "share", "hive")
+      FileUtils.mkdir_p(state)
+      File.write(File.join(state, "work.bin"), "important\n")
+
+      Hive::Project::Scaffold.new(dir).call
+      command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ])
+
+      out = StringIO.new
+      Hive::Commands::Uninstall.new(
+        project_path: dir,
+        dry_run: true,
+        command_path: command,
+        service_adapter: nil,
+        output: out,
+        input: StringIO.new
+      ).call
+
+      assert File.exist?(File.join(state, "work.bin")), "XDG state must survive uninstall preview"
+      assert_match(/preview/, out.string)
+    ensure
+      old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
+    end
+  end
+
+  def test_clean_project_removes_only_unmodified
+    with_tmp_dir do |dir|
+      old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] }
+      ENV["HOME"] = dir
+      ENV["XDG_DATA_HOME"] = File.join(dir, "share")
+      ENV["HIVE_HOME"] = File.join(dir, "hive-home")
+      FileUtils.mkdir_p(ENV["HIVE_HOME"])
+
+      Hive::Project::Scaffold.new(dir).call
+      File.write(File.join(dir, ".hive", "README.md"), "user changed\n")
+      command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ])
+
+      out = StringIO.new
+      Hive::Commands::Uninstall.new(
+        project_path: dir,
+        yes: true,
+        clean_project: true,
+        command_path: command,
+        service_adapter: nil,
+        output: out,
+        input: StringIO.new,
+        exec: ->(*) { true }
+      ).call
+
+      assert File.exist?(File.join(dir, ".hive", "README.md")), "modified scaffold must be preserved"
+      assert_match(/preserved/, out.string)
+    ensure
+      old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
+    end
+  end
+
+  def test_cancel_at_preview
+    with_tmp_dir do |dir|
+      old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] }
+      ENV["HOME"] = dir
+      ENV["XDG_DATA_HOME"] = File.join(dir, "share")
+      ENV["HIVE_HOME"] = File.join(dir, "hive-home")
+      FileUtils.mkdir_p(ENV["HIVE_HOME"])
+      command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ])
+
+      input = StringIO.new("n\n")
+      def input.tty? = true
+      out = StringIO.new
+      Hive::Commands::Uninstall.new(
+        project_path: dir,
+        yes: false,
+        command_path: command,
+        service_adapter: nil,
+        output: out,
+        input: input
+      ).call
+      assert_match(/no changes made/, out.string)
+    ensure
+      old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
+    end
+  end
+
+  def test_unknown_ownership_aborts_before_service_or_project_mutation
+    with_tmp_dir do |dir|
+      old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] }
+      ENV["HOME"] = dir
+      ENV["XDG_DATA_HOME"] = File.join(dir, "share")
+      ENV["HIVE_HOME"] = File.join(dir, "hive-home")
+      Hive::Project::Scaffold.new(dir).call
+      calls = []
+      adapter = Object.new
+      adapter.define_singleton_method(:stop!) { calls << :stop; true }
+      adapter.define_singleton_method(:unregister!) { calls << :unregister; true }
+
+      error = assert_raises(Hive::Error) do
+        Hive::Commands::Uninstall.new(
+          project_path: dir,
+          yes: true,
+          clean_project: true,
+          command_path: File.join(dir, "unrelated"),
+          service_adapter: adapter,
+          output: StringIO.new
+        ).call
+      end
+      assert_match(/No service, project, or binary changes/, error.message)
+      assert_empty calls
+      assert File.exist?(File.join(dir, ".hive", "README.md"))
+    ensure
+      old&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value }
+    end
+  end
+
+  def test_service_cleanup_failure_stops_before_binary_removal
+    with_tmp_dir do |dir|
+      old = { "HOME" => ENV["HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], "HIVE_HOME" => ENV["HIVE_HOME"] }
+      ENV["HOME"] = dir
+      ENV["XDG_DATA_HOME"] = File.join(dir, "share")
+      ENV["HIVE_HOME"] = File.join(dir, "hive-home")
+      command = install_fake_bash_channel(prefix: dir, command_names: [ "hv" ])
+      adapter = Object.new
+      adapter.define_singleton_method(:stop!) { false }
+      adapter.define_singleton_method(:unregister!) { raise "must not run" }
+
+      error = assert_raises(Hive::Error) do
+        Hive::Commands::Uninstall.new(
+          project_path: dir,
+          yes: true,
+          command_path: command,
+          service_adapter: adapter,
+          output: StringIO.new
+        ).call
+      end
+      assert_match(/failed to stop/, error.message)
+      assert File.exist?(command)
+      assert File.exist?(Hive::InstallProvenance.receipt_path)
+    ensure
+      old&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value }
+    end
+  end
+end
diff --git a/test/unit/commands/update_test.rb b/test/unit/commands/update_test.rb
new file mode 100644
index 00000000..194d1833
--- /dev/null
+++ b/test/unit/commands/update_test.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/commands/update"
+require "hive/install_provenance"
+
+class CommandsUpdateTest < Minitest::Test
+  include HiveTestHelper
+
+  def with_update_home
+    with_tmp_dir do |home|
+      keys = %w[HOME HIVE_HOME XDG_DATA_HOME XDG_BIN_HOME]
+      old = keys.to_h { |key| [ key, ENV[key] ] }
+      ENV["HOME"] = home
+      ENV.delete("HIVE_HOME")
+      ENV["XDG_DATA_HOME"] = File.join(home, "share")
+      ENV["XDG_BIN_HOME"] = File.join(home, "bin")
+      yield home
+    ensure
+      old&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value }
+    end
+  end
+
+  def test_unknown_ownership_raises
+    with_update_home do
+      err = assert_raises(Hive::Error) do
+        Hive::Commands::Update.new(command_path: "/tmp/nope", dry_run: true).call
+      end
+      assert_match(/ownership/, err.message)
+    end
+  end
+
+  def test_bash_channel_dry_run
+    with_update_home do |home|
+      command = install_fake_bash_channel(prefix: home)
+      executed = []
+      owner = Hive::Commands::Update.new(
+        command_path: command,
+        dry_run: true,
+        exec: ->(*args) { executed << args; true }
+      ).call
+      assert_equal :bash, owner.channel
+      assert_empty executed
+    end
+  end
+
+  def test_package_manager_failure_does_not_continue
+    with_update_home do |home|
+      command = install_fake_bash_channel(prefix: home)
+      err = assert_raises(Hive::Error) do
+        Hive::Commands::Update.new(
+          command_path: command,
+          dry_run: false,
+          exec: ->(*) { false }
+        ).call
+      end
+      assert_match(/package manager failed/, err.message)
+    end
+  end
+
+
+  def test_success_verifies_resulting_version_and_service_state
+    with_update_home do |home|
+      command = install_fake_bash_channel(prefix: home)
+      adapter = Struct.new(:state) do
+        def status = state.dup
+        def refresh!(was_enabled:, was_running:)
+          self.state = { "enabled" => was_enabled, "running" => was_running }
+          true
+        end
+      end.new({ "registered" => true, "enabled" => true, "running" => true })
+      out, = capture_io do
+        Hive::Commands::Update.new(
+          command_path: command,
+          exec: ->(*) { true },
+          service_adapter: adapter,
+          version_probe: ->(_path) { "0.2.0" }
+        ).call
+      end
+      assert_match(/version=0\.2\.0/, out)
+    end
+  end
+
+  def test_service_refresh_failure_is_fatal
+    with_update_home do |home|
+      command = install_fake_bash_channel(prefix: home)
+      adapter = Struct.new(:state) do
+        def status = state
+        def refresh!(**) = raise("refresh broke")
+      end.new({ "registered" => true, "enabled" => false, "running" => true })
+      error = assert_raises(Hive::Error) do
+        Hive::Commands::Update.new(
+          command_path: command,
+          exec: ->(*) { true },
+          service_adapter: adapter,
+          version_probe: ->(*) { "0.2.0" }
+        ).call
+      end
+      assert_match(/service refresh failed/, error.message)
+    end
+  end
+
+  def test_update_does_not_register_a_service_that_was_absent
+    with_update_home do |home|
+      command = install_fake_bash_channel(prefix: home)
+      refreshed = false
+      adapter = Object.new
+      adapter.define_singleton_method(:status) do
+        { "registered" => false, "enabled" => false, "running" => false }
+      end
+      adapter.define_singleton_method(:refresh!) { |**| refreshed = true }
+
+      Hive::Commands::Update.new(
+        command_path: command,
+        exec: ->(*) { true },
+        service_adapter: adapter,
+        version_probe: ->(*) { "0.2.0" }
+      ).call
+
+      refute refreshed
+    end
+  end
+end
diff --git a/test/unit/dependency_check_test.rb b/test/unit/dependency_check_test.rb
new file mode 100644
index 00000000..e219dc82
--- /dev/null
+++ b/test/unit/dependency_check_test.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/dependency_check"
+require "fileutils"
+
+class DependencyCheckTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_git_present_on_ci_host
+    results = Hive::DependencyCheck.check_all
+    git = results.find { |c| c.name == "git" }
+    assert git.present, "git should be present on the CI host"
+    assert_equal :hard, git.kind
+  end
+
+  def test_missing_tool_reported_with_hint
+    with_tmp_dir do |dir|
+      # Empty PATH → everything missing.
+      results = Hive::DependencyCheck.check_all(path_env: dir)
+      hard = results.select { |c| c.kind == :hard }
+      assert hard.all? { |c| !c.present }
+      hard.each { |c| refute_empty c.hint }
+
+      io = StringIO.new
+      Hive::DependencyCheck.report(io: io, path_env: dir)
+      assert_match(/dependency diagnostics/, io.string)
+      assert_match(/missing git/, io.string)
+    end
+  end
+
+  def test_optional_missing_does_not_appear_as_hard
+    with_tmp_dir do |dir|
+      # Provide only git and bash.
+      %w[git bash].each do |name|
+        path = File.join(dir, name)
+        File.write(path, "#!/bin/sh\n")
+        File.chmod(0o755, path)
+      end
+
+      results = Hive::DependencyCheck.check_all(path_env: dir)
+      assert results.find { |c| c.name == "git" }.present
+      assert results.find { |c| c.name == "bash" }.present
+
+      gh = results.find { |c| c.name == "gh" }
+      refute gh.present
+      assert_equal :optional, gh.kind
+      assert_equal "github_integration", gh.capability
+    end
+  end
+
+  def test_hard_missing_helper
+    with_tmp_dir do |dir|
+      missing = Hive::DependencyCheck.hard_missing(path_env: dir)
+      assert missing.any? { |c| c.name == "git" }
+    end
+  end
+end
diff --git a/test/unit/install/bash_installer_test.rb b/test/unit/install/bash_installer_test.rb
new file mode 100644
index 00000000..5e8b42c2
--- /dev/null
+++ b/test/unit/install/bash_installer_test.rb
@@ -0,0 +1,192 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "open3"
+require "json"
+require "digest"
+
+class BashInstallerTest < Minitest::Test
+  include HiveTestHelper
+
+  def install_sh
+    File.expand_path("../../../install.sh", __dir__)
+  end
+
+  def test_bash_syntax
+    _o, err, st = Open3.capture3("bash", "-n", install_sh)
+    assert st.success?, err
+  end
+
+  def test_dry_run_no_mutation
+    with_tmp_dir do |dir|
+      env = {
+        "HOME" => dir,
+        "PATH" => ENV["PATH"],
+        "XDG_DATA_HOME" => File.join(dir, "share"),
+        "XDG_BIN_HOME" => File.join(dir, "bin")
+      }
+      out, err, st = Open3.capture3(env, "bash", install_sh, "--dry-run", "--version", "0.1.0")
+      assert st.success?, err
+      assert_match(/dry-run/, out)
+      refute File.exist?(File.join(dir, "share", "hive"))
+    end
+  end
+
+  def test_rejects_bad_command_name
+    _o, err, st = Open3.capture3("bash", install_sh, "--command-name", "evil", "--dry-run")
+    refute st.success?
+    assert_match(/command-name/, err)
+  end
+
+  def test_rejects_traversal_version_before_deriving_install_paths
+    with_tmp_dir do |dir|
+      sentinel = File.join(dir, "sentinel")
+      File.write(sentinel, "keep\n")
+      env = isolated_env(dir)
+      _out, err, status = Open3.capture3(
+        env, "bash", install_sh, "--version", "../../sentinel", "--dry-run"
+      )
+      refute status.success?
+      assert_match(/MAJOR\.MINOR\.PATCH/, err)
+      assert_equal "keep\n", File.read(sentinel)
+    end
+  end
+
+  def test_installs_both_aliases_and_canonical_custom_prefix_receipt
+    with_tmp_dir do |dir|
+      release = build_release_fixture(dir)
+      prefix = File.join(dir, "custom-payload")
+      env = isolated_env(dir)
+      out, err, status = Open3.capture3(
+        env, "bash", install_sh,
+        "--version", "0.1.0", "--prefix", prefix,
+        "--base-url", "file://#{release}", "--skip-provenance"
+      )
+      assert status.success?, "#{err}\n#{out}"
+      %w[hive hv].each do |name|
+        command = File.join(dir, "bin", name)
+        assert File.symlink?(command)
+        assert_equal "0.1.0", Open3.capture2(command, "--version").first.strip
+      end
+      receipt = YAML.safe_load(File.read(File.join(dir, "share", "hive", "install-receipt.yml")))
+      assert_equal prefix, receipt["prefix"]
+      assert_equal %w[hive hv], receipt["command_names"]
+      refute File.exist?(File.join(prefix, "install-receipt.yml"))
+    end
+  end
+
+  def test_rejects_archive_links_before_extraction
+    with_tmp_dir do |dir|
+      release = build_release_fixture(dir, escaping_symlink: true)
+      env = isolated_env(dir)
+      _out, err, status = Open3.capture3(
+        env, "bash", install_sh,
+        "--version", "0.1.0", "--base-url", "file://#{release}", "--skip-provenance"
+      )
+      refute status.success?
+      assert_match(/link or special file/, err)
+      refute File.exist?(File.join(dir, "outside"))
+    end
+  end
+
+  def test_same_version_keeps_previous_payload_when_post_install_verification_fails
+    with_tmp_dir do |dir|
+      release = build_release_fixture(dir)
+      env = isolated_env(dir)
+      first = Open3.capture3(
+        env, "bash", install_sh,
+        "--version", "0.1.0", "--base-url", "file://#{release}", "--skip-provenance"
+      )
+      assert first[2].success?, first[1]
+
+      failing_body = <<~SH
+        #!/bin/sh
+        case "$0" in
+          *.staging.*) echo 0.1.0 ;;
+          *) exit 9 ;;
+        esac
+      SH
+      build_release_fixture(dir, script_body: failing_body, replace: true)
+      _out, err, status = Open3.capture3(
+        env, "bash", install_sh,
+        "--version", "0.1.0", "--base-url", "file://#{release}", "--skip-provenance"
+      )
+      refute status.success?
+      assert_match(/post-install verification failed/, err)
+      command = File.join(dir, "bin", "hive")
+      assert_equal "0.1.0", Open3.capture2(command, "--version").first.strip
+    end
+  end
+
+  def test_failed_collision_safe_install_never_removes_unrelated_hive
+    with_tmp_dir do |dir|
+      unrelated = File.join(dir, "bin", "hive")
+      FileUtils.mkdir_p(File.dirname(unrelated))
+      File.write(unrelated, "#!/bin/sh\necho Apache Hive\n")
+      FileUtils.chmod(0o755, unrelated)
+      failing_body = <<~SH
+        #!/bin/sh
+        case "$0" in
+          *.staging.*) echo 0.1.0 ;;
+          *) exit 9 ;;
+        esac
+      SH
+      release = build_release_fixture(dir, script_body: failing_body)
+      env = isolated_env(dir)
+
+      _out, err, status = Open3.capture3(
+        env, "bash", install_sh,
+        "--version", "0.1.0", "--command-name", "hv",
+        "--base-url", "file://#{release}", "--skip-provenance"
+      )
+
+      refute status.success?
+      assert_match(/post-install verification failed/, err)
+      assert_equal "#!/bin/sh\necho Apache Hive\n", File.read(unrelated)
+      refute File.exist?(File.join(dir, "bin", "hv"))
+    end
+  end
+
+  private
+
+  def isolated_env(dir)
+    {
+      "HOME" => dir,
+      "PATH" => "/usr/bin:/bin",
+      "XDG_DATA_HOME" => File.join(dir, "share"),
+      "XDG_BIN_HOME" => File.join(dir, "bin")
+    }
+  end
+
+  def build_release_fixture(dir, script_body: "#!/bin/sh\necho 0.1.0\n", escaping_symlink: false, replace: false)
+    release = File.join(dir, "release")
+    FileUtils.rm_rf(release) if replace
+    FileUtils.mkdir_p(release)
+    stage = File.join(dir, "stage")
+    FileUtils.rm_rf(stage)
+    FileUtils.mkdir_p(File.join(stage, "hive"))
+    binary = File.join(stage, "hive", "hive")
+    File.write(binary, script_body)
+    FileUtils.chmod(0o755, binary)
+    File.symlink("../../outside", File.join(stage, "hive", "escape")) if escaping_symlink
+
+    os = RbConfig::CONFIG["host_os"].include?("darwin") ? "macos" : "linux"
+    cpu = RbConfig::CONFIG["host_cpu"]
+    arch = %w[arm64 aarch64].include?(cpu) ? "arm64" : "x86_64"
+    archive_name = "hive-#{os}-#{arch}.tar.gz"
+    archive = File.join(release, archive_name)
+    _out, err, status = Open3.capture3("tar", "-C", stage, "-czf", archive, "hive")
+    raise err unless status.success?
+
+    digest = Digest::SHA256.file(archive).hexdigest
+    File.write(File.join(release, "SHA256SUMS"), "#{digest}  #{archive_name}\n")
+    File.write(File.join(release, "manifest.json"), JSON.generate(
+      "assets" => [ {
+        "filename" => archive_name,
+        "sha256" => digest,
+        "url" => "file://#{archive}"
+      } ]
+    ))
+    release
+  end
+end
diff --git a/test/unit/install/prompt_contract_test.rb b/test/unit/install/prompt_contract_test.rb
new file mode 100644
index 00000000..0c704c01
--- /dev/null
+++ b/test/unit/install/prompt_contract_test.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class PromptContractTest < Minitest::Test
+  def prompt
+    File.read(File.expand_path("../../../INSTALL_PROMPT.md", __dir__))
+  end
+
+  def test_mentions_collision_hv
+    assert_match(/--command-name hv/, prompt)
+    assert_match(/Apache Hive/, prompt)
+  end
+
+  def test_mentions_all_channels
+    assert_match(/brew install/, prompt)
+    assert_match(/hive-bin/, prompt)
+    assert_match(/install\.sh/, prompt)
+  end
+
+  def test_skills_native_only
+    assert_match(/claude plugin/, prompt)
+    assert_match(/codex plugin/, prompt)
+    assert_match(/pi install/, prompt)
+    assert_match(/without copying files into dot-directories/, prompt)
+  end
+
+  def test_no_autostart
+    assert_match(/Never enable or start the daemon without asking/, prompt)
+  end
+end
diff --git a/test/unit/install/selector_test.rb b/test/unit/install/selector_test.rb
new file mode 100644
index 00000000..38b6f4e0
--- /dev/null
+++ b/test/unit/install/selector_test.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "json"
+
+class InstallSelectorTest < Minitest::Test
+  FIXTURES = File.expand_path("../../fixtures/install_hosts", __dir__)
+
+  def select_channel(doc)
+    return "bash_hv" if doc["unrelated_hive"]
+    return "homebrew" if doc["os"] == "macos" && doc["homebrew"]
+    return "aur" if doc["distro"] == "arch" && doc["aur_helper"]
+
+    "bash"
+  end
+
+  def test_fixtures_match_selector
+    Dir[File.join(FIXTURES, "*.json")].each do |path|
+      doc = JSON.parse(File.read(path))
+      assert_equal doc["expected_channel"], select_channel(doc), path
+    end
+  end
+end
diff --git a/test/unit/install_provenance_test.rb b/test/unit/install_provenance_test.rb
new file mode 100644
index 00000000..45515109
--- /dev/null
+++ b/test/unit/install_provenance_test.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/install_provenance"
+require "hive/paths"
+
+class InstallProvenanceTest < Minitest::Test
+  include HiveTestHelper
+
+  def with_data_home
+    with_tmp_dir do |home|
+      old = { "HOME" => ENV["HOME"], "HIVE_HOME" => ENV["HIVE_HOME"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"] }
+      ENV["HOME"] = home
+      ENV.delete("HIVE_HOME")
+      ENV["XDG_DATA_HOME"] = File.join(home, "share")
+      begin
+        yield home
+      ensure
+        old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
+      end
+    end
+  end
+
+  def test_bash_receipt_selects_bash_channel
+    with_data_home do
+      command = install_fake_bash_channel(prefix: Hive::Paths.data_home, command_names: [ "hv" ])
+      owner = Hive::InstallProvenance.detect(command_path: command)
+      assert_equal :bash, owner.channel
+      assert_equal "hv", owner.details["command_name"]
+      assert owner.updater
+    end
+  end
+
+
+  def test_stale_receipt_does_not_claim_a_replaced_command
+    with_data_home do
+      command = install_fake_bash_channel(prefix: Hive::Paths.data_home, command_names: [ "hv" ])
+      FileUtils.rm_f(command)
+      File.write(command, "#!/bin/sh\necho unrelated\n")
+      FileUtils.chmod(0o755, command)
+
+      owner = Hive::InstallProvenance.detect(command_path: command)
+      assert_equal :unknown, owner.channel
+    end
+  end
+
+  def test_bash_updater_is_a_pinned_verifier_not_curl_pipe_bash
+    with_data_home do
+      command = install_fake_bash_channel(prefix: Hive::Paths.data_home, command_names: [ "hive", "hv" ])
+      owner = Hive::InstallProvenance.detect(command_path: command)
+      assert_equal :bash, owner.channel
+      assert_match(/bash_update\.sh\z/, owner.updater.first)
+      refute owner.updater.join(" ").include?("latest/download/install.sh")
+      assert File.executable?(owner.updater.first)
+    end
+  end
+
+
+  def test_custom_prefix_receipt_remains_discoverable_from_canonical_xdg_path
+    with_data_home do |home|
+      prefix = File.join(home, "custom-prefix")
+      command = install_fake_bash_channel(prefix: prefix, command_names: [ "hv" ])
+      assert_equal File.join(home, "share", "hive", "install-receipt.yml"),
+                   Hive::InstallProvenance.receipt_path
+      owner = Hive::InstallProvenance.detect(command_path: command)
+      assert_equal :bash, owner.channel
+      assert_equal prefix, owner.details["prefix"]
+    end
+  end
+
+  def test_stable_homebrew_symlink_uses_fully_qualified_tap_formula
+    with_data_home do |home|
+      tools = File.join(home, "tools")
+      prefix = File.join(home, "Cellar", "hive", "0.1.0")
+      FileUtils.mkdir_p([ tools, File.join(prefix, "bin"), File.join(home, "bin") ])
+      binary = File.join(prefix, "bin", "hive")
+      File.write(binary, "#!/bin/sh\necho 0.1.0\n")
+      FileUtils.chmod(0o755, binary)
+      stable = File.join(home, "bin", "hive")
+      FileUtils.ln_sf(binary, stable)
+      brew = File.join(tools, "brew")
+      File.write(brew, <<~SH)
+        #!/bin/sh
+        if [ "$1" = "--prefix" ] && [ "$2" = "ivankuznetsov/hive/hive" ]; then
+          echo "#{prefix}"
+          exit 0
+        fi
+        exit 1
+      SH
+      FileUtils.chmod(0o755, brew)
+      old_path = ENV["PATH"]
+      ENV["PATH"] = "#{tools}:#{old_path}"
+      begin
+        owner = Hive::InstallProvenance.detect(command_path: stable)
+        assert_equal :homebrew, owner.channel
+        assert_equal "ivankuznetsov/hive/hive", owner.details["formula"]
+        assert_equal %w[brew upgrade] + [ "ivankuznetsov/hive/hive" ], owner.updater
+        assert_equal %w[brew uninstall] + [ "ivankuznetsov/hive/hive" ], owner.uninstaller
+      ensure
+        ENV["PATH"] = old_path
+      end
+    end
+  end
+
+  def test_unknown_without_signals
+    with_data_home do
+      owner = Hive::InstallProvenance.detect(command_path: "/tmp/unrelated-binary")
+      assert_equal :unknown, owner.channel
+      assert_nil owner.updater
+    end
+  end
+
+  def test_homebrew_cellar_path
+    path = "/opt/homebrew/Cellar/hive/0.1.0/bin/hive"
+    # Stub brew absence: detect_homebrew returns nil without brew, but
+    # Cellar path alone is enough when brew exists. Force the path check
+    # by stubbing command -v brew via a fake owner call path.
+    # Unit-level: path pattern is covered when brew is present; when not,
+    # channel is unknown — both are valid.
+    owner = Hive::InstallProvenance.detect(command_path: path)
+    assert_includes %i[homebrew unknown bash ambiguous], owner.channel
+  end
+end
diff --git a/test/unit/packaging/aur_pkgbuild_test.rb b/test/unit/packaging/aur_pkgbuild_test.rb
new file mode 100644
index 00000000..d0e8923f
--- /dev/null
+++ b/test/unit/packaging/aur_pkgbuild_test.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "erb"
+require "json"
+require "open3"
+require "digest"
+
+class AurPkgbuildTest < Minitest::Test
+  def test_pkgbuild_template
+    template = File.read(File.expand_path("../../../packaging/aur/PKGBUILD.erb", __dir__))
+    version = "0.1.0"
+    url = "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive-linux-x86_64.tar.gz"
+    sha256 = "b" * 64
+    body = ERB.new(template).result(binding)
+    assert_includes body, "pkgname=hive-bin"
+    assert_includes body, "pkgver=0.1.0"
+    assert_includes body, "provides=('hive')"
+    assert_includes body, "conflicts=('hive')"
+    refute_includes body, "ruby"
+    assert_includes body, "usr/bin/hv"
+  end
+
+  def test_install_hook_has_no_user_state_mutation
+    hook = File.read(File.expand_path("../../../packaging/aur/hive.install", __dir__))
+    refute_match(/systemctl --user enable/, hook)
+    refute_match(/LaunchAgents/, hook)
+    refute_match(/mkdir.*\.config\/hive/, hook)
+  end
+
+
+  def test_render_script_emits_publishable_pkgbuild_and_srcinfo
+    Dir.mktmpdir("hive-aur-metadata") do |dir|
+      manifest = File.join(dir, "manifest.json")
+      digest = "c" * 64
+      File.write(manifest, JSON.generate(
+        "version" => "0.1.0",
+        "assets" => [ {
+          "platform" => "linux",
+          "arch" => "x86_64",
+          "url" => "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive-linux-x86_64.tar.gz",
+          "sha256" => digest
+        } ]
+      ))
+      script = File.expand_path("../../../scripts/render-package-metadata", __dir__)
+      _out, err, status = Open3.capture3("bash", script, manifest, File.join(dir, "out"))
+      assert status.success?, err
+      root = File.join(dir, "out", "aur", "hive-bin")
+      assert File.exist?(File.join(root, "PKGBUILD"))
+      srcinfo = File.read(File.join(root, ".SRCINFO"))
+      assert_includes srcinfo, "pkgname = hive-bin"
+      assert_includes srcinfo, "sha256sums = #{digest}"
+    end
+  end
+end
diff --git a/test/unit/packaging/hive_skills_test.rb b/test/unit/packaging/hive_skills_test.rb
new file mode 100644
index 00000000..cd3ba5e3
--- /dev/null
+++ b/test/unit/packaging/hive_skills_test.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "open3"
+
+class HiveSkillsPackageTest < Minitest::Test
+  def root
+    File.expand_path("../../../packaging/hive-skills", __dir__)
+  end
+
+  def test_validate_manifests
+    out, err, st = Open3.capture3("ruby", File.join(root, "test/validate_manifests.rb"))
+    assert st.success?, "#{err}\n#{out}"
+    assert_match(/ok/, out)
+  end
+
+  def test_skill_has_hv_fallback
+    skill = File.read(File.join(root, "plugins/hive/skills/hive/SKILL.md"))
+    assert_match(/\bhv\b/, skill)
+    assert_match(/command -v \"\$1\"/, skill)
+    assert_match(/is_hive_pipeline/, skill)
+    assert_operator skill.index("is_hive_pipeline hive"), :<, skill.index("is_hive_pipeline hv")
+    assert_match(/help/, skill)
+  end
+
+
+  def test_npm_pack_contains_only_the_shared_discovery_payload
+    skip "npm unavailable" unless system("npm", "--version", out: File::NULL, err: File::NULL)
+
+    out, err, st = Open3.capture3("ruby", File.join(root, "test/validate_package.rb"))
+    assert st.success?, "#{err}\n#{out}"
+    assert_match(/contains/, out)
+  end
+end
diff --git a/test/unit/packaging/homebrew_formula_test.rb b/test/unit/packaging/homebrew_formula_test.rb
new file mode 100644
index 00000000..ad88f987
--- /dev/null
+++ b/test/unit/packaging/homebrew_formula_test.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "json"
+require "open3"
+require "erb"
+require "digest"
+
+class HomebrewFormulaTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_template_renders_pinned_url_and_sha
+    template = File.read(File.expand_path("../../../packaging/homebrew/hive.rb.erb", __dir__))
+    b = binding
+    url = "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive-macos-arm64.tar.gz"
+    version = "0.1.0"
+    sha256 = "a" * 64
+    formula = ERB.new(template).result(b)
+    assert_includes formula, url
+    assert_includes formula, version
+    assert_includes formula, sha256
+    assert_includes formula, 'bin.install_symlink "hive" => "hv"'
+    refute_includes formula, "/latest/"
+  end
+
+  def test_render_script_from_manifest
+    with_tmp_dir do |dir|
+      dist = File.join(dir, "dist")
+      FileUtils.mkdir_p(dist)
+      # Build a real archive via package-release
+      env = { "DIST_DIR" => dist, "PATH" => ENV["PATH"], "HOME" => dir, "HIVE_ALLOW_SOURCE_BUNDLE" => "1" }
+      scripts = File.expand_path("../../../scripts", __dir__)
+      _o, err, st = Open3.capture3(env, "bash", File.join(scripts, "package-release"), "v0.1.0", "deadbee")
+      assert st.success?, err
+
+      # Ensure a macos asset exists for formula render — inject one if linux-only host.
+      manifest = JSON.parse(File.read(File.join(dist, "manifest.json")))
+      unless manifest["assets"].any? { |a| a["platform"] == "macos" }
+        sample = manifest["assets"].first.dup
+        sample["platform"] = "macos"
+        sample["arch"] = "arm64"
+        sample["filename"] = "hive-macos-arm64.tar.gz"
+        sample["url"] = sample["url"].sub("linux-x86_64", "macos-arm64")
+        File.write(File.join(dist, sample["filename"]), "fake")
+        sample["sha256"] = Digest::SHA256.file(File.join(dist, sample["filename"])).hexdigest
+        sample["size"] = File.size(File.join(dist, sample["filename"]))
+        manifest["assets"] << sample
+        File.write(File.join(dist, "manifest.json"), JSON.pretty_generate(manifest))
+      end
+
+      out_dir = File.join(dir, "pkg")
+      _o, err, st = Open3.capture3("bash", File.join(scripts, "render-package-metadata"),
+                                   File.join(dist, "manifest.json"), out_dir)
+      assert st.success?, err
+      formula = File.read(File.join(out_dir, "homebrew-tap", "Formula", "hive.rb"))
+      assert_match(/sha256 "/, formula)
+      refute_includes formula, "/latest/"
+    end
+  end
+end
diff --git a/test/unit/paths_test.rb b/test/unit/paths_test.rb
new file mode 100644
index 00000000..66d98d9f
--- /dev/null
+++ b/test/unit/paths_test.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/paths"
+require "fileutils"
+
+class PathsTest < Minitest::Test
+  include HiveTestHelper
+
+  def with_isolated_home
+    with_tmp_dir do |home|
+      old = capture_env(%w[HOME HIVE_HOME XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME XDG_RUNTIME_DIR])
+      ENV["HOME"] = home
+      ENV.delete("HIVE_HOME")
+      ENV.delete("XDG_CONFIG_HOME")
+      ENV.delete("XDG_DATA_HOME")
+      ENV.delete("XDG_STATE_HOME")
+      ENV.delete("XDG_CACHE_HOME")
+      ENV.delete("XDG_RUNTIME_DIR")
+      begin
+        yield home
+      ensure
+        restore_env(old)
+      end
+    end
+  end
+
+  def capture_env(keys)
+    keys.to_h { |k| [k, ENV[k]] }
+  end
+
+  def restore_env(old)
+    old.each do |k, v|
+      if v.nil?
+        ENV.delete(k)
+      else
+        ENV[k] = v
+      end
+    end
+  end
+
+  def test_xdg_vars_point_at_hive_subdirs
+    with_isolated_home do |home|
+      config = File.join(home, "cfg")
+      data = File.join(home, "data")
+      state = File.join(home, "state")
+      cache = File.join(home, "cache")
+      runtime = File.join(home, "run")
+      FileUtils.mkdir_p([config, data, state, cache, runtime])
+
+      ENV["XDG_CONFIG_HOME"] = config
+      ENV["XDG_DATA_HOME"] = data
+      ENV["XDG_STATE_HOME"] = state
+      ENV["XDG_CACHE_HOME"] = cache
+      ENV["XDG_RUNTIME_DIR"] = runtime
+
+      assert_equal File.join(config, "hive"), Hive::Paths.config_home
+      assert_equal File.join(data, "hive"), Hive::Paths.data_home
+      assert_equal File.join(state, "hive"), Hive::Paths.state_home
+      assert_equal File.join(cache, "hive"), Hive::Paths.cache_home
+      assert_equal File.join(runtime, "hive"), Hive::Paths.runtime_dir
+
+      [Hive::Paths.config_home, Hive::Paths.data_home, Hive::Paths.state_home,
+       Hive::Paths.cache_home, Hive::Paths.runtime_dir].each do |dir|
+        assert File.directory?(dir)
+        mode = File.stat(dir).mode & 0o777
+        assert_equal 0o700, mode, "expected 0700 on #{dir}, got #{mode.to_s(8)}"
+      end
+    end
+  end
+
+  def test_defaults_when_xdg_unset
+    with_isolated_home do |home|
+      assert_equal File.join(home, ".config", "hive"), Hive::Paths.config_home
+      assert_equal File.join(home, ".local", "share", "hive"), Hive::Paths.data_home
+      assert_equal File.join(home, ".local", "state", "hive"), Hive::Paths.state_home
+      assert_equal File.join(home, ".cache", "hive"), Hive::Paths.cache_home
+    end
+  end
+
+  def test_relative_xdg_raises_config_error
+    with_isolated_home do
+      ENV["XDG_CONFIG_HOME"] = "relative-config"
+      err = assert_raises(Hive::ConfigError) { Hive::Paths.config_home }
+      assert_match(/absolute path/, err.message)
+    end
+  end
+
+  def test_hive_home_honors_env_override
+    with_isolated_home do |home|
+      override = File.join(home, "override-home")
+      FileUtils.mkdir_p(override)
+      ENV["HIVE_HOME"] = override
+      assert_equal override, Hive::Paths.hive_home
+      assert_equal File.join(override, "config.yml"), Hive::Paths.global_registry_path
+    end
+  end
+
+  def test_write_private_file_is_0600
+    with_isolated_home do |home|
+      path = File.join(home, ".config", "hive", "config.yml")
+      Hive::Paths.write_private_file(path, "ok: true\n")
+      assert_equal "ok: true\n", File.read(path)
+      mode = File.stat(path).mode & 0o777
+      assert_equal 0o600, mode
+    end
+  end
+end
diff --git a/test/unit/platform_test.rb b/test/unit/platform_test.rb
new file mode 100644
index 00000000..9fe30561
--- /dev/null
+++ b/test/unit/platform_test.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/platform"
+
+class PlatformTest < Minitest::Test
+  def with_platform(os:, arch:, distro: nil, version: nil, macos_major: nil)
+    singleton = Hive::Platform.singleton_class
+    originals = %i[os_family arch linux_distro linux_version macos_major].to_h do |name|
+      [ name, Hive::Platform.method(name) ]
+    end
+    singleton.define_method(:os_family) { os }
+    singleton.define_method(:arch) { arch }
+    singleton.define_method(:linux_distro) { distro }
+    singleton.define_method(:linux_version) { version }
+    singleton.define_method(:macos_major) { macos_major }
+    yield
+  ensure
+    originals&.each { |name, implementation| singleton.define_method(name, implementation) }
+  end
+
+  def test_os_family_is_known_on_this_host
+    assert_includes %i[macos linux windows unknown], Hive::Platform.os_family
+  end
+
+  def test_arch_is_known_on_this_host
+    refute_equal :unknown, Hive::Platform.arch
+  end
+
+  def test_classify_returns_tier_struct
+    tier = Hive::Platform.classify
+    assert_kind_of Hive::Platform::Tier, tier
+    assert_includes [ 1, 2, 3 ], tier.level
+    refute_empty tier.message
+  end
+
+  def test_release_asset_triplet_format
+    triplet = Hive::Platform.release_asset_triplet
+    assert_match(/\A[a-z0-9]+-[a-z0-9_]+\z/, triplet)
+  end
+
+  def test_reject_if_unsupported_passes_on_supported_host
+    # This CI host is Linux x86_64 → tier-1 or tier-2.
+    tier = Hive::Platform.reject_if_unsupported!
+    assert tier.level <= 2
+  end
+
+  def test_exact_tier_one_boundaries
+    with_platform(os: :macos, arch: :arm64, macos_major: 14) do
+      assert_equal 1, Hive::Platform.classify.level
+    end
+    with_platform(os: :macos, arch: :arm64, macos_major: nil) do
+      assert_equal 2, Hive::Platform.classify.level
+    end
+    with_platform(os: :macos, arch: :arm64, macos_major: 13) do
+      assert_equal 2, Hive::Platform.classify.level
+    end
+    with_platform(os: :linux, arch: :x86_64, distro: "ubuntu", version: "22.04") do
+      assert_equal 1, Hive::Platform.classify.level
+    end
+    with_platform(os: :linux, arch: :x86_64, distro: "ubuntu", version: "20.04") do
+      assert_equal 2, Hive::Platform.classify.level
+    end
+    %w[debian manjaro endeavouros].each do |distro|
+      with_platform(os: :linux, arch: :x86_64, distro: distro, version: "99") do
+        assert_equal 2, Hive::Platform.classify.level
+      end
+    end
+    with_platform(os: :linux, arch: :x86_64, distro: "arch") do
+      assert_equal 1, Hive::Platform.classify.level
+    end
+  end
+end
diff --git a/test/unit/project/cleanup_test.rb b/test/unit/project/cleanup_test.rb
new file mode 100644
index 00000000..f046461e
--- /dev/null
+++ b/test/unit/project/cleanup_test.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/project/scaffold"
+require "hive/project/cleanup"
+
+class ProjectCleanupTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_removes_only_hash_matching
+    with_tmp_dir do |dir|
+      Hive::Project::Scaffold.new(dir).call
+      File.write(File.join(dir, ".hive", "README.md"), "changed by user\n")
+      # config.example.yml stays unmodified
+
+      plan = Hive::Project::Cleanup.new(dir).preview
+      assert plan.preserved.any? { |p| p.end_with?("README.md") }
+      assert plan.removed.any? { |p| p.end_with?("config.example.yml") }
+
+      result = Hive::Project::Cleanup.new(dir).call!(confirm: true)
+      assert File.exist?(File.join(dir, ".hive", "README.md"))
+      refute File.exist?(File.join(dir, ".hive", "config.example.yml"))
+      assert_includes result.removed, ".hive/config.example.yml"
+    end
+  end
+
+  def test_symlink_escape_preserved
+    with_tmp_dir do |dir|
+      Hive::Project::Scaffold.new(dir).call
+      outside = File.join(dir, "outside.txt")
+      File.write(outside, "secret\n")
+      # Replace a generated file with a symlink escaping ownership intent.
+      target = File.join(dir, ".hive", "config.example.yml")
+      FileUtils.rm_f(target)
+      File.symlink(outside, target)
+
+      plan = Hive::Project::Cleanup.new(dir).preview
+      refute_includes plan.removed, ".hive/config.example.yml"
+    end
+  end
+
+  def test_edited_manifest_cannot_claim_an_arbitrary_project_file
+    with_tmp_dir do |dir|
+      Hive::Project::Scaffold.new(dir).call
+      user_file = File.join(dir, "notes.txt")
+      File.write(user_file, "keep me\n")
+
+      manifest = Hive::Project::Manifest.load(dir)
+      manifest.data.fetch("generated")["notes.txt"] = {
+        "sha256" => Digest::SHA256.file(user_file).hexdigest,
+        "bytes" => File.size(user_file)
+      }
+      manifest.save!
+
+      plan = Hive::Project::Cleanup.new(dir).preview
+      refute_includes plan.removed, "notes.txt"
+      assert_includes plan.preserved, "notes.txt"
+
+      Hive::Project::Cleanup.new(dir).call!(confirm: true)
+      assert_equal "keep me\n", File.read(user_file)
+    end
+  end
+end
diff --git a/test/unit/project/manifest_test.rb b/test/unit/project/manifest_test.rb
new file mode 100644
index 00000000..01780549
--- /dev/null
+++ b/test/unit/project/manifest_test.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/project/manifest"
+require "digest"
+
+class ProjectManifestTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_record_and_unmodified
+    with_tmp_dir do |dir|
+      manifest = Hive::Project::Manifest.new(dir)
+      content = "hello\n"
+      rel = ".hive/README.md"
+      FileUtils.mkdir_p(File.join(dir, ".hive"))
+      File.write(File.join(dir, rel), content)
+      digest = manifest.record!(rel, content)
+      assert_equal Digest::SHA256.hexdigest(content), digest
+      manifest.save!
+      assert File.exist?(Hive::Project::Manifest.path_for(dir))
+      assert manifest.unmodified?(rel)
+
+      File.write(File.join(dir, rel), "changed\n")
+      refute manifest.unmodified?(rel)
+      assert_includes manifest.preserved_paths, rel
+    end
+  end
+
+  def test_load_roundtrip
+    with_tmp_dir do |dir|
+      m = Hive::Project::Manifest.new(dir)
+      m.record!(".hive/README.md", "x")
+      m.save!
+      loaded = Hive::Project::Manifest.load(dir)
+      assert_equal Hive::VERSION, loaded.template_version
+      assert loaded.generated[".hive/README.md"]
+    end
+  end
+end
diff --git a/test/unit/project/scaffold_test.rb b/test/unit/project/scaffold_test.rb
new file mode 100644
index 00000000..19129339
--- /dev/null
+++ b/test/unit/project/scaffold_test.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/project/scaffold"
+
+class ProjectScaffoldTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_creates_hive_scaffold
+    with_tmp_dir do |dir|
+      result = Hive::Project::Scaffold.new(dir).call
+      assert File.directory?(File.join(dir, ".hive"))
+      assert File.exist?(File.join(dir, ".hive", "manifest.yml"))
+      assert File.exist?(File.join(dir, ".hive", "README.md"))
+      assert result.created.any?
+      assert_empty result.conflicts
+    end
+  end
+
+  def test_idempotent_rerun
+    with_tmp_dir do |dir|
+      first = Hive::Project::Scaffold.new(dir).call
+      second = Hive::Project::Scaffold.new(dir).call
+      assert first.created.any?
+      assert_empty second.created
+      assert second.skipped.any?
+    end
+  end
+
+  def test_preserves_user_modified_file
+    with_tmp_dir do |dir|
+      Hive::Project::Scaffold.new(dir).call
+      path = File.join(dir, ".hive", "README.md")
+      File.write(path, "my custom notes\n")
+      result = Hive::Project::Scaffold.new(dir).call
+      assert_equal "my custom notes\n", File.read(path)
+      assert_includes result.conflicts, ".hive/README.md"
+    end
+  end
+
+  def test_unclaimed_hive_dir_refuses
+    with_tmp_dir do |dir|
+      FileUtils.mkdir_p(File.join(dir, ".hive"))
+      File.write(File.join(dir, ".hive", "other.txt"), "not ours\n")
+      err = assert_raises(Hive::Error) { Hive::Project::Scaffold.new(dir).call }
+      assert_match(/without a Hive manifest/, err.message)
+    end
+  end
+end
diff --git a/test/unit/release/manifest_test.rb b/test/unit/release/manifest_test.rb
new file mode 100644
index 00000000..e004a786
--- /dev/null
+++ b/test/unit/release/manifest_test.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "json"
+require "open3"
+
+class ReleaseManifestTest < Minitest::Test
+  include HiveTestHelper
+
+  def scripts
+    File.expand_path("../../../scripts", __dir__)
+  end
+
+  def test_package_release_roundtrip
+    with_tmp_dir do |dir|
+      dist = File.join(dir, "dist")
+      env = { "DIST_DIR" => dist, "PATH" => ENV["PATH"], "HOME" => dir, "HIVE_ALLOW_SOURCE_BUNDLE" => "1" }
+      out, err, status = Open3.capture3(env, "bash", File.join(scripts, "package-release"), "v0.1.0", "abc1234")
+      assert status.success?, "package-release failed: #{err}\n#{out}"
+      assert File.exist?(File.join(dist, "manifest.json"))
+      assert File.exist?(File.join(dist, "SHA256SUMS"))
+      assert File.exist?(File.join(dist, "install.sh"))
+      assert_match(/install\.sh/, File.read(File.join(dist, "SHA256SUMS")))
+
+      manifest = JSON.parse(File.read(File.join(dist, "manifest.json")))
+      assert_equal 1, manifest["schema_version"]
+      assert_equal "v0.1.0", manifest["tag"]
+      assert_equal "0.1.0", manifest["version"]
+      refute_empty manifest["assets"]
+      manifest["assets"].each do |a|
+        refute_includes a["url"], "/latest/"
+        assert_match(/\A[a-f0-9]{64}\z/, a["sha256"])
+      end
+
+      _o, _e, st = Open3.capture3(env, "bash", File.join(scripts, "verify-release"), dist)
+      assert st.success?
+
+      # One-byte mutation fails.
+      archive = Dir[File.join(dist, "hive-*.tar.gz")].first
+      File.open(archive, "ab") { |f| f.write("x") }
+      _o, _e, st2 = Open3.capture3(env, "bash", File.join(scripts, "verify-release"), dist)
+      refute st2.success?
+    end
+  end
+
+
+  def test_equivalent_builds_are_byte_for_byte_deterministic
+    with_tmp_dir do |dir|
+      epoch = "1700000000"
+      manifests = []
+      archives = []
+      2.times do |index|
+        dist = File.join(dir, "dist-#{index}")
+        env = {
+          "DIST_DIR" => dist,
+          "PATH" => ENV["PATH"],
+          "HOME" => dir,
+          "HIVE_ALLOW_SOURCE_BUNDLE" => "1",
+          "SOURCE_DATE_EPOCH" => epoch
+        }
+        out, err, status = Open3.capture3(
+          env, "bash", File.join(scripts, "package-release"), "v0.1.0", "abc1234"
+        )
+        assert status.success?, "#{err}\n#{out}"
+        manifests << File.binread(File.join(dist, "manifest.json"))
+        archives << File.binread(Dir[File.join(dist, "hive-*.tar.gz")].first)
+      end
+      assert_equal manifests.first, manifests.last
+      assert_equal archives.first, archives.last
+    end
+  end
+
+  def test_release_build_refuses_missing_tebako_without_test_override
+    with_tmp_dir do |dir|
+      env = { "DIST_DIR" => File.join(dir, "dist"), "PATH" => ENV["PATH"], "HOME" => dir }
+      _out, err, status = Open3.capture3(env, "bash", File.join(scripts, "build-release"))
+      refute status.success?
+      assert_match(/tebako is required/, err)
+    end
+  end
+
+  def test_manifest_schema_file_exists
+    path = File.expand_path("../../../release/manifest.schema.json", __dir__)
+    assert File.exist?(path)
+    schema = JSON.parse(File.read(path))
+    assert_equal 1, schema.dig("properties", "schema_version", "const")
+  end
+end
diff --git a/test/unit/schema_files_test.rb b/test/unit/schema_files_test.rb
index b5ce1b18..c85e0de0 100644
--- a/test/unit/schema_files_test.rb
+++ b/test/unit/schema_files_test.rb
@@ -1038,7 +1038,7 @@ class SchemaFilesTest < Minitest::Test
     # The producer's exhaustive key set (kept in sync with
     # Hive::Commands::Daemon#status_daemon's JSON.generate call).
     producer_required = %w[
-      schema schema_version ok running pid uptime_sec pid_file log_file
+      schema schema_version ok running pid uptime_sec pid_file log_file service
     ].sort
     assert_equal producer_required, schema_required,
                  "schema/producer required-key drift in hive-daemon-status.v1.json"
diff --git a/test/unit/service/launchd_test.rb b/test/unit/service/launchd_test.rb
new file mode 100644
index 00000000..73dd981f
--- /dev/null
+++ b/test/unit/service/launchd_test.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/service/launchd"
+
+class ServiceLaunchdTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_renders_valid_plist
+    with_tmp_dir do |dir|
+      contract = Hive::Service::Adapter::Contract.new(
+        command_path: "/usr/local/bin/hive",
+        state_dir: dir,
+        runtime_dir: File.join(dir, "run"),
+        log_path: File.join(dir, "daemon.log"),
+        env: { "HIVE_HOME" => dir }
+      )
+      adapter = Hive::Service::Launchd.new(contract)
+      plist = adapter.render
+      assert_includes plist, "<string>dev.hive.daemon</string>"
+      assert_includes plist, "<string>/usr/local/bin/hive</string>"
+      assert_includes plist, "<string>daemon</string>"
+      assert_includes plist, "<string>run</string>"
+      assert_includes plist, "<key>RunAtLoad</key>"
+      assert_includes plist, "<false/>"
+      refute_match(/RunAtLoad<\/key>\s*<true\/>/, plist)
+    end
+  end
+
+  def test_write_definition_idempotent
+    with_tmp_dir do |dir|
+      # Redirect LaunchAgents into tmp by subclassing path via HOME.
+      old_home = ENV["HOME"]
+      ENV["HOME"] = dir
+      begin
+        contract = Hive::Service::Adapter::Contract.new(
+          command_path: "#{dir}/bin/hive",
+          state_dir: dir,
+          runtime_dir: File.join(dir, "run"),
+          log_path: File.join(dir, "daemon.log"),
+          env: {}
+        )
+        adapter = Hive::Service::Launchd.new(contract)
+        path = adapter.definition_path
+        content = adapter.render
+        adapter.send(:write_definition, path, content)
+        assert File.exist?(path)
+        assert_equal content, File.read(path)
+      ensure
+        ENV["HOME"] = old_home
+      end
+    end
+  end
+
+  def test_prompt_declined_does_not_start
+    with_tmp_dir do |dir|
+      contract = Hive::Service::Adapter::Contract.new(
+        command_path: "hive", state_dir: dir, runtime_dir: dir,
+        log_path: File.join(dir, "d.log"), env: {}
+      )
+      adapter = Hive::Service::Launchd.new(contract)
+      input = StringIO.new("n\n")
+      def input.tty? = true
+      output = StringIO.new
+      result = adapter.prompt_start_if_interactive!(input: input, output: output)
+      assert_equal :declined, result
+      assert_match(/not started/, output.string)
+    end
+  end
+
+  def test_non_interactive_does_not_autostart
+    with_tmp_dir do |dir|
+      contract = Hive::Service::Adapter::Contract.new(
+        command_path: "hive", state_dir: dir, runtime_dir: dir,
+        log_path: File.join(dir, "d.log"), env: {}
+      )
+      adapter = Hive::Service::Launchd.new(contract)
+      input = StringIO.new
+      def input.tty? = false
+      output = StringIO.new
+      result = adapter.prompt_start_if_interactive!(input: input, output: output)
+      assert_equal :non_interactive, result
+      assert_match(/non-interactive/, output.string)
+    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..4e98da12
--- /dev/null
+++ b/test/unit/service/systemd_user_test.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/service/systemd_user"
+
+class ServiceSystemdUserTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_renders_valid_unit
+    with_tmp_dir do |dir|
+      contract = Hive::Service::Adapter::Contract.new(
+        command_path: "/usr/bin/hive",
+        state_dir: dir,
+        runtime_dir: File.join(dir, "run"),
+        log_path: File.join(dir, "daemon.log"),
+        env: { "HIVE_HOME" => dir, "XDG_STATE_HOME" => dir }
+      )
+      adapter = Hive::Service::SystemdUser.new(contract)
+      unit = adapter.render
+      assert_includes unit, "[Unit]"
+      assert_includes unit, "[Service]"
+      assert_includes unit, "[Install]"
+      assert_includes unit, "ExecStart=/usr/bin/hive daemon run"
+      assert_includes unit, "WantedBy=default.target"
+      assert_includes unit, "Restart=on-failure"
+      assert_includes unit, %(Environment="HIVE_HOME=#{dir}")
+      assert_includes unit, %(Environment="XDG_STATE_HOME=#{dir}")
+    end
+  end
+
+  def test_start_propagates_enable_failure
+    with_tmp_dir do |dir|
+      contract = Hive::Service::Adapter::Contract.new(
+        command_path: File.join(dir, "hive"), state_dir: dir, runtime_dir: dir,
+        log_path: File.join(dir, "d.log"), env: {}
+      )
+      adapter = Hive::Service::SystemdUser.new(contract)
+      adapter.define_singleton_method(:systemd_available?) { true }
+      calls = []
+      adapter.define_singleton_method(:system) do |*args, **_opts|
+        calls << args
+        false
+      end
+      error = assert_raises(Hive::Error) { adapter.start! }
+      assert_match(/enable hive\.service failed/, error.message)
+      assert_equal 1, calls.size
+    end
+  end
+
+  def test_nonzero_user_bus_probe_is_unavailable
+    with_tmp_dir do |dir|
+      systemctl = File.join(dir, "systemctl")
+      File.write(systemctl, "#!/bin/sh\necho 'Failed to connect to bus' >&2\nexit 1\n")
+      FileUtils.chmod(0o755, systemctl)
+      old_path = ENV["PATH"]
+      ENV["PATH"] = dir
+      contract = Hive::Service::Adapter::Contract.new(
+        command_path: "hive", state_dir: dir, runtime_dir: dir,
+        log_path: File.join(dir, "d.log"), env: {}
+      )
+      adapter = Hive::Service::SystemdUser.new(contract)
+      refute adapter.send(:systemd_available?)
+    ensure
+      ENV["PATH"] = old_path
+    end
+  end
+
+  def test_definition_path_respects_xdg_config
+    with_tmp_dir do |dir|
+      old = ENV["XDG_CONFIG_HOME"]
+      ENV["XDG_CONFIG_HOME"] = File.join(dir, "cfg")
+      begin
+        contract = Hive::Service::Adapter::Contract.new(
+          command_path: "hive", state_dir: dir, runtime_dir: dir,
+          log_path: File.join(dir, "d.log"), env: {}
+        )
+        adapter = Hive::Service::SystemdUser.new(contract)
+        assert_equal File.join(dir, "cfg", "systemd", "user", "hive.service"),
+                     adapter.definition_path
+      ensure
+        old.nil? ? ENV.delete("XDG_CONFIG_HOME") : ENV["XDG_CONFIG_HOME"] = old
+      end
+    end
+  end
+
+  def test_register_writes_unit_without_systemd
+    with_tmp_dir do |dir|
+      old_cfg = ENV["XDG_CONFIG_HOME"]
+      old_path = ENV["PATH"]
+      ENV["XDG_CONFIG_HOME"] = File.join(dir, "cfg")
+      # Empty PATH so systemctl is "missing" → graceful fallback.
+      ENV["PATH"] = dir
+      begin
+        contract = Hive::Service::Adapter::Contract.new(
+          command_path: File.join(dir, "hive"),
+          state_dir: dir, runtime_dir: dir,
+          log_path: File.join(dir, "d.log"), env: {}
+        )
+        adapter = Hive::Service::SystemdUser.new(contract)
+        # Force systemd_available? false
+        adapter.define_singleton_method(:systemd_available?) { false }
+        path = adapter.register!
+        assert File.exist?(path)
+        assert_includes File.read(path), "ExecStart="
+      ensure
+        old_cfg.nil? ? ENV.delete("XDG_CONFIG_HOME") : ENV["XDG_CONFIG_HOME"] = old_cfg
+        ENV["PATH"] = old_path
+      end
+    end
+  end
+
+  def test_stop_and_unregister_are_idempotent_without_a_definition
+    with_tmp_dir do |dir|
+      contract = Hive::Service::Adapter::Contract.new(
+        command_path: File.join(dir, "hive"), state_dir: dir, runtime_dir: dir,
+        log_path: File.join(dir, "d.log"), env: {}
+      )
+      adapter = Hive::Service::SystemdUser.new(contract)
+      definition = File.join(dir, "missing.service")
+      adapter.define_singleton_method(:definition_path) { definition }
+      adapter.define_singleton_method(:systemd_available?) { true }
+      adapter.define_singleton_method(:system) { |*| raise "systemctl must not run" }
+
+      assert adapter.stop!
+      assert adapter.unregister!
+    end
+  end
+end
diff --git a/test/unit/state_migration_test.rb b/test/unit/state_migration_test.rb
new file mode 100644
index 00000000..909cf158
--- /dev/null
+++ b/test/unit/state_migration_test.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/state_migration"
+require "hive/paths"
+
+class StateMigrationTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_no_legacy_is_noop
+    with_tmp_dir do |dir|
+      dest = File.join(dir, "state")
+      FileUtils.mkdir_p(dest)
+      m = Hive::StateMigration.new(
+        legacy_global: File.join(dir, "missing-global"),
+        legacy_state: File.join(dir, "missing-state"),
+        destination: dest
+      )
+      result = m.call
+      refute result.migrated
+      assert_match(/no legacy/, result.skipped_reason)
+    end
+  end
+
+  def test_copies_and_retains_source
+    with_tmp_dir do |dir|
+      legacy = File.join(dir, "Dev", "hive")
+      FileUtils.mkdir_p(legacy)
+      File.write(File.join(legacy, "config.yml"), "registered_projects: []\n")
+      File.write(File.join(legacy, "notes.txt"), "keep me\n")
+      dest = File.join(dir, "xdg-state")
+      FileUtils.mkdir_p(dest)
+
+      m = Hive::StateMigration.new(
+        legacy_global: legacy,
+        legacy_state: File.join(dir, "nope"),
+        destination: dest,
+        config_destination: File.join(dir, "config", "hive", "config.yml")
+      )
+      result = m.call(copy: true)
+      assert result.migrated
+      assert File.exist?(File.join(legacy, "notes.txt")), "legacy source must be retained"
+      imported = File.join(dest, "legacy-import", "hive", "notes.txt")
+      assert File.exist?(imported)
+      assert_equal "keep me\n", File.read(imported)
+      assert File.exist?(File.join(dest, ".migration.yml"))
+      active_config = YAML.safe_load(File.read(File.join(dir, "config", "hive", "config.yml")))
+      assert_equal [], active_config["registered_projects"]
+
+      # Second run is a no-op.
+      second = m.call(copy: true)
+      refute second.migrated
+    end
+  end
+
+
+  def test_adopts_legacy_registry_and_daemon_config_into_active_xdg_config
+    with_tmp_dir do |dir|
+      legacy = File.join(dir, "Dev", "hive")
+      FileUtils.mkdir_p(legacy)
+      File.write(File.join(legacy, "config.yml"), {
+        "registered_projects" => [ { "name" => "old", "path" => "/tmp/old" } ],
+        "daemon" => { "enabled" => true }
+      }.to_yaml)
+      active = File.join(dir, "config", "hive", "config.yml")
+      Hive::Paths.write_private_file(active, {
+        "provider" => "openai",
+        "registered_projects" => [ { "name" => "new", "path" => "/tmp/new" } ]
+      }.to_yaml)
+
+      migration = Hive::StateMigration.new(
+        legacy_global: legacy,
+        legacy_state: File.join(dir, "missing"),
+        destination: File.join(dir, "state"),
+        config_destination: active
+      )
+      migration.call(copy: true)
+
+      adopted = YAML.safe_load(File.read(active))
+      assert_equal %w[new old], adopted.fetch("registered_projects").map { |p| p.fetch("name") }
+      assert_equal true, adopted.dig("daemon", "enabled")
+      assert_equal "openai", adopted["provider"]
+      assert File.exist?(File.join(legacy, "config.yml"))
+    end
+  end
+end
diff --git a/test/unit/user_config_test.rb b/test/unit/user_config_test.rb
new file mode 100644
index 00000000..c37cff9b
--- /dev/null
+++ b/test/unit/user_config_test.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "hive/user_config"
+require "hive/paths"
+
+class UserConfigTest < Minitest::Test
+  include HiveTestHelper
+
+  def with_user_config_home
+    with_tmp_dir do |home|
+      old = {
+        "HOME" => ENV["HOME"],
+        "HIVE_HOME" => ENV["HIVE_HOME"],
+        "XDG_CONFIG_HOME" => ENV["XDG_CONFIG_HOME"]
+      }
+      ENV["HOME"] = home
+      ENV.delete("HIVE_HOME")
+      ENV["XDG_CONFIG_HOME"] = File.join(home, "config")
+      begin
+        yield home
+      ensure
+        old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
+      end
+    end
+  end
+
+  def test_writes_0600_with_env_reference
+    with_user_config_home do
+      path = Hive::UserConfig.ensure_defaults!(
+        provider: "openai",
+        model: "gpt-5",
+        api_key_env: "OPENAI_API_KEY"
+      )
+      assert File.exist?(path)
+      mode = File.stat(path).mode & 0o777
+      assert_equal 0o600, mode
+      data = YAML.safe_load(File.read(path))
+      assert_equal "openai", data["provider"]
+      assert_equal "OPENAI_API_KEY", data["api_key_env"]
+      refute data.key?("api_key")
+    end
+  end
+
+  def test_rejects_plaintext_secret
+    with_user_config_home do
+      err = assert_raises(Hive::ConfigError) do
+        Hive::UserConfig.write!("provider" => "x", "note" => "sk-abcdefghijklmnopqrstuv")
+      end
+      assert_match(/plaintext secret/, err.message)
+
+      err2 = assert_raises(Hive::ConfigError) do
+        Hive::UserConfig.write!("provider" => "x", "api_key" => "sk-abcdefghijklmnopqrstuv", "model" => "m")
+      end
+      assert_match(/plaintext secret/, err2.message)
+      refute File.exist?(Hive::UserConfig.path)
+    end
+  end
+
+  def test_redact
+    assert_equal "[REDACTED]", Hive::UserConfig.redact("sk-abcdefghijklmnopqrstuv")
+  end
+
+  def test_idempotent_ensure
+    with_user_config_home do
+      Hive::UserConfig.ensure_defaults!(provider: "a", model: "m1", api_key_env: "A_API_KEY")
+      Hive::UserConfig.ensure_defaults!(provider: "b", model: "m2", api_key_env: "B_API_KEY")
+      data = Hive::UserConfig.load
+      assert_equal "a", data["provider"]
+      assert_equal "m1", data["model"]
+    end
+  end
+
+  def test_non_interactive_first_run_requires_explicit_configuration_without_writing
+    with_user_config_home do
+      error = assert_raises(Hive::ConfigError) do
+        Hive::UserConfig.ensure_configured!(input: StringIO.new)
+      end
+      assert_match(/--provider/, error.message)
+      refute File.exist?(Hive::UserConfig.path)
+    end
+  end
+
+  def test_interactive_first_run_collects_references_not_secrets
+    with_user_config_home do
+      input = StringIO.new("anthropic\nclaude-sonnet-4-5\nANTHROPIC_API_KEY\n")
+      input.define_singleton_method(:tty?) { true }
+      output = StringIO.new
+      Hive::UserConfig.ensure_configured!(input: input, output: output)
+      data = Hive::UserConfig.load
+      assert_equal "anthropic", data["provider"]
+      assert_equal "claude-sonnet-4-5", data["model"]
+      assert_equal "ANTHROPIC_API_KEY", data["api_key_env"]
+      refute_match(/sk-|API key value/i, File.read(Hive::UserConfig.path))
+    end
+  end
+
+  def test_repairs_blank_values_in_a_partial_config
+    with_user_config_home do
+      Hive::UserConfig.write!(
+        "provider" => "",
+        "model" => "",
+        "api_key_env" => ""
+      )
+      Hive::UserConfig.ensure_configured!(
+        provider: "openai", model: "gpt-5", api_key_env: "OPENAI_API_KEY",
+        input: StringIO.new
+      )
+
+      data = Hive::UserConfig.load
+      assert_equal "openai", data["provider"]
+      assert_equal "gpt-5", data["model"]
+      assert_equal "OPENAI_API_KEY", data["api_key_env"]
+    end
+  end
+end
diff --git a/wiki/cli.md b/wiki/cli.md
index 179a4998..1e584023 100644
--- a/wiki/cli.md
+++ b/wiki/cli.md
@@ -3,7 +3,7 @@ title: CLI Surface
 type: api
 source: bin/hive, lib/hive/cli.rb
 created: 2026-04-25
-updated: 2026-05-14
+updated: 2026-07-15
 tags: [cli, api]
 ---
 
@@ -17,11 +17,13 @@ tags: [cli, api]
 
 | Command | Synopsis | Routes to | Page |
 |---------|----------|-----------|------|
-| `hive init [PROJECT_PATH]` | Bootstrap `.hive-state` orphan branch + worktree in a git project | `Hive::Commands::Init` | [[commands/init]] |
+| `hive init [PROJECT_PATH] [--provider NAME --model NAME --api-key-env ENV_NAME]` | Create or repair user configuration, `.hive-state`, registry, scaffold, migration, and service registration | `Hive::Commands::Init` | [[commands/init]] |
+| `hive update` | Detect the owning install channel, run its collision-safe updater, verify the resulting command version, and preserve service state | `Hive::Commands::Update` | [[distribution]] |
+| `hive uninstall [--yes] [--clean-project]` | Resolve ownership before mutation, remove service registration, delegate binary removal, and optionally remove allowlisted unmodified scaffold | `Hive::Commands::Uninstall` | [[distribution]] |
 | `hive new PROJECT TEXT...` | Create a task in `1-inbox/` of a registered project | `Hive::Commands::New` | [[commands/new]] |
 | `hive status [--diagnose SLUG [--write [--force]] [--project NAME] [--stage STAGE]]` | Action-grouped task list across registered projects. With `--diagnose <slug>`, prints the bounded diagnostic for one task (schema `hive-status-diagnose`). Add `--write` to spawn the configured execute `AgentProfile` and atomically write `<task>/diagnostics/red-status.md` (no lock, no marker mutation; `--force` bypasses the `marker_signature` idempotency short-circuit; green rows are rejected). | `Hive::Commands::Status` (delegates write path to `Hive::DiagnosisAgent`) | [[commands/status]] |
 | `hive tui` | Live, keystroke-driven Charm bubbletea + lipgloss dashboard over `hive status` (human-only; rejects `--json`) | `Hive::Tui` | [[commands/tui]] |
-| `hive daemon SUBCOMMAND` | Auto-advance pipeline dispatcher. Subcommands: `start` / `stop` / `status` / `reload` / `tail` for the daemon process; `enable PROJECT \| --all` and `disable` for per-project enrollment (atomic write to `<project>/.hive-state/config.yml`). Polls `hive status --json` and fires workflow verbs on tasks ready to advance; auto-archives 7-finalize after PR merge. New projects opt in at `hive init` (default Y); existing ones via `hive daemon enable`. See [[operating]] for the install + systemd/launchd setup guide. | `Hive::Commands::Daemon` | [[commands/daemon]] |
+| `hive daemon SUBCOMMAND` | Auto-advance pipeline dispatcher. Public `start` / `stop` / `status` control the registered launchd or `systemd --user` service; `run` is its foreground entry. `reload` / `tail` and project `enable` / `disable` remain available. | `Hive::Commands::Daemon` | [[commands/daemon]] |
 | `hive bot SUBCOMMAND` | Telegram bot lifecycle. Subcommands: `start` / `stop` / `status` / `reload` / `tail`. Long-polls Telegram, authenticates by `bot.chat_id_allowlist`, notifies on waiting/recovery gates, and dispatches existing `hive` commands from inline buttons. | `Hive::Commands::Bot` | [[commands/bot]] |
 | `hive brainstorm TARGET [--from STAGE]` | Start or re-run brainstorm by slug/path | `Hive::Commands::StageAction` → approve/run | [[commands/stage_action]] |
 | `hive plan TARGET [--from STAGE]` | Promote completed brainstorm to plan, or re-run plan | `Hive::Commands::StageAction` → approve/run | [[commands/stage_action]] |
@@ -39,7 +41,7 @@ tags: [cli, api]
 | `hive markers clear FOLDER --name <NAME> [--project NAME] [--json]` | Remove a recovery marker (`REVIEW_STALE`, `REVIEW_CI_STALE`, `REVIEW_ERROR`, `EXECUTE_STALE`, `ERROR`) from a task's state file (atomic write + hive_commit). Terminal-success markers (`REVIEW_COMPLETE` / `EXECUTE_COMPLETE` / `COMPLETE`) are deliberately rejected — use `hive approve` instead. | `Hive::Commands::Markers` | [[commands/markers]] |
 | `hive metrics SUBCOMMAND [--days N] [--project NAME] [--json]` | Compute project-wide metrics. Currently one subcommand: `rollback-rate` walks `git log --all` and reports the fraction of fix-commits (those carrying `Hive-Fix-Pass` trailer) that were later reverted, broken down by `Hive-Triage-Bias` and `Hive-Fix-Phase`. | `Hive::Commands::Metrics` → `Hive::Metrics` | — |
 | `hive doctor [--json]` | Walk `brainstorm` + `plan` stage configs and every `review.reviewers[]` entry, asking each agent profile to verify its configured skill resolves to an installed slash-command / SKILL.md on disk. When `brainstorm.runtime == "tmux_interactive"` also adds a `kind: "dependency"` row checking tmux availability + minimum version. Also runs non-fatally at the tail of `hive init`. Exits 0 / 65 / 78. | `Hive::Commands::Doctor` | [[commands/doctor]] |
-| `hive forget NAME [--json]` | Drop one named entry from the global registry (`~/Dev/hive/config.yml`). Inverse of `hive init`. The project's `.hive-state` directory on disk is not touched. Unknown name → exit 64. | `Hive::Commands::Forget` | [[commands/forget]] |
+| `hive forget NAME [--json]` | Drop one named entry from the global registry (`$XDG_CONFIG_HOME/hive/config.yml`). Inverse of `hive init`. The project's `.hive-state` directory on disk is not touched. Unknown name → exit 64. | `Hive::Commands::Forget` | [[commands/forget]] |
 | `hive prune [--dry-run] [--json]` | Drop every registry entry whose `path` is no longer a directory on disk OR whose row shape is invalid (hand-edit accident). `--dry-run` returns the would-be-removed list without writing. | `Hive::Commands::Prune` | [[commands/prune]] |
 | `hive version` / `hive --version` | Print `Hive::VERSION` and exit 0. Used by e2e environment snapshots and binary smoke tests. | `Hive::CLI#version` | — |
 
@@ -48,7 +50,9 @@ tags: [cli, api]
 - `new_task` is mapped to the user-visible `new` (Thor reserves `new`).
 - `run_task` is mapped to `run`.
 - Stage verbs use `--from` for source-stage disambiguation because the verb already implies the target stage.
-- `init` accepts `--force` (skip clean-tree check).
+- `init` accepts `--force` plus explicit first-run `--provider`, `--model`, and
+  `--api-key-env` choices. Reruns repair existing initialization rather than
+  rejecting the `hive/state` branch.
 - `--json` is a `class_option` honoured by `status`, `run`, `rebase-status`, `approve`, `findings`, `accept-finding`, `reject-finding`, the workflow verbs (`brainstorm`, `plan`, `develop`, `open-pr`, `review`, `finalize`, `archive`), `markers clear`, `metrics`, `forget`, `prune`, the `daemon` subcommands (`status`, `stop`, `reload`, `enable`, `disable`), and the `bot` lifecycle subcommands (`status`, `stop`, `reload`). Daemon lifecycle JSON is published as `hive-daemon-status.v1` / `-stop.v1` / `-reload.v1` / `-enroll.v1`; bot lifecycle JSON is published as `hive-bot-status.v1` / `-stop.v1` / `-reload.v1`. Each command with full envelope support emits a typed JSON document on success and a structured error envelope on failure. Workflow verbs emit a single `hive-stage-action` envelope (inner Approve and Run are passed `quiet: true` to avoid double-emission). `rebase-status` emits a sibling read-only `hive-rebase-status` envelope — not validated against `hive-run.v1`.
 - `bin/hive` rewrites `<cmd> --help` / `<cmd> -h` into `help <cmd>` before Thor dispatch, so the convention agents try first works (without the rewrite, Thor would consume `--help` as the next positional argument).
 - `bin/hive` handles top-level `--version` / `-v` before Thor dispatch so wrappers can smoke-test the binary without parsing help output.
@@ -59,7 +63,7 @@ tags: [cli, api]
 |------|----------|---------|-----------|
 | 0 | `SUCCESS` | command completed | — |
 | 1 | `GENERIC` | unclassified `Hive::Error` | base `Hive::Error` |
-| 2 | `ALREADY_INITIALIZED` | idempotent reject of `hive init` on existing project | `Hive::AlreadyInitialized` |
+| 2 | `ALREADY_INITIALIZED` | reserved compatibility code; current init repairs existing projects | legacy `Hive::AlreadyInitialized` callers only |
 | 3 | `TASK_IN_ERROR` | a stage agent recorded `:error` (runner itself succeeded) | `Hive::TaskInErrorState` |
 | 4 | `WRONG_STAGE` | `hive run` invoked on an inert stage (e.g. `1-inbox`) | `Hive::WrongStage` |
 | 64 | `USAGE` | EX_USAGE — bad slug, malformed task path | `Hive::InvalidTaskPath` |
@@ -93,7 +97,7 @@ The CLI itself has no auth. Preconditions checked at runtime by individual stage
 | `Hive::StageError` | `Commands::Run#pick_runner` for unknown stage names |
 | `Hive::TaskInErrorState` | `Commands::Run#report` when the stage marker is `:error` |
 | `Hive::WrongStage` | `Stages::Inbox#run!` (running an agent on an inert stage) |
-| `Hive::AlreadyInitialized` | `Commands::Init#call` when `hive/state` branch already exists |
+| `Hive::AlreadyInitialized` | compatibility class retained for older callers; the current init path does not raise it |
 
 A few stage runners still call `warn`/`exit N` directly for non-bug user errors that don't yet have a typed class — most notably `Init#validate_git_repo!` / `validate_clean_tree!` (exit 1), `Execute#run!` for `plan.md missing` (exit 1), and the `OpenPr` / `Finalize` network/auth abort paths. Migrating these to typed exceptions is tracked as Phase 2 follow-up work.
 
@@ -102,5 +106,6 @@ A few stage runners still call `warn`/`exit N` directly for non-bug user errors
 ## Backlinks
 
 - [[architecture]]
-- [[commands/init]] · [[commands/new]] · [[commands/run]] · [[commands/rebase-status]] · [[commands/status]] · [[commands/approve]] · [[commands/findings]] · [[commands/stage_action]] · [[commands/bot]]
+- [[commands/init]] · [[commands/new]] · [[commands/run]] · [[commands/rebase-status]] · [[commands/status]] · [[commands/approve]] · [[commands/findings]] · [[commands/stage_action]] · [[commands/daemon]] · [[commands/bot]]
+- [[distribution]] · [[modules/paths]]
 - [[stages/inbox]] · [[stages/brainstorm]] · [[stages/plan]] · [[stages/execute]] · [[stages/open-pr]] · [[stages/review]] · [[stages/finalize]] · [[stages/done]]
diff --git a/wiki/commands/bot.md b/wiki/commands/bot.md
index 0b360ad2..bacd719b 100644
--- a/wiki/commands/bot.md
+++ b/wiki/commands/bot.md
@@ -25,11 +25,11 @@ hive bot tail
 
 | Subcommand | Behavior |
 |-----------|----------|
-| `start` | Loads `bot:` from `~/Dev/hive/config.yml`, requires `HIVE_TELEGRAM_BOT_TOKEN`, writes `~/Dev/hive/.bot.pid`, then starts the long-poll/status/reaper supervisor. With `--detach`, daemonizes. With `--dry-run`, outbound Telegram parsing still works but child `hive ...` dispatches are reported rather than spawned. A second live bot exits `75 (TEMPFAIL)`. |
+| `start` | Loads `bot:` from `$XDG_CONFIG_HOME/hive/config.yml`, requires `HIVE_TELEGRAM_BOT_TOKEN`, writes the PID under `$XDG_STATE_HOME/hive`, then starts the long-poll/status/reaper supervisor. With `--detach`, daemonizes. With `--dry-run`, outbound Telegram parsing still works but child `hive ...` dispatches are reported rather than spawned. A second live bot exits `75 (TEMPFAIL)`. |
 | `stop` | Sends `SIGTERM` to the PID file's process and waits up to `bot.shutdown_grace_sec`, then escalates to `SIGKILL`. Idempotent when no bot is running. With `--json`, emits `hive-bot-stop.v1`. |
 | `status` | Reports running/not-running and exits `0` when running, `1` when not. With `--json`, emits `hive-bot-status.v1` with `running`, `pid`, `uptime_sec`, `pid_file`, and `log_file`. |
 | `reload` | Sends `SIGHUP`; the supervisor reloads config at the next loop boundary while preserving in-flight children and conversations. With `--json`, emits `hive-bot-reload.v1`. |
-| `tail` | Streams `~/Dev/hive/logs/bot.log`; exits 1 if the log does not exist. |
+| `tail` | Streams `$XDG_STATE_HOME/hive/logs/bot.log`; exits 1 if the log does not exist. |
 
 ## Commands in Telegram
 
@@ -67,9 +67,9 @@ bot:
   poll_interval_sec: 30
   long_poll_timeout_sec: 25
   notification_dedupe_window_sec: 300
-  pid_file: ~/Dev/hive/.bot.pid
-  log_file: ~/Dev/hive/logs/bot.log
-  last_seen_state_file: ~/Dev/hive/.bot.last_seen_update_id
+  pid_file: ~/.local/state/hive/.bot.pid
+  log_file: ~/.local/state/hive/logs/bot.log
+  last_seen_state_file: ~/.local/state/hive/.bot.last_seen_update_id
 ```
 
 `HIVE_TELEGRAM_BOT_TOKEN` is the only supported token source. Missing
@@ -79,7 +79,7 @@ silently.
 
 ## Structured log
 
-`~/Dev/hive/logs/bot.log` is one JSON document per line with schema
+`$XDG_STATE_HOME/hive/logs/bot.log` is one JSON document per line with schema
 `hive-bot-log.v1`. The event enum is closed in
 `Hive::Bot::Logger::EVENTS`; unknown events raise at the call site.
 Events include `bot_started`, `poll_failure`, `update_received`,
diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md
index 308f04ce..717d9b90 100644
--- a/wiki/commands/daemon.md
+++ b/wiki/commands/daemon.md
@@ -3,7 +3,7 @@ title: hive daemon
 type: command
 source: lib/hive/commands/daemon.rb, lib/hive/daemon/*
 created: 2026-05-06
-updated: 2026-05-15
+updated: 2026-07-15
 tags: [command, daemon, automation, json]
 ---
 
@@ -19,7 +19,8 @@ GitHub.
 ## Subcommands
 
 ```
-hive daemon start [--detach] [--dry-run]
+hive daemon start
+hive daemon run [--dry-run]
 hive daemon stop
 hive daemon status [--json]
 hive daemon reload
@@ -30,11 +31,12 @@ 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. |
-| `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`. |
+| `start`    | Starts the registered launchd or `systemd --user` service and propagates service-manager failures. If no service is registered, falls back to the legacy direct-process start. Managed start rejects `--detach` / `--dry-run`; those flags belong to the internal `run` entry. |
+| `run`      | Foreground service-manager entry that acquires the PID file and runs the dispatcher. It accepts `--dry-run` for bounded diagnostics but never detaches from its supervisor. Refuses with exit `75 (TEMPFAIL)` if a live daemon already owns the PID file. |
+| `stop`     | Stops the registered user service and propagates service-manager failures. If unregistered, uses the direct PID controller, including its idempotent no-PID and stale-PID behavior. With `--json`, emits a `hive-daemon-stop` envelope. |
+| `status`   | Combines direct-process state with the registered service state. The v1 JSON envelope always includes required `service`, as an adapter object or `null`; process fields remain present. |
 | `reload`   | Sends `SIGHUP` to the running daemon's PID, which triggers config reload at the next tick boundary. In-flight children continue uninterrupted. Exit 1 if no daemon running. With `--json`, emits a `hive-daemon-reload` envelope (`ok`, `reason`, `pid`, `message`). |
-| `tail`     | `tail -F` semantics on `~/Dev/hive/logs/daemon.log` (self-implemented; doesn't shell out to the `tail` binary). Exit 1 if the log file doesn't exist. |
+| `tail`     | `tail -F` semantics on `$XDG_STATE_HOME/hive/logs/daemon.log` (self-implemented; doesn't shell out to the `tail` binary). Exit 1 if the log file doesn't exist. |
 | `enable`   | Sets `daemon.enabled: true` in `<project>/.hive-state/config.yml`. Surgical line-level YAML editor (upsert) preserves comments, key order, and file-mode bits across enable/disable flips; rejects inline-flow `daemon: { ... }`, CRLF endings, and 4-space-indented children before any write. Atomic write goes via tempfile + `flock(LOCK_EX)` + `fsync` + rename; tempfile is ensure-cleaned on rename failure (ENOSPC / EACCES / EXDEV). Pre-flight (`preflight_targets`) validates every target before any write so `--all` cannot half-flip the registry on a bad middle project. Pass a registered project name OR `--all` (mutually exclusive — passing both raises USAGE 64). Exit 64 on missing/unknown target / not-initialised project / no registered projects. With `--json`, emits a `hive-daemon-enroll` envelope on success and an `EnrollErrorKind` JSON error envelope on failure (`missing_project` / `unknown_project` / `project_and_all` / `not_initialised` / `no_projects` / `config` / `internal`); YAML parse failures surface as `Hive::ConfigError` (exit 78). |
 | `disable`  | Same shape as `enable`, sets `daemon.enabled: false`. The next dispatcher tick honours the change automatically (per-tick enable-cache invalidation); `hive daemon reload` is optional for instant pickup. |
 
@@ -81,7 +83,7 @@ stage agents. Operators of legacy projects opt in by adding `daemon: { enabled:
 
 ## Concurrency caps
 
-All under `daemon:` in `~/Dev/hive/config.yml`:
+All under `daemon:` in `$XDG_CONFIG_HOME/hive/config.yml`:
 
 | Key | Default | Purpose |
 |-----|---------|---------|
@@ -93,7 +95,7 @@ All under `daemon:` in `~/Dev/hive/config.yml`:
 | `max_runs_per_day_per_project` | 50 | Circuit breaker for runaway loops. |
 | `transient_retry_backoff_sec` | 60 | Base of `60 → 120 → 300 s` backoff schedule. |
 | `shutdown_grace_sec` | 600 | TERM→KILL window for in-flight children on `daemon stop`. |
-| `log_file` | `~/Dev/hive/logs/daemon.log` | Structured-log destination. |
+| `log_file` | `$XDG_STATE_HOME/hive/logs/daemon.log` | Structured-log destination. |
 | `log_max_bytes` | 10485760 | 10 MB rotation threshold. |
 | `log_max_files` | 5 | 5 × 10 MB = 50 MB log budget. |
 
@@ -111,7 +113,7 @@ All under `daemon:` in `~/Dev/hive/config.yml`:
 
 ## Structured log
 
-`~/Dev/hive/logs/daemon.log` is one JSON document per line:
+`$XDG_STATE_HOME/hive/logs/daemon.log` is one JSON document per line:
 
 ```json
 {"ts":"2026-05-06T12:00:00Z","schema":"hive-daemon-log","schema_version":1,"event":"dispatched","pid":12345,"project":"writero","slug":"fix-x","stage":"6-review","command":"hive run fix-x --json","dry_run":false}
@@ -123,21 +125,21 @@ new events are caught at CI rather than logged silently.
 
 ## Operational notes
 
-- **Day-2 guide is at [[operating]]** — install path (`hive daemon
-  enable PROJECT|--all`), autostart on Linux (systemd-user) and
-  macOS (launchd) with sample units in `examples/systemd/` and
-  `examples/launchd/`, dry-run shakedown, and troubleshooting.
-- **First-time rollout:** `hive daemon start --dry-run` for ~24 hours
-  before going live. Inspect `daemon.log` to validate dispatch
-  decisions. Then `hive daemon stop` and re-start without `--dry-run`.
+- **Day-2 guide is at [[operating]]** — project enrollment, registered
+  launchd/systemd-user lifecycle, logging, and troubleshooting.
+- **Service ownership:** `hive init` renders the unit from the verified stable
+  `hive`/`hv` command path. The systemd template emits `HIVE_HOME` and all XDG
+  roots as individual escaped environment entries.
+- **Truthful lifecycle:** unavailable user buses, failed enable/start, and failed
+  stop/unregister operations remain errors. Init and uninstall do not continue
+  with a success claim or binary removal after those failures.
 - **Cost runaway response:** `hive daemon stop` is one command. To
   exclude a single project mid-flight: `hive daemon disable PROJECT`
   (the next tick honours it automatically). In-flight children
   continue to completion.
-- **macOS / Linux:** `Process.daemon` works on both; the daemon does
-  not require `systemd`. Sample autostart units ship at
-  `examples/systemd/hive-daemon.service` (Linux) and
-  `examples/launchd/hive-daemon.plist` (macOS).
+- **macOS / Linux:** normal lifecycle uses the generated LaunchAgent or
+  `systemd --user` unit. Direct process control is retained only when no service
+  registration exists.
 - **Pausing a single task:** edit the state file to remove the terminal
   marker (e.g., delete `<!-- EXECUTE_COMPLETE -->`) so the row no
   longer classifies as advance-ready. Daemon will skip until you write
@@ -147,8 +149,10 @@ new events are caught at CI rather than logged silently.
 
 | Subcommand | Code | Condition |
 |------------|------|-----------|
-| `start`    | 0    | (foreground daemon exited cleanly) |
-| `start`    | 75   | Another daemon already running (TEMPFAIL) |
+| `start`    | 0    | registered service started |
+| `start`    | 1/70 | service manager unavailable or start failed |
+| `run`      | 0    | foreground dispatcher exited cleanly |
+| `run`      | 75   | another daemon already running (TEMPFAIL) |
 | `stop`     | 0    | Always (idempotent) |
 | `status`   | 0    | Daemon is running |
 | `status`   | 1    | Daemon is not running |
diff --git a/wiki/commands/forget.md b/wiki/commands/forget.md
index 460118f0..63f8bde8 100644
--- a/wiki/commands/forget.md
+++ b/wiki/commands/forget.md
@@ -7,7 +7,10 @@ updated: 2026-05-06
 tags: [command, registry, cleanup, json]
 ---
 
-**TLDR**: `hive forget NAME [--json]` removes the entry whose `name` matches NAME from the global registry (`~/Dev/hive/config.yml`). Inverse of `hive init`. The project's `.hive-state` directory on disk is **not** touched — registry and on-disk state are intentionally independent. Unknown name is a USAGE error (exit 64); empty NAME positional is a distinct USAGE error with `error_kind: "missing_name"`. Bulk version: [[commands/prune]].
+**TLDR**: `hive forget NAME [--json]` removes the entry whose `name` matches
+NAME from the global registry (`$XDG_CONFIG_HOME/hive/config.yml`, or the
+explicit `HIVE_HOME` override). Inverse of `hive init`. The project's
+`.hive-state` directory is not touched. Bulk version: [[commands/prune]].
 
 ## Usage
 
diff --git a/wiki/commands/init.md b/wiki/commands/init.md
index ee73c372..40914cab 100644
--- a/wiki/commands/init.md
+++ b/wiki/commands/init.md
@@ -1,103 +1,107 @@
 ---
 title: hive init
 type: command
-source: lib/hive/commands/init.rb
+source: lib/hive/commands/init.rb, lib/hive/user_config.rb, lib/hive/state_migration.rb
 created: 2026-04-25
-updated: 2026-05-04
-tags: [command, bootstrap, git, prompts]
+updated: 2026-07-15
+tags: [command, bootstrap, git, prompts, xdg]
 ---
 
-**TLDR**: `hive init [PATH]` bootstraps a project for hive: creates the orphan `hive/state` branch, attaches it as a worktree at `<project>/.hive-state/`, scaffolds stage folders, asks the operator (on TTY) which agents to use for planning / development / review and what budget+timeout sanity caps to set, scaffolds `config.yml` from those answers, ignores `.hive-state/` in master, and registers the project globally.
+**TLDR**: `hive init [PATH]` creates or repairs the orphan `hive/state`
+worktree, user/provider configuration, project configuration, generated
+scaffold, global registry row, legacy adoption, and user-service registration.
+The operation is idempotent: rerunning after partial success completes the
+missing steps instead of rejecting an existing branch.
 
 ## Usage
 
-```
+```text
 hive init [PROJECT_PATH] [--force]
+          [--provider NAME] [--model NAME] [--api-key-env ENV_NAME]
 ```
 
-`PROJECT_PATH` defaults to `Dir.pwd`. `--force` skips the clean-tree check.
+`PROJECT_PATH` defaults to `Dir.pwd`. `--force` skips the clean-tree check when
+creating state for the first time. The provider options may instead come from
+`HIVE_PROVIDER`, `HIVE_MODEL`, and `HIVE_API_KEY_ENV`.
 
 ## Preconditions
 
-1. Path must be a git repository — `git -C <path> rev-parse --git-common-dir` must succeed.
-2. Path must be the **main checkout**, not a worktree. The check compares git's common-dir against `<path>/.git`. Running inside an existing worktree exits 1 with `"target appears to be inside a worktree"`.
-3. Working tree must be clean unless `--force` is passed; otherwise exit 1 with `"uncommitted changes"`.
-4. `hive/state` branch must not already exist; if it does, prints `"already initialized"` and exits 2 (idempotent).
-
-## Steps performed
-
-1. **Validate** — `validate_git_repo!` then `validate_clean_tree!` (skipped under `--force`).
-2. **Already-initialized guard** — if `hive/state` exists, raise `Hive::AlreadyInitialized` (exit 2). Runs **before** the prompt so a re-run never asks the user anything.
-3. **Collect prompt answers** — `Hive::Commands::Init::Prompts.new(input: $stdin, output: $stderr, summary_io: $stdout).collect`. Prompt UI (intro / menus / re-prompts / confirmation) goes to **stderr**; the non-TTY one-line summary goes to **stdout** so scripted callers can `summary=$(hive init)` cleanly. On TTY this opens the interactive flow described below; on non-TTY (CI, pipes, test harness) it short-circuits to recommended defaults. Two abort paths exit **64** (`Hive::ExitCodes::USAGE`, distinct from generic crashes at 1) with **zero disk side effects** — no orphan branch, no worktree, no master gitignore commit:
-   - Operator answers `n` at the final confirmation prompt.
-   - Input stream closes mid-flow (Ctrl-D / EOF / disconnected pipe). `Prompts#read_line` distinguishes `nil` (closed stream) from `""` (blank line for default) and raises `Aborted`. Treating EOF as a blank confirmation would silently write disk state with whatever was already collected.
-4. **Create orphan worktree** via `Hive::GitOps#hive_state_init` (`lib/hive/git_ops.rb:34`):
-   - `git worktree add --no-checkout --detach <path>/.hive-state <default_branch>`
-   - `git -C .hive-state checkout --orphan hive/state`
-   - `git rm -rf .` plus glob cleanup of any leftover dotfiles (preserving `.git`).
-   - Create `stages/{1-inbox,2-brainstorm,3-plan,4-execute,7-finalize,8-done}/` with `.gitkeep` markers and `logs/.gitkeep`.
-   - Initial commit `hive: bootstrap` on `hive/state`.
-5. **Render `<path>/.hive-state/config.yml`** from `templates/project_config.yml.erb`, threading the answers hash from step 3 through `ProjectConfigBinding`. Skipped if the file already exists.
-6. **Ignore `.hive-state/` on master** via `GitOps#add_hive_state_to_master_gitignore!`: appends `/.hive-state/` to `.gitignore` (idempotent), then commits `chore: ignore .hive-state worktree` on master.
-7. **Register globally** via `Hive::Config.register_project(name: basename(path), path: path)`, writing into `~/Dev/hive/config.yml`.
-8. Print summary: project name, default branch, hive-state path, worktree root, and a `next:` line with the `hive new ...` invocation.
-
-## Prompt flow (ADR-023)
-
-On TTY input streams the prompt walks the operator through four sections in order:
-
-1. **Planning agent** (`brainstorm.agent` + `plan.agent`): one combined choice; the answer maps to both keys. Recommended default `claude`.
-2. **Development agent** (`execute.agent`): the implementer in `4-execute`. Recommended default `codex` (its edit-mode is more efficient for implementation work). Codex's status-detection mode is `:output_file_exists`, but the execute spawn pins `status_mode: :state_file_marker` because the stage's lifecycle contract is the marker the agent writes — the pin keeps that contract independent of the chosen profile.
-3. **Review agents** (`review.reviewers[]`): multi-select over the three default reviewers (claude-ce-code-review, codex-ce-code-review, pr-review-toolkit). Disabled entries are omitted from the rendered array.
-4. **Per-stage limits**: budget+timeout for each of 8 effective keys (`brainstorm`, `plan`, `execute_implementation`, `pr`, `review_ci`, `review_triage`, `review_fix`, `review_browser`). Defaults are generous sanity caps — most tasks finish well within them.
-
-Each agent and reviewer prompt accepts **either a name or a 1-based index** (e.g., `codex` or `2`; `claude-ce-code-review,pr-review-toolkit` or `1,3`). Name strings are the recommended path for scripted automation since they're stable across template-default reordering.
-
-### Stable-iteration-order contract
-
-The prompt's choice list is rendered in a documented stable order:
-- **Agent profiles**: `claude`, `codex`, `pi` — the order in which `lib/hive/agent_profiles.rb` requires them at boot. `Hive::AgentProfiles.registered_names` returns them in this order.
-- **Default reviewers**: `claude-ce-code-review`, `codex-ce-code-review`, `pr-review-toolkit` — the order shipped in `templates/project_config.yml.erb` and surfaced via `Hive::Commands::Init::Prompts::DEFAULT_REVIEWER_NAMES`.
-
-Reordering either is a **breaking change for scripted automation** that uses index answers — index `1` would silently shift to a different value. Prefer names in scripts.
-
-### Non-TTY contract
-
-When `$stdin.tty?` is false the prompt module skips every question and emits exactly one line to `$stdout`:
-
-```
-hive: using defaults — planning=claude, dev=codex, reviewers=all3, limits=defaults
-```
-
-Piped input is **not** consumed — `printf 'codex\n...' | hive init` ignores the piped data and uses defaults. Document this contract for any automation that wants to set non-default values: use `--force` plus an explicitly-edited YAML rather than expecting heredoc piping to populate answers.
-
-## Default-branch detection
-
-`GitOps#detect_default_branch` (`lib/hive/git_ops.rb:92`) tries:
-
-1. `git symbolic-ref refs/remotes/origin/HEAD` → strip `refs/remotes/origin/` prefix.
-2. Fallback: `git rev-parse --abbrev-ref HEAD` (if not detached).
-3. Fallback: `git config init.defaultBranch`.
-4. Final fallback: literal `"master"`.
-
-This branch is what the orphan worktree is initially based on, and what feature worktrees are branched from.
+1. The path must be a git repository and its main checkout, not a feature
+   worktree.
+2. The working tree must be clean unless `--force` is passed. On a rerun where
+   `hive/state` already exists, the clean-tree bootstrap guard is skipped so
+   repair can proceed.
+3. First-run provider, model, and credential environment-variable name must all
+   be available. Interactive runs prompt; non-interactive runs fail with an
+   actionable `Hive::ConfigError` naming the three flags.
+4. Hive never asks for or stores the credential value. The configured
+   environment variable must hold it when provider access is needed.
+
+## Idempotent steps
+
+1. **Validate the checkout.** Detect the default branch and protect a new
+   bootstrap from tracked local changes.
+2. **Collect user configuration.** `Hive::UserConfig#ensure_configured!` writes
+   `$XDG_CONFIG_HOME/hive/config.yml` mode `0600` with `provider`, `model`, and
+   `api_key_env`. Existing explicit values win.
+3. **Adopt legacy state.** `Hive::StateMigration` copies and verifies legacy
+   trees into `$XDG_STATE_HOME/hive/legacy-import` and non-destructively merges
+   the legacy `~/Dev/hive/config.yml` registry and daemon consent into the
+   active XDG config. Legacy sources are retained.
+4. **Create or reuse the state worktree.** `GitOps#hive_state_init` creates the
+   orphan `hive/state` branch and `<project>/.hive-state/`, or returns the
+   existing attachment on a repair run.
+5. **Collect project choices when needed.** A missing project config triggers
+   the planning/development/reviewer/limit prompt flow. Existing config is not
+   overwritten and reruns do not repeat those prompts.
+6. **Render config and scaffold.** Missing `.hive-state/config.yml` and shipped
+   `.hive/` scaffold files are materialized; existing operator content is
+   preserved.
+7. **Ignore and register.** `/.hive-state/` is added to the main checkout's
+   `.gitignore`, and the project row is upserted in
+   `$XDG_CONFIG_HOME/hive/config.yml`.
+8. **Register the user service.** The collision-safe verified command path is
+   used in launchd or `systemd --user`. Non-interactive init registers but does
+   not start. Interactive init may start only after consent, and start failures
+   propagate.
+9. **Run non-fatal preflight and print the next command.** Repeated invocations
+   converge on the same state.
+
+## Project prompt flow (ADR-023)
+
+When project config is absent and input is a TTY, the prompt asks for planning
+agent, development agent, reviewer set, and per-stage limits. Agent and reviewer
+answers accept stable names or 1-based indices. Name strings are safer for
+automation because index ordering is a compatibility contract.
+
+On non-TTY input, project choices use the documented defaults and emit the
+one-line summary. Provider configuration is different: it has no invented
+default and must be supplied explicitly on its first run.
 
 ## Failure modes
 
 | Symptom | Cause | Fix |
 |---------|-------|-----|
-| `not a git repository` | path isn't a git repo | `git init` first |
-| `target appears to be inside a worktree` | running from a feature worktree | run on main checkout |
-| `uncommitted changes` | dirty working tree | commit/stash, or pass `--force` |
-| `already initialized` (exit 2) | `hive/state` already exists | nothing to do |
+| `not a git repository` | path is not a git repo | `git init` first |
+| `target appears to be inside a worktree` | command ran in a feature worktree | run on the main checkout |
+| `uncommitted changes` | first bootstrap sees a dirty tree | commit/stash, or deliberately pass `--force` |
+| `first-run configuration requires ...` | a non-TTY run omitted provider fields | supply all three flags or environment variables |
+| service registration/start error | launchd or the systemd user bus rejected the operation | repair the user service environment and rerun `hive init` |
+
+`Hive::AlreadyInitialized` and exit code 2 remain compatibility constants for
+older callers; the current init path does not raise them for an existing branch.
 
 ## Tests
 
-- `test/integration/init_test.rb` covers all five preconditions, the `--force` path, the idempotent double-init, the rendered template's stage-agent blocks, the bumped-generous limits, the dropped `execute_review` key, and the U5 piped-input + abort + already-initialized-guard scenarios.
-- `test/unit/commands/init/prompts_test.rb` covers the prompt module in isolation: 29 cases over happy paths, edge re-prompts, the non-TTY summary contract, and the testability invariant.
+- `test/integration/init_test.rb` covers bootstrap, repair, explicit provider
+  configuration, migration, registry, scaffold, and repeated init.
+- `test/unit/user_config_test.rb` covers interactive/non-interactive provider
+  setup without secret persistence.
+- `test/unit/state_migration_test.rb` covers verified legacy copy and active-XDG
+  registry adoption.
 
 ## Backlinks
 
-- [[cli]] · [[commands/run]]
-- [[modules/git_ops]] · [[modules/config]] · [[modules/agent_profile]]
-- [[state-model]] · [[decisions]] (ADR-023)
+- [[cli]] · [[commands/daemon]]
+- [[modules/git_ops]] · [[modules/config]] · [[modules/paths]]
+- [[distribution]] · [[state-model]] · [[decisions]] (ADR-023)
diff --git a/wiki/commands/prune.md b/wiki/commands/prune.md
index 531131be..e628132f 100644
--- a/wiki/commands/prune.md
+++ b/wiki/commands/prune.md
@@ -7,7 +7,10 @@ updated: 2026-05-06
 tags: [command, registry, cleanup, json]
 ---
 
-**TLDR**: `hive prune [--dry-run] [--json]` drops every registry entry in `~/Dev/hive/config.yml` whose `path` no longer points at a directory on disk **or** whose row shape is invalid (non-Hash, missing `path`, non-String name/path — all hand-edit accidents). The project's `.hive-state` directory on disk (when present) is **not** touched. Bulk version of [[commands/forget]] for the common-case `mktemp -d`-style stale-entry pile-up.
+**TLDR**: `hive prune [--dry-run] [--json]` drops every registry entry in
+`$XDG_CONFIG_HOME/hive/config.yml` (or the explicit `HIVE_HOME` override) whose
+project path is missing or whose row shape is invalid. Project `.hive-state`
+directories are not touched. Bulk version of [[commands/forget]].
 
 ## Usage
 
diff --git a/wiki/decisions.md b/wiki/decisions.md
index 9ec47ffb..9c8aa0d3 100644
--- a/wiki/decisions.md
+++ b/wiki/decisions.md
@@ -27,7 +27,10 @@ tags: [decisions, adr]
 
 **Status:** Active
 **Context:** A single `~/Dev/hive/state/` would route work by project name → path lookup. Per-project state lets each project's own `CLAUDE.md` / `.claude/` / hooks apply automatically (claude picks them up from `cwd`).
-**Decision:** Each project owns `<project>/.hive-state/` plus a registration entry in `~/Dev/hive/config.yml`. `~/Dev/hive/` is a thin control plane (CLI + global config + shared logs only).
+**Decision:** Each project owns `<project>/.hive-state/` plus a registration
+entry in `$XDG_CONFIG_HOME/hive/config.yml`. Current per-user config, data, and
+state use [[modules/paths]]; legacy `~/Dev/hive/` is retained only as a
+non-destructive migration source.
 **Consequences:** Routing is free (project name = path). Per-project tooling works without magic. Failure in one project doesn't infect others. Cost: duplicate config knobs per project (acceptable; defaults cover most cases).
 
 ## ADR-003: Orphan branch `hive/state` checked out as a separate worktree
diff --git a/wiki/distribution.md b/wiki/distribution.md
new file mode 100644
index 00000000..aa3c095c
--- /dev/null
+++ b/wiki/distribution.md
@@ -0,0 +1,101 @@
+---
+title: Distribution and installation
+type: architecture
+source: lib/hive/paths.rb, lib/hive/platform.rb, lib/hive/install_provenance.rb, install.sh, packaging/
+created: 2026-07-15
+updated: 2026-07-15
+tags: [architecture, install, packaging, xdg, release]
+---
+
+**TLDR**: Hive ships as Tebako-packaged, runtime-free binaries from immutable
+semver GitHub Releases. Tier-1 channels are the fully qualified
+`ivankuznetsov/hive/hive` Homebrew formula (macOS 14+ arm64), AUR `hive-bin`
+(current Arch x86_64), and a verified Bash installer (Ubuntu 22.04+ x86_64).
+All channels consume the same deterministic artifacts; update/uninstall require
+positive ownership proof. Agent skills live in a separately published
+`@ivankuznetsov/hive-skills` package.
+
+## Runtime boundary
+
+| Module | Role |
+|--------|------|
+| [[modules/paths]] `Hive::Paths` | XDG config/data/state/cache/runtime resolution |
+| `Hive::Platform` | Tier-1/2/3 classification |
+| `Hive::Assets` | Materialize package seeds under `$XDG_DATA_HOME/hive/versions/<ver>` |
+| `Hive::DependencyCheck` | Hard / channel / optional tool diagnostics |
+| `Hive::InstallProvenance` | Detect brew / AUR / Bash ownership |
+
+## Commands
+
+- `hive update` — channel-aware upgrade ([[commands]] surface)
+- `hive uninstall` — stop service, remove binary via owner, optional hash-verified project cleanup
+- `hive` / `hv` — identical CLI (`bin/hv` → `bin/hive`)
+
+## Release trust boundary
+
+- `scripts/build-release` requires Tebako for normal release builds and fails
+  closed when packaging fails. Its source-wrapper escape hatch exists only for
+  explicit tests.
+- `SOURCE_DATE_EPOCH`, normalized GNU tar headers, gzip `-n`, sorted metadata,
+  and epoch-derived manifest timestamps make equivalent source inputs produce
+  identical bytes.
+- `scripts/generate-release-manifest` requires the tag, `Hive::VERSION`, staged
+  `VERSION`, and packaged `--version` identity to agree. Every release includes
+  `install.sh`, and `SHA256SUMS` covers it along with all archives.
+- Publication depends on runtime-closure tests of extracted artifacts plus clean
+  Ubuntu Bash, macOS Homebrew, and Arch package acceptance jobs. Release
+  promotion pushes the generated tap Formula and AUR `PKGBUILD`/`.SRCINFO`.
+
+## Bash installer safety
+
+- `--version` is strict semver and all derived roots are validated before use.
+- Archive paths and member types are checked before extraction; links and
+  traversal paths are rejected.
+- The staged payload must report the requested version before the current
+  payload changes. Same-version replacement retains a rollback copy until both
+  installed command links pass exact-version verification.
+- The normal channel installs both `hive` and `hv`; `--command-name hv` preserves
+  an unrelated `hive`. The canonical receipt remains at
+  `$XDG_DATA_HOME/hive/install-receipt.yml` for custom prefixes.
+
+## Ownership and lifecycle
+
+- Bash receipts bind the invoked command path, versioned payload, and digest.
+  Replaced commands and stale receipts do not establish ownership.
+- Homebrew detection resolves the stable symlink against
+  `brew --prefix ivankuznetsov/hive/hive`; delegation never uses the colliding
+  unqualified `hive` formula name.
+- Bash update resolves a semver release and verifies the immutable tag's
+  installer checksum before execution. Update then probes the resulting command
+  version and requires service refresh/state preservation.
+- Uninstall resolves unambiguous ownership before service or project mutation,
+  stops on service cleanup failure, and removes only receipt-revalidated Bash
+  links. Project cleanup is constrained to the shipped scaffold allowlist plus
+  recorded hashes.
+
+## Service adapters
+
+- `Hive::Service::Launchd` — macOS LaunchAgent `dev.hive.daemon`
+- `Hive::Service::SystemdUser` — Linux user unit `hive.service`
+- Registered by `hive init`; never autostarted non-interactively
+
+## Packaging tree
+
+```
+install.sh
+INSTALL_PROMPT.md
+packaging/homebrew/hive.rb.erb
+packaging/aur/{PKGBUILD.erb,hive.install}
+packaging/hive-skills/   # separate multi-agent skills package
+scripts/{build-release,generate-release-manifest,verify-release}
+scripts/render-package-metadata
+.github/workflows/{release,release-smoke,tier1-e2e}.yml
+```
+
+## Backlinks
+
+- [[architecture]]
+- [[commands/init]]
+- [[commands/daemon]]
+- [[modules/config]]
+- [[modules/paths]]
diff --git a/wiki/index.md b/wiki/index.md
index 2b728a8f..374207a0 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -3,15 +3,15 @@ title: hive Wiki
 type: index
 source: wiki/**/*.md
 created: 2026-05-14
-updated: 2026-05-16
+updated: 2026-07-15
 tags: [index, wiki]
 ---
 
 
 **TLDR**: Catalog of the LLM-maintained wiki for `hive`.
 
-Page count: 60
-Updated: 2026-05-16
+Page count: 62
+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`
@@ -54,6 +55,7 @@ Folder-as-agent pipeline: a Ruby 3.4 / Thor CLI control plane that drives an eig
 - [[modules/lock]] — `wiki/modules/lock.md`
 - [[modules/markers]] — `wiki/modules/markers.md`
 - [[modules/metrics]] — `wiki/modules/metrics.md`
+- [[modules/paths]] — `wiki/modules/paths.md`
 - [[modules/protected_files]] — `wiki/modules/protected_files.md`
 - [[modules/rebase]] — `wiki/modules/rebase.md`
 - [[modules/reviewers]] — `wiki/modules/reviewers.md`
diff --git a/wiki/log.md b/wiki/log.md
index 641bec70..0c464778 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -1607,3 +1607,41 @@ dispatch while preserving first-sight brainstorm baseline behavior.
 - `wiki/commands/tui.md` — documented red-status detail mode, keybindings, snapshot refresh behavior, and preserved direct recovery exceptions.
 - `wiki/decisions.md` — ADR-027 records the diagnose-then-act policy and its relationship to ADR-025.
 - `wiki/index.md` — bumped refresh date.
+
+## [2026-07-15T00:00:00Z] distribution and installation (U1–U9)
+
+**Action:** Implemented runtime-free distribution: XDG Paths, Platform tiers,
+Assets materialization, DependencyCheck, project `.hive/` ownership scaffold,
+legacy-state migration, user config with credential references only,
+LaunchAgent/systemd service adapters, channel-aware `update`/`uninstall`,
+release scripts + provenance gates, Homebrew/AUR templates, Bash installer +
+install prompt, hive-skills package scaffolding, and install documentation.
+
+**Refreshed pages:**
+- `wiki/distribution.md` — new page for packaging/install architecture
+- `wiki/index.md` — catalog entry for distribution
+
+## [2026-07-15T12:00:00Z] distribution review hardening
+
+**Action:** Closed the stage-6 distribution and lifecycle review findings. The
+release pipeline now produces deterministic Tebako artifacts and gates
+publication on runtime closure and native tier-1 installs; Bash installation,
+receipt ownership, update, uninstall, and rollback paths fail closed. Init is
+repairable and adopts legacy registry data into explicit XDG configuration,
+while daemon lifecycle uses truthful launchd/systemd-user control. Published
+install guidance now uses executable, fully qualified channels and explicit
+provider/model credential references.
+
+**Refreshed pages:**
+- `wiki/distribution.md` — release identity, deterministic packaging, installer
+  validation/rollback, channel ownership, and publication gates.
+- `wiki/cli.md` — init flags and repair semantics, update/uninstall, managed
+  daemon lifecycle, active XDG registry.
+- `wiki/commands/init.md` — idempotent bootstrap/repair, first-run provider
+  configuration, legacy adoption, and service registration.
+- `wiki/commands/daemon.md`, `wiki/operating.md` — launchd/systemd-user routing,
+  required nullable service status, truthful failures, and XDG paths.
+- `wiki/modules/config.md` — combined XDG registry/provider config and legacy
+  merge behavior.
+- `wiki/modules/paths.md` — new page resolving the former broken backlink.
+- `wiki/index.md` — cataloged `modules/paths` and bumped the page count.
diff --git a/wiki/modules/bot.md b/wiki/modules/bot.md
index c7df393a..77d3241d 100644
--- a/wiki/modules/bot.md
+++ b/wiki/modules/bot.md
@@ -38,7 +38,7 @@ and subprocess I/O.
 hive bot start
   └─ Hive::Commands::Bot
        ├─ validates global bot config + HIVE_TELEGRAM_BOT_TOKEN
-       ├─ writes ~/Dev/hive/.bot.pid
+       ├─ writes $XDG_STATE_HOME/hive/.bot.pid
        └─ Hive::Bot::Supervisor.run_forever
             ├─ poll loop: Telegram.getUpdates → Router → handler descriptors
             ├─ status loop: hive status --json → NotificationDispatcher
diff --git a/wiki/modules/config.md b/wiki/modules/config.md
index b7c53d6f..a35c601e 100644
--- a/wiki/modules/config.md
+++ b/wiki/modules/config.md
@@ -3,11 +3,18 @@ title: Hive::Config
 type: module
 source: lib/hive/config.rb
 created: 2026-04-25
-updated: 2026-05-14
-tags: [config, yaml, validation]
+updated: 2026-07-15
+tags: [config, yaml, validation, xdg]
 ---
 
-**TLDR**: Two YAML configs — global at `~/Dev/hive/config.yml` (registered projects) and per-project at `<project>/.hive-state/config.yml` (default branch, worktree root, budgets, timeouts, **stage agents**, review-stage roles). `Config.load(project_root)` **recursively** deep-merges per-project values onto `Config::DEFAULTS`, then runs `validate!`. Arrays (notably `review.reviewers`) are replaced wholesale, never per-element merged.
+**TLDR**: Two YAML configs — global at
+`$XDG_CONFIG_HOME/hive/config.yml` (provider/model credential reference,
+registered projects, and global daemon/bot settings) and per-project at
+`<project>/.hive-state/config.yml` (default branch, worktree root, budgets,
+timeouts, **stage agents**, review-stage roles). `Config.load(project_root)`
+**recursively** deep-merges per-project values onto `Config::DEFAULTS`, then
+runs `validate!`. Arrays (notably `review.reviewers`) are replaced wholesale,
+never per-element merged.
 
 ## Defaults (`Config::DEFAULTS`)
 
@@ -68,8 +75,8 @@ tags: [config, yaml, validation]
 
 | Function | Returns / does |
 |----------|----------------|
-| `hive_home` | `ENV["HIVE_HOME"] || ~/Dev/hive` |
-| `global_config_path` | `<hive_home>/config.yml` |
+| `hive_home` | explicit `ENV["HIVE_HOME"]`, otherwise `$XDG_STATE_HOME/hive` |
+| `global_config_path` | `HIVE_HOME/config.yml` when overridden, otherwise `$XDG_CONFIG_HOME/hive/config.yml` |
 | `hive_state_dir(project_root, name = ".hive-state")` | `<project_root>/<name>` |
 | `load(project_root)` | Reads `<project_root>/.hive-state/config.yml`, recursively deep-merges onto DEFAULTS, validates, returns Hash with `"project_root"` injected. Returns DEFAULTS-only hash if config absent. |
 | `registered_projects` | Reads global config; returns `[{name, path, hive_state_path}, …]` (paths `expand_path`-ed). |
@@ -121,9 +128,25 @@ cfg.dig("review", "reviewers")
 cfg["worktree_root"]
 ```
 
+## XDG user configuration and legacy adoption
+
+`Hive::UserConfig#ensure_configured!` owns the first-run `provider`, `model`,
+and `api_key_env` keys in the global config. It requires explicit values from
+the init flags/environment or an interactive prompt, validates that
+`api_key_env` is an environment-variable name, and never stores a secret value.
+
+`Hive::StateMigration` copies verified legacy material to
+`$XDG_STATE_HOME/hive/legacy-import` and merges legacy
+`~/Dev/hive/config.yml` data into the active global XDG config. Active values
+win; missing registered-project rows are adopted; the source is retained. This
+keeps legacy project enrollment and daemon consent visible after the XDG switch.
+
 ## `HIVE_HOME` override
 
-Tests use `with_tmp_global_config` (`test/test_helper.rb:30`) to point `HIVE_HOME` at a tmp dir, ensuring no test ever writes the real global config.
+Tests use `with_tmp_global_config` (`test/test_helper.rb`) to point `HIVE_HOME`
+and XDG config at temporary directories, ensuring no test writes real user
+state. The helper restores every process-global environment variable in an
+`ensure` block so randomized suite order cannot inherit deleted temp paths.
 
 ## Tests
 
@@ -132,4 +155,4 @@ Tests use `with_tmp_global_config` (`test/test_helper.rb:30`) to point `HIVE_HOM
 ## Backlinks
 
 - [[commands/init]] · [[commands/new]] · [[commands/run]] · [[commands/status]]
-- [[modules/agent]] · [[state-model]]
+- [[modules/agent]] · [[modules/paths]] · [[state-model]] · [[distribution]]
diff --git a/wiki/modules/daemon.md b/wiki/modules/daemon.md
index 81498482..0f1d20f4 100644
--- a/wiki/modules/daemon.md
+++ b/wiki/modules/daemon.md
@@ -31,9 +31,9 @@ decisions are unit-testable without forking.
 ```
 hive daemon start
   └─ Hive::Commands::Daemon
-       ├─ writes ~/Dev/hive/.daemon.pid
+       ├─ writes $XDG_STATE_HOME/hive/.daemon.pid
        └─ Hive::Daemon::Dispatcher.run_forever
-            ├─ Hive::Daemon::Logger      (~/Dev/hive/logs/daemon.log, JSON-line)
+            ├─ Hive::Daemon::Logger      ($XDG_STATE_HOME/hive/logs/daemon.log, JSON-line)
             ├─ Hive::Daemon::ConcurrencyController
             ├─ Hive::Daemon::ChildSupervisor   (Process.spawn pgroup: true)
             ├─ Hive::Daemon::StatusConsumer    (Open3.capture3 hive status --json)
diff --git a/wiki/modules/paths.md b/wiki/modules/paths.md
new file mode 100644
index 00000000..6346bde1
--- /dev/null
+++ b/wiki/modules/paths.md
@@ -0,0 +1,50 @@
+---
+title: Hive::Paths
+type: module
+source: lib/hive/paths.rb
+created: 2026-07-15
+updated: 2026-07-15
+tags: [paths, xdg, install, state]
+---
+
+**TLDR**: `Hive::Paths` is the single resolver for per-user config, data,
+state, cache, runtime, binary, version, and legacy-adoption locations. XDG
+environment values must be absolute. `HIVE_HOME` remains an explicit combined
+config/state override; legacy `~/Dev/hive` is an import source, never the
+automatic live home.
+
+## Resolved locations
+
+| Method | Default |
+|--------|---------|
+| `config_home` | `$XDG_CONFIG_HOME/hive` or `~/.config/hive` |
+| `data_home` | `$XDG_DATA_HOME/hive` or `~/.local/share/hive` |
+| `state_home` | `$XDG_STATE_HOME/hive` or `~/.local/state/hive` |
+| `cache_home` | `$XDG_CACHE_HOME/hive` or `~/.cache/hive` |
+| `runtime_dir` | `$XDG_RUNTIME_DIR/hive` or a private UID-scoped temp root |
+| `bin_dir` | `$XDG_BIN_HOME` or `~/.local/bin` |
+| `versions_dir` | `<data_home>/versions` |
+| `user_config_path` / `global_registry_path` | `<config_home>/config.yml` |
+| `hive_home` | explicit `HIVE_HOME`, otherwise `<state_home>` |
+
+When `HIVE_HOME` is explicitly set, registry/config access remains isolated
+below that directory for compatibility and tests. It does not cause the normal
+XDG layout to collapse in production.
+
+## Safety properties
+
+- Relative XDG paths raise `Hive::ConfigError` before a write can land relative
+  to the current project.
+- Runtime and created directories are mode `0700`; private config writes are
+  atomic, fsynced, renamed, and mode `0600`.
+- Version directories live below the immutable data root. Bash install receipts
+  stay at `$XDG_DATA_HOME/hive/install-receipt.yml` even when the payload uses a
+  custom prefix, so lifecycle commands can discover ownership.
+- `legacy_global_home` (`~/Dev/hive`) and `legacy_state_home` (`~/.hive-state`)
+  are only inputs to `Hive::StateMigration`. Verified copies go to XDG state,
+  while the legacy registry is merged into the active XDG config and retained.
+
+## Backlinks
+
+- [[distribution]] · [[modules/config]] · [[commands/init]]
+- [[commands/daemon]] · [[architecture]]
diff --git a/wiki/operating.md b/wiki/operating.md
index bf12c796..d4fba388 100644
--- a/wiki/operating.md
+++ b/wiki/operating.md
@@ -3,14 +3,14 @@ title: Operating Hive
 type: operating
 source: lib/hive/commands/daemon.rb, lib/hive/commands/bot.rb, examples/systemd/, examples/launchd/
 created: 2026-05-07
-updated: 2026-05-14
+updated: 2026-07-15
 tags: [operating, daemon, bot, systemd, launchd, install]
 ---
 
 **TLDR**: Day-2 guide for running the hive daemon and Telegram bot.
 Covers per-project daemon enrollment, bot token/allowlist setup,
-autostart on macOS (launchd) and Linux (systemd), dry-run shakedowns,
-log inspection, and how to disable automation mid-flight.
+registered service lifecycle on macOS (launchd) and Linux (systemd), log
+inspection, and how to disable automation mid-flight.
 
 ## Prerequisites
 
@@ -27,7 +27,8 @@ Once per workstation:
   Verify: `ls /proc/$$ >/dev/null && echo OK` or `command -v ps`.
 - **Telegram bot token** if using `hive bot`. Create the bot with
   BotFather, export `HIVE_TELEGRAM_BOT_TOKEN`, and add your numeric
-  `chat_id` to `bot.chat_id_allowlist` in `~/Dev/hive/config.yml`.
+  `chat_id` to `bot.chat_id_allowlist` in
+  `$XDG_CONFIG_HOME/hive/config.yml`.
 
 ## Enrolling existing projects
 
@@ -57,55 +58,48 @@ toggle only flips `enabled`.
 For new projects, `hive init` asks at the TTY prompt and defaults to Y
 — no separate enable step.
 
-## First run: the mandatory `--dry-run` shakedown
+## First registered-service run
 
-The daemon's worst-case in-flight cost ceiling is
-`max_concurrent_runs × per-task budget cap` from ADR-023, which works
-out to ~$4425 at default caps. Before letting it spawn real children,
-run it in dry-run for ~24 hours:
+`hive init` writes the platform service definition but never starts it in a
+non-interactive session. Confirm the enrolled projects, then explicitly start
+the registered service:
 
 ```bash
-hive daemon start --dry-run --detach
-hive daemon status                  # verify running
-hive daemon tail                    # ctrl-C to leave; daemon keeps running
+hive daemon status
+hive daemon start
+hive daemon status --json
+hive daemon tail                    # Ctrl-C leaves the service running
 ```
 
-Inspect `~/Dev/hive/logs/daemon.log` (one JSON document per line):
+Inspect `$XDG_STATE_HOME/hive/logs/daemon.log` (one JSON document per line):
 
 ```bash
 # What WOULD have been dispatched, by project
 jq -r 'select(.event=="dispatched") | "\(.project)/\(.slug): \(.command)"' \
-   ~/Dev/hive/logs/daemon.log | sort -u
+   "$XDG_STATE_HOME/hive/logs/daemon.log" | sort -u
 
 # Things skipped — the `reason` field tells you why
 jq -r 'select(.event=="skipped") | "\(.project)/\(.slug): action=\(.action) reason=\(.reason // "—")"' \
-   ~/Dev/hive/logs/daemon.log | sort -u
+   "$XDG_STATE_HOME/hive/logs/daemon.log" | sort -u
 
 # Anything blocked by caps (means at least one cap is too tight, or
 # expected throttling under load)
 jq -r 'select(.event=="blocked") | "\(.project)/\(.slug): \(.reason)"' \
-   ~/Dev/hive/logs/daemon.log | sort -u
-```
-
-Once the would-be dispatches look right, swap to live mode:
-
-```bash
-hive daemon stop
-hive daemon start --detach
+   "$XDG_STATE_HOME/hive/logs/daemon.log" | sort -u
 ```
 
 ## Autostart
 
 ### Linux (systemd-user)
 
-A sample unit ships at `examples/systemd/hive-daemon.service`. Install:
+`hive init` renders `$XDG_CONFIG_HOME/systemd/user/hive.service` with the stable,
+verified `hive` or `hv` command and explicit `HIVE_HOME`/XDG environment. Start
+and inspect it through Hive:
 
 ```bash
-mkdir -p ~/.config/systemd/user
-cp examples/systemd/hive-daemon.service ~/.config/systemd/user/
-systemctl --user daemon-reload
-systemctl --user enable --now hive-daemon
-journalctl --user -u hive-daemon -f
+hive daemon start
+hive daemon status --json
+journalctl --user -u hive -f
 ```
 
 If you log out and want the daemon to keep running:
@@ -114,10 +108,10 @@ If you log out and want the daemon to keep running:
 sudo loginctl enable-linger $USER
 ```
 
-The unit declares `Type=simple` and runs `hive daemon start` in the
-foreground — systemd is the supervisor. `Restart=on-failure` brings
-the daemon back after a crash; the daemon's own SIGTERM handler does
-the graceful drain (`daemon.shutdown_grace_sec`, default 600 s).
+The unit runs `hive daemon run` in the foreground so systemd remains the
+supervisor. A missing user bus or failed enable/start operation is an error;
+repair the user session and rerun `hive init` rather than assuming registration
+succeeded.
 
 The shipped unit hardcodes `TimeoutStopSec=900` (15 min — drain budget
 plus headroom). If you raise `daemon.shutdown_grace_sec` above 900,
@@ -126,51 +120,23 @@ plus headroom). If you raise `daemon.shutdown_grace_sec` above 900,
 systemd will SIGKILL still-running stage children mid-`hive run`,
 losing in-flight work.
 
-If `hive` lives behind a version manager (rbenv / asdf / mise), edit
-the `ExecStart=` line to use the shim's absolute path — systemd-user
-doesn't load your shell's rc files.
-
 ### macOS (launchd)
 
-A sample plist ships at `examples/launchd/hive-daemon.plist`.
-Edit the absolute paths first (replace `/Users/YOU/...` with your
-real paths — `which hive` shows the binary), then:
+`hive init` renders `~/Library/LaunchAgents/dev.hive.daemon.plist` from the
+stable verified command. Control the registered job through Hive:
 
 ```bash
-mkdir -p ~/Library/LaunchAgents
-cp examples/launchd/hive-daemon.plist ~/Library/LaunchAgents/
-launchctl load ~/Library/LaunchAgents/hive-daemon.plist
-```
-
-Stop / restart:
-
-```bash
-launchctl unload ~/Library/LaunchAgents/hive-daemon.plist
-launchctl load   ~/Library/LaunchAgents/hive-daemon.plist
+hive daemon start
+hive daemon status --json
+hive daemon stop
 ```
 
-launchd captures stdout/stderr to the paths declared in the plist
-(`~/Library/Logs/hive-daemon.{out,err}.log` by default). The daemon's
-own structured log (`~/Dev/hive/logs/daemon.log`) is independent and
-preferred for parsing — the launchd capture is mostly empty.
-
-`KeepAlive` with `SuccessfulExit: false` means launchd respawns on
-crash but not after a clean `hive daemon stop`. `ThrottleInterval: 30`
-is the floor between respawn attempts.
-
-The plist's `ProgramArguments` wraps the invocation in a tiny
-`/bin/sh -c '[ -x "$0" ] || exit 0; exec "$0" "$@"'` precheck —
-launchd has no native equivalent of systemd's `StartLimitBurst`, so
-without this wrapper a wrong binary path would respawn every 30 s
-forever (filling `hive-daemon.err.log` with `command not found`). The
-wrapper turns "binary missing or not executable" into a clean exit 0,
-which `KeepAlive { SuccessfulExit: false }` then respects (no respawn).
-A real daemon crash still exits non-zero through `exec` and respawns
-normally. If you customise `ProgramArguments`, keep the precheck.
+Launchd failures propagate to the CLI. The daemon's structured log under
+`$XDG_STATE_HOME/hive/logs/daemon.log` remains the preferred diagnostic source.
 
 ## Bot setup
 
-The bot is global and uses the registry in `~/Dev/hive/config.yml`.
+The bot is global and uses the registry in `$XDG_CONFIG_HOME/hive/config.yml`.
 Minimum config:
 
 ```yaml
@@ -186,12 +152,12 @@ export HIVE_TELEGRAM_BOT_TOKEN=123456:token-from-botfather
 hive bot start --dry-run
 ```
 
-`--dry-run` is useful for first shakedown: inbound command parsing and
+`--dry-run` is useful for the bot's first shakedown: inbound command parsing and
 status notifications run, but state-changing child commands are not
-spawned. The bot log is `~/Dev/hive/logs/bot.log`:
+spawned. The bot log is `$XDG_STATE_HOME/hive/logs/bot.log`:
 
 ```bash
-jq -r '.event' ~/Dev/hive/logs/bot.log | sort | uniq -c
+jq -r '.event' "$XDG_STATE_HOME/hive/logs/bot.log" | sort | uniq -c
 ```
 
 Unauthorized chat IDs are logged once and receive no reply. Missing
@@ -229,14 +195,14 @@ drain path when you are not using systemd/launchd.
 |-----------------------------------------------|-------------------------------------------------------|
 | Status + uptime                               | `hive daemon status` (`--json` for envelope)          |
 | Follow the structured log                     | `hive daemon tail`                                    |
-| Reload caps without restart                   | edit `~/Dev/hive/config.yml` → `hive daemon reload`   |
+| Reload caps without restart                   | edit `$XDG_CONFIG_HOME/hive/config.yml` → `hive daemon reload`   |
 | Disable a project mid-flight                  | `hive daemon disable PROJECT` → `hive daemon reload`* |
 | Enable a project mid-flight                   | `hive daemon enable PROJECT` → `hive daemon reload`*  |
 | Drain + stop                                  | `hive daemon stop` (graceful TERM, ≤ `shutdown_grace_sec`) |
 | Force-stop after a hang                       | `hive daemon stop` then check PID; the stop CLI escalates to KILL |
 | Bot status / uptime                           | `hive bot status` (`--json` for envelope)          |
 | Follow bot log                                | `hive bot tail`                                    |
-| Reload bot allowlist / polling config         | edit `~/Dev/hive/config.yml` → `hive bot reload`   |
+| Reload bot allowlist / polling config         | edit `$XDG_CONFIG_HOME/hive/config.yml` → `hive bot reload`   |
 | Drain + stop bot                              | `hive bot stop`                                    |
 
 \* `hive daemon reload` clears the per-tick enable cache, but the
@@ -271,7 +237,7 @@ Defaults in `Config::DEFAULTS["daemon"]`:
 | `log_max_bytes`                 | 10 MB   | Rotation threshold.                                         |
 | `log_max_files`                 | 5       | 5 × 10 MB = 50 MB log budget.                               |
 
-To override, edit `~/Dev/hive/config.yml`:
+To override, edit `$XDG_CONFIG_HOME/hive/config.yml`:
 
 ```yaml
 registered_projects:
@@ -293,7 +259,7 @@ If `daemon.log` shows unexpected dispatch volume:
 hive daemon stop                          # immediate halt
 hive daemon disable --all                 # belt-and-suspenders
 # investigate (jq filters above)
-hive daemon start --dry-run --detach      # re-shakedown
+hive daemon start                         # restart registered service
 ```
 
 The per-task `.lock` (ADR-007) is the last-resort safety net; the
@@ -308,7 +274,7 @@ images). Mount `/proc`, install `procps`, or run the daemon in an
 environment where one of the two is reachable.
 
 **`hive daemon status` says "not running" but I just started it.**
-Check `~/Dev/hive/.daemon.pid`. If it's missing, the daemon failed
+Check `$XDG_STATE_HOME/hive/.daemon.pid`. If it's missing, the daemon failed
 during startup — check `journalctl --user -u hive-daemon` (Linux) or
 the launchd `~/Library/Logs/hive-daemon.err.log` (macOS) for the
 crash reason.
@@ -333,4 +299,4 @@ safest path.
 - [[commands/daemon]] · [[modules/daemon]]
 - [[commands/bot]] · [[modules/bot]]
 - [[decisions]] (ADR-024, ADR-026) · [[active-areas]]
-- [[architecture]] · [[cli]]
+- [[architecture]] · [[cli]] · [[modules/paths]] · [[distribution]]
diff --git a/wiki/state-model.md b/wiki/state-model.md
index 29a9e1a2..6efbf8f0 100644
--- a/wiki/state-model.md
+++ b/wiki/state-model.md
@@ -7,7 +7,12 @@ updated: 2026-05-14
 tags: [state, filesystem, model, architecture, review]
 ---
 
-**TLDR**: Hive has no database. Persistent state lives entirely in two filesystem trees per project — `<project>/.hive-state/` (an orphan-branch worktree holding task folders, configs, locks, logs) and `~/Dev/<project>.worktrees/<slug>/` (feature worktrees holding actual code) — plus one global `~/Dev/hive/config.yml`. The "data model" is the directory layout, marker grammar, and YAML schemas described below.
+**TLDR**: Hive has no database. Persistent state lives entirely in two
+filesystem trees per project — `<project>/.hive-state/` (an orphan-branch
+worktree holding task folders, configs, locks, logs) and
+`~/Dev/<project>.worktrees/<slug>/` (feature worktrees holding actual code) —
+plus one global `$XDG_CONFIG_HOME/hive/config.yml`. The "data model" is the
+directory layout, marker grammar, and YAML schemas described below.
 
 ## Stage directory layout
 
@@ -142,7 +147,7 @@ No `pass:` frontmatter or sidecar — recovery is "delete the highest-NN files t
 
 ## Configs
 
-### Global: `~/Dev/hive/config.yml`
+### Global: `$XDG_CONFIG_HOME/hive/config.yml`
 
 ```yaml
 registered_projects:
@@ -159,18 +164,31 @@ bot:
   codex_budget_usd: 1
   codex_timeout_sec: 120
   shutdown_grace_sec: 60
-  pid_file: ~/Dev/hive/.bot.pid
-  log_file: ~/Dev/hive/logs/bot.log
+  pid_file: ~/.local/state/hive/.bot.pid
+  log_file: ~/.local/state/hive/logs/bot.log
   log_max_bytes: 10485760
   log_max_files: 5
-  last_seen_state_file: ~/Dev/hive/.bot.last_seen_update_id
+  last_seen_state_file: ~/.local/state/hive/.bot.last_seen_update_id
 ```
 
-Managed by `Hive::Config.register_project` (`lib/hive/config.rb:79`); deregistered by `unregister_project` (one row, by name) and `prune_missing_projects!` (every row whose `path` is missing OR whose shape is invalid). `HIVE_HOME` env var overrides the default `~/Dev/hive`.
-
-Loader tolerance (`Config.registered_projects` / `load_global_config`): a non-Hash row, a row missing `name`, or a row whose `path` isn't a String is *skipped silently* instead of raising — a single hand-edit accident can no longer brick `status`/`forget`/`prune`/TUI. `Psych::Exception` (any malformed YAML — syntax, disallowed-class, alias-not-enabled) plus `Errno::EACCES`/`EISDIR` are rewrapped as `ConfigError` (exit 78); `chmod 000 ~/Dev/hive/config.yml` no longer leaks as exit-70 InternalError. `prune` is the cleanup verb for invalid rows surfaced this way (predicate `Config.droppable_registry_entry?` covers both missing-path and invalid-shape; `valid_registry_entry?` is the shared shape gate). All writes go through `write_global_config!` so the read/write classification is symmetric and a future flock upgrade (Issue #31) lands in one place. See [[commands/forget]] · [[commands/prune]] · [[modules/config]]. All writers (`register_project`, `unregister_project`, `prune_missing_projects!`) go through `Hive::Config.write_global_config!`, which rewraps `Errno::EACCES`/`EROFS`/`ENOSPC` as `Hive::ConfigError` (exit 78). The reader (`load_global_config`) likewise rewraps `Psych::SyntaxError` AND `Errno::EACCES`/`EISDIR` to `ConfigError` so a `chmod 000` on `~/Dev/hive/config.yml` surfaces as exit 78, not exit 70. Name matching in `unregister_project` is `to_s`-symmetric so a hand-edited Integer `name:` in YAML still resolves. `forget`/`prune` `--json` envelopes use `Hive::Schemas::EnvelopeEmitter` (`lib/hive.rb`) and `File.expand_path` raw `path` / `hive_state_path` to honor the schemas' "Absolute path" contract regardless of how the registry row was hand-edited.
-
-`bot:` is a global operator-surface block, not a per-project enrollment knob. `Config.load_global_bot(require_runtime: true)` merges it over `Config.global_bot_defaults`, validates integer chat IDs, poll bounds, and path strings, then requires both a non-empty allowlist and `HIVE_TELEGRAM_BOT_TOKEN` before `hive bot start` can run. Runtime files are global: `~/Dev/hive/.bot.pid` for the single-instance lock, `~/Dev/hive/logs/bot.log` for structured JSON lines, and `~/Dev/hive/.bot.last_seen_update_id` for Telegram reconnect summaries.
+Managed by `Hive::Config.register_project`; deregistered by
+`unregister_project` (one row, by name) and `prune_missing_projects!` (every row
+whose `path` is missing OR whose shape is invalid). `HIVE_HOME` is an explicit
+combined config/state override; the default is the XDG split in [[modules/paths]].
+
+Loader tolerance (`Config.registered_projects` / `load_global_config`): a
+non-Hash row, a row missing `name`, or a row whose `path` is not a String is
+skipped rather than bricking `status`/`forget`/`prune`/TUI. Malformed YAML and
+config access errors become `ConfigError` (exit 78). `prune` removes invalid or
+missing-path rows. All writers use `write_global_config!`; registry paths exposed
+through JSON are expanded to the schemas' absolute-path contract. See
+[[commands/forget]] · [[commands/prune]] · [[modules/config]].
+
+`bot:` is a global operator-surface block, not a per-project enrollment knob.
+`Config.load_global_bot(require_runtime: true)` merges it over XDG-state-derived
+defaults, validates integer chat IDs, poll bounds, and paths, then requires both
+a non-empty allowlist and `HIVE_TELEGRAM_BOT_TOKEN`. Runtime PID, JSON log, and
+last-seen files live under `$XDG_STATE_HOME/hive` by default.
 
 ### Per-project: `<project>/.hive-state/config.yml`
 
diff --git a/wiki/testing.md b/wiki/testing.md
index 28096a6d..3253c9cb 100644
--- a/wiki/testing.md
+++ b/wiki/testing.md
@@ -30,7 +30,10 @@ task default: :test
 
 - `with_tmp_dir` — `Dir.mktmpdir("hive-test", &block)`.
 - `with_tmp_git_repo` — `git init -b master`, configures user/email and disables GPG signing, makes one initial commit, yields the path.
-- `with_tmp_global_config` — overrides `ENV["HIVE_HOME"]` to a tmp dir and writes an empty `registered_projects: []` YAML so tests don't touch `~/Dev/hive/config.yml`.
+- `with_tmp_global_config` — overrides `HIVE_HOME`, XDG config, and first-run
+  provider-reference environment values in a tmp dir; all original process
+  environment is restored in `ensure` so randomized order never inherits a
+  deleted test home.
 - `run!(*cmd)` — shells out and raises on non-zero exit (used in setup helpers; not for testing the CLI itself).
 
 ## Fixtures
