diff --git a/README.md b/README.md
index 2fca31369..8cb8cc155 100644
--- a/README.md
+++ b/README.md
@@ -259,5 +259,5 @@ Full per-command reference, every flag, every envelope field, and every exit cod
 - **[docs/workflows.md](docs/workflows.md)** — How to author a project-local workflow descriptor: `hive workflow new` (with `--template`), `hive init --new-workflow`, `skill:` versus `instruction:`, and per-stage permissions. The full public walkthrough lives at **[hivecli.sh/docs/custom-workflows](https://hivecli.sh/docs/custom-workflows/)**.
 - **[wiki/operating.md](wiki/operating.md)** — Day-2 operations: install matrix, XDG paths, autostart (systemd-user on Linux, launchd on macOS), enrolling existing projects, the mandatory `--dry-run` shakedown, bot setup, tuning concurrency, cost-runaway response, troubleshooting. Read this before running the daemon live and any time you operate Hive across more than one project.
 - **[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 project-level daemon enrollment, why no built-in web UI. Read this when you hit a surprise or want to know "why is it like this?".
+- **[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 project-level daemon enrollment, why the web UI is local-first (with Docker/hivebox still supported). Read this when you hit a surprise or want to know "why is it like this?".
 - **[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/docs/faq.md b/docs/faq.md
index a0971ceb1..8312bb2cc 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -22,9 +22,9 @@ Hive state changes often and should not pollute the project's code history or tr
 
 The daemon service is installed as global user infrastructure so it survives login and reboot. Project enrollment stays explicit because the daemon can spend real agent time and move many tasks; `daemon.enabled: true` is the durable consent signal for a specific repository, and `--dry-run` lets you inspect dispatches before live mode.
 
-### Why no built-in web UI?
+### Why is the web UI local-first (and what about Docker)?
 
-The core interface is the filesystem and CLI. A web UI would add another state surface before the file protocol is finished.
+Hive ships a first-class **local** web install: `hive setup` provisions the Rails bundle, the daemon service, and the web service, then serves the UI at `http://127.0.0.1:4567` with no GitHub login required on loopback. The Docker/hivebox image remains supported (it is the path that keeps the GitHub device-flow / owner gate). The gem itself stays web-less (ADR-037) — local mode obtains the matching Rails bundle separately, pinned to the installed gem version.
 
 ### Why more than one agent?
 
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 104028df2..582bfc236 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -21,6 +21,32 @@ hive --version
 hive daemon install
 ```
 
+### Local web UI (optional)
+
+For a non-Docker machine, one command provisions and validates the full local
+surface — CLI deps, the Rails web bundle, the daemon service, and the web
+service — and serves the web UI at `http://127.0.0.1:4567`:
+
+```bash
+hive setup
+```
+
+On the loopback bind (`127.0.0.1`) the web UI needs no GitHub login by
+default (`web.auth_mode: loopback`). The web service is managed separately
+from the daemon:
+
+```bash
+hive web install    # write/enable the per-user systemd/launchd unit
+hive web start      # run the web server in the foreground/background
+hive web status     # running/pid/uptime + service install state
+hive web status --json
+```
+
+`hive setup` also enrolls the current repo in the daemon, so a task created
+in the CLI/TUI appears in the web UI and dispatches automatically (and vice
+versa). External agent CLIs (`claude` / `codex` / `gh`) are diagnosed, never
+installed or authenticated for you.
+
 ## Step 2 - Attach Hive To A Project
 
 ```bash
diff --git a/examples/launchd/hive-web.plist b/examples/launchd/hive-web.plist
new file mode 100644
index 000000000..d850b229e
--- /dev/null
+++ b/examples/launchd/hive-web.plist
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Sample launchd plist for `hive web` (macOS) — the local managed web
+  service, kept SEPARATE from `hive-daemon` (R7).
+
+  This file is installer-managed: `hive web install` rewrites the binary
+  path, the bin directory in PATH, and HIVEBOX_WEB_APP_DIR to match the
+  resolved binary + your home + the Rails app dir, then loads it. You
+  normally do not edit this by hand — re-run `hive web install` instead.
+
+  Install:
+    mkdir -p ~/Library/LaunchAgents
+    cp examples/launchd/hive-web.plist ~/Library/LaunchAgents/
+    $EDITOR ~/Library/LaunchAgents/local.hive-web.plist   # confirm paths
+    launchctl load ~/Library/LaunchAgents/local.hive-web.plist
+
+  Verify it actually started:
+    launchctl list | grep hive-web
+    cat ~/Library/Logs/hive-web.err.log
+
+  View logs:
+    tail -f ~/Library/Logs/hive-web.{out,err}.log
+
+  Stop / restart:
+    launchctl unload ~/Library/LaunchAgents/local.hive-web.plist
+    launchctl load   ~/Library/LaunchAgents/local.hive-web.plist
+
+  Note the absolute-path requirement: launchd doesn't read your shell's rc
+  files, so the `hive` binary must be invoked at its real location and
+  HIVEBOX_WEB_APP_DIR must point at the Rails app (config/application.rb).
+  Replace `/Users/YOU/.local/bin/hive` with the path on your machine.
+
+  RESPAWN-LOOP CIRCUIT-BREAKER: mirrors hive-daemon.plist — the /bin/sh
+  wrapper converts "binary not executable" into a clean exit 0, which
+  KeepAlive { SuccessfulExit: false } then respects (no respawn). A real
+  crash still exits non-zero and respawns normally per ThrottleInterval.
+-->
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN"
+  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>local.hive-web</string>
+
+  <!-- ProgramArguments wraps the real invocation in a tiny /bin/sh
+       precheck. If the binary path passed as $0 is missing or not
+       executable, the wrapper exits 0 (clean) so KeepAlive's
+       SuccessfulExit:false STOPS respawning. See the RESPAWN-LOOP
+       CIRCUIT-BREAKER note above for the rationale. -->
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/sh</string>
+    <string>-c</string>
+    <string>[ -x "$0" ] || exit 0; exec "$0" "$@"</string>
+    <string>/Users/YOU/.local/bin/hive</string>
+    <string>web</string>
+    <string>--bind</string>
+    <string>127.0.0.1</string>
+  </array>
+
+  <key>RunAtLoad</key>
+  <true/>
+
+  <key>KeepAlive</key>
+  <dict>
+    <key>SuccessfulExit</key>
+    <false/>
+  </dict>
+
+  <key>ThrottleInterval</key>
+  <integer>30</integer>
+
+  <key>StandardOutPath</key>
+  <string>/Users/YOU/Library/Logs/hive-web.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/YOU/Library/Logs/hive-web.err.log</string>
+
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>HIVEBOX_WEB_APP_DIR</key>
+    <string>/Users/YOU/.local/share/hive/web</string>
+  </dict>
+</dict>
+</plist>
diff --git a/examples/systemd/hive-web.service b/examples/systemd/hive-web.service
new file mode 100644
index 000000000..573cf1805
--- /dev/null
+++ b/examples/systemd/hive-web.service
@@ -0,0 +1,64 @@
+# Sample systemd-user unit for `hive web` (Linux) — the local managed web
+# service, kept SEPARATE from `hive-daemon` (R7).
+#
+# This file is installer-managed: `hive web install` rewrites ExecStart=,
+# Environment=PATH=, and Environment=HIVEBOX_WEB_APP_DIR= to match the
+# resolved binary + Ruby manager + Rails app dir detected on the host, then
+# enables + starts the unit. You normally do not edit this by hand — re-run
+# `hive web install` instead.
+#
+# BEFORE INSTALLING BY HAND: edit ExecStart= to match where YOUR `hive`
+# binary lives, and HIVEBOX_WEB_APP_DIR to the directory holding the Rails
+# app's config/application.rb. `which hive` shows the binary.
+#
+# Install:
+#   mkdir -p ~/.config/systemd/user
+#   cp examples/systemd/hive-web.service ~/.config/systemd/user/
+#   $EDITOR ~/.config/systemd/user/hive-web.service   # confirm ExecStart=
+#   systemctl --user daemon-reload
+#   systemctl --user enable --now hive-web
+#
+# Verify it actually started:
+#   systemctl --user status hive-web
+#   journalctl --user -u hive-web -n 50
+#
+# View logs:
+#   journalctl --user -u hive-web -f
+#
+# Stop / restart:
+#   systemctl --user stop hive-web
+#   systemctl --user restart hive-web
+#
+# To survive logout (run when no user session is active):
+#   sudo loginctl enable-linger $USER
+#
+# This unit runs `hive web` in the foreground (no subcommand = foreground);
+# systemd is the supervisor and Restart=on-failure brings the web tier back
+# if it crashes. The web tier binds 127.0.0.1 by default and, in the local
+# `web.auth_mode: loopback` policy, needs no GitHub login there.
+
+[Unit]
+Description=Hive web UI (local mode)
+After=network-online.target
+Wants=network-online.target
+# Hard cap on the auto-restart loop, mirroring hive-daemon.service.
+StartLimitBurst=3
+StartLimitIntervalSec=300
+
+[Service]
+Type=simple
+# systemd user services do NOT inherit your interactive shell's PATH, so
+# PATH below covers (a) incidental shell-outs the web tier performs and
+# (b) the gem's bin/hive wrapper whose `#!/usr/bin/env ruby` shebang needs
+# to find a Ruby with the gem's dependencies. `hive web install` detects
+# mise/rbenv/asdf and prepends the matching shim directory automatically.
+# HIVEBOX_WEB_APP_DIR points `hive web` at the Rails app so the managed
+# service survives login/reboot without relying on the caller's cwd.
+Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
+Environment=HIVEBOX_WEB_APP_DIR=%h/.local/share/hive/web
+ExecStart=%h/.local/bin/hive web --bind 127.0.0.1
+Restart=on-failure
+RestartSec=10
+
+[Install]
+WantedBy=default.target
diff --git a/install.md b/install.md
index 8ad54e54d..dcc807f3f 100644
--- a/install.md
+++ b/install.md
@@ -136,6 +136,30 @@ If the current directory is a git project and the user wants Hive enabled here,
 
 During `hive init`, keep the user's prompt choices. The daemon prompt is per-project enrollment (`daemon.enabled`) only; the service autostart has already been installed globally. If init is non-interactive, Hive uses recommended defaults and enrolls the project. `hive doctor` runs AFTER `hive init` because it requires an initialized project root.
 
+## Local Web UI (Optional)
+
+For a non-Docker machine that wants the web UI, offer (do not force) the
+local surface. One command provisions the web bundle, daemon service, and web
+service and serves the UI at `http://127.0.0.1:4567` with no GitHub login
+required on loopback:
+
+```bash
+"$hive_cmd" setup --json
+```
+
+`hive setup` diagnoses external agent CLIs (`gh` / `claude` / `codex`) and
+reports the exact fix command for any missing or unauthenticated one — it
+never installs or authenticates them. The managed web service is separate
+from the daemon:
+
+```bash
+"$hive_cmd" web install    # write/enable the per-user systemd/launchd unit
+"$hive_cmd" web status --json
+```
+
+Docker/hivebox remains supported and unchanged; it is the path that keeps the
+GitHub device-flow / owner gate.
+
 ## Optional Skills
 
 The Hive skills package is deferred to a v0.1.x follow-up (tracked in `wiki/gaps.md`). DO NOT RUN these commands until that package is published; treat the slugs below as the intended marketplace identifiers. If/when the package is published, offer the matching command:
@@ -159,4 +183,5 @@ Report:
 - whether `hive init` was run
 - missing runtime dependencies from `hive doctor`
 - `qmd --version` output, or the reason QMD install/repair was skipped
+- whether `hive setup` / `hive web install` (local web UI) was run
 - whether the optional skills package was installed or skipped
diff --git a/lib/hive.rb b/lib/hive.rb
index b22bfe0e3..de0cb93de 100644
--- a/lib/hive.rb
+++ b/lib/hive.rb
@@ -57,6 +57,10 @@ module Hive
       "hive-bot-stop" => 1,
       "hive-bot-reload" => 1,
       "hive-bot-install" => 1,
+      # Local managed web service status (`hive web status --json`).
+      "hive-web-status" => 1,
+      # One-shot local surface provisioning (`hive setup --json`).
+      "hive-setup" => 1,
       # File-backed dispatch request the bot writes for the daemon to
       # consume. One JSON file per pending request under the state-home
       # `dispatch_requests/` directory. See
@@ -536,6 +540,23 @@ module Hive
     end
   end
 
+  # `hive web install` mirrors the daemon/bot install outcome split: drift is
+  # a recoverable USAGE error (re-run with --force) while a service-manager
+  # failure (systemctl / launchctl) is SOFTWARE. Separate classes so the
+  # top-level rescue maps each surface independently while still producing
+  # the stable 64 / 70 exit codes automation branches on.
+  class WebInstallDriftError < Error
+    def exit_code
+      ExitCodes::USAGE
+    end
+  end
+
+  class WebInstallFailed < Error
+    def exit_code
+      ExitCodes::SOFTWARE
+    end
+  end
+
   # Raised by `hive run` when the stage's terminal marker is :error. The
   # runner itself succeeded — the agent recorded a task-level failure.
   # Distinct from StageError (which signals a runner bug / git failure).
diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb
index cd8090689..02078492f 100644
--- a/lib/hive/cli.rb
+++ b/lib/hive/cli.rb
@@ -257,6 +257,44 @@ module Hive
       ).call
     end
 
+    desc "setup", "Provision and validate the local Hive install (deps → qmd → web bundle → daemon → enrollment → web service)"
+    long_desc <<~DESC
+      One-shot setup for the first-class LOCAL (non-Docker) install. Runs, in
+      order, with each step idempotent and fail-fast-with-fix-command:
+
+        1. Backend prompt — persist which agents to provision globally
+           (Claude / Codex / Pi). Non-TTY (--non-interactive / piped stdin)
+           uses the defaults.
+        2. Dependency check — ruby (3.4), git, tmux, gh (auth), claude,
+           codex, node/npm, sqlite3. External agent CLIs (gh/claude/codex)
+           are DIAGNOSE-ONLY: missing/unauth prints the exact fix command
+           and is reported blocked, never installed or authenticated.
+        3. Bootstrap Hive-owned deps — qmd (managed npm install) and the
+           web bundle (obtain + bundle the Rails app at the installed gem
+           version).
+        4. Daemon service — `hive daemon install --force` + a status/version
+           consistency probe.
+        5. Project enrollment — `hive init .` (or `hive daemon enable` for an
+           already-registered project).
+        6. Web service — `hive web install` + a deep health probe at
+           http://127.0.0.1:4567/health?deep=1.
+
+      With --json, emits a single hive-setup.v1 document. A non-zero exit
+      means a hard dependency is missing or a step failed; blocked external
+      CLIs are warnings, not failures (the local web UI works without them).
+
+      Exit codes: 0 success; 1 incomplete; 64 usage; 70 internal; 78 config.
+    DESC
+    option :non_interactive, type: :boolean, default: false,
+                             desc: "skip prompts and use the default backends"
+    def setup
+      require "hive/commands/setup"
+      Hive::Commands::Setup.new(
+        json: options[:json],
+        non_interactive: options[:non_interactive]
+      ).call
+    end
+
     desc "update", "Update hive via the install channel that installed it"
     long_desc <<~DESC
       Reads the install-channel marker written by the installer and delegates
@@ -1332,18 +1370,46 @@ module Hive
       ).call
     end
 
-    desc "web", "Run the hivebox web UI"
+    desc "web [SUBCOMMAND]", "Run the hivebox web UI (foreground) or manage the local web service (install/start/stop/status)"
+    long_desc <<~DESC
+      With no subcommand, boots the Rails web UI in the foreground and
+      replaces this process with the Rails server (default bind
+      web.bind / web.port — 127.0.0.1:4567). The app must exist on disk:
+      run `hive setup` to provision it into ~/.local/share/hive/web, run
+      from the hivebox Docker image, or set HIVEBOX_WEB_APP_DIR.
+
+      Subcommands manage a SEPARATE per-user service (systemd-user on
+      Linux, launchd on macOS), distinct from `hive daemon`:
+
+        install [--force]  (Re)write + enable the platform-native unit
+                           (hive-web.service / local.hive-web.plist).
+                           Without --force, refuses to overwrite a
+                           pre-existing unit and exits 64 (USAGE); with
+                           --force it backs up the prior file to
+                           <path>.bak-<UTC-stamp> and restarts.
+        start              Run the web server detached (pidfile at
+                           <state_home>/.web.pid).
+        stop               SIGTERM the running web server (idempotent).
+        status [--json]    running/pid/uptime + service install state.
+                           --json emits hive-web-status.v1.
+
+      Exit codes: 0 success; 1 web-not-running for `status` (scriptable
+      precondition probe); 64 USAGE for drift without --force / unknown
+      subcommand; 70 SOFTWARE for a service-manager failure; 75 TEMPFAIL
+      when `start` finds a live web server; 78 CONFIG for a refused
+      non-loopback bind under web.auth_mode=loopback.
+    DESC
     option :bind, type: :string, desc: "override web.bind"
     option :port, type: :numeric, desc: "override web.port"
-    def web
-      if options[:json]
+    option :force, type: :boolean, default: false,
+                   desc: "for install: overwrite an existing unit (saves <path>.bak)"
+    def web(subcommand = nil)
+      require "hive/commands/web"
+      if options[:json] && subcommand != "status"
         require "json"
-        message = "hive web has no JSON output (it runs a long-lived server). " \
-                  "Use 'hive status --json' for machine-readable task data."
-        # Mirror `hive tui`'s rejection: emit a structured error envelope (sans
-        # `schema`, since web has no registered hive-* schema) and raise
-        # InvalidTaskPath for the USAGE (64) exit code — parity with every
-        # other --json failure on this surface.
+        message = "hive web #{subcommand || '(foreground)'} has no JSON output " \
+                  "(the foreground server runs long-lived; only `hive web status --json` " \
+                  "emits an envelope)."
         puts JSON.generate(
           "ok" => false,
           "error_class" => "InvalidTaskPath",
@@ -1354,8 +1420,13 @@ module Hive
         raise Hive::InvalidTaskPath, message
       end
 
-      require "hive/commands/web"
-      Hive::Commands::Web.new(bind: options[:bind], port: options[:port]).call
+      Hive::Commands::Web.new(
+        subcommand,
+        bind: options[:bind],
+        port: options[:port],
+        force: options[:force],
+        json: options[:json]
+      ).call
     end
 
     desc "tui", "Open the live, keystroke-driven dashboard for every active task"
diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb
index 6c91610b1..d15e9e159 100644
--- a/lib/hive/commands/daemon.rb
+++ b/lib/hive/commands/daemon.rb
@@ -371,6 +371,7 @@ module Hive
 
         if @json
           service_state = probe_service_state
+          consistency = consistency_payload(pid: running ? pid : nil)
           puts JSON.generate(
             "schema" => "hive-daemon-status",
             "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"),
@@ -383,6 +384,12 @@ module Hive
             "service_installed" => service_state["service_installed"],
             "service_enabled" => service_state["service_enabled"],
             "unit_path" => service_state["unit_path"],
+            # Binary-consistency guard (U5): the resolved binary the installer
+            # would write, the CLI version, and whether the installed unit and
+            # running process agree with both.
+            "binary" => consistency["binary"],
+            "binary_version" => consistency["binary_version"],
+            "consistent" => consistency["consistent"],
             # Agent-native parity with the TUI footer / bot push: expose the
             # update nudge so a programmatic caller can detect "behind" too.
             "current_version" => Hive::VERSION,
@@ -397,6 +404,16 @@ module Hive
         raise Hive::Error, "daemon not running" unless running
       end
 
+      # Read-only binary-consistency probe for the status envelope. Any
+      # failure degrades the three fields to null instead of raising out of
+      # the whole command (mirrors probe_service_state).
+      def consistency_payload(pid:)
+        require "hive/daemon/consistency"
+        Hive::Daemon::Consistency.new(pid: pid).probe
+      rescue StandardError
+        { "binary" => nil, "binary_version" => Hive::VERSION, "consistent" => nil }
+      end
+
       # Read-only autostart-state snapshot for the status envelope. A status
       # probe must never take down the running/pid reporting that precedes
       # it, so any failure degrades the three service fields to null (the
diff --git a/lib/hive/commands/daemon/service_installer.rb b/lib/hive/commands/daemon/service_installer.rb
index 02cda2fbf..5e0d2aac2 100644
--- a/lib/hive/commands/daemon/service_installer.rb
+++ b/lib/hive/commands/daemon/service_installer.rb
@@ -44,11 +44,14 @@ module Hive
           template = File.read(File.expand_path("../../../../examples/systemd/hive-daemon.service", __dir__))
           # systemd .service files are POSIX-shell-ish — escape the
           # resolved binary path so whitespace, `%`, or other special
-          # characters don't produce a malformed unit.
+          # characters don't produce a malformed ExecStart=. Environment=
+          # does NOT parse shell backslash/quote escaping, so HIVE_BIN keeps
+          # the RAW path (the daemon reads ENV["HIVE_BIN"] verbatim into an
+          # argv element; an escaped value would point at a nonexistent file).
           escaped = Shellwords.escape(resolved_binary)
           template
             .sub(/^ExecStart=.*$/, "ExecStart=#{escaped} daemon start")
-            .sub(/^Environment=HIVE_BIN=.*$/, "Environment=HIVE_BIN=#{escaped}")
+            .sub(/^Environment=HIVE_BIN=.*$/, "Environment=HIVE_BIN=#{resolved_binary}")
             .sub(/^Environment=PATH=.*$/, build_path_line)
         end
 
diff --git a/lib/hive/commands/service_installer/base.rb b/lib/hive/commands/service_installer/base.rb
index 04f9ba760..08701f865 100644
--- a/lib/hive/commands/service_installer/base.rb
+++ b/lib/hive/commands/service_installer/base.rb
@@ -126,6 +126,15 @@ module Hive
           nil
         end
 
+        # Public accessor for the resolved binary the installer would bake into
+        # the unit. The daemon consistency guard (Hive::Daemon::Consistency)
+        # compares the installed unit and the running process against this, so
+        # drift detection reuses the exact resolution the installer writes
+        # (brew stable symlink → binary_path → PATH → source fallback).
+        def resolved_binary
+          resolve_binary
+        end
+
         private
 
         def install_macos!(autostart:, force:)
@@ -305,7 +314,7 @@ module Hive
           nil
         end
 
-        def resolved_binary
+        def resolve_binary
           if (brew_binary = homebrew_stable_binary)
             return File.expand_path(brew_binary)
           end
diff --git a/lib/hive/commands/setup.rb b/lib/hive/commands/setup.rb
new file mode 100644
index 000000000..4931df62e
--- /dev/null
+++ b/lib/hive/commands/setup.rb
@@ -0,0 +1,296 @@
+require "json"
+require "fileutils"
+require "stringio"
+require "hive/config"
+require "hive/paths"
+require "hive/invoked_binary"
+require "hive/commands/setup/backend_prompt"
+require "hive/commands/setup/deps"
+
+module Hive
+  module Commands
+    # `hive setup` — one-shot provisioning + validation of the first-class
+    # LOCAL install surface (Linux systemd-user / macOS launchd). It wires the
+    # sub-pieces in dependency order: backend selection → dependency check →
+    # Hive-owned deps (qmd + web bundle) → daemon service → project enrollment
+    # → web service. Every step is idempotent; external agent CLIs are
+    # diagnosed (never installed/authenticated), and each failure carries the
+    # exact fix command.
+    #
+    # Collaborators (deps / web_bundle / runner / health_probe) are injectable
+    # so the orchestration order is unit-testable without real subprocesses.
+    class Setup
+      # How long the web health probe keeps retrying connection failures
+      # before giving up. First-run Rails boot + db:prepare after
+      # `hive web install` can exceed a single 5s timeout, so a bounded
+      # retry loop (not a single unbounded-by-retry probe) absorbs it.
+      WEB_HEALTH_PROBE_DEADLINE_SECONDS = 30
+
+      def initialize(json: false, non_interactive: false, project_path: Dir.pwd,
+                     input: $stdin, output: $stderr, summary_io: $stdout,
+                     deps: nil, web_bundle: nil, runner: nil, health_probe: nil)
+        @json = json
+        @non_interactive = non_interactive
+        @project_path = File.expand_path(project_path)
+        @input = input
+        @output = output
+        @summary_io = summary_io
+        @deps = deps
+        @web_bundle = web_bundle
+        @runner = runner || method(:run_real)
+        @health_probe = health_probe || method(:default_health_probe)
+      end
+
+      def call
+        backends = select_backends
+        persist_backends(backends)
+
+        steps = []
+        warnings = []
+
+        dep_results = deps.check
+        steps << { "name" => "deps", "ok" => true, "message" => "checked #{dep_results.size} dependencies" }
+        dep_results.select(&:blocked).each { |r| warnings << "#{r.name}: #{r.fix}" }
+
+        qmd = bootstrap_qmd
+        steps << { "name" => "qmd", "ok" => qmd[:ok], "message" => qmd[:message] }
+
+        bundle = web_bundle.provision
+        steps << { "name" => "web_bundle", "ok" => bundle[:ok], "message" => bundle[:message] }
+
+        daemon = ensure_daemon
+        steps << { "name" => "daemon", "ok" => daemon[:ok], "message" => daemon[:message] }
+
+        enroll = enroll_project
+        steps << { "name" => "enroll", "ok" => enroll[:ok], "message" => enroll[:message] }
+
+        web = ensure_web_service
+        steps << { "name" => "web", "ok" => web[:ok], "message" => web[:message] }
+
+        # Success = every step passed AND every non-blocked dependency is ok.
+        # Blocked external CLIs (claude/codex/gh) are diagnose-only warnings —
+        # they never gate the local web bring-up.
+        ok = dep_results.all? { |r| r.ok || r.blocked } && steps.all? { |s| s["ok"] }
+
+        if @json
+          puts JSON.generate(
+            hive_setup_envelope(backends, dep_results, steps, warnings, ok)
+          )
+        else
+          print_human_summary(backends, dep_results, steps, warnings, ok)
+        end
+
+        raise Hive::Error, "hive setup incomplete — see the summary above" unless ok
+
+        ok
+      end
+
+      private
+
+      def deps
+        @deps ||= Hive::Commands::Setup::Deps.new
+      end
+
+      def web_bundle
+        @web_bundle ||= begin
+          require "hive/commands/setup/web_bundle"
+          Hive::Commands::Setup::WebBundle.new
+        end
+      end
+
+      def hive_bin
+        @hive_bin ||= (Hive::InvokedBinary.path || "hive")
+      end
+
+      def select_backends
+        input = @non_interactive ? StringIO.new : @input
+        Hive::Commands::Setup::BackendPrompt.new(
+          input: input,
+          output: @output,
+          # In JSON mode the summary line must not land on stdout — it
+          # would corrupt the single-document contract (U3). Route it to
+          # @output ($stderr) instead of @summary_io ($stdout).
+          summary_io: @json ? @output : @summary_io
+        ).collect
+      end
+
+      def persist_backends(backends)
+        Hive::Config.write_global_agents!(backends)
+        backends
+      end
+
+      # `qmd` (the Hive-managed wiki indexer) is bootstrapped into Hive's own
+      # data prefix, mirroring install.sh's managed npm install contract. Never
+      # touches the user's global npm prefix or authenticates anything.
+      def bootstrap_qmd
+        qmd_home = File.join(Hive::Paths.data_home, "qmd")
+        qmd_bin = File.join(qmd_home, "bin", "qmd")
+        return { ok: true, message: "qmd already installed" } if File.executable?(qmd_bin)
+
+        _out, npm_ok = @runner.call(%w[npm --version])
+        return { ok: false, message: "npm missing — install Node.js/npm, then re-run hive setup" } unless npm_ok
+
+        FileUtils.mkdir_p(qmd_home)
+        package = ENV["HIVE_QMD_NPM_PACKAGE"] || "@tobilu/qmd"
+        _out, ok = @runner.call([ "npm", "install", "--global", "--prefix", qmd_home,
+                                  "--no-audit", "--no-fund", package ])
+        return { ok: false, message: "qmd install failed; run: npm install --global --prefix #{qmd_home} #{package}" } unless ok
+
+        link_qmd(qmd_home, qmd_bin)
+        { ok: true, message: "qmd installed to #{qmd_home}" }
+      end
+
+      def link_qmd(qmd_home, qmd_bin)
+        bin_dir = Hive::Paths.bin_home
+        FileUtils.mkdir_p(bin_dir)
+        link = File.join(bin_dir, "qmd")
+        FileUtils.ln_sf(qmd_bin, link)
+      rescue StandardError
+        nil
+      end
+
+      def ensure_daemon
+        _out, install_ok = @runner.call([ hive_bin, "daemon", "install", "--force" ])
+        return { ok: false, message: "hive daemon install --force failed" } unless install_ok
+
+        # `hive daemon status --json` exits 1 when the daemon is not running
+        # but STILL emits its JSON envelope on stdout, so the exit code is
+        # not a command-failure signal. Parse the envelope unconditionally:
+        # a systemd-less host (install → autostart_unavailable) or a restart
+        # race right after `install --force` must not fail setup just because
+        # the daemon briefly reports "not running". A truly broken status
+        # command yields invalid JSON and fails below.
+        status_out, = @runner.call([ hive_bin, "daemon", "status", "--json" ])
+
+        begin
+          payload = JSON.parse(status_out)
+        rescue JSON::ParserError
+          return { ok: false, message: "hive daemon status returned invalid JSON" }
+        end
+
+        # U5 consistency guard: false = drifted binary/version, true =
+        # verified consistent, nil = the probe could not verify (e.g. /proc
+        # and ps both unavailable). nil must NOT be reported as pass —
+        # unverifiable drift is not "consistent".
+        case payload["consistent"]
+        when false
+          return { ok: false, message: "daemon binary is inconsistent with the CLI; run `hive daemon install --force`" }
+        when nil
+          return { ok: false, message: "daemon consistency could not be verified; run `hive daemon status --json` to inspect" }
+        end
+
+        { ok: true, message: "daemon service installed and consistent" }
+      end
+
+      def enroll_project
+        registered = Hive::Config.registered_projects.any? do |p|
+          File.expand_path(p["path"]) == @project_path
+        end
+
+        if registered
+          name = Hive::Config.registered_projects.find { |p| File.expand_path(p["path"]) == @project_path }["name"]
+          _out, ok = @runner.call([ hive_bin, "daemon", "enable", name ])
+          return { ok: false, message: "hive daemon enable #{name} failed" } unless ok
+
+          { ok: true, message: "enrolled #{name} in the daemon" }
+        else
+          _out, ok = @runner.call([ hive_bin, "init", @project_path, "--force" ])
+          return { ok: false, message: "hive init #{@project_path} failed" } unless ok
+
+          { ok: true, message: "initialized #{@project_path}" }
+        end
+      end
+
+      def ensure_web_service
+        _out, install_ok = @runner.call([ hive_bin, "web", "install" ])
+        return { ok: false, message: "hive web install failed" } unless install_ok
+
+        # Persist the local loopback web contract (U1/R4). `hive web install`
+        # (the subprocess above) writes it too, but the in-process write is
+        # what keeps this observable when the runner is injected/stubbed and
+        # guarantees a fresh `hive setup` never leaves web.auth_mode at the
+        # "owner" default.
+        Hive::Config.write_local_web_loopback!
+
+        ok, message = @health_probe.call
+        { ok: ok, message: message }
+      end
+
+      # Bounded HTTP probe with retry: open_timeout caps the TCP connect and
+      # read_timeout caps the response wait, so a hung web server (or a port
+      # that accepts but never answers) cannot hang `hive setup`
+      # indefinitely. The probe targets the RESOLVED web_url (so a
+      # non-default configured port is probed too, not the hardcoded
+      # 127.0.0.1:4567), and retries connection failures up to
+      # WEB_HEALTH_PROBE_DEADLINE_SECONDS because first-run Rails boot +
+      # db:prepare can exceed a single 5s timeout.
+      def default_health_probe
+        require "net/http"
+        base = web_url
+        uri = URI("#{base}/health?deep=1")
+
+        deadline = Time.now + WEB_HEALTH_PROBE_DEADLINE_SECONDS
+        last_error = nil
+        loop do
+          begin
+            res = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 5) do |http|
+              http.request(Net::HTTP::Get.new(uri))
+            end
+            ok = res.is_a?(Net::HTTPSuccess)
+            return [ ok, ok ? "web health OK at #{base}" : "web health returned #{res.code}" ]
+          rescue StandardError => e
+            last_error = e
+          end
+          break if Time.now >= deadline
+
+          sleep 1
+        end
+
+        [ false, "web health probe failed: #{last_error ? "#{last_error.class}: #{last_error.message}" : 'no response'}" ]
+      end
+
+      def run_real(argv)
+        require "open3"
+        out, _err, status = Open3.capture3(*argv)
+        [ out, status.success? ]
+      rescue Errno::ENOENT, Errno::EACCES
+        [ "", false ]
+      end
+
+      def web_url
+        cfg = Hive::Config.load_global_web
+        "http://#{cfg.fetch('bind')}:#{cfg.fetch('port')}"
+      rescue Hive::ConfigError
+        "http://127.0.0.1:4567"
+      end
+
+      def hive_setup_envelope(backends, dep_results, steps, warnings, ok)
+        {
+          "schema" => "hive-setup",
+          "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-setup"),
+          "ok" => ok,
+          "backends" => backends,
+          "deps" => dep_results.map(&:to_h),
+          "steps" => steps,
+          "web_url" => web_url,
+          "warnings" => warnings
+        }
+      end
+
+      def print_human_summary(backends, dep_results, steps, warnings, ok)
+        @summary_io.puts "hive setup: backends = #{backends.join(', ')}"
+        dep_results.each do |r|
+          mark = r.ok ? "ok" : (r.blocked ? "blocked" : "missing")
+          line = "  #{mark.ljust(7)} #{r.name.ljust(9)} #{r.message}"
+          line += "  (#{r.fix})" if r.fix
+          @summary_io.puts line
+        end
+        steps.each do |s|
+          @summary_io.puts "  #{s['ok'] ? 'ok' : 'FAILED'.ljust(6)} #{s['name'].ljust(11)} #{s['message']}"
+        end
+        warnings.each { |w| @summary_io.puts "  warn: #{w}" }
+        @summary_io.puts(ok ? "hive setup: complete — web UI at #{web_url}" : "hive setup: incomplete")
+      end
+    end
+  end
+end
diff --git a/lib/hive/commands/setup/deps.rb b/lib/hive/commands/setup/deps.rb
new file mode 100644
index 000000000..c54260702
--- /dev/null
+++ b/lib/hive/commands/setup/deps.rb
@@ -0,0 +1,254 @@
+require "open3"
+
+module Hive
+  module Commands
+    class Setup
+      # Dependency checker for `hive setup`. Verifies the local surface's
+      # required CLIs and reports a per-dep Result. Two classes of dependency:
+      #
+      #   * External agent CLIs (gh / claude / codex) are DIAGNOSE-ONLY: a
+      #     missing or unauthenticated CLI is reported `blocked` with the
+      #     exact fix command, but `hive setup` NEVER installs or
+      #     authenticates them (R10).
+      #   * Everything else (ruby/git/tmux/node/npm/sqlite3) is a hard check —
+      #     missing → ok:false with a fix command, but not "blocked" (the
+      #     operator runs the fix and re-runs setup; setup does not gate on
+      #     them itself).
+      #
+      # `qmd` and the web bundle are Hive-owned and bootstrapped by the setup
+      # orchestrator (U3/U4), not diagnosed here.
+      class Deps
+        MIN_RUBY = "3.4.0".freeze
+        MIN_CLAUDE = "2.1.118".freeze
+        MIN_CODEX = "0.125.0".freeze
+
+        # Ruby version managers and their install + select commands. The
+        # `root` segments mirror ServiceInstaller::Base::RUBY_SHIM_MANAGERS
+        # keys so the detector here and the unit-PATH builder agree on what
+        # counts as mise / rbenv / asdf. `3.4` is the version token each
+        # manager resolves to the latest 3.4.x.
+        RUBY_MANAGER_FIXES = {
+          "mise" => { root: ".local/share/mise", fix: "mise install ruby@3.4 && mise use ruby@3.4" },
+          "rbenv" => { root: ".rbenv", fix: "rbenv install 3.4 && rbenv global 3.4" },
+          "asdf" => { root: ".asdf", fix: "asdf install ruby 3.4 && asdf global ruby 3.4" }
+        }.freeze
+
+        # Fallback when no manager is detectable (system Ruby, or Ruby not
+        # yet installed with no manager CLI on PATH): point at every supported
+        # manager rather than a single hardcoded one.
+        SYSTEM_RUBY_FIX = "install Ruby 3.4 with rbenv / mise / asdf, or your OS package manager".freeze
+
+        # One row of the dependency report. `ok` = present and satisfies the
+        # gate; `blocked` = an external CLI that setup cannot fix itself
+        # (missing/unauth); `found` = parsed version (or nil); `fix` = exact
+        # command the operator must run (nil when ok).
+        Result = Struct.new(:name, :ok, :blocked, :found, :required, :message, :fix, keyword_init: true) do
+          def to_h
+            {
+              "name" => name,
+              "ok" => ok,
+              "blocked" => blocked,
+              "found" => found,
+              "required" => required,
+              "message" => message,
+              "fix" => fix
+            }
+          end
+        end
+
+        # `runner` is callable with an argv Array and returns [stdout, ok].
+        # Injectable so tests can stub `claude`/`codex`/`gh`/`qmd` on PATH.
+        def initialize(runner: nil)
+          @runner = runner || method(:run_real)
+        end
+
+        def check
+          [
+            check_ruby,
+            check_git,
+            check_tmux,
+            check_gh,
+            check_claude,
+            check_codex,
+            check_node,
+            check_sqlite3
+          ]
+        end
+
+        def blocked_names
+          check.select(&:blocked).map(&:name)
+        end
+
+        def all_ok?
+          check.all?(&:ok)
+        end
+
+        private
+
+        def run_real(argv)
+          out, _err, status = Open3.capture3(*argv)
+          [ out, status.success? ]
+        rescue Errno::ENOENT, Errno::EACCES
+          [ "", false ]
+        end
+
+        # Run `argv` and extract the first version-looking token. Returns
+        # [version, ok]; version is nil when the command failed or produced
+        # no parseable version.
+        def probe_version(argv, pattern)
+          out, ok = @runner.call(argv)
+          return [ nil, false ] unless ok
+
+          match = out.to_s[pattern]
+          [ match, !match.nil? ]
+        end
+
+        def version_tuple(version)
+          version.to_s.split(".").map(&:to_i)
+        end
+
+        def at_least?(found, min)
+          (version_tuple(found) <=> version_tuple(min)) >= 0
+        end
+
+        # Ruby 3.4 is exact (major.minor), not a floor — a 3.5/4.x Ruby would
+        # drift from the pinned .ruby-version / Gemfile.lock and fail the
+        # bundle later anyway, so say so up front.
+        def ruby_major_minor_match?(version)
+          tuple = version_tuple(version)
+          tuple[0] == 3 && tuple[1] == 4
+        end
+
+        def check_ruby
+          version, ok = probe_version(%w[ruby --version], /\d+\.\d+\.\d+/)
+          unless ok && version
+            return Result.new(name: "ruby", ok: false, blocked: false, found: nil, required: "3.4.x",
+                              message: "ruby 3.4 is required",
+                              fix: ruby_fix)
+          end
+          unless ruby_major_minor_match?(version)
+            return Result.new(name: "ruby", ok: false, blocked: false, found: version, required: "3.4.x",
+                              message: "ruby #{version} found; 3.4.x is required",
+                              fix: ruby_fix)
+          end
+          Result.new(name: "ruby", ok: true, blocked: false, found: version, required: "3.4.x",
+                     message: "ok", fix: nil)
+        end
+
+        # The fix command for a missing / wrong-version Ruby, tailored to the
+        # version manager actually in use (mise / rbenv / asdf), with a
+        # manager-agnostic fallback for system Ruby. Mirrors
+        # ServiceInstaller::Base#ruby_shim_dir's detection so the two surfaces
+        # agree on which manager is active.
+        def ruby_fix
+          RUBY_MANAGER_FIXES.dig(ruby_manager, :fix) || SYSTEM_RUBY_FIX
+        end
+
+        # Detect the active Ruby version manager: prefer the realpath of the
+        # `ruby` on PATH (matched against each manager's install root),
+        # falling back to whichever manager CLI is itself on PATH when Ruby
+        # isn't installed yet.
+        def ruby_manager
+          ruby_path = which("ruby")
+          if ruby_path
+            resolved = File.exist?(ruby_path) ? File.realpath(ruby_path) : ruby_path
+            home = File.expand_path(ENV["HOME"] || Dir.home)
+            RUBY_MANAGER_FIXES.each do |name, spec|
+              root = File.join(home, spec[:root])
+              return name if resolved.start_with?("#{root}/")
+            end
+          end
+
+          RUBY_MANAGER_FIXES.keys.find { |name| which(name) }
+        end
+
+        # Pure PATH lookup (no spawn) — the same predicate
+        # ServiceInstaller::Base uses to resolve binaries.
+        def which(name)
+          ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
+            path = File.join(dir, name)
+            return path if File.file?(path) && File.executable?(path)
+          end
+          nil
+        end
+
+        def check_git
+          version, ok = probe_version(%w[git --version], /\d+\.\d+(?:\.\d+)?/)
+          ok ? Result.new(name: "git", ok: true, blocked: false, found: version, required: "any",
+                          message: "ok", fix: nil)
+             : Result.new(name: "git", ok: false, blocked: false, found: nil, required: "any",
+                          message: "git not found", fix: "brew install git")
+        end
+
+        def check_tmux
+          version, ok = probe_version(%w[tmux -V], /\d+\.\d+/)
+          ok ? Result.new(name: "tmux", ok: true, blocked: false, found: version, required: "any",
+                          message: "ok", fix: nil)
+             : Result.new(name: "tmux", ok: false, blocked: false, found: nil, required: "any",
+                          message: "tmux not found", fix: "brew install tmux")
+        end
+
+        def check_gh
+          version, ok = probe_version(%w[gh --version], /\d+\.\d+(?:\.\d+)?/)
+          unless ok
+            return Result.new(name: "gh", ok: false, blocked: true, found: nil, required: "authenticated",
+                              message: "gh not found", fix: "brew install gh && gh auth login")
+          end
+          unless gh_authed?
+            return Result.new(name: "gh", ok: false, blocked: true, found: version, required: "authenticated",
+                              message: "gh is installed but not authenticated", fix: "gh auth login")
+          end
+          Result.new(name: "gh", ok: true, blocked: false, found: version, required: "authenticated",
+                     message: "ok", fix: nil)
+        end
+
+        def gh_authed?
+          _out, ok = @runner.call(%w[gh auth status])
+          ok
+        end
+
+        def check_claude
+          check_agent_cli("claude", MIN_CLAUDE)
+        end
+
+        def check_codex
+          check_agent_cli("codex", MIN_CODEX)
+        end
+
+        def check_agent_cli(name, min_version)
+          version, ok = probe_version([ name, "--version" ], /\d+\.\d+\.\d+/)
+          unless ok
+            return Result.new(name: name, ok: false, blocked: true, found: nil, required: ">= #{min_version}",
+                              message: "#{name} not found",
+                              fix: "brew install #{name} && #{name} login")
+          end
+          unless at_least?(version, min_version)
+            return Result.new(name: name, ok: false, blocked: true, found: version, required: ">= #{min_version}",
+                              message: "#{name} #{version} below minimum #{min_version}",
+                              fix: "brew upgrade #{name}")
+          end
+          Result.new(name: name, ok: true, blocked: false, found: version, required: ">= #{min_version}",
+                     message: "ok", fix: nil)
+        end
+
+        def check_node
+          node, node_ok = probe_version(%w[node --version], /\d+\.\d+\.\d+/)
+          npm, npm_ok = probe_version(%w[npm --version], /\d+\.\d+\.\d+/)
+          ok = node_ok && npm_ok
+          Result.new(name: "node/npm", ok: ok, blocked: false, found: ok ? "node #{node}, npm #{npm}" : nil,
+                     required: "any",
+                     message: ok ? "ok" : "node or npm not found",
+                     fix: ok ? nil : "brew install node")
+        end
+
+        def check_sqlite3
+          version, ok = probe_version(%w[sqlite3 --version], /\d+\.\d+\.\d+/)
+          ok ? Result.new(name: "sqlite3", ok: true, blocked: false, found: version, required: "any",
+                          message: "ok", fix: nil)
+             : Result.new(name: "sqlite3", ok: false, blocked: false, found: nil, required: "any",
+                          message: "sqlite3 not found", fix: "brew install sqlite")
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hive/commands/setup/web_bundle.rb b/lib/hive/commands/setup/web_bundle.rb
new file mode 100644
index 000000000..f44d202e1
--- /dev/null
+++ b/lib/hive/commands/setup/web_bundle.rb
@@ -0,0 +1,167 @@
+require "fileutils"
+require "open3"
+require "hive/paths"
+
+module Hive
+  module Commands
+    class Setup
+      # Web bundle provisioning for the first-class LOCAL mode (U4). The gem
+      # does not package the Rails app (ADR-037), so `hive setup` obtains and
+      # bundles it separately, pinned to the installed gem version so the web
+      # tier and CLI agree.
+      #
+      # Resolution order: HIVEBOX_WEB_APP_DIR → managed `~/.local/share/hive/web`
+      # → sibling source checkout. When nothing resolves, fetch the release
+      # tarball for `v#{Hive::VERSION}` (git clone --depth 1 --branch fallback)
+      # into the managed dir, keep only `web/`, then `bundle install`
+      # (BUNDLE_GEMFILE=web/Gemfile) and `bin/rails db:prepare`. A marker file
+      # records the pinned version so `hive setup` / `hive web` can warn (and
+      # re-fetch) when the managed bundle drifts from `Hive::VERSION`.
+      class WebBundle
+        MARKER_FILENAME = ".hive-web-version".freeze
+
+        # `fetcher` is callable with (target_dir) and returns {ok:, message:};
+        # injectable so tests never touch the network. `runner` is callable
+        # with an argv Array and returns [stdout, ok].
+        def initialize(fetcher: nil, runner: nil)
+          @fetcher = fetcher || method(:default_fetch)
+          @runner = runner || method(:run_real)
+        end
+
+        # Provision the bundle. Returns { ok:, message:, app_dir: }. Idempotent:
+        # an already-present, version-matching bundle only re-runs the
+        # idempotent bundle/db steps; a stale managed bundle is re-fetched.
+        def provision
+          existing = resolve_app_dir
+          dir = existing || Hive::Paths.managed_web_dir
+
+          if existing.nil? || stale_managed?(dir)
+            result = @fetcher.call(dir)
+            return { ok: false, message: result[:message], app_dir: dir } unless result[:ok]
+          end
+
+          install = bundle_install(dir)
+          return { ok: false, message: install[:message], app_dir: dir } unless install[:ok]
+
+          db = db_prepare(dir)
+          return { ok: false, message: db[:message], app_dir: dir } unless db[:ok]
+
+          write_marker(dir) if managed_dir?(dir)
+
+          { ok: true, message: "web bundle ready at #{dir}", app_dir: dir }
+        end
+
+        # Resolution order: env override → managed dir → sibling source
+        # checkout. Only a dir holding `config/application.rb` counts.
+        def resolve_app_dir
+          candidates = [
+            ENV["HIVEBOX_WEB_APP_DIR"],
+            Hive::Paths.managed_web_dir,
+            File.expand_path("../../../../web", __dir__)
+          ].compact
+          candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) }
+        end
+
+        def marker_path(dir)
+          File.join(dir, MARKER_FILENAME)
+        end
+
+        def write_marker(dir)
+          FileUtils.mkdir_p(dir)
+          File.write(marker_path(dir), "#{Hive::VERSION}\n")
+        end
+
+        # The pinned version recorded in the marker, or nil when absent.
+        def marker_version(dir)
+          path = marker_path(dir)
+          return nil unless File.file?(path)
+
+          value = File.read(path).strip
+          value.empty? ? nil : value
+        rescue SystemCallError
+          nil
+        end
+
+        # True when the managed bundle's marker disagrees with Hive::VERSION.
+        def version_mismatch?(dir)
+          pinned = marker_version(dir)
+          !pinned.nil? && pinned != Hive::VERSION
+        end
+
+        def managed_dir?(dir)
+          File.expand_path(dir) == File.expand_path(Hive::Paths.managed_web_dir)
+        end
+
+        # A managed bundle is stale when it has no marker (an older setup
+        # before markers) or a marker that disagrees with Hive::VERSION.
+        def stale_managed?(dir)
+          return false unless managed_dir?(dir)
+
+          marker_version(dir) != Hive::VERSION
+        end
+
+        private
+
+        def bundle_install(dir)
+          in_dir(dir) do
+            _out, ok = @runner.call([ "bundle", "install" ])
+            return { ok: false, message: "bundle install failed; run: cd #{dir} && bundle install" } unless ok
+          end
+          { ok: true, message: "bundle installed" }
+        end
+
+        def db_prepare(dir)
+          in_dir(dir) do
+            _out, ok = @runner.call([ "bin/rails", "db:prepare" ])
+            return { ok: false, message: "db:prepare failed; run: cd #{dir} && bin/rails db:prepare" } unless ok
+          end
+          { ok: true, message: "db prepared" }
+        end
+
+        def in_dir(dir, &blk)
+          Dir.chdir(dir, &blk)
+        end
+
+        # Fetch the matching release into the managed dir. Tries a shallow
+        # clone of the pinned tag first (git is a verified setup dep), keeps
+        # only `web/`, and records no marker (the caller writes it after a
+        # successful bundle). Returns {ok:, message:}.
+        def default_fetch(dir)
+          version = Hive::VERSION
+          tag = "v#{version}"
+          url = "https://github.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}.git"
+          parent = File.dirname(dir)
+          FileUtils.mkdir_p(parent)
+          tmp = File.join(parent, ".hive-web-src-#{Process.pid}-#{rand(1_000_000)}")
+          FileUtils.rm_rf(tmp)
+
+          _out, ok = @runner.call([ "git", "clone", "--depth", "1", "--branch", tag, url, tmp ])
+          unless ok
+            FileUtils.rm_rf(tmp)
+            return { ok: false,
+                     message: "could not fetch web bundle #{tag}; run: git clone --depth 1 " \
+                              "--branch #{tag} #{url} /tmp/hive-web-src && mv /tmp/hive-web-src/web #{dir}" }
+          end
+
+          web_src = File.join(tmp, "web")
+          unless File.directory?(web_src)
+            FileUtils.rm_rf(tmp)
+            return { ok: false, message: "release #{tag} has no web/ directory" }
+          end
+
+          FileUtils.rm_rf(dir)
+          FileUtils.mv(web_src, dir)
+          FileUtils.rm_rf(tmp)
+          { ok: true, message: "fetched web bundle #{tag}" }
+        end
+
+        def run_real(argv)
+          out, _err, status = Open3.capture3(*argv)
+          [ out, status.success? ]
+        rescue Errno::ENOENT, Errno::EACCES
+          [ "", false ]
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hive/commands/web.rb b/lib/hive/commands/web.rb
index eb3cd40fc..2317dc90b 100644
--- a/lib/hive/commands/web.rb
+++ b/lib/hive/commands/web.rb
@@ -1,31 +1,91 @@
+require "json"
+require "time"
 require "hive/config"
+require "hive/paths"
+require "hive/pid_file"
 require "hive/web/session_secret"
 
 module Hive
   module Commands
-    # Boots the hivebox web UI — a Rails app living in web/ at the repo root
-    # (shipped in the Docker image at /app/web). hive itself stays a plain
-    # CLI gem; the web tier is only supported where the Rails app and its
-    # bundle exist: the hivebox container or a source checkout.
+    # `hive web` boots the hivebox web UI — a Rails app living in web/ at the
+    # repo root (shipped in the Docker image at /app/web, or provisioned into
+    # the managed `~/.local/share/hive/web` dir by `hive setup`). hive itself
+    # stays a plain CLI gem; the web tier is only supported where the Rails
+    # app and its bundle exist.
+    #
+    # With no subcommand, `hive web` runs the server in the foreground. With a
+    # subcommand it manages a SEPARATE per-user service (systemd-user /
+    # launchd), distinct from `hive daemon`:
+    #
+    #   install [--force]   (re)write + enable the platform-native unit
+    #   start               run the web server detached (pidfile)
+    #   stop                SIGTERM the running web server
+    #   status [--json]     running/pid/uptime + service/install state
     class Web
-      def initialize(bind: nil, port: nil)
+      include Hive::PidFile
+
+      VALID_SUBCOMMANDS = %w[install start stop status].freeze
+
+      def initialize(subcommand = nil, bind: nil, port: nil, force: false, json: false,
+                     hive_home: Hive::Paths.state_home)
+        @subcommand = subcommand
         @bind = bind
         @port = port
+        @force = force
+        @json = json
+        @hive_home = hive_home
       end
 
       def call
+        unless @subcommand.nil? || VALID_SUBCOMMANDS.include?(@subcommand)
+          raise Hive::InvalidTaskPath,
+                "hive web: unknown subcommand #{@subcommand.inspect} " \
+                "(expected: #{VALID_SUBCOMMANDS.join(', ')})"
+        end
+
+        case @subcommand
+        when nil       then run_foreground
+        when "install" then install_web_service
+        when "start"   then start_web_service
+        when "stop"    then stop_web_service
+        when "status"  then status_web_service
+        end
+      end
+
+      def pid_file
+        @pid_file ||= File.join(@hive_home, ".web.pid")
+      end
+
+      # Resolve the Rails app dir the same way both the foreground command and
+      # the ServiceInstaller do. Candidate order: HIVEBOX_WEB_APP_DIR → managed
+      # `~/.local/share/hive/web` (provisioned by `hive setup`) → sibling
+      # source checkout. A candidate only counts when it holds
+      # `config/application.rb`.
+      def self.rails_app_dir
+        candidates = [
+          ENV["HIVEBOX_WEB_APP_DIR"],
+          Hive::Paths.managed_web_dir,
+          File.expand_path("../../../web", __dir__)
+        ].compact
+        candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) }
+      end
+
+      def run_foreground
         cfg = Hive::Config.load_global_web
         bind = @bind || cfg.fetch("bind")
         port = (@port || cfg.fetch("port")).to_i
         app_dir = rails_app_dir
         unless app_dir
           warn "hive web: the hivebox web app (web/) was not found. " \
-               "Run from the hivebox Docker image or a source checkout, " \
-               "or point HIVEBOX_WEB_APP_DIR at the Rails app."
+               "Run `hive setup` to provision it, run from the hivebox Docker " \
+               "image or a source checkout, or point HIVEBOX_WEB_APP_DIR at " \
+               "the Rails app."
           exit 1
         end
 
         warn_on_public_bind(bind, cfg)
+        refuse_unsafe_public_bind(bind, cfg)
+        warn_on_managed_bundle_mismatch(app_dir)
 
         env = {
           "RAILS_ENV" => ENV.fetch("RAILS_ENV", "production"),
@@ -64,12 +124,155 @@ module Hive
 
       private
 
+      def web_config
+        @web_config ||= Hive::Config.load_global_web
+      end
+
+      def resolved_bind
+        (@bind || web_config.fetch("bind")).to_s
+      end
+
+      def resolved_port
+        (@port || web_config.fetch("port")).to_i
+      end
+
+      def install_web_service
+        require "hive/commands/web/service_installer"
+        installer = Hive::Commands::Web::ServiceInstaller.new(binary_path: current_binary_path)
+        begin
+          result = installer.install!(autostart: true, force: @force)
+        rescue Hive::Error
+          raise
+        rescue StandardError => e
+          raise Hive::WebInstallFailed,
+                "web service install failed: #{e.class}: #{e.message}"
+        end
+        installer.messages.each { |line| warn "hive: #{line}" }
+        emit_install_summary(installer, result)
+        emit_install_outcome(installer, result)
+        # Persist the local loopback web contract (U1/R4) so a fresh
+        # `hive web install` is not left gated behind the GitHub owner flow.
+        Hive::Config.write_local_web_loopback!
+      end
+
+      def emit_install_summary(installer, outcome)
+        case outcome.kind
+        when :written
+          puts "hive web: installed unit at #{installer.target_path}"
+        when :upgraded
+          msg = "hive web: upgraded unit at #{installer.target_path}"
+          msg += " (backup: #{outcome.backup_path})" if outcome.backup_path
+          puts msg
+        when :unchanged
+          puts "hive web: unit already up to date at #{installer.target_path}"
+        when :autostart_unavailable
+          puts "hive web: unit written at #{installer.target_path}; autostart not enabled on this host"
+        when :unsupported, :drifted, :failed
+          # handled by emit_install_outcome / installer.messages
+        end
+      end
+
+      def emit_install_outcome(installer, outcome)
+        if outcome.drifted?
+          msg = "web unit at #{installer.target_path} differs from the current template. " \
+                "Re-run with `hive web install --force` to overwrite (a timestamped .bak " \
+                "will be saved)."
+          raise Hive::WebInstallDriftError, msg
+        elsif outcome.failed?
+          raise Hive::WebInstallFailed,
+                "web service install reported a failure; see messages above"
+        end
+      end
+
+      def start_web_service
+        FileUtils.mkdir_p(@hive_home)
+
+        if (existing = read_live_pid)
+          raise Hive::ConcurrentRunError.new(
+            "hive web already running (pid #{existing})",
+            holder: { pid: existing }, lock_path: pid_file
+          )
+        end
+
+        File.delete(pid_file) if File.exist?(pid_file)
+
+        Process.daemon(true, true)
+        write_own_pid_file!
+        run_foreground
+      end
+
+      def write_own_pid_file!
+        File.write(pid_file, pid_file_payload(Process.pid).to_yaml)
+      end
+
+      def stop_web_service
+        pid = read_live_pid
+        unless pid
+          File.delete(pid_file) if File.exist?(pid_file)
+          return puts("hive web: not running")
+        end
+
+        send_signal_safely(pid, :TERM)
+        deadline = Time.now + 30
+        sleep 0.2 while pid_alive?(pid) && Time.now < deadline
+        send_signal_safely(pid, :KILL) if pid_alive?(pid)
+        File.delete(pid_file) if File.exist?(pid_file)
+        puts("hive web: stopped (pid #{pid})")
+      end
+
+      def status_web_service
+        running = false
+        pid = nil
+        uptime_sec = nil
+
+        if File.exist?(pid_file)
+          payload = read_pid_file_payload
+          pid = payload && payload["pid"]
+          if pid && pid > 0 && pid_alive?(pid) && pid_owned_by_us?(payload, pid)
+            running = true
+            uptime_sec = (Time.now - File.stat(pid_file).mtime).to_i
+          end
+        end
+
+        service_state = probe_service_state
+        payload = {
+          "schema" => "hive-web-status",
+          "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"),
+          "ok" => true,
+          "running" => running,
+          "pid" => running ? pid : nil,
+          "uptime_sec" => uptime_sec,
+          "pid_file" => pid_file,
+          "service_installed" => service_state["service_installed"],
+          "service_enabled" => service_state["service_enabled"],
+          "unit_path" => service_state["unit_path"],
+          "app_dir" => rails_app_dir || Hive::Paths.managed_web_dir,
+          "bind" => resolved_bind,
+          "port" => resolved_port
+        }
+
+        if @json
+          puts JSON.generate(payload)
+        else
+          puts(running ? "hive web: running (pid #{pid}, uptime #{uptime_sec}s) at http://#{payload['bind']}:#{payload['port']}" : "hive web: not running")
+        end
+        raise Hive::Error, "web not running" if !running && !@json
+      end
+
+      def probe_service_state
+        require "hive/commands/web/service_installer"
+        Hive::Commands::Web::ServiceInstaller.new.service_state
+      rescue StandardError
+        { "service_installed" => nil, "service_enabled" => nil, "unit_path" => nil }
+      end
+
+      def current_binary_path
+        require "hive/invoked_binary"
+        Hive::InvokedBinary.path
+      end
+
       def rails_app_dir
-        candidates = [
-          ENV["HIVEBOX_WEB_APP_DIR"],
-          File.expand_path("../../../web", __dir__)
-        ].compact
-        candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) }
+        self.class.rails_app_dir
       end
 
       # Rails' production host authorization is inactive by default — the box
@@ -83,6 +286,39 @@ module Hive
         warn "hive web: WARNING binding 0.0.0.0 without an https origin — " \
              "ensure a trusted reverse proxy validates the Host header."
       end
+
+      # U4 / R-skew mitigation: `hive web` warns when the managed bundle's
+      # pinned version marker disagrees with Hive::VERSION. Only the managed
+      # dir carries the marker; a source checkout or HIVEBOX_WEB_APP_DIR
+      # override is the operator's explicit choice and is not warned about.
+      def warn_on_managed_bundle_mismatch(app_dir)
+        return unless File.expand_path(app_dir) == File.expand_path(Hive::Paths.managed_web_dir)
+
+        require "hive/commands/setup/web_bundle"
+        return unless Hive::Commands::Setup::WebBundle.new.version_mismatch?(app_dir)
+
+        warn "hive web: the managed web bundle at #{app_dir} is pinned to a " \
+             "different version than the installed hive gem (#{Hive::VERSION}); " \
+             "re-run `hive setup` (or `hive web install --force`) to re-fetch it."
+      end
+
+      # Local loopback auth policy guard (U1): loopback binds skip GitHub
+      # owner auth, but a NON-loopback bind under web.auth_mode=loopback
+      # would expose an ownerless web tier. Refuse loudly unless the operator
+      # explicitly sets web.unsafe_public_no_auth (or switches back to the
+      # owner flow). Docker/hivebox keeps binding 0.0.0.0 with the "owner"
+      # default, so this never fires there.
+      def refuse_unsafe_public_bind(bind, cfg)
+        return if Hive::Config.web_bind_loopback?(bind)
+        return unless Hive::Config.local_web_no_auth?(cfg)
+        return if cfg["unsafe_public_no_auth"]
+
+        raise Hive::ConfigError,
+              "hive web: refusing to bind #{bind.inspect} with web.auth_mode=loopback " \
+              "(no auth). Loopback no-auth is only safe on 127.0.0.0/8 or ::1. " \
+              "Set web.auth_mode=owner (GitHub device flow), or set " \
+              "web.unsafe_public_no_auth=true to accept the risk."
+      end
     end
   end
 end
diff --git a/lib/hive/commands/web/service_installer.rb b/lib/hive/commands/web/service_installer.rb
new file mode 100644
index 000000000..4afccc9d3
--- /dev/null
+++ b/lib/hive/commands/web/service_installer.rb
@@ -0,0 +1,88 @@
+require "cgi"
+require "shellwords"
+require "hive/commands/service_installer/base"
+require "hive/commands/web"
+require "hive/paths"
+
+module Hive
+  module Commands
+    class Web
+      # Per-user autostart installer for the managed local web service. The
+      # web tier is a SEPARATE service from the daemon (R7): it inherits the
+      # platform-agnostic mechanics (drift/backup, atomic write, shim-PATH
+      # detection, enable/load orchestration) from the shared base and
+      # supplies only the web identity and rendered unit/plist bodies.
+      #
+      # The one web-specific behavior is resolving the Rails app dir into the
+      # unit (`HIVEBOX_WEB_APP_DIR`) so the managed service survives
+      # login/reboot without relying on the caller's cwd — the gem does not
+      # package the Rails app (ADR-037), so `hive web` must be told where it
+      # was provisioned.
+      class ServiceInstaller < Hive::Commands::ServiceInstaller::Base
+        def service_name
+          "hive-web"
+        end
+
+        def cli_label
+          "web"
+        end
+
+        def service_noun
+          "web service"
+        end
+
+        def unit_noun
+          "web unit"
+        end
+
+        def target_path
+          case platform
+          when :macos then File.join(@home, "Library/LaunchAgents/local.hive-web.plist")
+          when :linux then File.join(@home, ".config/systemd/user/hive-web.service")
+          end
+        end
+
+        # The Rails app dir baked into the unit. Prefer a real resolved app
+        # (env override / managed dir / sibling source checkout); fall back to
+        # the managed location when nothing exists yet so a fresh install
+        # still points `hive web` at the dir `hive setup` will populate.
+        def resolved_app_dir
+          Hive::Commands::Web.rails_app_dir || Hive::Paths.managed_web_dir
+        end
+
+        private
+
+        def render_systemd
+          template = File.read(File.expand_path("../../../../examples/systemd/hive-web.service", __dir__))
+          escaped = Shellwords.escape(resolved_binary)
+          # ExecStart= parses shell-ish quoting, so the binary stays escaped.
+          # Environment= does NOT parse shell backslash/quote escaping (unlike
+          # ExecStart=), so the app dir is interpolated RAW — a path with
+          # spaces/special characters would otherwise be corrupted (matching
+          # the daemon installer's build_path_line, which leaves Environment=
+          # values unescaped).
+          app_dir = resolved_app_dir
+          template
+            .sub(/^ExecStart=.*$/, "ExecStart=#{escaped} web --bind 127.0.0.1")
+            .sub(/^Environment=HIVEBOX_WEB_APP_DIR=.*$/, "Environment=HIVEBOX_WEB_APP_DIR=#{app_dir}")
+            .sub(/^Environment=PATH=.*$/, build_path_line)
+        end
+
+        def render_launchd
+          template = File.read(File.expand_path("../../../../examples/launchd/hive-web.plist", __dir__))
+          binary = resolved_binary
+          binary_dir = File.dirname(binary)
+          escaped_binary = CGI.escapeHTML(binary)
+          escaped_binary_dir = CGI.escapeHTML(binary_dir)
+          escaped_home = CGI.escapeHTML(@home)
+          escaped_app_dir = CGI.escapeHTML(resolved_app_dir)
+          template
+            .gsub(%r{<string>/Users/YOU/\.local/bin/hive</string>}, "<string>#{escaped_binary}</string>")
+            .gsub(%r{<string>/Users/YOU/\.local/share/hive/web</string>}, "<string>#{escaped_app_dir}</string>")
+            .gsub("/Users/YOU/Library/Logs", "#{escaped_home}/Library/Logs")
+            .gsub("/Users/YOU/.local/bin", escaped_binary_dir)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hive/config.rb b/lib/hive/config.rb
index da8bd31af..daf9d0d30 100644
--- a/lib/hive/config.rb
+++ b/lib/hive/config.rb
@@ -2,6 +2,7 @@ require "yaml"
 require "fileutils"
 require "securerandom"
 require "pathname"
+require "ipaddr"
 require "hive/agent_profiles"
 require "hive/babysitter/interval"
 require "hive/permission_scope"
@@ -373,6 +374,15 @@ module Hive
         "bind" => "127.0.0.1",
         "port" => 4567,
         "origin" => "http://127.0.0.1:4567",
+        # Auth model for the web tier. "owner" (default) keeps the GitHub
+        # device-flow / owner-claim gate — the Docker/hivebox path, and the
+        # optional local opt-in. "loopback" means a loopback bind
+        # (127.0.0.0/8 or ::1) needs no login at all; `hive setup` / `hive
+        # web install` write this for the first-class local mode. A
+        # non-loopback bind under "loopback" is REFUSED at startup unless
+        # unsafe_public_no_auth is explicitly set (see Hive::Commands::Web).
+        "auth_mode" => "owner",
+        "unsafe_public_no_auth" => false,
         "github" => {
           "owner" => nil,
           # The shared hivebox OAuth app (device flow only — public by
@@ -691,6 +701,34 @@ module Hive
       end
     end
 
+    # The accepted `web.auth_mode` values. "owner" is the Docker/hivebox
+    # GitHub owner-claim gate; "loopback" is the local no-auth mode gated on
+    # a loopback bind (see local_web_no_auth? and web_bind_loopback?).
+    WEB_AUTH_MODES = %w[owner loopback].freeze
+
+    # True when the resolved web config opts into the local loopback
+    # no-auth policy (`web.auth_mode: loopback`). The Rails tier and the CLI
+    # both read this through the same resolved config, so request gating
+    # and startup refusal cannot drift.
+    def local_web_no_auth?(cfg)
+      cfg["auth_mode"] == "loopback"
+    end
+
+    # Conservative loopback predicate for a bind string: `localhost` and the
+    # 127.0.0.0/8 + ::1 ranges are loopback; everything else (0.0.0.0,
+    # public IPs, other hostnames) is not. A nil/blank bind is treated as
+    # loopback — the resolved default is always 127.0.0.1.
+    def web_bind_loopback?(bind)
+      host = bind.to_s.strip
+      return true if host.empty?
+      return true if host.casecmp("localhost").zero?
+
+      ip = IPAddr.new(host)
+      ip.loopback?
+    rescue IPAddr::InvalidAddressError
+      false
+    end
+
     # argv fragment for hive-launched claude: ["--model", m, "--effort", e].
     # Shared by the tmux wrapper and the headless Agent path. Semantics:
     # model "inherit"/blank omits the flag (operator's interactive default
@@ -963,6 +1001,43 @@ module Hive
       (GLOBAL_AGENT_BACKENDS & selected).freeze
     end
 
+    # The first-class local web contract persisted by `hive setup` and
+    # `hive web install` (U1): a loopback bind needs no GitHub login, so
+    # writing auth_mode=loopback (plus the canonical bind/port) here is what
+    # makes the definition-of-done "no login on loopback" true for a fresh
+    # install instead of leaving web.auth_mode at the "owner" default.
+    LOCAL_WEB_CONTRACT = {
+      "auth_mode" => "loopback",
+      "bind" => "127.0.0.1",
+      "port" => 4567
+    }.freeze
+
+    # Persist a web config block into the global config, mirroring
+    # write_global_agents!. Only the keys explicitly passed are rewritten;
+    # operator-owned sibling keys (origin, github.client_id,
+    # unsafe_public_no_auth, …) are preserved. The merged result is
+    # validated against the same defaults/loader contract as
+    # load_global_web so a bad write can never persist an invalid block.
+    def write_global_web!(web)
+      update_global_config! do |data|
+        existing = data["web"]
+        unless existing.nil? || existing.is_a?(Hash)
+          raise ConfigError, "web in #{describe_source(global_config_path)} must be a Hash; got #{existing.class}"
+        end
+
+        merged = (existing || {}).merge(web)
+        validate_web_config!({ "web" => deep_merge(global_web_defaults, merged) }, global_config_path)
+        data["web"] = merged
+        merged
+      end
+    end
+
+    # Convenience wrapper that persists the local loopback web contract
+    # (auth_mode/bind/port). Called by `hive setup` and `hive web install`.
+    def write_local_web_loopback!
+      write_global_web!(LOCAL_WEB_CONTRACT)
+    end
+
     # Shape gate shared by the loader and `prune`'s predicate so the
     # two surfaces agree on what counts as a valid registry row.
     # Without this, the loader would silently skip a corrupted entry
@@ -2277,6 +2352,20 @@ module Hive
               "web.origin in #{describe_source(source_path)} must be an http(s) URL"
       end
 
+      auth_mode = web["auth_mode"]
+      unless WEB_AUTH_MODES.include?(auth_mode)
+        raise ConfigError,
+              "web.auth_mode in #{describe_source(source_path)} must be one of " \
+              "#{WEB_AUTH_MODES.inspect}; got #{auth_mode.inspect} (#{auth_mode.class})"
+      end
+
+      unsafe = web["unsafe_public_no_auth"]
+      unless unsafe == true || unsafe == false
+        raise ConfigError,
+              "web.unsafe_public_no_auth in #{describe_source(source_path)} must be true or false; " \
+              "got #{unsafe.inspect} (#{unsafe.class})"
+      end
+
       github = web["github"]
       unless github.is_a?(Hash)
         raise ConfigError,
diff --git a/lib/hive/daemon/consistency.rb b/lib/hive/daemon/consistency.rb
new file mode 100644
index 000000000..2786479d1
--- /dev/null
+++ b/lib/hive/daemon/consistency.rb
@@ -0,0 +1,133 @@
+require "open3"
+require "shellwords"
+require "hive/commands/daemon/service_installer"
+
+module Hive
+  module Daemon
+    # Daemon binary-consistency guard (U5). Detects a daemon whose installed
+    # unit (ExecStart / ProgramArguments) or running process points at a
+    # DIFFERENT binary or version than the CLI, and exposes the result as a
+    # read-only probe for `hive daemon status --json` and the web health card.
+    #
+    # The "correct" binary is the same one `ServiceInstaller::Base` would write
+    # (brew stable symlink → HIVE_INVOKED_BIN/binary_path → PATH → source
+    # fallback), so repair (`hive daemon install --force`) rewrites to exactly
+    # the target this probe compares against.
+    class Consistency
+      def initialize(installer: nil, pid: nil, exe_reader: nil, version_probe: nil)
+        @installer = installer || Hive::Commands::Daemon::ServiceInstaller.new
+        @pid = pid
+        @exe_reader = exe_reader || method(:read_process_exe)
+        @version_probe = version_probe || method(:read_binary_version)
+      end
+
+      def expected_binary
+        @installer.resolved_binary
+      end
+
+      # The binary baked into the installed unit's ExecStart / ProgramArguments,
+      # or nil when no unit exists / no binary could be parsed.
+      def unit_binary
+        path = @installer.target_path
+        return nil if path.nil? || !File.exist?(path)
+
+        body = File.read(path)
+        case @installer.envelope_platform
+        when "linux" then systemd_unit_binary(body)
+        when "macos" then launchd_plist_binary(body)
+        end
+      rescue StandardError
+        nil
+      end
+
+      def running_binary
+        return nil if @pid.nil?
+
+        @exe_reader.call(@pid)
+      end
+
+      # Read-only probe: { binary, binary_version, unit_binary,
+      # running_binary, running_version, consistent }.
+      def probe
+        expected = expected_binary
+        ub = unit_binary
+        rb = running_binary
+        rv = rb && @version_probe.call(rb)
+
+        consistent = (ub.nil? || ub == expected) &&
+                     (rb.nil? || rb == expected) &&
+                     (rv.nil? || rv == Hive::VERSION)
+
+        {
+          "binary" => expected,
+          "binary_version" => Hive::VERSION,
+          "unit_binary" => ub,
+          "running_binary" => rb,
+          "running_version" => rv,
+          "consistent" => consistent
+        }
+      end
+
+      private
+
+      def systemd_unit_binary(body)
+        line = body.lines.map(&:strip).find { |l| l.start_with?("ExecStart=") }
+        return nil unless line
+
+        Shellwords.split(line.sub(/\AExecStart=/, "")).first
+      end
+
+      def launchd_plist_binary(body)
+        strings = body.scan(%r{<string>([^<]+)</string>}).flatten
+        strings.find { |s| s.match?(%r{/(?:hive|hv)\z}) }
+      end
+
+      # Resolve the running daemon's executable. `bin/hive` is a
+      # `#!/usr/bin/env ruby` script (and the daemon runs in-process as Ruby),
+      # so /proc/<pid>/exe resolves to the ruby interpreter, never the hive
+      # script. Read /proc/<pid>/cmdline (NUL-separated argv) instead — it
+      # carries the actual script path — and return the `hive`/`hv` script
+      # argument. Falls back to `ps -o args= -p <pid>` (same script-argument
+      # scan) when /proc is absent.
+      def read_process_exe(pid)
+        cmdline = "/proc/#{pid}/cmdline"
+        return read_exe_via_ps(pid) unless File.exist?(cmdline)
+
+        argv = File.read(cmdline).split("\0")
+        script = argv.find { |arg| hive_script_argument?(arg) }
+        script || read_exe_via_ps(pid)
+      rescue SystemCallError
+        read_exe_via_ps(pid)
+      end
+
+      def read_exe_via_ps(pid)
+        out, status = Open3.capture2("ps", "-o", "args=", "-p", pid.to_s)
+        return nil unless status.success?
+
+        script = out.strip.split(/\s+/).find { |tok| hive_script_argument?(tok) }
+        script
+      rescue StandardError
+        nil
+      end
+
+      # True when the argument is the hive launcher (bin/hive) or the hv
+      # fallback launcher. Rejects argv[0] (`ruby`) and the subcommand tokens
+      # (`daemon`, `start`), so the cmdline/ps scan selects the script path
+      # regardless of where the interpreter places it.
+      def hive_script_argument?(arg)
+        return false if arg.nil? || arg.empty?
+
+        %w[hive hv].include?(File.basename(arg))
+      end
+
+      def read_binary_version(binary)
+        out, status = Open3.capture2(binary, "--version")
+        return nil unless status.success?
+
+        out[/\d+\.\d+\.\d+/]
+      rescue StandardError
+        nil
+      end
+    end
+  end
+end
diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb
index 752a5c7dc..e2d3be254 100644
--- a/lib/hive/paths.rb
+++ b/lib/hive/paths.rb
@@ -20,6 +20,13 @@ module Hive
       hive_home_override || File.join(base_home("XDG_CACHE_HOME", ".cache"), "hive")
     end
 
+    # Where `hive setup` provisions the web bundle for the first-class local
+    # mode (the gem does not package the Rails app — ADR-037). Lives under
+    # XDG data so it survives login/reboot and stays out of the git repo.
+    def managed_web_dir
+      File.join(data_home, "web")
+    end
+
     def task_counter_path
       File.join(state_home, "task-counter.yml")
     end
diff --git a/openclaw/skills/hive/SKILL.md b/openclaw/skills/hive/SKILL.md
index 4eecc409c..78de50eec 100644
--- a/openclaw/skills/hive/SKILL.md
+++ b/openclaw/skills/hive/SKILL.md
@@ -4,7 +4,7 @@ description: >-
   Run Hive's folder-based coding-agent pipeline from OpenClaw: guided CLI setup,
   project init, task creation, plan/develop/review workflows, status, daemon,
   and guarded admin commands.
-version: 0.1.1
+version: 0.1.2
 user-invocable: true
 metadata:
   openclaw:
@@ -23,7 +23,7 @@ metadata:
 
 Hive turns a repository into a folder-based coding-agent pipeline: ideas become tasks, tasks move through brainstorm, plan, develop, review, artifacts, and finalize stages, and the daemon keeps enrolled projects moving in the background.
 
-Use this skill when the user wants to install Hive from OpenClaw, initialize the current project, create a task, inspect status, move a task through plan/develop/review, run diagnostics, start the Hivebox web UI, compile wiki changelog fragments, or administer Hive's daemon, bot, markers, metrics, and task registry.
+Use this skill when the user wants to install Hive from OpenClaw, initialize the current project, create a task, inspect status, move a task through plan/develop/review, run diagnostics, start the local web UI (foreground or as a managed service), compile wiki changelog fragments, or administer Hive's daemon, bot, markers, metrics, and task registry.
 
 ## Install From ClawHub
 
@@ -41,11 +41,11 @@ That listing installs the `/hive` slash command. First run should normally be:
 
 ## Common Paths
 
-- `/hive setup` installs or verifies the Hive CLI, enables the per-user daemon service, and optionally initializes the current repository.
+- `/hive setup` installs or verifies the Hive CLI, provisions the local surface (web bundle + daemon service + web service), and optionally initializes the current repository.
 - `/hive status --json` shows the task board and next actions.
 - `/hive new . "build this feature"` creates a new Hive task in the current project.
 - `/hive plan <task-slug>`, `/hive develop <task-slug>`, and `/hive review <task-slug>` advance a task through the main coding workflow.
-- `/hive web` starts the Hivebox browser surface when a user wants the local web UI.
+- `/hive web` runs the local web UI in the foreground; `/hive web install` writes/enables the per-user systemd/launchd service (separate from the daemon), `/hive web start|stop|status` manage the lifecycle. On the loopback bind (`127.0.0.1:4567`) the web UI needs no GitHub login by default.
 - `/hive wiki compile-log --check` verifies that `wiki/log.md` matches the fragments in `wiki/log.d/`.
 - `/hive doctor` checks local runtime and skill configuration.
 
@@ -81,7 +81,7 @@ curl -fsSL https://raw.githubusercontent.com/ivankuznetsov/hive/v0.2.0/install.s
 bash "$tmpdir/hive-install.sh"
 ```
 
-After install, run the strict `hive` / `hv` version check again. If neither command prints a bare `X.Y.Z` version, stop and report that setup failed or Apache Hive may be shadowing the command. If verification succeeds, run `"${hive_cmd}" daemon install` once. Then ask whether to initialize the current project; if yes, run `"${hive_cmd}" init . --json </dev/null`, followed by `"${hive_cmd}" doctor` non-fatally. Summarize the installed version, daemon setup result, project initialization result, and any missing runtime dependencies.
+After install, run the strict `hive` / `hv` version check again. If neither command prints a bare `X.Y.Z` version, stop and report that setup failed or Apache Hive may be shadowing the command. If verification succeeds, run `"${hive_cmd}" daemon install` once. Then ask whether to initialize the current project; if yes, run `"${hive_cmd}" init . --json </dev/null`, followed by `"${hive_cmd}" doctor` non-fatally. If the user wants the local web UI, offer `"${hive_cmd}" setup --json` (provisions the web bundle + daemon + web service) or `"${hive_cmd}" web install` + `"${hive_cmd}" web start`. Summarize the installed version, daemon setup result, project initialization result, local web result (when run), and any missing runtime dependencies.
 
 ## Dispatch Rules
 
diff --git a/packaging/verify-release.sh b/packaging/verify-release.sh
index a66153eaa..bc45068c1 100755
--- a/packaging/verify-release.sh
+++ b/packaging/verify-release.sh
@@ -544,7 +544,72 @@ else
   log "step: daemon install — SKIPPED (pinned release predates the subcommand)"
 fi
 
-# ─── 5. uninstall ────────────────────────────────────────────────────
+# ─── 5. local web service + hive setup --json ─────────────────────────
+
+# U8 acceptance surface: the local (non-Docker) web install/run mode is
+# shipped behind two commands — `hive web status --json` (a fast,
+# network-free read) and `hive setup --json` (the full provisioning
+# orchestration, which in a CI sandbox legitimately lacks the external
+# agent CLIs / network and therefore must still emit its typed envelope
+# rather than crash). Both are feature-detected so this script stays
+# green against pre-feature releases.
+
+step "hive web status --json (local web service surface)"
+set +e
+"$XDG_BIN_HOME/hive" web status --json >"$PREFIX/web-status.json" 2>"$PREFIX/web-status.err"
+WEB_STATUS_RC=$?
+set -e
+WEB_SCHEMA="$(jq -r '.schema // empty' "$PREFIX/web-status.json" 2>/dev/null || true)"
+if [[ "$WEB_SCHEMA" == "hive-web-status" ]]; then
+  ok "web status envelope schema=hive-web-status (rc=$WEB_STATUS_RC)"
+  WEB_BIND="$(jq -r '.bind // empty' "$PREFIX/web-status.json" 2>/dev/null || true)"
+  WEB_PORT="$(jq -r '.port // empty' "$PREFIX/web-status.json" 2>/dev/null || true)"
+  if [[ "$WEB_BIND" == "127.0.0.1" ]]; then ok "web status bind=127.0.0.1"
+  else fail "web status bind='$WEB_BIND', want 127.0.0.1"; fi
+  if [[ "$WEB_PORT" == "4567" ]]; then ok "web status port=4567"
+  else fail "web status port='$WEB_PORT', want 4567"; fi
+else
+  log "web status — SKIPPED (release predates the `hive web` subcommand)"
+fi
+
+# Feature-detect `hive setup` by asking Thor for its help: the command
+# exists when `--help` exits 0 (a missing command exits non-zero).
+set +e
+"$XDG_BIN_HOME/hive" setup --help >/dev/null 2>&1
+SETUP_HELP_RC=$?
+set -e
+if [[ "$SETUP_HELP_RC" -eq 0 ]]; then
+  step "hive setup --json (walk provisioning; external CLIs/network may be absent in CI)"
+  # `hive setup` runs every step before reporting, so even a partial
+  # bring-up must emit the single hive-setup envelope (ok=false + per-step
+  # detail) on stdout and then exit non-zero. Run from the scratch project
+  # (already registered) so enrollment exercises the enable path, pipe empty
+  # stdin for the non-interactive backend defaults, and bound the whole walk
+  # against a hung network-bound dep (npm install / web-bundle git clone).
+  set +e
+  ( cd "$PROJECT" \
+      && timeout 240 "$XDG_BIN_HOME/hive" setup --json </dev/null \
+           >"$PREFIX/setup.json" 2>"$PREFIX/setup.err" )
+  SETUP_RC=$?
+  set -e
+  SETUP_SCHEMA="$(jq -r '.schema // empty' "$PREFIX/setup.json" 2>/dev/null || true)"
+  if [[ "$SETUP_SCHEMA" == "hive-setup" ]]; then
+    SETUP_OK="$(jq -r '.ok | tostring' "$PREFIX/setup.json" 2>/dev/null || true)"
+    ok "setup envelope schema=hive-setup ok=$SETUP_OK (rc=$SETUP_RC)"
+    SETUP_WEB_URL="$(jq -r '.web_url // empty' "$PREFIX/setup.json" 2>/dev/null || true)"
+    if [[ -n "$SETUP_WEB_URL" ]]; then ok "setup envelope carries web_url=$SETUP_WEB_URL"
+    else fail "setup envelope missing web_url"; fi
+  elif [[ "$SETUP_RC" -eq 124 ]]; then
+    log "setup --json timed out (network-bound deps in CI); envelope walk skipped"
+  else
+    cat "$PREFIX/setup.err" >&2 2>/dev/null || true
+    fail "setup did not emit a hive-setup envelope (rc=$SETUP_RC)"
+  fi
+else
+  log "step: hive setup — SKIPPED (pinned release predates the command)"
+fi
+
+# ─── 6. uninstall ────────────────────────────────────────────────────
 
 if [[ $RUN_UNINSTALL -eq 1 ]]; then
   step "hive uninstall"
@@ -588,7 +653,7 @@ if [[ $RUN_UNINSTALL -eq 1 ]]; then
   fi
 fi
 
-# ─── 6. leak detection ───────────────────────────────────────────────
+# ─── 7. leak detection ───────────────────────────────────────────────
 
 # Verify that nothing the install/test sequence wrote leaked outside
 # the prefix. We anchor on the real (un-mocked) user's HOME — but
diff --git a/schemas/hive-daemon-status.v1.json b/schemas/hive-daemon-status.v1.json
index 1ee77d0d2..ae6bc60e9 100644
--- a/schemas/hive-daemon-status.v1.json
+++ b/schemas/hive-daemon-status.v1.json
@@ -22,6 +22,9 @@
         "service_installed",
         "service_enabled",
         "unit_path",
+        "binary",
+        "binary_version",
+        "consistent",
         "current_version",
         "update_nudge"
       ],
@@ -61,6 +64,18 @@
           "type": ["string", "null"],
           "description": "Absolute path of the autostart unit file; null on unsupported platforms or if the probe could not run."
         },
+        "binary": {
+          "type": ["string", "null"],
+          "description": "The resolved binary the service installer would write (the CLI's own binary)."
+        },
+        "binary_version": {
+          "type": "string",
+          "description": "The running hive version (Hive::VERSION) the consistency guard compares against."
+        },
+        "consistent": {
+          "type": ["boolean", "null"],
+          "description": "True when the installed unit's binary and the running daemon's executable/version all match the CLI. Null only when the probe could not run."
+        },
         "current_version": {
           "type": "string",
           "description": "The running hive version, so a caller can compare against update_nudge.latest itself."
diff --git a/schemas/hive-setup.v1.json b/schemas/hive-setup.v1.json
new file mode 100644
index 000000000..7abdc304b
--- /dev/null
+++ b/schemas/hive-setup.v1.json
@@ -0,0 +1,84 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-setup.v1.json",
+  "title": "hive setup output (v1)",
+  "description": "Stable contract emitted by `hive setup --json`. Reports the resolved agent backends, the per-dependency report, and the ordered provisioning steps (deps → qmd → web_bundle → daemon → enroll → web). `ok` is true when every step passed and every non-blocked dependency is ok; blocked external agent CLIs (claude/codex/gh) are surfaced as warnings, never gate the local web bring-up.",
+  "oneOf": [
+    { "$ref": "#/$defs/SuccessPayload" }
+  ],
+  "$defs": {
+    "SuccessPayload": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "schema",
+        "schema_version",
+        "ok",
+        "backends",
+        "deps",
+        "steps",
+        "web_url",
+        "warnings"
+      ],
+      "properties": {
+        "schema": { "const": "hive-setup" },
+        "schema_version": { "const": 1 },
+        "ok": {
+          "type": "boolean",
+          "description": "True when every step passed and every non-blocked dependency is ok."
+        },
+        "backends": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "The resolved global agent backends persisted by the setup prompt."
+        },
+        "deps": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/Dep" },
+          "description": "Per-dependency report in check order."
+        },
+        "steps": {
+          "type": "array",
+          "items": { "$ref": "#/$defs/Step" },
+          "description": "Ordered provisioning steps."
+        },
+        "web_url": {
+          "type": "string",
+          "description": "The loopback URL the web UI serves at after a successful setup."
+        },
+        "warnings": {
+          "type": "array",
+          "items": { "type": "string" },
+          "description": "Diagnose-only notices for blocked external agent CLIs, each carrying the exact fix command."
+        }
+      }
+    },
+    "Dep": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["name", "ok", "blocked", "found", "required", "message", "fix"],
+      "properties": {
+        "name": { "type": "string" },
+        "ok": { "type": "boolean" },
+        "blocked": {
+          "type": "boolean",
+          "description": "True for an external agent CLI (gh/claude/codex) that setup cannot install or authenticate itself."
+        },
+        "found": { "type": ["string", "null"] },
+        "required": { "type": "string" },
+        "message": { "type": "string" },
+        "fix": { "type": ["string", "null"] }
+      }
+    },
+    "Step": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["name", "ok", "message"],
+      "properties": {
+        "name": { "type": "string" },
+        "ok": { "type": "boolean" },
+        "message": { "type": "string" }
+      }
+    }
+  }
+}
diff --git a/schemas/hive-web-status.v1.json b/schemas/hive-web-status.v1.json
new file mode 100644
index 000000000..9183f7d0d
--- /dev/null
+++ b/schemas/hive-web-status.v1.json
@@ -0,0 +1,75 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-web-status.v1.json",
+  "title": "hive web status output (v1)",
+  "description": "Stable contract emitted by `hive web status --json`. Reports whether the local managed web service is running, its PID and uptime, the autostart unit state, and the resolved app dir / bind / port. `--json` always exits 0 (emitting `ok: true` with `running: false` when not running); only bare `status` (no `--json`) exits 1 when not running.",
+  "oneOf": [
+    { "$ref": "#/$defs/SuccessPayload" }
+  ],
+  "$defs": {
+    "SuccessPayload": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "schema",
+        "schema_version",
+        "ok",
+        "running",
+        "pid",
+        "uptime_sec",
+        "pid_file",
+        "service_installed",
+        "service_enabled",
+        "unit_path",
+        "app_dir",
+        "bind",
+        "port"
+      ],
+      "properties": {
+        "schema": { "const": "hive-web-status" },
+        "schema_version": { "const": 1 },
+        "ok": { "const": true },
+        "running": {
+          "type": "boolean",
+          "description": "Whether a live, ownership-verified web server process holds the PID file."
+        },
+        "pid": {
+          "type": ["integer", "null"],
+          "description": "PID of the running web server, or null when running=false."
+        },
+        "uptime_sec": {
+          "type": ["integer", "null"],
+          "description": "Seconds since the PID file was written (mtime), or null when running=false."
+        },
+        "pid_file": {
+          "type": "string",
+          "description": "Absolute path of the PID file the web server writes to."
+        },
+        "service_installed": {
+          "type": ["boolean", "null"],
+          "description": "Whether the per-user autostart unit file exists on disk (non-mutating probe). Always present; null only if the probe itself could not run."
+        },
+        "service_enabled": {
+          "type": ["boolean", "null"],
+          "description": "Whether the service manager reports the autostart unit as enabled/loaded (non-mutating probe). Always present; null only if the probe itself could not run."
+        },
+        "unit_path": {
+          "type": ["string", "null"],
+          "description": "Absolute path of the autostart unit file; null on unsupported platforms or if the probe could not run."
+        },
+        "app_dir": {
+          "type": ["string", "null"],
+          "description": "The Rails app dir the web service resolves (env override, managed ~/.local/share/hive/web, or source checkout), or the managed dir fallback when not yet provisioned."
+        },
+        "bind": {
+          "type": "string",
+          "description": "Resolved bind address for the web server (default 127.0.0.1)."
+        },
+        "port": {
+          "type": "integer",
+          "description": "Resolved port for the web server (default 4567)."
+        }
+      }
+    }
+  }
+}
diff --git a/test/e2e/scenarios/local_web_setup.yml b/test/e2e/scenarios/local_web_setup.yml
new file mode 100644
index 000000000..02ce282c1
--- /dev/null
+++ b/test/e2e/scenarios/local_web_setup.yml
@@ -0,0 +1,151 @@
+name: local_web_setup
+description: >-
+  End-to-end acceptance for the first-class local (non-Docker) web
+  install/run mode (R13/U8): a single `hive setup --json` — with the
+  external agent CLIs and Hive-owned dep bootstrappers stubbed on PATH and
+  a hermetic deep-health server on the loopback port — brings up the full
+  surface (deps → qmd → web bundle → daemon service → project enrollment →
+  web service + health probe) and persists the loopback no-auth contract.
+  A task created afterwards is visible in the shared `hive status` source
+  of truth that both the TUI and web UI read.
+tags: [local, web, daemon, setup]
+steps:
+  - kind: ruby_block
+    block: |
+      require "socket"
+      require "open3"
+      require "rbconfig"
+
+      # Stub the diagnose-only agent CLIs and the Hive-owned dep
+      # bootstrappers (qmd npm / web bundle + rails) on PATH so `hive setup`
+      # runs hermetically: version-probes pass, bootstraps no-op, no network.
+      stubs = File.join(sandbox, "stubs")
+      FileUtils.mkdir_p(stubs)
+      stub = lambda do |name, body|
+        path = File.join(stubs, name)
+        File.write(path, body)
+        File.chmod(0o755, path)
+      end
+      stub.call("claude", "#!/bin/sh\necho '2.1.118 (Claude Code)'\n")
+      stub.call("codex", "#!/bin/sh\necho '0.125.0'\n")
+      stub.call("gh", "#!/bin/sh\nif [ \"$1\" = \"auth\" ]; then exit 0; fi; echo 'gh version 2.66.1'\n")
+      stub.call("node", "#!/bin/sh\necho 'v18.20.4'\n")
+      stub.call("npm", "#!/bin/sh\necho '10.8.2'\n")
+      stub.call("sqlite3", "#!/bin/sh\necho '3.45.3'\n")
+      stub.call("tmux", "#!/bin/sh\necho 'tmux 3.5a'\n")
+      stub.call("bundle", "#!/bin/sh\nexit 0\n")
+
+      # A stub Rails app dir so the web bundle resolver (U4) never needs to
+      # fetch a release; `bin/rails` no-ops db:prepare.
+      webapp = File.join(sandbox, "webapp")
+      FileUtils.mkdir_p(File.join(webapp, "config"))
+      File.write(File.join(webapp, "config", "application.rb"), "# stub rails app marker\n")
+      FileUtils.mkdir_p(File.join(webapp, "bin"))
+      rails = File.join(webapp, "bin", "rails")
+      File.write(rails, "#!/bin/sh\nexit 0\n")
+      File.chmod(0o755, rails)
+
+      # Hermetic deep-health server on the loopback port so `hive setup`'s
+      # web health probe succeeds without a real Rails boot.
+      server = TCPServer.new("127.0.0.1", 4567)
+      health_thread = Thread.new do
+        loop do
+          client = begin
+            server.accept
+          rescue IOError, Errno::EBADF
+            break # socket closed by the ensure block below
+          end
+          begin
+            client.gets("\r\n\r\n")
+            body = "{\"ok\":true,\"deep\":true}"
+            client.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" \
+                         "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
+          rescue StandardError
+            nil
+          ensure
+            client.close rescue nil
+          end
+        end
+      end
+
+      begin
+        env = {
+          "HOME" => run_home,
+          "HIVE_HOME" => run_home,
+          "HIVEBOX_WEB_APP_DIR" => webapp,
+          # Prepend the repo's own bin/ so `which("hive")` (used by the daemon
+          # consistency probe) resolves to THIS checkout's binary, never a
+          # globally-installed hive on the host PATH — otherwise the installed
+          # unit (written from $PROGRAM_NAME) and the probe's "expected" binary
+          # (resolved from PATH) can disagree and report a false drift.
+          "PATH" => [ File.dirname(Hive::E2E::Paths.hive_bin), stubs, File.dirname(RbConfig.ruby), ENV["PATH"] ].join(File::PATH_SEPARATOR)
+        }
+        out, err, status = Open3.capture3(env, RbConfig.ruby, "-I#{Paths.lib_dir}",
+                                          Paths.hive_bin, "setup", "--json", chdir: sandbox)
+        unless status.success?
+          raise "hive setup --json failed (exit #{status.exitstatus}):\n#{out}\n#{err}"
+        end
+
+        doc = JSON.parse(out)
+        unless doc["ok"] == true
+          raise "hive setup --json reported ok=false:\n#{JSON.pretty_generate(doc)}"
+        end
+        steps = doc["steps"].to_h { |s| [ s["name"], s["ok"] ] }
+        %w[deps qmd web_bundle daemon enroll web].each do |name|
+          raise "missing/failed setup step #{name}: #{steps.inspect}" unless steps[name] == true
+        end
+        unless doc["web_url"] == "http://127.0.0.1:4567"
+          raise "unexpected web_url #{doc['web_url'].inspect}"
+        end
+
+        File.write(File.join(sandbox, "setup-envelope.json"), out)
+      ensure
+        server.close rescue nil
+        health_thread.kill
+      end
+
+  - kind: state_assert
+    path: "{sandbox}/setup-envelope.json"
+
+  # `hive setup` must persist the local loopback contract (U1/R4): a fresh
+  # install is not left gated behind the GitHub owner flow.
+  - kind: state_assert
+    path: "{run_home}/config.yml"
+    contains: "auth_mode: loopback"
+
+  # Project enrollment: `hive setup` enables the project for the daemon.
+  - kind: state_assert
+    path: "{sandbox}/.hive-state/config.yml"
+    contains: "enabled: true"
+
+  # The loopback web service status surface reflects the resolved bind/port.
+  - kind: json_assert
+    args: [web, status, --json]
+    schema: hive-web-status
+    pick: [bind]
+    equals: "127.0.0.1"
+  - kind: json_assert
+    args: [web, status, --json]
+    schema: hive-web-status
+    pick: [port]
+    equals: 4567
+
+  # `hive daemon status --json` exits 1 when no daemon is running (scriptable
+  # precondition probe) but still emits the full envelope; after `hive setup`
+  # the installed unit matches the CLI binary so `consistent` is true.
+  - kind: json_assert
+    args: [daemon, status, --json]
+    expect_exit: 1
+    schema: hive-daemon-status
+    pick: [consistent]
+    equals: true
+
+  # R13 shared source of truth: a task created through the CLI shows up in
+  # the same `hive status` document the TUI and web UI both read.
+  - kind: cli
+    args: [new, "{project}", "local web round-trip task"]
+  - kind: json_assert
+    args: [status, --json]
+    schema: hive-status
+    pick: [projects, 0, tasks, 0, stage]
+    equals: 1-inbox
diff --git a/test/integration/daemon_status_consistency_test.rb b/test/integration/daemon_status_consistency_test.rb
new file mode 100644
index 000000000..7ba308d26
--- /dev/null
+++ b/test/integration/daemon_status_consistency_test.rb
@@ -0,0 +1,46 @@
+require "test_helper"
+require "json"
+require "open3"
+require "tmpdir"
+
+# Integration test for the U5 binary-consistency fields on
+# `hive daemon status --json`. Uses real bin/hive subprocesses against an
+# isolated HIVE_HOME/HOME so the unit-file probe never touches the real user.
+class DaemonStatusConsistencyTest < Minitest::Test
+  include HiveTestHelper
+
+  REPO_ROOT = File.expand_path("../..", __dir__)
+  HIVE_BIN = File.join(REPO_ROOT, "bin", "hive")
+
+  def status_json(env)
+    out, _err, = Open3.capture3(env, "ruby", "-Ilib", HIVE_BIN, "daemon", "status", "--json")
+    JSON.parse(out)
+  end
+
+  def test_status_json_includes_consistency_fields_when_consistent
+    Dir.mktmpdir("hive-consistency") do |home|
+      env = ENV.to_h.merge("HIVE_HOME" => home, "HOME" => home)
+      doc = status_json(env)
+
+      assert_kind_of String, doc["binary"], "binary must be the resolved CLI binary"
+      assert_equal Hive::VERSION, doc["binary_version"]
+      assert_equal true, doc["consistent"], "no unit + no running daemon = consistent"
+    end
+  end
+
+  def test_drifted_unit_surfaces_consistent_false
+    Dir.mktmpdir("hive-consistency") do |home|
+      env = ENV.to_h.merge("HIVE_HOME" => home, "HOME" => home)
+      unit = File.join(home, ".config", "systemd", "user", "hive-daemon.service")
+      FileUtils.mkdir_p(File.dirname(unit))
+      # A stale Apache-Hive / different-binary ExecStart must drift.
+      File.write(unit, "ExecStart=/usr/bin/hive daemon start\n")
+
+      doc = status_json(env)
+      assert_equal false, doc["consistent"],
+                   "a unit whose ExecStart binary differs from the resolved CLI binary must be inconsistent"
+      refute_equal "/usr/bin/hive", doc["binary"],
+                   "the resolved binary must NOT be the stale /usr/bin/hive (Apache Hive shadowing)"
+    end
+  end
+end
diff --git a/test/integration/setup_test.rb b/test/integration/setup_test.rb
new file mode 100644
index 000000000..959a98ff9
--- /dev/null
+++ b/test/integration/setup_test.rb
@@ -0,0 +1,70 @@
+require "test_helper"
+require "hive/commands/setup/deps"
+
+# Integration test for `hive setup`'s dependency checker against REAL
+# subprocesses: stub the external agent CLIs on PATH and assert they are
+# probed (never installed or authenticated) and that a real `hive setup`'s
+# deps step reports them accurately. The full provisioning pipeline (web
+# bundle fetch + daemon/web service) is exercised by the e2e scenario (U8);
+# this test pins the diagnose-only contract cheaply.
+class SetupIntegrationTest < Minitest::Test
+  include HiveTestHelper
+
+  def stub_bin(dir, name, body)
+    path = File.join(dir, name)
+    File.write(path, body)
+    FileUtils.chmod(0755, path)
+    path
+  end
+
+  def with_stubbed_path
+    with_tmp_dir do |bin|
+      stub_bin(bin, "claude", "#!/bin/sh\necho '2.1.118 (Claude Code)'\n")
+      stub_bin(bin, "codex", "#!/bin/sh\necho '0.125.0'\n")
+      stub_bin(bin, "gh", "#!/bin/sh\nif [ \"$1\" = \"auth\" ]; then exit 0; fi; echo 'gh version 2.66.1'\n")
+      stub_bin(bin, "node", "#!/bin/sh\necho 'v18.20.4'\n")
+      stub_bin(bin, "npm", "#!/bin/sh\necho '10.8.2'\n")
+      stub_bin(bin, "sqlite3", "#!/bin/sh\necho '3.45.3'\n")
+      stub_bin(bin, "tmux", "#!/bin/sh\necho 'tmux 3.5a'\n")
+      with_env("PATH" => [ bin, ENV.fetch("PATH", "") ].join(File::PATH_SEPARATOR)) do
+        yield bin
+      end
+    end
+  end
+
+  def test_deps_probe_real_stub_binaries_without_modifying_them
+    with_stubbed_path do |bin|
+      before = Dir.children(bin).to_h { |name| [ name, File.read(File.join(bin, name)) ] }
+
+      results = Hive::Commands::Setup::Deps.new.check
+      by_name = results.to_h { |r| [ r.name, r ] }
+
+      assert by_name["claude"].ok, "stubbed claude 2.1.118 must satisfy the minimum"
+      assert by_name["codex"].ok
+      assert by_name["gh"].ok, "gh auth status returning 0 must count as authenticated"
+      assert by_name["node/npm"].ok
+      assert by_name["sqlite3"].ok
+      assert by_name["tmux"].ok
+
+      # git / ruby come from the real PATH (this host has both).
+      assert by_name["git"].ok, "real git must be detected on PATH"
+      assert by_name["ruby"].ok, "the test host's ruby 3.4 must satisfy the gate"
+
+      after = Dir.children(bin).to_h { |name| [ name, File.read(File.join(bin, name)) ] }
+      assert_equal before, after,
+                   "the dependency check must be read-only — external agent CLIs are never modified"
+    end
+  end
+
+  def test_unauthenticated_gh_is_reported_blocked
+    with_tmp_dir do |bin|
+      stub_bin(bin, "gh", "#!/bin/sh\nif [ \"$1\" = \"auth\" ]; then exit 1; fi; echo 'gh version 2.66.1'\n")
+      with_env("PATH" => [ bin, ENV.fetch("PATH", "") ].join(File::PATH_SEPARATOR)) do
+        gh = Hive::Commands::Setup::Deps.new.check.find { |r| r.name == "gh" }
+        refute gh.ok
+        assert gh.blocked, "an unauthenticated gh is blocked, not fixed by setup"
+        assert_equal "gh auth login", gh.fix
+      end
+    end
+  end
+end
diff --git a/test/unit/cli_test.rb b/test/unit/cli_test.rb
index dafa1c291..ab987c98a 100644
--- a/test/unit/cli_test.rb
+++ b/test/unit/cli_test.rb
@@ -519,7 +519,9 @@ class HiveCliTest < Minitest::Test
     require "hive/commands/web"
     captured = []
     recorder = Class.new do
-      define_method(:initialize) { |bind:, port:| captured << { bind: bind, port: port } }
+      define_method(:initialize) do |subcommand = nil, bind:, port:, force:, json:|
+        captured << { subcommand: subcommand, bind: bind, port: port, force: force, json: json }
+      end
       define_method(:call) { captured << :called }
     end
 
@@ -533,7 +535,7 @@ class HiveCliTest < Minitest::Test
       Hive::Commands.const_set(:Web, original)
     end
 
-    assert_equal({ bind: "0.0.0.0", port: 9123 }, captured.first,
+    assert_equal({ subcommand: nil, bind: "0.0.0.0", port: 9123, force: false, json: false }, captured.first,
                  "the --bind/--port flags must reach the web command")
     assert_equal :called, captured.last, "hive web must invoke the web command's #call"
   end
diff --git a/test/unit/commands/setup/deps_test.rb b/test/unit/commands/setup/deps_test.rb
new file mode 100644
index 000000000..59e28f96d
--- /dev/null
+++ b/test/unit/commands/setup/deps_test.rb
@@ -0,0 +1,113 @@
+require "test_helper"
+require "hive/commands/setup/deps"
+
+class SetupDepsTest < Minitest::Test
+  include HiveTestHelper
+
+  # A fake `runner` that answers per-argv. `script` is a Hash of
+  # [argv.join(" ")] => [stdout, ok].
+  def fake_runner(script)
+    ->(argv) { script.fetch(argv.join(" ")) { [ "", true ] } }
+  end
+
+  def build(script)
+    Hive::Commands::Setup::Deps.new(runner: fake_runner(script))
+  end
+
+  def result_for(results, name)
+    results.find { |r| r.name == name }
+  end
+
+  def test_all_present_reports_every_dependency_ok
+    script = {
+      "ruby --version" => [ "ruby 3.4.10 (2026-06-30)", true ],
+      "git --version" => [ "git version 2.39.5", true ],
+      "tmux -V" => [ "tmux 3.5a", true ],
+      "gh --version" => [ "gh version 2.66.1", true ],
+      "gh auth status" => [ "", true ],
+      "claude --version" => [ "2.1.118 (Claude Code)", true ],
+      "codex --version" => [ "0.125.0", true ],
+      "node --version" => [ "v18.20.4", true ],
+      "npm --version" => [ "10.8.2", true ],
+      "sqlite3 --version" => [ "3.45.3 2024-04-15", true ]
+    }
+    results = build(script).check
+    assert_equal 8, results.size
+    assert results.all?(&:ok), "every dep present should be ok: #{results.reject(&:ok).map(&:name)}"
+  end
+
+  def test_ruby_3_4_is_exact_not_a_floor
+    script = { "ruby --version" => [ "ruby 3.5.0", true ] }
+    r = result_for(build(script).check, "ruby")
+    refute r.ok
+    assert_match(/3\.4/, r.message)
+  end
+
+  def test_missing_gh_is_blocked_with_install_and_login_fix
+    script = { "gh --version" => [ "", false ] }
+    r = result_for(build(script).check, "gh")
+    refute r.ok
+    assert r.blocked, "gh is an external CLI — missing must be blocked, not silently installed"
+    assert_match(/brew install gh/, r.fix)
+    assert_match(/gh auth login/, r.fix)
+  end
+
+  def test_installed_but_unauthenticated_gh_is_blocked
+    script = { "gh --version" => [ "gh version 2.66.1", true ], "gh auth status" => [ "", false ] }
+    r = result_for(build(script).check, "gh")
+    refute r.ok
+    assert r.blocked
+    assert_equal "gh auth login", r.fix
+  end
+
+  def test_claude_below_minimum_is_blocked
+    script = { "claude --version" => [ "2.1.100", true ] }
+    r = result_for(build(script).check, "claude")
+    refute r.ok
+    assert r.blocked
+    assert_match(/brew upgrade claude/, r.fix)
+  end
+
+  def test_codex_missing_is_blocked_with_login_fix
+    script = { "codex --version" => [ "", false ] }
+    r = result_for(build(script).check, "codex")
+    refute r.ok
+    assert r.blocked
+    assert_match(/brew install codex/, r.fix)
+    assert_match(/codex login/, r.fix)
+  end
+
+  def test_missing_node_blocks_node_npm_check
+    script = { "node --version" => [ "", false ], "npm --version" => [ "10.8.2", true ] }
+    r = result_for(build(script).check, "node/npm")
+    refute r.ok
+    refute r.blocked, "node is not an external agent CLI"
+  end
+
+  def test_blocked_names_returns_only_external_clis
+    script = {
+      "gh --version" => [ "", false ],
+      "claude --version" => [ "", false ],
+      "codex --version" => [ "", false ],
+      "git --version" => [ "git version 2.39.5", true ],
+      "tmux -V" => [ "tmux 3.5a", true ],
+      "ruby --version" => [ "ruby 3.4.10", true ],
+      "node --version" => [ "v18.20.4", true ],
+      "npm --version" => [ "10.8.2", true ],
+      "sqlite3 --version" => [ "3.45.3", true ]
+    }
+    assert_equal %w[claude codex gh], build(script).blocked_names.sort
+  end
+
+  def test_to_h_is_schema_shaped
+    r = Hive::Commands::Setup::Deps::Result.new(
+      name: "ruby", ok: true, blocked: false, found: "3.4.10", required: "3.4.x",
+      message: "ok", fix: nil
+    )
+    assert_equal(
+      { "name" => "ruby", "ok" => true, "blocked" => false, "found" => "3.4.10",
+        "required" => "3.4.x", "message" => "ok", "fix" => nil },
+      r.to_h
+    )
+  end
+end
diff --git a/test/unit/commands/setup/web_bundle_test.rb b/test/unit/commands/setup/web_bundle_test.rb
new file mode 100644
index 000000000..7adadc727
--- /dev/null
+++ b/test/unit/commands/setup/web_bundle_test.rb
@@ -0,0 +1,101 @@
+require "test_helper"
+require "hive/commands/setup/web_bundle"
+
+class SetupWebBundleTest < Minitest::Test
+  include HiveTestHelper
+
+  def make_app(dir)
+    FileUtils.mkdir_p(File.join(dir, "config"))
+    File.write(File.join(dir, "config", "application.rb"), "# app marker\n")
+    FileUtils.mkdir_p(File.join(dir, "bin"))
+    File.write(File.join(dir, "bin", "rails"), "#!/bin/sh\nexit 0\n")
+    FileUtils.chmod(0755, File.join(dir, "bin", "rails"))
+    dir
+  end
+
+  def test_resolution_order_env_then_managed_then_source
+    with_tmp_global_config do |home|
+      env_dir = make_app(File.join(home, "env-web"))
+      managed = Hive::Paths.managed_web_dir
+
+      with_env("HIVEBOX_WEB_APP_DIR" => env_dir) do
+        assert_equal env_dir, Hive::Commands::Setup::WebBundle.new.resolve_app_dir,
+                     "HIVEBOX_WEB_APP_DIR must win over managed + source"
+      end
+
+      # No env override, but a populated managed dir → managed wins.
+      make_app(managed)
+      assert_equal managed, Hive::Commands::Setup::WebBundle.new.resolve_app_dir
+
+      # No env, no managed app → the sibling source checkout resolves.
+      FileUtils.rm_rf(managed)
+      source = File.expand_path("../../../../web", __dir__)
+      assert_equal source, Hive::Commands::Setup::WebBundle.new.resolve_app_dir
+    end
+  end
+
+  def test_marker_write_read_and_mismatch
+    with_tmp_dir do |dir|
+      bundle = Hive::Commands::Setup::WebBundle.new
+      refute bundle.version_mismatch?(dir), "no marker = no mismatch"
+
+      bundle.write_marker(dir)
+      assert_equal Hive::VERSION, bundle.marker_version(dir)
+      refute bundle.version_mismatch?(dir)
+
+      File.write(bundle.marker_path(dir), "0.0.1\n")
+      assert bundle.version_mismatch?(dir), "a stale marker must mismatch"
+    end
+  end
+
+  def test_provision_fetches_when_no_app_resolves
+    with_tmp_global_config do |home|
+      calls = []
+      fetcher = ->(dir) { calls << [ :fetch, dir ]; make_app(dir); { ok: true, message: "fetched" } }
+      bundle = Hive::Commands::Setup::WebBundle.new(fetcher: fetcher, runner: ->(_argv) { [ "", true ] })
+      with_replaced_singleton_method(bundle, :resolve_app_dir, -> { nil }) do
+        result = bundle.provision
+        assert result[:ok], result[:message]
+        assert_equal 1, calls.size, "no app resolved → must fetch"
+        assert_equal Hive::Paths.managed_web_dir, calls.first[1]
+        assert_equal Hive::VERSION, bundle.marker_version(Hive::Paths.managed_web_dir)
+      end
+    end
+  end
+
+  def test_provision_fetch_failure_produces_exact_fix_command
+    with_tmp_global_config do
+      fetcher = ->(_dir) { { ok: false, message: "run: git clone --depth 1 --branch v#{Hive::VERSION} …" } }
+      bundle = Hive::Commands::Setup::WebBundle.new(fetcher: fetcher, runner: ->(_argv) { [ "", true ] })
+      with_replaced_singleton_method(bundle, :resolve_app_dir, -> { nil }) do
+        result = bundle.provision
+        refute result[:ok]
+        assert_match(/git clone/, result[:message])
+      end
+    end
+  end
+
+  def test_stale_managed_bundle_is_refetched
+    with_tmp_global_config do |home|
+      managed = Hive::Paths.managed_web_dir
+      make_app(managed)
+      File.write(File.join(managed, Hive::Commands::Setup::WebBundle::MARKER_FILENAME), "0.0.1\n")
+
+      calls = []
+      fetcher = ->(dir) { calls << [ :fetch, dir ]; { ok: true, message: "fetched" } }
+      bundle = Hive::Commands::Setup::WebBundle.new(fetcher: fetcher, runner: ->(_argv) { [ "", true ] })
+      with_replaced_singleton_method(bundle, :resolve_app_dir, -> { managed }) do
+        bundle.provision
+      end
+      assert_equal 1, calls.size, "a stale managed bundle must be re-fetched"
+    end
+  end
+
+  def test_managed_dir_predicate
+    with_tmp_global_config do
+      bundle = Hive::Commands::Setup::WebBundle.new
+      assert bundle.managed_dir?(Hive::Paths.managed_web_dir)
+      refute bundle.managed_dir?(File.join(Hive::Paths.managed_web_dir, "..", "other"))
+    end
+  end
+end
diff --git a/test/unit/commands/setup_test.rb b/test/unit/commands/setup_test.rb
new file mode 100644
index 000000000..f3ecd6682
--- /dev/null
+++ b/test/unit/commands/setup_test.rb
@@ -0,0 +1,212 @@
+require "test_helper"
+require "hive/commands/setup"
+
+class SetupTest < Minitest::Test
+  include HiveTestHelper
+
+  def ok_result(name)
+    Hive::Commands::Setup::Deps::Result.new(
+      name: name, ok: true, blocked: false, found: "x", required: "any", message: "ok", fix: nil
+    )
+  end
+
+  def blocked_result(name)
+    Hive::Commands::Setup::Deps::Result.new(
+      name: name, ok: false, blocked: true, found: nil, required: "any",
+      message: "missing", fix: "brew install #{name}"
+    )
+  end
+
+  def missing_result(name)
+    Hive::Commands::Setup::Deps::Result.new(
+      name: name, ok: false, blocked: false, found: nil, required: "any",
+      message: "missing", fix: "brew install #{name}"
+    )
+  end
+
+  # Fake deps / web_bundle / runner / health_probe recording calls.
+  def build_setup(script: {}, dep_results: [ ok_result("ruby") ], health: [ true, "ok" ], json: true, **opts)
+    calls = []
+    runner = ->(argv) do
+      calls << argv
+      if argv[1] == "daemon" && argv[2] == "status"
+        # A canned, consistent daemon status so the orchestrator's status
+        # step passes without a real daemon.
+        return [ JSON.generate("ok" => true, "running" => true, "consistent" => true), true ]
+      end
+
+      script.fetch(argv.join(" ")) { [ "", true ] }
+    end
+    web_bundle = Object.new
+    web_bundle.define_singleton_method(:provision) { calls << [ :provision ]; { ok: true, message: "bundled", app_dir: "/app" } }
+    deps = Object.new
+    deps.define_singleton_method(:check) { dep_results }
+    setup = Hive::Commands::Setup.new(
+      json: json,
+      non_interactive: true,
+      project_path: Dir.pwd,
+      input: StringIO.new,
+      output: StringIO.new,
+      summary_io: StringIO.new,
+      deps: deps,
+      web_bundle: web_bundle,
+      runner: runner,
+      health_probe: -> { health },
+      **opts
+    )
+    [ setup, calls ]
+  end
+
+  # Setup with a fully controlled daemon-status response (canned JSON + exit
+  # code) so the `ensure_daemon` status/consistency handling is testable
+  # without a real daemon.
+  def build_setup_with_daemon_status(status_json, status_ok)
+    runner = ->(argv) do
+      return [ status_json, status_ok ] if argv[1] == "daemon" && argv[2] == "status"
+
+      [ "", true ]
+    end
+    web_bundle = Object.new
+    web_bundle.define_singleton_method(:provision) { { ok: true, message: "bundled", app_dir: "/app" } }
+    deps = Object.new
+    deps.define_singleton_method(:check) { [ ok_result("ruby") ] }
+    Hive::Commands::Setup.new(
+      json: true, non_interactive: true, project_path: Dir.pwd,
+      input: StringIO.new, output: StringIO.new, summary_io: StringIO.new,
+      deps: deps, web_bundle: web_bundle, runner: runner,
+      health_probe: -> { [ true, "ok" ] }
+    )
+  end
+
+  def test_orchestrates_steps_in_dependency_order
+    with_tmp_global_config do
+      setup, calls = build_setup
+      out, = capture_io { setup.call }
+      payload = JSON.parse(out)
+
+      step_names = payload.fetch("steps").map { |s| s["name"] }
+      assert_equal %w[deps qmd web_bundle daemon enroll web], step_names,
+                   "setup must run the steps in dependency order"
+
+      # The runner sees daemon install before status, then init/enroll, then web install.
+      runner_calls = calls.reject { |c| c == [ :provision ] }
+      assert_includes runner_calls, [ setup.send(:hive_bin), "daemon", "install", "--force" ]
+      assert_includes runner_calls, [ setup.send(:hive_bin), "daemon", "status", "--json" ]
+      assert_includes runner_calls, [ setup.send(:hive_bin), "web", "install" ]
+    end
+  end
+
+  def test_envelope_matches_hive_setup_schema_shape
+    with_tmp_global_config do
+      setup, = build_setup
+      out, = capture_io { setup.call }
+      payload = JSON.parse(out)
+      assert_equal "hive-setup", payload["schema"]
+      assert_equal Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-setup"), payload["schema_version"]
+      assert_equal true, payload["ok"]
+      assert_equal %w[claude codex], payload["backends"], "non-interactive defaults are claude+codex"
+      assert_equal "http://127.0.0.1:4567", payload["web_url"]
+      assert_kind_of Array, payload["deps"]
+      assert_kind_of Array, payload["steps"]
+      assert_kind_of Array, payload["warnings"]
+    end
+  end
+
+  def test_non_tty_uses_default_backends
+    with_tmp_global_config do
+      setup, = build_setup
+      capture_io { setup.call }
+      assert_equal %w[claude codex], Hive::Config.load_global_agents
+    end
+  end
+
+  def test_setup_persists_local_loopback_web_contract
+    with_tmp_global_config do
+      setup, = build_setup
+      capture_io { setup.call }
+      cfg = Hive::Config.load_global_web
+      assert_equal "loopback", cfg["auth_mode"],
+                   "a fresh `hive setup` must persist web.auth_mode=loopback, not the owner default"
+      assert_equal "127.0.0.1", cfg["bind"]
+      assert_equal 4567, cfg["port"]
+    end
+  end
+
+  def test_daemon_status_exit_1_not_running_is_not_a_hard_failure
+    with_tmp_global_config do
+      setup = build_setup_with_daemon_status(JSON.generate("ok" => true, "running" => false, "consistent" => true), false)
+      result = setup.send(:ensure_daemon)
+      assert result[:ok], "exit-1 (not running) must not fail the daemon step: #{result[:message]}"
+      assert_match(/consistent/, result[:message])
+    end
+  end
+
+  def test_daemon_status_inconsistent_is_a_hard_failure
+    with_tmp_global_config do
+      setup = build_setup_with_daemon_status(JSON.generate("ok" => true, "running" => true, "consistent" => false), true)
+      result = setup.send(:ensure_daemon)
+      refute result[:ok]
+      assert_match(/inconsistent/, result[:message])
+    end
+  end
+
+  def test_daemon_status_consistent_null_is_unverifiable_not_pass
+    with_tmp_global_config do
+      setup = build_setup_with_daemon_status(JSON.generate("ok" => true, "running" => true, "consistent" => nil), true)
+      result = setup.send(:ensure_daemon)
+      refute result[:ok], "consistent:null must not be reported as pass"
+      assert_match(/could not be verified/, result[:message])
+    end
+  end
+
+  def test_blocked_external_cli_is_a_warning_not_a_failure
+    with_tmp_global_config do
+      setup, = build_setup(dep_results: [ ok_result("ruby"), blocked_result("claude") ])
+      out, = capture_io { setup.call }
+      payload = JSON.parse(out)
+      assert_equal true, payload["ok"], "blocked external CLIs must not fail the local bring-up"
+      assert payload["warnings"].any? { |w| w.include?("claude") }
+    end
+  end
+
+  def test_missing_hard_dependency_fails_setup
+    with_tmp_global_config do
+      setup, = build_setup(dep_results: [ ok_result("ruby"), missing_result("git") ])
+      err = assert_raises(Hive::Error) do
+        capture_io { setup.call }
+      end
+      assert_match(/incomplete/, err.message)
+    end
+  end
+
+  def test_failed_step_fails_setup
+    with_tmp_global_config do
+      # daemon install fails → step ok:false → overall ok:false.
+      script = { "#{Hive::InvokedBinary.path || 'hive'} daemon install --force" => [ "", false ] }
+      setup, = build_setup(script: script)
+      err = assert_raises(Hive::Error) do
+        capture_io { setup.call }
+      end
+      assert_match(/incomplete/, err.message)
+    end
+  end
+
+  def test_enroll_prefers_enable_for_registered_project
+    with_tmp_global_config do |home|
+      # Register a project whose path == the setup project_path (Dir.pwd here is
+      # the repo root; use a path that will match via a tmp project).
+      with_tmp_dir do |proj|
+        FileUtils.mkdir_p(File.join(home))
+        cfg = { "registered_projects" => [ { "name" => "demo", "path" => proj } ] }
+        File.write(File.join(home, "config.yml"), cfg.to_yaml)
+
+        setup, calls = build_setup
+        setup.instance_variable_set(:@project_path, File.expand_path(proj))
+        capture_io { setup.call }
+        bin = setup.send(:hive_bin)
+        assert_includes calls, [ bin, "daemon", "enable", "demo" ],
+                        "an already-registered project must be enrolled via `hive daemon enable`, not re-init"
+      end
+    end
+  end
+end
diff --git a/test/unit/commands/web/service_installer_test.rb b/test/unit/commands/web/service_installer_test.rb
new file mode 100644
index 000000000..4d79ec0e1
--- /dev/null
+++ b/test/unit/commands/web/service_installer_test.rb
@@ -0,0 +1,156 @@
+require "test_helper"
+require "hive/commands/web/service_installer"
+
+class WebServiceInstallerTest < Minitest::Test
+  include HiveTestHelper
+
+  def installer_with(dir, hive:, app_dir:, **opts)
+    FileUtils.mkdir_p(File.dirname(hive))
+    File.write(hive, "#!/bin/sh\n")
+    FileUtils.chmod(0755, hive)
+    Hive::Commands::Web::ServiceInstaller.new(
+      host_os: "linux-gnu",
+      home: dir,
+      binary_path: hive,
+      systemctl_available: false,
+      **opts
+    ).tap do |installer|
+      installer.define_singleton_method(:resolved_app_dir) { app_dir }
+    end
+  end
+
+  def test_linux_writes_systemd_unit_with_resolved_binary_and_app_dir
+    with_tmp_dir do |dir|
+      hive = File.join(dir, "bin", "hive")
+      app_dir = File.join(dir, "share", "hive", "web")
+      installer = installer_with(dir, hive: hive, app_dir: app_dir)
+
+      installer.install!(autostart: false)
+      unit = File.join(dir, ".config/systemd/user/hive-web.service")
+      assert File.exist?(unit)
+      body = File.read(unit)
+      assert_includes body, "ExecStart=#{hive} web --bind 127.0.0.1",
+                      "ExecStart must run the web tier in the foreground on the loopback bind"
+      assert_includes body, "Environment=HIVEBOX_WEB_APP_DIR=#{app_dir}",
+                      "the unit must bake the resolved Rails app dir so the service survives login/reboot"
+      assert_includes body, "Environment=PATH=",
+                      "minimal PATH must be rewritten so the web tier's bin/hive shebang resolves a Ruby with gem deps"
+    end
+  end
+
+  def test_linux_autostart_invokes_enable_when_systemd_available
+    with_tmp_dir do |dir|
+      commands = []
+      hive = File.join(dir, "bin", "hive")
+      FileUtils.mkdir_p(File.dirname(hive))
+      File.write(hive, "#!/bin/sh\n")
+      FileUtils.chmod(0755, hive)
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux",
+        home: dir,
+        binary_path: hive,
+        systemctl_available: true,
+        runner: ->(argv) { commands << argv }
+      )
+
+      result = installer.install!(autostart: true)
+      assert_equal :written, result.kind
+      assert_includes commands, %w[systemctl --user daemon-reload]
+      assert_includes commands, %w[systemctl --user enable --now hive-web]
+    end
+  end
+
+  def test_linux_without_systemd_writes_unit_and_reports_autostart_unavailable
+    with_tmp_dir do |dir|
+      installer = Hive::Commands::Web::ServiceInstaller.new(
+        host_os: "linux",
+        home: dir,
+        binary_path: "/tmp/hive",
+        systemctl_available: false,
+        runner: ->(_argv) { false }
+      )
+
+      result = installer.install!(autostart: true)
+      assert_equal :autostart_unavailable, result.kind
+      assert File.exist?(File.join(dir, ".config/systemd/user/hive-web.service"))
+      assert installer.messages.any? { |msg| msg.include?("hive web start") },
+             "the graceful-degradation message should point at `hive web start`"
+    end
+  end
+
+  def test_macos_writes_plist_with_resolved_binary_and_app_dir
+    with_tmp_dir do |dir|
+      hive = File.join(dir, ".local", "bin", "hive")
+      app_dir = File.join(dir, ".local", "share", "hive", "web")
+      installer = installer_with(dir, hive: hive, app_dir: app_dir)
+      installer.instance_variable_set(:@host_os, "darwin23")
+
+      installer.install!(autostart: false)
+      plist = File.join(dir, "Library/LaunchAgents/local.hive-web.plist")
+      assert File.exist?(plist)
+      body = File.read(plist)
+      assert_includes body, "<string>#{hive}</string>"
+      assert_includes body, "<string>web</string>"
+      assert_includes body, "<string>--bind</string>"
+      assert_includes body, "<string>127.0.0.1</string>"
+      assert_includes body, "<string>#{app_dir}</string>",
+                      "launchd plist must bake HIVEBOX_WEB_APP_DIR as an absolute path"
+      assert_includes body, "<key>RunAtLoad</key>"
+    end
+  end
+
+  def test_drifted_existing_unit_is_not_overwritten_without_force
+    with_tmp_dir do |dir|
+      unit = File.join(dir, ".config/systemd/user/hive-web.service")
+      FileUtils.mkdir_p(File.dirname(unit))
+      File.write(unit, "custom\n")
+      installer = installer_with(dir, hive: File.join(dir, "bin", "hive"), app_dir: "/app")
+
+      result = installer.install!(autostart: false)
+      assert_equal :drifted, result.kind
+      assert_equal "custom\n", File.read(unit)
+      assert installer.messages.any? { |msg| msg.include?("hive web install --force") }
+    end
+  end
+
+  def test_force_overwrites_drifted_unit_and_writes_backup
+    with_tmp_dir do |dir|
+      unit = File.join(dir, ".config/systemd/user/hive-web.service")
+      FileUtils.mkdir_p(File.dirname(unit))
+      File.write(unit, "previous-stale-content\n")
+      installer = installer_with(dir, hive: File.join(dir, "bin", "hive"), app_dir: "/app")
+
+      result = installer.install!(autostart: false, force: true)
+      assert_equal :upgraded, result.kind
+      backups = Dir["#{unit}.bak-*"]
+      assert_equal 1, backups.size
+      assert_equal "previous-stale-content\n", File.read(backups.first)
+      assert_includes File.read(unit), "ExecStart="
+    end
+  end
+
+  def test_service_state_reports_install_and_enable_state
+    with_tmp_dir do |dir|
+      installer = installer_with(dir, hive: File.join(dir, "bin", "hive"), app_dir: "/app")
+      state = installer.service_state
+      refute state["service_installed"]
+
+      installer.install!(autostart: false)
+      state = installer.service_state
+      assert state["service_installed"]
+      assert_equal "linux", state["platform"]
+      assert_equal File.join(dir, ".config/systemd/user/hive-web.service"), state["unit_path"]
+    end
+  end
+
+  def test_app_dir_falls_back_to_managed_dir_when_unresolved
+    with_tmp_global_config do
+      installer = Hive::Commands::Web::ServiceInstaller.new(host_os: "linux")
+      # Stub the resolver to simulate a host with no source checkout / env
+      # override: the managed dir is the correct fallback.
+      with_replaced_singleton_method(Hive::Commands::Web, :rails_app_dir, -> { nil }) do
+        assert_equal Hive::Paths.managed_web_dir, installer.resolved_app_dir
+      end
+    end
+  end
+end
diff --git a/test/unit/config_web_test.rb b/test/unit/config_web_test.rb
new file mode 100644
index 000000000..b8550e618
--- /dev/null
+++ b/test/unit/config_web_test.rb
@@ -0,0 +1,62 @@
+require "test_helper"
+require "hive/config"
+
+# U1 / R4 — persisting the local loopback web contract. `hive setup` and
+# `hive web install` write `web.auth_mode: "loopback"` (plus the canonical
+# loopback bind/port) into the global config so a fresh local install is
+# not left gated behind the GitHub owner flow.
+class ConfigWebTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_write_local_web_loopback_persists_the_loopback_contract
+    with_tmp_global_config do |home|
+      Hive::Config.write_local_web_loopback!
+
+      cfg = Hive::Config.load_global_web
+      assert_equal "loopback", cfg["auth_mode"]
+      assert_equal "127.0.0.1", cfg["bind"]
+      assert_equal 4567, cfg["port"]
+      assert Hive::Config.local_web_no_auth?(cfg), "the persisted block must resolve to loopback no-auth"
+
+      data = YAML.safe_load(File.read(File.join(home, "config.yml")))
+      assert_equal "loopback", data.dig("web", "auth_mode")
+    end
+  end
+
+  def test_write_global_web_preserves_operator_sibling_keys
+    with_tmp_global_config do |home|
+      File.write(File.join(home, "config.yml"), {
+        "registered_projects" => [],
+        "web" => { "origin" => "https://box.example.com", "github" => { "client_id" => "custom" } }
+      }.to_yaml)
+
+      Hive::Config.write_local_web_loopback!
+
+      cfg = Hive::Config.load_global_web
+      assert_equal "https://box.example.com", cfg["origin"], "operator origin must be preserved"
+      assert_equal "custom", cfg.dig("github", "client_id"), "operator github.client_id must be preserved"
+      assert_equal "loopback", cfg["auth_mode"]
+    end
+  end
+
+  def test_write_global_web_rejects_pre_existing_non_hash_web_block
+    with_tmp_global_config do |home|
+      File.write(File.join(home, "config.yml"), {
+        "registered_projects" => [],
+        "web" => "not-a-hash"
+      }.to_yaml)
+
+      err = assert_raises(Hive::ConfigError) { Hive::Config.write_local_web_loopback! }
+      assert_match(/web .* must be a Hash/, err.message)
+    end
+  end
+
+  def test_write_global_web_validates_the_merged_block
+    with_tmp_global_config do
+      err = assert_raises(Hive::ConfigError) do
+        Hive::Config.write_global_web!("auth_mode" => "not-a-mode")
+      end
+      assert_match(/auth_mode/, err.message)
+    end
+  end
+end
diff --git a/test/unit/daemon/consistency_test.rb b/test/unit/daemon/consistency_test.rb
new file mode 100644
index 000000000..5c8065881
--- /dev/null
+++ b/test/unit/daemon/consistency_test.rb
@@ -0,0 +1,138 @@
+require "test_helper"
+require "hive/daemon/consistency"
+
+class DaemonConsistencyTest < Minitest::Test
+  include HiveTestHelper
+
+  FakeInstaller = Struct.new(:resolved_binary, :target_path, :envelope_platform)
+
+  def build(installer:, pid: nil, exe_reader: nil, version_probe: nil)
+    Hive::Daemon::Consistency.new(
+      installer: installer,
+      pid: pid,
+      exe_reader: exe_reader,
+      version_probe: version_probe
+    )
+  end
+
+  def test_matching_binary_with_no_unit_and_no_pid_is_consistent
+    installer = FakeInstaller.new("/usr/local/bin/hive", "/nonexistent-unit", "linux")
+    result = build(installer: installer).probe
+
+    assert_equal "/usr/local/bin/hive", result["binary"]
+    assert_equal Hive::VERSION, result["binary_version"]
+    assert_nil result["unit_binary"], "no unit file → no unit drift"
+    assert_nil result["running_binary"]
+    assert result["consistent"]
+  end
+
+  def test_stale_execstart_binary_is_detected
+    with_tmp_dir do |dir|
+      unit = File.join(dir, "hive-daemon.service")
+      File.write(unit, "ExecStart=/usr/bin/hive daemon start\n")
+      installer = FakeInstaller.new("/usr/local/bin/hive", unit, "linux")
+
+      result = build(installer: installer).probe
+      assert_equal "/usr/bin/hive", result["unit_binary"]
+      refute result["consistent"], "a stale /usr/bin/hive ExecStart must be detected"
+    end
+  end
+
+  def test_stale_running_version_is_detected
+    installer = FakeInstaller.new("/usr/local/bin/hive", nil, "linux")
+    result = build(
+      installer: installer,
+      pid: 123,
+      exe_reader: ->(_pid) { "/usr/local/bin/hive" },
+      version_probe: ->(_bin) { "0.2.0" }
+    ).probe
+
+    refute result["consistent"]
+    assert_equal "0.2.0", result["running_version"], "a stale daemon --version must surface"
+  end
+
+  def test_running_binary_drift_is_detected
+    installer = FakeInstaller.new("/usr/local/bin/hive", nil, "linux")
+    result = build(
+      installer: installer,
+      pid: 123,
+      exe_reader: ->(_pid) { "/usr/bin/hive" },
+      version_probe: ->(_bin) { Hive::VERSION }
+    ).probe
+
+    refute result["consistent"], "a running daemon from a different binary must be detected"
+    assert_equal "/usr/bin/hive", result["running_binary"]
+  end
+
+  def test_matching_running_binary_and_version_is_consistent
+    installer = FakeInstaller.new("/usr/local/bin/hive", nil, "linux")
+    result = build(
+      installer: installer,
+      pid: 123,
+      exe_reader: ->(_pid) { "/usr/local/bin/hive" },
+      version_probe: ->(_bin) { Hive::VERSION }
+    ).probe
+
+    assert result["consistent"]
+  end
+
+  def test_ps_fallback_reads_hive_script_argument
+    with_tmp_dir do |dir|
+      fake_exe = File.join(dir, "bin", "hive")
+      FileUtils.mkdir_p(File.dirname(fake_exe))
+      File.write(fake_exe, "#!/bin/sh\n")
+      FileUtils.chmod(0755, fake_exe)
+
+      installer = FakeInstaller.new("/usr/local/bin/hive", nil, "linux")
+      consistency = build(installer: installer)
+      # `ps` is not on PATH in minimal CI images — stub Open3.capture2 to
+      # exercise the fallback parser (script-argument scan, not the first
+      # token — which is the ruby interpreter for a shebang script).
+      status = Struct.new(:success?).new(true)
+      with_replaced_singleton_method(Open3, :capture2, ->(*_args) { [ "ruby #{fake_exe} daemon start\n", status ] }) do
+        assert_equal fake_exe, consistency.send(:read_exe_via_ps, 123),
+                     "ps fallback must select the hive script argument, not the ruby interpreter"
+      end
+    end
+  end
+
+  def test_read_process_exe_reads_cmdline_not_exe_for_script_daemon
+    with_tmp_dir do |dir|
+      script = File.join(dir, "hive")
+      File.write(script, "#!/usr/bin/env ruby\nsleep 30\n")
+      FileUtils.chmod(0755, script)
+
+      pid = Process.spawn(script, "daemon", "start")
+      begin
+        sleep 0.1
+        consistency = build(installer: FakeInstaller.new(script, nil, "linux"))
+        exe = consistency.send(:read_process_exe, pid)
+        assert_equal script, exe,
+                     "/proc/<pid>/cmdline must resolve the hive script, never the ruby interpreter (/exe)"
+      ensure
+        Process.kill("KILL", pid) rescue nil
+        Process.waitpid(pid) rescue nil
+      end
+    end
+  end
+
+  def test_launchd_plist_binary_is_parsed
+    with_tmp_dir do |dir|
+      plist = File.join(dir, "local.hive-daemon.plist")
+      File.write(plist, <<~XML)
+        <plist version="1.0"><dict>
+        <key>ProgramArguments</key>
+        <array>
+          <string>/bin/sh</string><string>-c</string>
+          <string>[ -x "$0" ] || exit 0; exec "$0" "$@"</string>
+          <string>/usr/local/bin/hive</string>
+        </array>
+        </dict></plist>
+      XML
+      installer = FakeInstaller.new("/opt/homebrew/bin/hive", plist, "macos")
+      result = build(installer: installer).probe
+      assert_equal "/usr/local/bin/hive", result["unit_binary"]
+      refute result["consistent"], "a plist binary differing from the resolved brew binary must drift"
+    end
+  end
+end
diff --git a/test/unit/openclaw_skills_test.rb b/test/unit/openclaw_skills_test.rb
index eb700e97e..e519b71e9 100644
--- a/test/unit/openclaw_skills_test.rb
+++ b/test/unit/openclaw_skills_test.rb
@@ -23,7 +23,7 @@ class OpenClawSkillsTest < Minitest::Test
 
     assert_equal "hive", metadata.fetch("name")
     assert_equal CLAWHUB_DESCRIPTION, metadata.fetch("description")
-    assert_equal "0.1.1", metadata.fetch("version")
+    assert_equal "0.1.2", metadata.fetch("version")
     assert_equal true, metadata.fetch("user-invocable")
     assert_equal HOMEPAGE, openclaw_metadata.fetch("homepage")
     assert_equal true, openclaw_metadata.fetch("always"), "umbrella skill must remain visible for setup"
diff --git a/test/unit/paths_test.rb b/test/unit/paths_test.rb
index 80ee2b5be..a5885575b 100644
--- a/test/unit/paths_test.rb
+++ b/test/unit/paths_test.rb
@@ -51,6 +51,20 @@ class PathsTest < Minitest::Test
     end
   end
 
+  def test_managed_web_dir_lives_under_data_home
+    with_xdg_home do |dir|
+      assert_equal File.join(dir, "data", "hive", "web"), Hive::Paths.managed_web_dir
+    end
+  end
+
+  def test_managed_web_dir_collapses_under_hive_home_override
+    with_tmp_dir do |dir|
+      with_env("HIVE_HOME" => File.join(dir, "legacy"), "XDG_DATA_HOME" => File.join(dir, "data")) do
+        assert_equal File.join(dir, "legacy", "web"), Hive::Paths.managed_web_dir
+      end
+    end
+  end
+
   def test_legacy_registry_migrates_once_to_xdg_config
     with_tmp_dir do |dir|
       home = File.join(dir, "home")
diff --git a/test/unit/schema_files_test.rb b/test/unit/schema_files_test.rb
index 2d10237de..879cd977d 100644
--- a/test/unit/schema_files_test.rb
+++ b/test/unit/schema_files_test.rb
@@ -1475,12 +1475,100 @@ class SchemaFilesTest < Minitest::Test
     # are required-but-nullable in the schema.
     producer_required = %w[
       schema schema_version ok running pid uptime_sec pid_file log_file
-      service_installed service_enabled unit_path current_version update_nudge
+      service_installed service_enabled unit_path binary binary_version
+      consistent current_version update_nudge
     ].sort
     assert_equal producer_required, schema_required,
                  "schema/producer required-key drift in hive-daemon-status.v1.json"
   end
 
+  # ── hive-web-status ─────────────────────────────────────────────────────
+
+  def test_hive_web_status_schema_file_exists_and_is_valid_json
+    path = Hive::Schemas.schema_path("hive-web-status")
+    assert File.exist?(path), "schema file missing: #{path}"
+
+    doc = JSON.parse(File.read(path))
+    assert_equal "https://json-schema.org/draft/2020-12/schema", doc["$schema"]
+    assert_equal "hive-web-status",
+                 doc.dig("$defs", "SuccessPayload", "properties", "schema", "const")
+    assert_equal 1,
+                 doc.dig("$defs", "SuccessPayload", "properties", "schema_version", "const")
+  end
+
+  def test_hive_web_status_required_keys_match_producer_emission
+    doc = JSON.parse(File.read(Hive::Schemas.schema_path("hive-web-status")))
+    schema_required = doc.dig("$defs", "SuccessPayload", "required").sort
+    # Mirrors the producer's JSON.generate call in Hive::Commands::Web#status_web_service.
+    producer_required = %w[
+      schema schema_version ok running pid uptime_sec pid_file
+      service_installed service_enabled unit_path app_dir bind port
+    ].sort
+    assert_equal producer_required, schema_required,
+                 "schema/producer required-key drift in hive-web-status.v1.json"
+  end
+
+  def test_hive_web_status_payload_validates_against_published_schema
+    schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-web-status"))))
+    payload = {
+      "schema" => "hive-web-status",
+      "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"),
+      "ok" => true,
+      "running" => false,
+      "pid" => nil,
+      "uptime_sec" => nil,
+      "pid_file" => "/tmp/.web.pid",
+      "service_installed" => true,
+      "service_enabled" => true,
+      "unit_path" => "/home/you/.config/systemd/user/hive-web.service",
+      "app_dir" => "/home/you/.local/share/hive/web",
+      "bind" => "127.0.0.1",
+      "port" => 4567
+    }
+    errors = schemer.validate(payload).map { |e| e["error"] }
+    assert_empty errors,
+                 "hive-web-status success payload must validate (errors: #{errors.inspect})"
+  end
+
+  # ── hive-setup ──────────────────────────────────────────────────────────
+
+  def test_hive_setup_schema_file_exists_and_is_valid_json
+    path = Hive::Schemas.schema_path("hive-setup")
+    assert File.exist?(path), "schema file missing: #{path}"
+
+    doc = JSON.parse(File.read(path))
+    assert_equal "https://json-schema.org/draft/2020-12/schema", doc["$schema"]
+    assert_equal "hive-setup",
+                 doc.dig("$defs", "SuccessPayload", "properties", "schema", "const")
+    assert_equal 1,
+                 doc.dig("$defs", "SuccessPayload", "properties", "schema_version", "const")
+  end
+
+  def test_hive_setup_success_payload_validates_against_published_schema
+    schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-setup"))))
+    payload = {
+      "schema" => "hive-setup",
+      "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-setup"),
+      "ok" => true,
+      "backends" => [ "claude", "codex" ],
+      "deps" => [
+        { "name" => "ruby", "ok" => true, "blocked" => false, "found" => "3.4.10",
+          "required" => "3.4.x", "message" => "ok", "fix" => nil },
+        { "name" => "claude", "ok" => false, "blocked" => true, "found" => nil,
+          "required" => ">= 2.1.118", "message" => "missing", "fix" => "brew install claude && claude login" }
+      ],
+      "steps" => [
+        { "name" => "deps", "ok" => true, "message" => "checked 2 dependencies" },
+        { "name" => "web", "ok" => true, "message" => "web health OK" }
+      ],
+      "web_url" => "http://127.0.0.1:4567",
+      "warnings" => [ "claude: brew install claude && claude login" ]
+    }
+    errors = schemer.validate(payload).map { |e| e["error"] }
+    assert_empty errors,
+                 "hive-setup success payload must validate (errors: #{errors.inspect})"
+  end
+
   # ── hive-daemon-stop ───────────────────────────────────────────────────
 
   def test_hive_daemon_stop_schema_file_exists_and_is_valid_json
diff --git a/test/unit/web/config_test.rb b/test/unit/web/config_test.rb
index a9ed23895..106a8158b 100644
--- a/test/unit/web/config_test.rb
+++ b/test/unit/web/config_test.rb
@@ -63,4 +63,40 @@ class WebConfigTest < Minitest::Test
   def test_blank_web_session_secret_file_is_rejected
     assert_web_config_error({ "session_secret_file" => "  " }, /web\.session_secret_file/)
   end
+
+  def test_web_auth_mode_defaults_to_owner
+    with_tmp_global_config do
+      assert_equal "owner", Hive::Config.load_global_web["auth_mode"]
+    end
+  end
+
+  def test_invalid_web_auth_mode_is_rejected
+    assert_web_config_error({ "auth_mode" => "open" }, /web\.auth_mode/)
+  end
+
+  def test_non_boolean_unsafe_public_no_auth_is_rejected
+    assert_web_config_error({ "unsafe_public_no_auth" => "yes" }, /web\.unsafe_public_no_auth/)
+  end
+
+  def test_local_web_no_auth_truth_table
+    refute Hive::Config.local_web_no_auth?({ "auth_mode" => "owner" })
+    assert Hive::Config.local_web_no_auth?({ "auth_mode" => "loopback" })
+    refute Hive::Config.local_web_no_auth?({})
+    refute Hive::Config.local_web_no_auth?({ "auth_mode" => nil })
+  end
+
+  def test_web_bind_loopback_truth_table
+    assert Hive::Config.web_bind_loopback?("127.0.0.1")
+    assert Hive::Config.web_bind_loopback?("127.0.0.0")
+    assert Hive::Config.web_bind_loopback?("127.255.255.255")
+    assert Hive::Config.web_bind_loopback?("::1")
+    assert Hive::Config.web_bind_loopback?("localhost")
+    assert Hive::Config.web_bind_loopback?("LOCALHOST")
+    assert Hive::Config.web_bind_loopback?("")
+    assert Hive::Config.web_bind_loopback?(nil)
+
+    refute Hive::Config.web_bind_loopback?("0.0.0.0")
+    refute Hive::Config.web_bind_loopback?("192.168.1.10")
+    refute Hive::Config.web_bind_loopback?("example.com")
+  end
 end
diff --git a/test/unit/web/web_command_test.rb b/test/unit/web/web_command_test.rb
index 11de4ab06..e9a37f680 100644
--- a/test/unit/web/web_command_test.rb
+++ b/test/unit/web/web_command_test.rb
@@ -48,6 +48,80 @@ class WebCommandTest < Minitest::Test
       assert_empty err, "an https origin implies a fronting proxy — no warning"
     end
   end
+
+  # ── U1: local loopback auth policy guard ───────────────────────────────
+
+  def test_non_loopback_bind_with_loopback_auth_is_refused
+    with_tmp_global_config do
+      command = Hive::Commands::Web.new
+      cfg = { "auth_mode" => "loopback", "unsafe_public_no_auth" => false }
+      err = assert_raises(Hive::ConfigError) do
+        command.send(:refuse_unsafe_public_bind, "0.0.0.0", cfg)
+      end
+      assert_match(/refusing to bind/, err.message)
+      assert_match(/loopback/, err.message)
+    end
+  end
+
+  def test_non_loopback_bind_with_owner_auth_is_accepted
+    with_tmp_global_config do
+      command = Hive::Commands::Web.new
+      # "owner" is the default and keeps Docker/hivebox behavior (0.0.0.0).
+      command.send(:refuse_unsafe_public_bind, "0.0.0.0", { "auth_mode" => "owner", "unsafe_public_no_auth" => false })
+      command.send(:refuse_unsafe_public_bind, "0.0.0.0", {}) # missing auth_mode = owner default
+    end
+  end
+
+  def test_non_loopback_bind_with_unsafe_flag_is_accepted
+    with_tmp_global_config do
+      command = Hive::Commands::Web.new
+      command.send(:refuse_unsafe_public_bind, "0.0.0.0", { "auth_mode" => "loopback", "unsafe_public_no_auth" => true })
+    end
+  end
+
+  def test_loopback_bind_is_always_accepted
+    with_tmp_global_config do
+      command = Hive::Commands::Web.new
+      command.send(:refuse_unsafe_public_bind, "127.0.0.1", { "auth_mode" => "loopback", "unsafe_public_no_auth" => false })
+      command.send(:refuse_unsafe_public_bind, "::1", { "auth_mode" => "loopback", "unsafe_public_no_auth" => false })
+      command.send(:refuse_unsafe_public_bind, "localhost", { "auth_mode" => "loopback", "unsafe_public_no_auth" => false })
+    end
+  end
+
+  # ── U2: subcommand routing + status envelope ────────────────────────────
+
+  def test_unknown_subcommand_raises_usage
+    err = assert_raises(Hive::InvalidTaskPath) do
+      Hive::Commands::Web.new("bogus").call
+    end
+    assert_match(/unknown subcommand/, err.message)
+  end
+
+  def test_status_json_emits_hive_web_status_envelope
+    with_tmp_global_config do
+      command = Hive::Commands::Web.new("status", json: true)
+      out, = capture_io { command.call }
+      payload = JSON.parse(out)
+      assert_equal "hive-web-status", payload["schema"]
+      assert_equal Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"), payload["schema_version"]
+      assert_equal true, payload["ok"]
+      assert_equal false, payload["running"]
+      assert_nil payload["pid"]
+      assert payload.key?("app_dir"), "status must report the resolved app dir"
+      assert_equal "127.0.0.1", payload["bind"]
+      assert_equal 4567, payload["port"]
+    end
+  end
+
+  def test_status_bare_text_reports_not_running_and_exits_nonzero
+    with_tmp_global_config do
+      command = Hive::Commands::Web.new("status")
+      err = assert_raises(Hive::Error) do
+        capture_io { command.call }
+      end
+      assert_match(/web not running/, err.message)
+    end
+  end
   # Drive the full "app found" path with a stub Rails app: db:prepare
   # failure raises typed guidance (never a raw backtrace looping under the
   # container supervisor), and a passing prepare reaches Kernel.exec with
diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb
index 2a1e5e28f..6a5afe55e 100644
--- a/web/app/controllers/application_controller.rb
+++ b/web/app/controllers/application_controller.rb
@@ -11,7 +11,7 @@ class ApplicationController < ActionController::Base
 
   before_action :require_login
 
-  helper_method :current_login
+  helper_method :current_login, :local_auth_mode?, :container_managed?
 
   # Hive's typed errors are operator-readable by design ("task not in stage",
   # "invalid clone URL"). Render them on an error page instead of a blank
@@ -49,7 +49,34 @@ class ApplicationController < ActionController::Base
     session[:github_login]
   end
 
+  # Resolved global web config, memoized per request. The Rails tier reads the
+  # same resolved config the CLI refuses on (Hive::Config.load_global_web), so
+  # the loopback no-auth policy cannot drift between startup refusal and
+  # per-request gating.
+  def web_config
+    @web_config ||= Hive::Config.load_global_web
+  end
+
+  # True when the web tier runs in local loopback no-auth mode
+  # (`web.auth_mode: loopback`). Drives the auth-disabled banner (U6) and the
+  # require_login skip (U1).
+  def local_auth_mode?
+    Hive::Config.local_web_no_auth?(web_config)
+  end
+
+  # True when the web tier runs under the hivebox container supervisor (the
+  # daemon is Supervisor-managed, not a per-user systemd/launchd service).
+  # The supervisor sets HIVEBOX_SUPERVISOR_PID on its children; local mode
+  # starts the Rails server directly and leaves it unset.
+  def container_managed?
+    ENV["HIVEBOX_SUPERVISOR_PID"].to_i > 0
+  end
+
   def require_login
+    # Loopback no-auth mode: no GitHub owner gate at all. Still allow an
+    # explicit login (the optional owner path stays usable locally).
+    return if local_auth_mode?
+
     return redirect_to login_path unless current_login
 
     # Sessions must track the CURRENT owner, not the owner at sign-in time:
@@ -57,7 +84,7 @@ class ApplicationController < ActionController::Base
     # old sessions alive with repo-scoped credentials. The dev/test seam
     # signs in arbitrary logins, so it is exempt only where real GitHub auth
     # is (local envs).
-    auth = Hive::Web::GithubAuth.new(config: Hive::Config.load_global_web)
+    auth = Hive::Web::GithubAuth.new(config: web_config)
     return if auth.owner?(current_login)
     return if Rails.env.local? && session[:github_token].blank?
 
diff --git a/web/app/controllers/daemon_controller.rb b/web/app/controllers/daemon_controller.rb
new file mode 100644
index 000000000..e9b4da8bc
--- /dev/null
+++ b/web/app/controllers/daemon_controller.rb
@@ -0,0 +1,73 @@
+require "open3"
+require "tempfile"
+require "hive/invoked_binary"
+
+# Web repair surface for the daemon (U5). `POST /daemon/repair` runs the
+# bounded repair as a lifecycle verb — `hive daemon install --force` (rewrites
+# the unit's ExecStart to the resolved binary and restarts) then
+# `hive daemon start` — NOT through the dispatch queue, matching how the
+# task Drop/Approve mutations are in-process. CSRF-protected and
+# owner/loopback-gated like every other mutation route.
+class DaemonController < ApplicationController
+  # The repair command runner seam (mirrors SessionsController.http_client).
+  # Tests inject a recorder; production uses the default bounded subprocess.
+  class_attribute :repair_runner, default: nil
+
+  DAEMON_REPAIR_TIMEOUT_SEC = Integer(ENV.fetch("HIVEBOX_DAEMON_REPAIR_TIMEOUT_SEC", 120))
+
+  def repair
+    # The repair verb rewrites a per-user systemd/launchd unit and restarts
+    # it — lifecycle actions that only make sense in local/service-managed
+    # mode. In the Docker/hivebox path the daemon is Supervisor-managed
+    # (no per-user unit exists), so `install`/`start` can't repair it; tell
+    # the operator the real remediation instead of misfiring lifecycle verbs.
+    if container_managed?
+      redirect_to root_path,
+                  notice: "The daemon is managed by the container supervisor — restart the container to repair it."
+      return
+    end
+
+    bin = Hive::InvokedBinary.path || "hive"
+    commands = [
+      [ bin, "daemon", "install", "--force" ],
+      [ bin, "daemon", "start" ]
+    ]
+
+    if self.class.repair_runner
+      self.class.repair_runner.call(commands)
+    else
+      commands.each { |argv| bounded_run(argv) }
+    end
+
+    redirect_to root_path, notice: "Daemon repair completed"
+  end
+
+  private
+
+  # Bounded subprocess discipline shared with ReposController#clone! and
+  # TasksController#bounded_diff: own process group, hard wall-clock deadline,
+  # output to a tempfile, typed error page on timeout/failure.
+  def bounded_run(argv)
+    log = Tempfile.create("hivebox-daemon-repair")
+    pid = Process.spawn(*argv, pgroup: true, out: log.path, err: log.path)
+    deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + DAEMON_REPAIR_TIMEOUT_SEC
+    status = nil
+    loop do
+      _, status = Process.waitpid2(pid, Process::WNOHANG)
+      break if status
+
+      if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
+        Process.kill("KILL", -pid) rescue nil
+        Process.waitpid2(pid) rescue nil
+        raise Hive::Error, "daemon repair timed out after #{DAEMON_REPAIR_TIMEOUT_SEC}s"
+      end
+      sleep 0.2
+    end
+    return if status.success?
+
+    raise Hive::Error, "daemon repair failed: #{File.read(log.path).strip}"
+  ensure
+    log&.close
+    File.unlink(log.path) if log && File.exist?(log.path)
+  end
+end
diff --git a/web/app/controllers/health_controller.rb b/web/app/controllers/health_controller.rb
index cda67e408..4cd5f8436 100644
--- a/web/app/controllers/health_controller.rb
+++ b/web/app/controllers/health_controller.rb
@@ -3,29 +3,20 @@ require "hive/pid_file"
 class HealthController < ApplicationController
   skip_before_action :require_login
 
-  # Reads the daemon's pidfile the same way `hive daemon status` does —
-  # stale files and reused PIDs don't count as alive.
-  class DaemonProbe
-    include Hive::PidFile
-
-    def pid_file
-      File.join(Hive::Paths.state_home, ".daemon.pid")
-    end
-  end
-
   # `/health` is web liveness (also the supervisor/installer smoke), while
   # `/health?deep=1` is the container readiness probe: the box is only
   # useful when the daemon child is running too — a crashlooping daemon
   # must turn the container unhealthy, not sit invisible behind a green
-  # web tier.
+  # web tier. Deep health also carries the U5 binary-consistency fields so
+  # the readiness gate and the status card read the same data.
   def show
     return render json: { ok: true } unless params[:deep].present?
 
-    daemon_pid = DaemonProbe.new.read_live_pid
-    if daemon_pid
-      render json: { ok: true, daemon: { running: true, pid: daemon_pid } }
+    daemon = DaemonHealth.new.snapshot
+    if daemon[:running]
+      render json: { ok: true, daemon: daemon }
     else
-      render json: { ok: false, daemon: { running: false } }, status: :service_unavailable
+      render json: { ok: false, daemon: daemon }, status: :service_unavailable
     end
   end
 end
diff --git a/web/app/controllers/sessions_controller.rb b/web/app/controllers/sessions_controller.rb
index f1fecfd8c..769316ee5 100644
--- a/web/app/controllers/sessions_controller.rb
+++ b/web/app/controllers/sessions_controller.rb
@@ -16,6 +16,12 @@ class SessionsController < ApplicationController
   def new
     return redirect_to root_path if current_login
 
+    # Loopback no-auth mode: the first-run claim flow is not just broken — it
+    # is unnecessary. Show an explicit "auth disabled locally" notice instead
+    # of a device-flow button that can never complete (no owner gate).
+    @auth_disabled = local_auth_mode?
+    return if @auth_disabled
+
     # Surface a misconfigured box on the page itself — a sign-in button that
     # errors only after the click is a broken first-run.
     @configured = github_auth.configured?
diff --git a/web/app/controllers/status_controller.rb b/web/app/controllers/status_controller.rb
index c44440a83..0d673db5c 100644
--- a/web/app/controllers/status_controller.rb
+++ b/web/app/controllers/status_controller.rb
@@ -2,5 +2,6 @@ class StatusController < ApplicationController
   def index
     @payload = StatusBroadcaster.snapshot
     @projects = @payload.fetch("projects", [])
+    @daemon = DaemonHealth.new.snapshot
   end
 end
diff --git a/web/app/models/daemon_health.rb b/web/app/models/daemon_health.rb
new file mode 100644
index 000000000..ff4e2db99
--- /dev/null
+++ b/web/app/models/daemon_health.rb
@@ -0,0 +1,27 @@
+require "hive/pid_file"
+require "hive/daemon/consistency"
+
+# Shared daemon health snapshot for the web tier: reads the daemon's pidfile
+# (same liveness + ownership checks as `hive daemon status`) and layers the
+# U5 binary-consistency probe on top. Used by the deep health endpoint and the
+# status-page daemon card so the two surfaces can never disagree.
+class DaemonHealth
+  include Hive::PidFile
+
+  def pid_file
+    File.join(Hive::Paths.state_home, ".daemon.pid")
+  end
+
+  def snapshot
+    pid = read_live_pid
+    consistency = Hive::Daemon::Consistency.new(pid: pid).probe
+    {
+      running: !pid.nil?,
+      pid: pid,
+      binary_version: consistency["binary_version"],
+      consistent: consistency["consistent"]
+    }
+  rescue StandardError
+    { running: false, pid: nil, binary_version: Hive::VERSION, consistent: nil }
+  end
+end
diff --git a/web/app/views/layouts/application.html.erb b/web/app/views/layouts/application.html.erb
index 77e0c5f9d..2f6925156 100644
--- a/web/app/views/layouts/application.html.erb
+++ b/web/app/views/layouts/application.html.erb
@@ -24,6 +24,9 @@
     <header class="topbar">
       <div class="topbar-inner">
         <%= link_to "hivebox", root_path, class: "brand" %>
+        <% if local_auth_mode? %>
+          <span class="local-mode-badge" title="web.auth_mode: loopback — no GitHub login required on 127.0.0.1">local mode — no auth</span>
+        <% end %>
         <% if current_login %>
           <nav aria-label="Primary">
             <%= link_to "Status", root_path, class: nav_class(:status) %>
diff --git a/web/app/views/sessions/new.html.erb b/web/app/views/sessions/new.html.erb
index c83e881b9..5537ecbb9 100644
--- a/web/app/views/sessions/new.html.erb
+++ b/web/app/views/sessions/new.html.erb
@@ -1,7 +1,12 @@
 <% content_for(:title, "hivebox — sign in") %>
 <div class="card auth-card">
   <h1>hivebox</h1>
-  <% if @configured %>
+  <% if @auth_disabled %>
+    <p>Auth is disabled — this box runs in local mode
+      (<code>web.auth_mode: loopback</code>). The web UI needs no sign-in on
+      <code>127.0.0.1</code>; GitHub sign-in remains available only if you
+      switch <code>web.auth_mode</code> back to <code>owner</code>.</p>
+  <% elsif @configured %>
     <% if @claimable %>
       <p>Fresh box — the first GitHub sign-in becomes its owner.</p>
       <p class="muted">You'll get a short code to type at github.com/login/device
diff --git a/web/app/views/status/_daemon_health.html.erb b/web/app/views/status/_daemon_health.html.erb
new file mode 100644
index 000000000..a701278ed
--- /dev/null
+++ b/web/app/views/status/_daemon_health.html.erb
@@ -0,0 +1,28 @@
+<%# Daemon health card (U5/U6): running/version/consistency from the shared
+    DaemonHealth snapshot, plus a bounded Repair action that posts to the
+    lifecycle route (not the dispatch queue). %>
+<% daemon ||= @daemon %>
+<section class="project-section daemon-health" id="daemon-health">
+  <div class="project-title">
+    <h2>Daemon</h2>
+    <% if daemon[:running] %>
+      <span class="muted">v<%= daemon[:binary_version] %></span>
+    <% end %>
+  </div>
+  <div class="daemon-health-status">
+    <% if daemon[:running] %>
+      <span class="status-dot status-dot-running" title="running (pid <%= daemon[:pid] %>)"></span>
+      <span>running</span>
+      <% if daemon[:consistent] == false %>
+        <span class="daemon-health-drift" role="alert">binary/version drift detected — repair to match the CLI</span>
+      <% end %>
+    <% else %>
+      <span class="status-dot status-dot-idle"></span>
+      <span>not running</span>
+    <% end %>
+  </div>
+  <% if !container_managed? && (!daemon[:running] || daemon[:consistent] == false) %>
+    <%= button_to "Repair daemon", daemon_repair_path, class: "btn btn-sm",
+          form_class: "inline-form", data: { turbo_submits_with: "Repairing…" } %>
+  <% end %>
+</section>
diff --git a/web/app/views/status/index.html.erb b/web/app/views/status/index.html.erb
index 25bcdf793..6308337e6 100644
--- a/web/app/views/status/index.html.erb
+++ b/web/app/views/status/index.html.erb
@@ -26,6 +26,8 @@
   </aside>
   <div class="status-main">
 
+<%= render "status/daemon_health", daemon: @daemon %>
+
 <%# data-turbo-permanent: a morph must never touch the composer — it holds
     typed-but-unsent idea text and staged image attachments (Stimulus state
     the server can't re-render). %>
diff --git a/web/config/routes.rb b/web/config/routes.rb
index 659576df3..61017cf34 100644
--- a/web/config/routes.rb
+++ b/web/config/routes.rb
@@ -3,6 +3,10 @@ Rails.application.routes.draw do
   get "health" => "health#show"
   get "up" => "rails/health#show", as: :rails_health_check
 
+  # Daemon health + bounded repair (U5). The repair is a lifecycle verb,
+  # not a workflow verb — it runs in-process, CSRF-protected + auth-gated.
+  post "daemon/repair" => "daemon#repair", as: :daemon_repair
+
   # GitHub device-flow sign-in (RFC 8628). Start is a POST (it creates a
   # GitHub device code); the wait page polls the grant.
   # Development/test only: the auth seam Capybara logs in through (and dev
diff --git a/web/test/e2e/golden_path_e2e.rb b/web/test/e2e/golden_path_e2e.rb
index 5d6c83021..950354ed5 100644
--- a/web/test/e2e/golden_path_e2e.rb
+++ b/web/test/e2e/golden_path_e2e.rb
@@ -149,8 +149,47 @@ class GoldenPathE2E < ApplicationSystemTestCase
                     "the fake agent's commit must be a real commit in a real worktree"
   end
 
+  test "local loopback mode: no login, idea auto-dispatches through the daemon" do
+    # U1 / R13 local path: loopback no-auth skips the GitHub owner gate, so
+    # the status grid renders directly (no claim flow) and the daemon still
+    # auto-dispatches a dropped idea — the web↔daemon shared source of truth
+    # the definition-of-done requires, exercised WITHOUT the Docker claim flow.
+    configure_loopback_no_auth!
+
+    visit "/"
+    assert_no_current_path "/login", "loopback no-auth must not redirect to /login"
+    assert_selector "#projects", wait: 15
+
+    # --- Sample idea -------------------------------------------------------
+    fill_in "New idea", with: "Local mode sample idea"
+    find(".composer select[name='project']").find("option[value='#{@project}']").select_option
+    click_button "Add idea"
+    assert_selector ".task-row", text: "Local mode sample idea", wait: 10
+
+    # No manual claim or login: the daemon drives the task forward on its own.
+    assert_selector ".stage-badge", text: "execute", wait: 90
+    assert_selector ".task-meta", text: "Ready to open PR", wait: 90
+  end
+
   private
 
+  # Local loopback no-auth config (U1): the same block `hive setup` /
+  # `hive web install` persist. Unlike configure_owner! (Docker claimable
+  # box), this sets auth_mode=loopback + a loopback bind so require_login
+  # skips the owner gate entirely.
+  def configure_loopback_no_auth!
+    path = File.join(ENV["HIVE_HOME"], "config.yml")
+    data = File.exist?(path) ? YAML.safe_load_file(path) : {}
+    data ||= {}
+    data["web"] = {
+      "origin" => "http://127.0.0.1:4567",
+      "bind" => "127.0.0.1",
+      "auth_mode" => "loopback",
+      "github" => { "client_id" => "client" }
+    }
+    File.write(path, data.to_yaml)
+  end
+
   # The status grid is Turbo-replaced while the daemon advances tasks. Read the
   # slug from a single current-DOM query instead of retaining a Capybara element.
   def task_slug_from_grid!(title, timeout: 10)
diff --git a/web/test/integration/auth_local_mode_test.rb b/web/test/integration/auth_local_mode_test.rb
new file mode 100644
index 000000000..977203f21
--- /dev/null
+++ b/web/test/integration/auth_local_mode_test.rb
@@ -0,0 +1,62 @@
+require "test_helper"
+
+# U1 — local loopback auth policy. When `web.auth_mode: loopback` is set (the
+# mode `hive setup` / `hive web install` write for the first-class local
+# install), the web tier skips the GitHub owner gate entirely: `/` renders
+# without a login. The non-loopback half of the policy is enforced in the CLI
+# (`Hive::Commands::Web#refuse_unsafe_public_bind`), so the Rails tier only
+# needs to prove the config-derived predicate agrees.
+class AuthLocalModeTest < ActionDispatch::IntegrationTest
+  def write_loopback_config!(bind: "127.0.0.1", auth_mode: "loopback", unsafe: false)
+    path = File.join(ENV["HIVE_HOME"], "config.yml")
+    data = File.exist?(path) ? YAML.safe_load_file(path) : {}
+    data ||= {}
+    data["web"] = {
+      "bind" => bind,
+      "origin" => "http://127.0.0.1:4567",
+      "auth_mode" => auth_mode,
+      "unsafe_public_no_auth" => unsafe,
+      "github" => { "client_id" => "client" }
+    }
+    File.write(path, data.to_yaml)
+  end
+
+  test "loopback no-auth renders the status grid without login" do
+    write_loopback_config!
+    get "/"
+    assert_response :success, "loopback no-auth mode must render / without redirecting to /login"
+  end
+
+  test "loopback no-auth does not create a session" do
+    write_loopback_config!
+    get "/"
+    assert_nil session[:github_login], "no-auth mode must not fabricate a login"
+  end
+
+  test "owner mode still redirects unauthenticated requests to login" do
+    write_loopback_config!(auth_mode: "owner")
+    get "/"
+    assert_redirected_to "/login", "owner mode must keep the existing auth gate"
+  end
+
+  test "login page reflects auth disabled in loopback mode" do
+    write_loopback_config!
+    get "/login"
+    assert_response :success
+    assert_match(/auth is disabled/i, response.body)
+    assert_select "form[action='/auth/github']", 0,
+                  "the device-flow button must not render when auth is disabled locally"
+  end
+
+  test "config-derived predicate agrees with the CLI refusal guard" do
+    write_loopback_config!
+    cfg = Hive::Config.load_global_web
+    assert Hive::Config.local_web_no_auth?(cfg), "loopback config must resolve to no-auth"
+    refute Hive::Config.web_bind_loopback?("0.0.0.0"),
+           "a non-loopback bind under loopback mode is what the CLI refuses"
+
+    # The CLI guard refuses exactly when the app would otherwise run
+    # ownerless on a public interface.
+    assert Hive::Config.local_web_no_auth?(cfg) && !Hive::Config.web_bind_loopback?("0.0.0.0")
+  end
+end
diff --git a/web/test/integration/daemon_health_test.rb b/web/test/integration/daemon_health_test.rb
new file mode 100644
index 000000000..4f84a5b84
--- /dev/null
+++ b/web/test/integration/daemon_health_test.rb
@@ -0,0 +1,72 @@
+require "test_helper"
+require "hive/pid_file"
+
+class DaemonHealthTest < ActionDispatch::IntegrationTest
+  include Hive::PidFile
+
+  def pid_file
+    File.join(Hive::Paths.state_home, ".daemon.pid")
+  end
+
+  def write_live_pid!
+    FileUtils.mkdir_p(File.dirname(pid_file))
+    File.write(pid_file, pid_file_payload(Process.pid).to_yaml)
+  end
+
+  teardown do
+    DaemonController.repair_runner = nil
+    FileUtils.rm_f(pid_file)
+  end
+
+  test "deep health includes the binary-consistency fields" do
+    write_live_pid!
+    get "/health", params: { deep: "1" }
+    assert_response :success
+    daemon = response.parsed_body.dig("daemon")
+    assert_equal Process.pid, daemon.dig("pid")
+    assert daemon.key?("binary_version"), "deep health must carry the daemon binary version"
+    refute_nil daemon.dig("consistent"),
+               "deep health must carry the binary-consistency verdict (true/false)"
+  end
+
+  test "repair posts the lifecycle commands through the injected runner" do
+    sign_in!(login: "alice")
+    commands = nil
+    DaemonController.repair_runner = ->(cmds) { commands = cmds }
+
+    post "/daemon/repair"
+
+    assert_redirected_to "/"
+    assert_equal 2, commands.size
+    assert_includes commands, [ commands.first.first, "daemon", "install", "--force" ]
+    assert_includes commands, [ commands.first.first, "daemon", "start" ]
+  end
+
+  test "repair renders a typed error when the bounded subprocess fails" do
+    sign_in!(login: "alice")
+    DaemonController.repair_runner = ->(_cmds) { raise Hive::Error, "daemon repair failed: boom" }
+
+    post "/daemon/repair"
+
+    assert_response :unprocessable_entity
+    assert_match(/daemon repair failed/, response.body)
+  end
+
+  test "repair is a no-op under the container supervisor" do
+    sign_in!(login: "alice")
+    commands = nil
+    DaemonController.repair_runner = ->(cmds) { commands = cmds }
+
+    prev = ENV["HIVEBOX_SUPERVISOR_PID"]
+    ENV["HIVEBOX_SUPERVISOR_PID"] = Process.pid.to_s
+    begin
+      post "/daemon/repair"
+    ensure
+      prev.nil? ? ENV.delete("HIVEBOX_SUPERVISOR_PID") : ENV["HIVEBOX_SUPERVISOR_PID"] = prev
+    end
+
+    assert_redirected_to "/"
+    assert_nil commands, "container-managed daemons must not run local lifecycle verbs"
+    assert_match(/container supervisor/, flash[:notice])
+  end
+end
diff --git a/web/test/integration/local_mode_banner_test.rb b/web/test/integration/local_mode_banner_test.rb
new file mode 100644
index 000000000..d05491e14
--- /dev/null
+++ b/web/test/integration/local_mode_banner_test.rb
@@ -0,0 +1,37 @@
+require "test_helper"
+
+# U6 — the local-mode banner must render only in loopback no-auth mode and
+# never leak into the owner-gated (Docker/hivebox) surface.
+class LocalModeBannerTest < ActionDispatch::IntegrationTest
+  def write_web_config!(auth_mode:, owner: "alice")
+    path = File.join(ENV["HIVE_HOME"], "config.yml")
+    data = File.exist?(path) ? YAML.safe_load_file(path) : {}
+    data ||= {}
+    github = { "client_id" => "client" }
+    github["owner"] = owner unless owner.to_s.strip.empty?
+    data["web"] = {
+      "bind" => "127.0.0.1",
+      "origin" => "http://127.0.0.1:4567",
+      "auth_mode" => auth_mode,
+      "unsafe_public_no_auth" => false,
+      "github" => github
+    }
+    File.write(path, data.to_yaml)
+  end
+
+  test "loopback no-auth mode shows the local-mode badge" do
+    write_web_config!(auth_mode: "loopback", owner: "")
+    get "/"
+    assert_response :success
+    assert_match(/local mode — no auth/, response.body)
+  end
+
+  test "owner mode does not show the local-mode badge" do
+    write_web_config!(auth_mode: "owner")
+    sign_in!(login: "alice")
+    get "/"
+    assert_response :success
+    refute_match(/local mode — no auth/, response.body,
+                 "the local-mode badge must be absent from the owner-gated surface")
+  end
+end
diff --git a/web/test/system/daemon_health_card_test.rb b/web/test/system/daemon_health_card_test.rb
new file mode 100644
index 000000000..c96df5510
--- /dev/null
+++ b/web/test/system/daemon_health_card_test.rb
@@ -0,0 +1,30 @@
+require "application_system_test_case"
+
+# U6 — the status page daemon health card renders the shared DaemonHealth
+# snapshot (running/version/consistent) and its Repair button posts the
+# bounded lifecycle repair. Runs under Capybara + Playwright (chromium) in CI.
+class DaemonHealthCardTest < ApplicationSystemTestCase
+  teardown do
+    DaemonController.repair_runner = nil
+  end
+
+  test "daemon card renders not-running and repairs through the bounded runner" do
+    sign_in!
+
+    # No daemon pidfile → the card shows "not running" + a Repair button.
+    visit "/"
+    assert_selector "#daemon-health", wait: 5
+    assert_selector "#daemon-health", text: "not running", wait: 5
+
+    commands = nil
+    DaemonController.repair_runner = ->(cmds) { commands = cmds }
+
+    click_button "Repair daemon"
+
+    assert_current_path "/", wait: 5
+    assert_text "Daemon repair completed", wait: 5
+    assert_equal 2, commands.size
+    assert_includes commands, [ commands.first.first, "daemon", "install", "--force" ]
+    assert_includes commands, [ commands.first.first, "daemon", "start" ]
+  end
+end
diff --git a/wiki/commands/setup.md b/wiki/commands/setup.md
new file mode 100644
index 000000000..ccd2ebfb0
--- /dev/null
+++ b/wiki/commands/setup.md
@@ -0,0 +1,59 @@
+---
+title: hive setup
+type: command
+source: lib/hive/commands/setup.rb, lib/hive/commands/setup/{deps,web_bundle,backend_prompt}.rb, lib/hive/daemon/consistency.rb
+created: 2026-08-13
+updated: 2026-08-13
+tags: [command, setup, local, web, daemon, deps]
+---
+
+**TLDR**: `hive setup` is the one-shot provisioning + validation of the
+first-class **local** (non-Docker) install surface (Linux systemd-user /
+macOS launchd). It wires the sub-pieces in dependency order — backend
+selection → dependency check → Hive-owned deps (qmd + web bundle) → daemon
+service → project enrollment → web service — each idempotent and
+fail-fast-with-fix-command. External agent CLIs (`gh` / `claude` / `codex`)
+are **diagnosed, never installed or authenticated**: a missing/unauthed CLI
+is reported `blocked` with the exact fix command and never gates the local
+web bring-up.
+
+## CLI
+
+`hive setup [--json] [--non-interactive]`. Steps, in order:
+
+1. **Backend prompt** — `Setup::BackendPrompt` persists which agents to
+   provision globally (`agents.selected`); non-TTY (or `--non-interactive`)
+   uses the `claude, codex` defaults.
+2. **Deps** — `Setup::Deps` verifies ruby (exact 3.4), git, tmux, gh (auth),
+   claude (≥ 2.1.118), codex (≥ 0.125.0), node/npm, sqlite3. External agent
+   CLIs are diagnose-only.
+3. **Bootstrap** — `qmd` (managed npm install into `<data_home>/qmd`) and the
+   web bundle (`Setup::WebBundle`: resolve env → managed → source, else fetch
+   `v<Hive::VERSION>`, `bundle install`, `db:prepare`, and record a
+   `.hive-web-version` marker).
+4. **Daemon** — `hive daemon install --force` + `hive daemon status --json`
+   (reads the `consistent` field from the U5 binary-consistency guard).
+5. **Enrollment** — `hive init .` (or `hive daemon enable <project>` for an
+   already-registered project).
+6. **Web service** — `hive web install` + a deep health probe at
+   `http://127.0.0.1:4567/health?deep=1`.
+
+With `--json` it emits a single `hive-setup.v1` document (`ok`, `backends`,
+`deps[]`, `steps[]`, `web_url`, `warnings[]`). Success = every step passed AND
+every non-blocked dependency is ok; blocked external CLIs are warnings, not
+failures. The loopback web UI then needs no GitHub login (`web.auth_mode:
+loopback`, see [[commands/web]]).
+
+## Tests
+
+- `test/unit/commands/setup/deps_test.rb` — version parsing, gh auth
+  status, exact fix commands, blocked-vs-missing classification.
+- `test/unit/commands/setup_test.rb` — orchestration ordering with injected
+  collaborators, non-TTY defaults, blocked-vs-hard-failure split, envelope.
+- `test/unit/commands/setup/web_bundle_test.rb` — resolution order, marker
+  read/write/mismatch, fetch-failure fix command, stale re-fetch.
+- `test/integration/setup_test.rb` — real subprocess probes against stub
+  `gh`/`claude`/`codex` on PATH (read-only, never modified).
+
+Backlinks: [[commands/web]], [[commands/daemon]], [[modules/config]],
+[[decisions]], [[operating]].
diff --git a/wiki/commands/web.md b/wiki/commands/web.md
index f6f253737..68f950483 100644
--- a/wiki/commands/web.md
+++ b/wiki/commands/web.md
@@ -9,7 +9,9 @@ tags: [command, web, hivebox, rails, turbo]
 
 **TLDR**: `hive web` boots the hivebox web UI — a vanilla **Rails 8** app
 (importmap, Turbo, Stimulus, propshaft, solid_cable) living in `web/` at the
-repo root, shipped in the Docker image at `/app/web`. The web tier adds no
+repo root, shipped in the Docker image at `/app/web` OR provisioned into the
+managed `~/.local/share/hive/web` dir by `hive setup` for the first-class
+**local** (non-Docker) mode. The web tier adds no
 pipeline logic: status reads call `Hive::Commands::Status#json_payload` (via
 `Hive::Web::StatusFeed`), gate approval calls `Hive::Commands::Approve`
 in-process, task Drop calls `Hive::Commands::Drop` in-process, stage runs go
@@ -22,21 +24,35 @@ path with separate gates.
 
 ## CLI
 
-`hive web [--bind] [--port]` (defaults from the `web:` config block). The
-command locates the Rails app (`HIVEBOX_WEB_APP_DIR` override, else `web/`
-next to `lib/`), exports `SECRET_KEY_BASE` (derived from the same persisted
+`hive web [--bind] [--port]` (defaults from the `web:` config block) boots the
+server in the foreground; `hive web install|start|stop|status` manage a
+SEPARATE per-user service (systemd-user on Linux, launchd on macOS, via
+`Web::ServiceInstaller`) distinct from `hive daemon` — `status --json` emits
+`hive-web-status.v1` with `app_dir`/`bind`/`port`. The command locates the
+Rails app (`HIVEBOX_WEB_APP_DIR` override, else the managed
+`~/.local/share/hive/web` provisioned by `hive setup`, else `web/` next to
+`lib/`), exports `SECRET_KEY_BASE` (derived from the same persisted
 `Hive::Web::SessionSecret` file as before — sessions survive container
 recreation), `HIVEBOX_ORIGIN` (extra Action Cable origin allow; same-origin
 host traffic is accepted without config), and
 `HIVEBOX_STORAGE_DIR` (the solid-stack sqlite files, under
 `Hive::Paths.state_home/web-storage` so they live on the `/data` mount), runs
-`bin/rails db:prepare`, then execs `bin/rails server`. Outside the container
-or a source checkout the command exits 1 with guidance — the gem itself does
-not package the Rails app (`test/unit/gemspec_test.rb` pins that).
+`bin/rails db:prepare`, then execs `bin/rails server`. Outside the container,
+a source checkout, or a provisioned managed dir the command exits 1 with
+`run hive setup` guidance — the gem itself does not package the Rails app
+(`test/unit/gemspec_test.rb` pins that).
 
 ## Auth
 
+Two auth modes, selected by `web.auth_mode`: **owner** (default) keeps the
 GitHub **device flow** (RFC 8628, see [[decisions]] ADR-036), owner-only.
+**loopback** (written by `hive setup` / `hive web install` for local mode)
+skips the owner gate entirely on a loopback bind — `require_login` returns
+early, the login page shows an "auth disabled locally" notice, and the
+layout header shows a "local mode — no auth" badge. A non-loopback bind under
+`web.auth_mode: loopback` is REFUSED at `hive web` startup (exit 78) unless
+`web.unsafe_public_no_auth: true` is set, so an ownerless public box cannot
+come up by accident. Docker/hivebox stays on `owner` with `0.0.0.0`.
 An ownerless box is CLAIMABLE: the first successful device-flow login writes
 itself into `web.github.owner` (config-lock-guarded so concurrent first
 logins race safely; the claim is logged loudly) — the install path has no
@@ -69,7 +85,12 @@ usable. The local dev/test seam is exempt only for tokenless local sessions.
   replace/morph), composer (new idea with image attach: clipboard
   paste AND upload button; images become `[imageN]` placeholders and land in
   the task's `assets/` dir — `Commands::New`'s TUI contract), per-project
-  task rows with stage badges and liveness dots. Live-updates over **Turbo
+  task rows with stage badges and liveness dots, and a daemon health card
+  (`#daemon-health`) rendering the shared `DaemonHealth` snapshot
+  (running / `binary_version` / `consistent`) with a `POST /daemon/repair`
+  button that runs `hive daemon install --force` + `hive daemon start` as a
+  bounded lifecycle subprocess (own process group + timeout; typed error
+  page). Live-updates over **Turbo
   Streams**: `StatusBroadcaster` subscribes to `StatusFeed#each_snapshot`;
   `StatusFeed` suppresses unchanged snapshots by comparing with only
   `generated_at` and `age_seconds` removed while keeping `mtime` /
diff --git a/wiki/decisions.md b/wiki/decisions.md
index 371a107c2..951a07367 100644
--- a/wiki/decisions.md
+++ b/wiki/decisions.md
@@ -3,11 +3,26 @@ title: Architectural Decisions
 type: decisions
 source: code + author's local planning notes (not committed)
 created: 2026-04-25
-updated: 2026-06-16
+updated: 2026-08-13
 tags: [decisions, adr]
 ---
 
-**TLDR**: ADRs below were authored alongside implementation work. ADR-024 records both the PR-first workflow/stage renumbering and daemon autonomy; ADR-026 covers the Telegram bot mobile surface (subprocess caller for non-state-mutating verbs); ADR-027 records the diagnose-then-act surface for red status rows; ADR-029 records the 7-artifacts stage insertion; ADR-030 records the project-global Claude launch mode plus permission/model/effort follow-ups; **ADR-033 supersedes the subprocess-caller portion of ADR-026 for state-mutating verbs — the bot now writes file-backed dispatch requests that the daemon consumes, making the daemon the sole spawner of `hive run`-class children**; ADR-034 records Hive-owned fallback commits for successful fix-agent edits and pre-fix dirty-worktree snapshots; ADR-035 records hivebox's PTY agent-login relay for paste-back and operator-ward device flows, now also used for `gh auth login`, instead of provider-page proxying; ADR-036 records hivebox's switch to GitHub device-flow sign-in, including ownerless first-login claim (no callback URL, no client secret, no required config edit).
+**TLDR**: ADRs below were authored alongside implementation work. ADR-024 records both the PR-first workflow/stage renumbering and daemon autonomy; ADR-026 covers the Telegram bot mobile surface (subprocess caller for non-state-mutating verbs); ADR-027 records the diagnose-then-act surface for red status rows; ADR-029 records the 7-artifacts stage insertion; ADR-030 records the project-global Claude launch mode plus permission/model/effort follow-ups; **ADR-033 supersedes the subprocess-caller portion of ADR-026 for state-mutating verbs — the bot now writes file-backed dispatch requests that the daemon consumes, making the daemon the sole spawner of `hive run`-class children**; ADR-034 records Hive-owned fallback commits for successful fix-agent edits and pre-fix dirty-worktree snapshots; ADR-035 records hivebox's PTY agent-login relay for paste-back and operator-ward device flows, now also used for `gh auth login`, instead of provider-page proxying; ADR-036 records hivebox's switch to GitHub device-flow sign-in, including ownerless first-login claim (no callback URL, no client secret, no required config edit); ADR-038 records the first-class local (non-Docker) web install/run mode — loopback no-auth default plus web-bundle version pinning.
+
+## ADR-038: First-class local web install/run mode — loopback no-auth default + web-bundle version pinning
+
+**Status:** Active (introduced for the local hive web install/run mode, 2026-08-13).
+
+**Context:** The pieces for a non-Docker web UI already existed (`hive web` boots the Rails app; `hive daemon install` writes/enables a per-user unit; `Setup::BackendPrompt` was scaffolded), but nothing tied them together, nothing provisioned the web bundle on a plain `hive-cli` install, loopback auth was still gated by the GitHub owner flow, and nothing detected a daemon whose unit drifted to a stale binary. Docker/hivebox (ADR-036 owner flow, `0.0.0.0` bind) stays supported.
+
+**Decision:**
+
+1. **Local loopback auth policy.** `web.auth_mode` selects the model: `owner` (default) keeps the GitHub device-flow / owner gate; `loopback` skips the owner gate on a loopback bind (`127.0.0.0/8`, `::1`, `localhost`). A non-loopback bind under `loopback` is refused at `hive web` startup unless `web.unsafe_public_no_auth: true` is set. `owner` is the default so Docker semantics are byte-for-byte preserved; local no-auth is opt-in via `hive setup` / `hive web install`.
+2. **Web bundle version pinning.** The gem does not package the Rails app (ADR-037 stands). Local mode obtains the matching release (`v<Hive::VERSION>`), keeps only `web/`, `bundle install`s + `db:prepare`s it, and records a `.hive-web-version` marker so `hive setup` / `hive web` can warn (and re-fetch) when the managed bundle drifts from the installed gem.
+3. **Separate web service.** `hive web install|start|stop|status` manage a `Web::ServiceInstaller` service distinct from the daemon (R7 — never merged locally).
+4. **Daemon binary-consistency guard.** `hive daemon status --json` (and `/health?deep=1`) surface `binary` / `binary_version` / `consistent`, comparing the resolved CLI binary against the installed unit's ExecStart and the running process's `/proc/<pid>/exe` (+ `--version`). Repair is `hive daemon install --force`.
+
+**Consequences:** `hive setup` is the one-shot local surface. External agent CLIs (`gh`/`claude`/`codex`) are diagnosed with exact fix commands, never installed/authenticated. Docker/hivebox is unaffected (`owner` default + smoke re-run). Windows stays out of scope.
 
 ## ADR-037: Hivebox web tier is a vanilla Rails 8 + Turbo app, replacing the Sinatra tier
 
diff --git a/wiki/gaps.md b/wiki/gaps.md
index 2d71cc615..1ce805a11 100644
--- a/wiki/gaps.md
+++ b/wiki/gaps.md
@@ -47,6 +47,17 @@ Latest refresh note (2026-06-16): the babysitter gh-hostname dry-run audit remai
 
 ## Open questions about the codebase
 
+### 2026-08-13 local web live-provider smoke (R-e2e)
+
+The local web install/run mode (ADR-038) ships a CLI-level e2e scenario
+(`test/e2e/scenarios/local_web_setup.yml`) plus unit/integration coverage, but
+the full live-provider acceptance — a real authenticated `hive setup` bringing
+up `http://127.0.0.1:4567` with a real daemon dispatching a task created in
+the TUI/web and back — is NOT exercised in CI (it needs authenticated
+`claude`/`gh` and a running web server). It remains a documented manual step:
+run `hive setup`, open the web UI, create a task in the TUI, and confirm it
+appears in the web UI and dispatches automatically (and vice versa).
+
 ### 2026-06-22 dependency-stacking placeholder branch investigation
 
 Branch-creator inventory for the U1-U10 inversion dogfood found no separate
@@ -106,7 +117,7 @@ Residual audits of commits `6a6cf990`, `2d15e9ee`, and `5e8723fa` carried this b
 33. **Finalize merged-PR recovery is unit/integration-pinned but not live-smoked.** The merged-error archive recovery change routes whitelisted `8-finalize` `ERROR reason=git_status_failed` / `reason=claude_launch_failed` rows to `Hive::Daemon::PrMergeWatcher`; when GitHub reports the PR as `MERGED`, the watcher dispatches `hive archive --recover-merged-error-reason <reason>`, and `Hive::Commands::StageAction` re-confirms the current marker reason plus `Hive::Gh.pr_state(pr_url) == "MERGED"` before moving the task to `9-done`. Commit `118ed2fd` also adds an earlier `Stages::Finalize.pr_already_merged?` short-circuit: if `pr.md` points at a PR that is already `MERGED`, finalize stamps `COMPLETE pr_url=... is_draft=false merged=true` and returns `finalize_already_merged` before auth, git status, body-refresh agent spawn, or `gh pr ready`. `test/unit/daemon/pr_merge_watcher_test.rb`, `test/unit/daemon/dispatcher_test.rb`, `test/unit/gh_test.rb`, `test/integration/run_stage_action_test.rb`, and `test/integration/run_finalize_test.rb` cover the archive command generation, routing, `pr_state` success/error parsing, accept/reject boundaries, GhError fall-through, and direct already-merged finalize completion. This refresh did not find an in-tree artifact showing either live path against GitHub: a daemon observing a red finalized row after a real merge and archiving it, or a normal `hive finalize` run seeing an out-of-band merged PR and surfacing the completed task through `hive status`/TUI/bot.
 34. **Claude/tmux orphan-sweep server skip is unit-pinned but not post-fix parallel live-smoked.** Commit `024b29b0` changes `Hive::ClaudeLauncher.sweep_orphan_processes` from a blanket `pkill -f` to `pgrep` plus per-PID `TERM`, skipping matched `tmux` commands because the tmux server can retain the first session's full `new-session ... --add-dir <task.folder>` argv. `test/unit/stages/brainstorm_tmux_sentinel_test.rb` covers the observed shape: one matched tmux server line plus one matched Claude line must kill only the Claude PID and log `skipped=1`. The 2026-06-11 refreshes did not find an in-tree artifact showing two real Claude/tmux-backed Hive tasks running in parallel after the fix, one finishing, and the sibling session surviving without `tmux_session_terminated`.
 35. Hivebox web-tier residuals after the Rails rewrite (ADR-037): browser-level coverage of agents/telegram/repos pages beyond the pipeline system test (the Telegram page now has source-level integration coverage for its first-run setup guide, strict numeric chat-ID validation, and blank/@handle refusals, but no browser/Docker smoke; repos has source-level coverage for the first-run questionnaire, SSH-origin normalization, and non-directory clone-target refusal, but no live GitHub/Docker smoke; task-page red recovery now has source/Rails integration coverage and commit-message live verification, and oversized diff rendering is capped by source/Rails integration coverage, but no checked-in browser-system or Docker artifact); Action Cable behavior under many tabs; diff happy-path tests; cross-round brainstorm answer-numbering semantics (see dispatcher answer_questions); hoisting the action→verb map into the gem (duplicated in Dispatcher and bot NotificationBuilders). Commits `eb971b55`, `463fff29`, `0dea8aa6`, `d7ce55a9`, `70d60980`, `24c41980`, `b47f6627`, `9d0fc9ef`, `65e90ebe`, and `c0630426` add Playwright/system or Rails integration coverage for the task log tail's follow/pause/resume behavior, node-preserving log-frame morph reloads, artifact open-state preservation across pushed morphs, status-grid scroll plus composer draft preservation across a live broadcast, project-rail filtering with URL/composer sync, `+ Add project` routing, and re-application after a live broadcast, Telegram first-timer setup guide open-state/BotFather/userinfobot/three-step rendering and strict chat-ID validation, red-task diagnostic banner plus Retry route queueing, Q&A round replacement without permanent stale forms, finalize-first artifact ordering, chronological ordering for earlier stages, Artifacts-before-Log layout, sanitized markdown rendering, non-directory repo-target refusal, plain-vs-deep health, and bounded diff output. `StatusBroadcaster` is source/model-test pinned for self-healing after a raising broadcast, and commit `65e90ebe` moves the task-page refresh signal before the fallible grid render, but this refresh did not find a focused test or live artifact proving task pages still refresh when the projects partial itself raises. Commit `c52e4e83` styles artifact summaries as filename-tab chrome and rendered markdown as a bordered document panel, but this refresh found no screenshot or visual-regression artifact proving that distinction in a browser. Commit `279a9380` adds `web/script/record_box_demo.rb` for a staged real Rails + daemon + Playwright demo recording, and commit `c0630426` adds a real-resume helper path that reruns a stranded `3-plan` stage through the product CLI before resuming filming, but this refresh only source-inspected the recorder scripts; no checked-in `box-demo` artifact or local run evidence proves the recorder currently completes with Playwright and ffmpeg. Apart from commit `9d0fc9ef`'s live-verified stuck-review recovery note, this refresh also did not find an in-tree live Docker or long-running-agent artifact proving the same behavior against a deployed hivebox while real agents are appending logs/artifacts and status updates.
-36. **Root README/FAQ still mentions "why no built-in web UI".** The committed hivebox work touched packaging and OpenClaw/wiki docs, but the root README still points readers to a FAQ entry framed as "why no built-in web UI" and `docs/faq.md` still says a web UI would add another state surface before the file protocol is finished. This refresh did not edit user-facing README/FAQ content because the request was scoped to the LLM wiki.
+36. ~~**Root README/FAQ still mentions "why no built-in web UI".**~~ — closed 2026-08-13. The local hive web install/run mode (ADR-038) replaced the framing: `docs/faq.md` now explains the local-first web UI (with Docker/hivebox still supported), and `README.md` / `docs/getting-started.md` / `install.md` / `openclaw/skills/hive/SKILL.md` document `hive setup` and the `hive web install|start|stop|status` lifecycle plus the loopback no-auth model.
 37. **Hivebox HTTPS-origin push path is source/integration-pinned but not live-Docker-smoked.** Commit `8be458bd` added `ReposController#normalize_origin!`, a Rails integration regression proving an existing `git@github.com:` origin is rewritten to `https://github.com/...`, and a Dockerfile system credential helper for `https://github.com` via `gh auth git-credential`. This refresh did not find an in-tree artifact showing the full Dockerized path after a real Agents-page `gh` login: register/clone a repo whose `gh` config prefers SSH, open a Hive PR, and observe `5-open-pr` push succeeding over the rewritten https origin.
 38. **Hivebox Advanced Drop is source/unit/integration-pinned but not live-browser/Docker-smoked.** Commit `4a09cdb9` adds `POST /tasks/:project/:slug/drop`, `TasksController#drop`, `Hive::Web::Dispatcher#drop`, the Advanced Drop card, and tests proving the card is not a primary action, successful posts delete the task folder, and stale `from` stages return 422 without deletion. Existing `Commands::Drop` tests cover agent kill, folder/log/worktree/branch cleanup, draft-PR close, JSON/error contracts, and TUI Shift+X dispatch; commit `65e90ebe` pins the in-process return payload and the clarified `pr_closed` contract (`true` for no recorded PR, `false` only when a recorded PR could not be closed) so the web notice can stay honest. Commit `279a9380` bumps the current `hive-drop` schema to v2 while preserving v1 for pinned validators; commit `c0630426` fixes the copied v1 `$id`/title in `schemas/hive-drop.v2.json` and adds a schema-identity regression covering every exported schema file. This refresh did not find an in-tree artifact showing a real browser confirmation flow against a running hivebox instance or a Dockerized web drop that exercises full cleanup of an active worktree/branch/draft PR.
 39. **3-plan terminal-error healer requeue is unit/integration-pinned but not live-smoked.** Commit `5f7ba051` changes `Hive::Daemon::StaleAgentHealer` so `3-plan` `ERROR reason=tmux_session_terminated` / `reason=agent_orphaned` clears also write a dispatch request for `hive plan <slug> --project <project> --from 3-plan` (`requestor=healer`, `trigger=terminal_agent_loss`) and log `heal_requeued`. Commit `65e90ebe` adds the distinct `heal_requeue_failed` event when the marker clear succeeded but queue write failed, plus integration coverage proving a real status row feeds the healer and lands an allowlisted dispatch request in `Hive::Daemon::DispatchRequestQueue`. Commit `279a9380` broadens the `3-plan` requeue to every successful terminal `ERROR` clear, including elapsed `limits_reached` cooldown markers, because they leave the same markerless empty `plan.md`; `test/unit/daemon/stale_agent_healer_test.rb` pins the limits path. Commit `c0630426` bumps the dispatch-request schema to v2 so `requestor=healer` is part of the published queue contract, and queue/schema tests track the new const rather than hard-coded v1 fixtures. This refresh did not find an in-tree live artifact showing a daemon observing such a red `3-plan` row, writing the queue file, dispatching the queued rerun, and surfacing either a recovered `WAITING`/`COMPLETE` plan or a bounded red state after repeated real failures.
diff --git a/wiki/index.md b/wiki/index.md
index d59b93e36..4a50cb589 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -10,8 +10,8 @@ tags: [index, wiki]
 
 **TLDR**: Catalog of the LLM-maintained wiki for `hive`.
 
-Page count: 84
-Updated: 2026-06-25
+Page count: 85
+Updated: 2026-08-13
 
 Folder-as-agent workflow engine: a Ruby 3.4 / Thor CLI control plane where descriptor-backed workflows move task folders through filesystem stages, stage agents run via configurable AgentProfile CLIs (`claude` default, `codex`, `pi`), and `mv` between directories remains the approval primitive. The built-in `coding` workflow drives the nine-stage PR pipeline (`1-inbox` → `2-brainstorm` → `3-plan` → `4-execute` → `5-open-pr` → `6-review` → `7-artifacts` → `8-finalize` → `9-done`), while `content` and project-authored workflows share the same generic runner/status/action machinery. The public release surface is the `hive-cli` rubygem installed through Homebrew, AUR, or `install.sh`, with `hv` as the Apache Hive collision fallback entrypoint, plus the hivebox GHCR Docker image and one-command `hivecli.sh/box` shell / `hivecli.sh/box.ps1` PowerShell installers; `hive web`/hivebox, `hive init` workflow selection and normal-vs-patrol reviewer split, project-global Claude model/effort pins, `hive connect screenote` for OAuth-backed Screenote MCP uploads, `hive patrol` handoff into `6-review`, `hive babysit`, `hive bench submit` for hive-bench corpus submissions, `hive digest` for the daily shipped digest, and the single ClawHub `hive-cli` listing that installs the OpenClaw `/hive` skill are covered by dedicated command/module pages.
 
@@ -42,6 +42,7 @@ Folder-as-agent workflow engine: a Ruby 3.4 / Thor CLI control plane where descr
 - [[commands/rebase-status]] — `wiki/commands/rebase-status.md`
 - [[commands/run]] — `wiki/commands/run.md`
 - [[commands/screenote]] — `wiki/commands/screenote.md`
+- [[commands/setup]] — `wiki/commands/setup.md`
 - [[commands/stage_action]] — `wiki/commands/stage_action.md`
 - [[commands/status]] — `wiki/commands/status.md`
 - [[commands/tui]] — `wiki/commands/tui.md`
diff --git a/wiki/log.d/20260813T000000Z-local-hive-web-install.md b/wiki/log.d/20260813T000000Z-local-hive-web-install.md
new file mode 100644
index 000000000..49701f6b4
--- /dev/null
+++ b/wiki/log.d/20260813T000000Z-local-hive-web-install.md
@@ -0,0 +1,16 @@
+## [2026-08-13T00:00:00Z] feat — first-class local (non-Docker) Hive web install/run mode
+
+**Action:** Added a first-class local install/run mode for the Hive web UI alongside the existing Docker/hivebox path (which stays supported): loopback no-auth policy (`web.auth_mode`), a managed `hive web install|start|stop|status` lifecycle, a `hive setup` orchestration command, web-bundle provisioning pinned to the gem version, a daemon binary-consistency guard, and web daemon-health/repair surfaces.
+
+**Code:**
+- `lib/hive/config.rb` — `web.auth_mode` / `web.unsafe_public_no_auth` + `local_web_no_auth?` / `web_bind_loopback?` helpers.
+- `lib/hive/commands/web.rb` + `web/service_installer.rb` — lifecycle subcommands + a separate `hive-web` service (systemd/launchd).
+- `lib/hive/commands/setup.rb` + `setup/{deps,web_bundle}.rb` — one-shot provisioning; external agent CLIs are diagnose-only.
+- `lib/hive/daemon/consistency.rb` — binary/version drift probe surfaced in `hive daemon status --json` and `/health?deep=1`.
+- `web/` — loopback auth skip, local-mode banner, daemon health card + `POST /daemon/repair`.
+
+**Validation:**
+- `bundle exec rake test` (gem unit/integration; tmux/hv environmental failures are pre-existing and unchanged).
+- `web` job: `bundle exec rails test` (integration), new system test for CI.
+
+**Backlinks:** [[commands/setup]], [[commands/web]], [[commands/daemon]], [[decisions]] (ADR-038), [[operating]].
diff --git a/wiki/operating.md b/wiki/operating.md
index 2b24d46a7..5e07c70ee 100644
--- a/wiki/operating.md
+++ b/wiki/operating.md
@@ -3,15 +3,36 @@ title: Operating Hive
 type: operating
 source: README.md, bin/hv, install.sh, lib/hive/commands/daemon.rb, lib/hive/commands/babysit.rb, lib/hive/commands/bot.rb, examples/systemd/, examples/launchd/, openclaw/skills/hive/SKILL.md, openclaw/README.md
 created: 2026-05-07
-updated: 2026-06-25
-tags: [operating, daemon, bot, systemd, launchd, install]
+updated: 2026-08-13
+tags: [operating, daemon, bot, systemd, launchd, install, web]
 ---
 
-**TLDR**: Day-2 guide for running the hive daemon, experimental PR babysitter, and Telegram bot.
+**TLDR**: Day-2 guide for running the hive daemon, experimental PR babysitter, Telegram bot, and the local web UI.
 Covers install-time daemon autostart, per-project daemon/babysitter enrollment, bot token/allowlist setup,
 autostart on macOS (launchd) and Linux (systemd), dry-run shakedowns,
 log inspection, community support, and how to disable automation mid-flight.
 
+## Local web UI
+
+For a non-Docker machine, `hive setup` provisions the full local surface
+(Rails web bundle + daemon service + web service) and serves the UI at
+`http://127.0.0.1:4567`. On the loopback bind the UI needs no GitHub login
+(`web.auth_mode: loopback`); a non-loopback bind is refused unless
+`web.unsafe_public_no_auth: true`. The web service is managed SEPARATELY from
+the daemon:
+
+```bash
+hive setup                # one-shot: deps → qmd → web bundle → daemon → enroll → web
+hive web install          # write/enable the hive-web systemd/launchd unit
+hive web start            # run the server detached (pidfile <state_home>/.web.pid)
+hive web status --json    # running/pid/uptime + service install state
+```
+
+`hive daemon status --json` reports `binary` / `binary_version` /
+`consistent`; a `consistent: false` means the unit or running daemon drifted
+from the CLI binary — repair with `hive daemon install --force`. Docker/hivebox
+stays the owner-gated `0.0.0.0` path and is unchanged.
+
 ## Worktree-first workflow
 
 All new feature, bugfix, or refactor work on `hive` itself starts in
