diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 39fa61fb..8295479e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,23 +24,36 @@ jobs:
           ruby-version: "3.4"
           bundler-cache: true
       - name: Build gem
+        id: build_gem
         # Hive ships as a rubygem (`hive-cli`) attached to the GitHub
         # Release; install channels (install.sh, brew, AUR) download
         # the .gem and run `gem install` on it. No native build chain
         # (tebako/dwarfs/Boost) — the user provides Ruby 3.4 already
         # because the rest of the toolchain needs it.
-        run: gem build hive.gemspec
+        run: |
+          gem build hive.gemspec
+          gem_files=(hive-cli-*.gem)
+          [[ ${#gem_files[@]} -eq 1 && -s "${gem_files[0]}" ]] || {
+            echo "expected one non-empty hive-cli-*.gem in workspace" >&2
+            exit 1
+          }
+          echo "gem_file=${gem_files[0]}" >> "$GITHUB_OUTPUT"
+      - name: Verify built gem contents
+        env:
+          GEM_FILE: ${{ steps.build_gem.outputs.gem_file }}
+        run: ruby packaging/verify_gem_contents.rb "$GEM_FILE"
       - name: Smoke test built gem
         # Confirm the gemspec is well-formed and the `hive`/`hv`
         # executables resolve before we attach the artifact to a
         # release. Installs into a throwaway $HOME-scoped gem prefix
         # so the runner's default gem env stays clean.
+        env:
+          GEM_FILE: ${{ steps.build_gem.outputs.gem_file }}
         run: |
-          gem_file="$(ls hive-cli-*.gem | head -n 1)"
-          [[ -s "$gem_file" ]] || { echo "no hive-cli-*.gem in workspace" >&2; exit 1; }
+          [[ -s "$GEM_FILE" ]] || { echo "no built hive-cli gem in workspace" >&2; exit 1; }
           sandbox="$(mktemp -d)/gem-sandbox"
           mkdir -p "$sandbox"
-          GEM_HOME="$sandbox" GEM_PATH="$sandbox" gem install "$gem_file" \
+          GEM_HOME="$sandbox" GEM_PATH="$sandbox" gem install "$GEM_FILE" \
             --install-dir "$sandbox" \
             --bindir "$sandbox/bin" \
             --no-document \
@@ -49,7 +62,7 @@ jobs:
       - uses: actions/upload-artifact@v7
         with:
           name: hive-cli-gem
-          path: hive-cli-*.gem
+          path: ${{ steps.build_gem.outputs.gem_file }}
           if-no-files-found: error
 
   install-gate:
diff --git a/docs/notes/claude-tmux-launch-mode.md b/docs/notes/claude-tmux-launch-mode.md
index 7fd1d5fd..7e0e8c92 100644
--- a/docs/notes/claude-tmux-launch-mode.md
+++ b/docs/notes/claude-tmux-launch-mode.md
@@ -17,11 +17,26 @@ until Claude's TUI is ready before pasting the stage prompt; if Claude
 shows its first-run folder-trust prompt, Hive confirms it for the task
 folder and then waits for the normal prompt.
 
-The trust and ready predicates are pinned in `Hive::ClaudeLauncher` against the
-Claude Code 2.1.133 TUI observed during the 2026-05-25 dogfood, and
-ready detection requires the prompt marker on the last non-blank pane line,
-classifies trust and permission prompts from the current prompt block instead of
-stale scrollback, and rejects numbered menu options as non-ready.
+`Hive::ClaudeLauncher` isolates the current prompt region, inspects its last
+ten nonblank lines, and finds an idle caret without assuming a fixed footer
+offset. A line-start caret accepts ASCII or Unicode separator whitespace
+(including NBSP) or end-of-line; the supported context-prefixed line-end form
+remains valid. Separator and real Claude status/hint footer chrome
+(middle-dot-separated segments ending in `← for agents`, Unicode-flexible after
+`←`) may follow a candidate, but ordinary output/progress (including prose that
+merely mentions "for agents" or an unanchored `← for agents` phrase) below it
+invalidates the candidate. Banner-scrolled captures use the same footer shape as
+the alternate presence signal. Trust, permission, numbered-menu, embedded-caret,
+and stale-scrollback protections remain rejection guards.
+
+## Runtime package guard
+
+`ClaudeLauncher::LAUNCHER_SCRIPTS` is the authoritative gem-relative registry
+for shell scripts launched by Claude tmux mode. `hive.gemspec` includes all
+`lib/hive/scripts/**/*.sh` files, and `packaging/verify_gem_contents.rb` opens
+the built `.gem` archive and requires every registered launcher script. The
+release workflow verifies the exact selected artifact before its smoke install
+and upload, matching the rubygem release contract in ADR-032.
 
 ## Scope
 
diff --git a/hive.gemspec b/hive.gemspec
index d1e8c7cf..e54a3dec 100644
--- a/hive.gemspec
+++ b/hive.gemspec
@@ -35,6 +35,7 @@ Gem::Specification.new do |spec|
     "bin/hive-babysitter-stub-git",
     "bin/hv",
     "lib/**/*.rb",
+    "lib/hive/scripts/**/*.sh",
     "templates/**/*",
     "schemas/**/*.json",
     "examples/systemd/*",
diff --git a/lib/hive/claude_launcher.rb b/lib/hive/claude_launcher.rb
index 742bbdc5..94a63f86 100644
--- a/lib/hive/claude_launcher.rb
+++ b/lib/hive/claude_launcher.rb
@@ -38,14 +38,11 @@ module Hive
     # Robustness note: readiness detection keys on the `❯` input caret, the
     # most stable signal across the Claude Code TUI revisions seen so far.
     # What churns between releases is the caret's POSITION on its line
-    # (older builds: `❯ Try …` at line start; newer: `<cwd> <git-status>  ❯`
-    # at line end) and what renders BELOW it (e.g. a `⏵⏵ bypass permissions …`
-    # hint footer, so the caret is no longer the last line). To tolerate that
-    # without treating a `❯` Claude prints in its OWN output (shell snippets,
-    # prose, bullets) as ready, we (a) require the caret to be the first or
-    # last glyph of its line — never mid-prose — and (b) only look at the
-    # bottom of the input box: the last non-blank line, or the line above it
-    # when a one-line hint footer renders beneath the caret.
+    # (older builds: `❯ Try …`, newer: `<cwd> <git-status>  ❯`) and the
+    # variable separator/footer chrome rendered below it. We inspect the
+    # bottom ten nonblank lines of the isolated current prompt region, then
+    # accept a caret only when every line below it is known chrome. That keeps
+    # output/progress below a stale caret from reading as an idle prompt.
     #
     # Copy strings still gate readiness in two places, both version-coupled
     # and both to update when Claude Code changes them: the positive
@@ -57,17 +54,31 @@ module Hive
     ].freeze
     CLAUDE_PERMISSION_PROMPT_MARKER = "Do you want to".freeze
     CLAUDE_READY_BANNER_MARKER = "Claude Code".freeze
-    CLAUDE_READY_FOOTER_MARKER = "for agents".freeze
+    # Claude Code status/hint footers are a chrome line of middle-dot-separated
+    # segments that ends with left-arrow + "for agents" (optional PR/context
+    # segments earlier). Matching an unanchored "← for agents" (or bare
+    # "for agents") is too loose: response prose such as "See docs ← for agents"
+    # or a lone "← for agents" line would count as chrome under the ten-line
+    # scan (false ready). Require the real status-bar shape: optional leading
+    # whitespace, a · separator before the arrow, Unicode-flexible space after
+    # ← (Claude may emit NBSP), and end of line.
+    CLAUDE_READY_FOOTER_LINE =
+      /\A[[:space:]\p{Zs}]*.*·[[:space:]\p{Zs}]*←[[:space:]\p{Zs}]*for agents[[:space:]\p{Zs}]*\z/u.freeze
     # The caret as the FIRST glyph (`❯ …`, older builds) or the LAST glyph
     # (`… ?  ❯`, the line-end caret newer builds render after the cwd/git
-    # context). A caret embedded mid-line is Claude's own output, not the
-    # idle prompt, so it is intentionally not matched.
-    CLAUDE_READY_PROMPT_LINE = /\A❯(?:\s|\z)|\s❯\z/.freeze
-    CLAUDE_MENU_OPTION_LINE = /\A\s*❯\s*\d+\./.freeze
-    CLAUDE_PROMPT_CONTEXT_LINES = 4
-    # Lines at the bottom of the input box to inspect for the idle caret:
-    # the caret line itself plus at most one hint footer rendered below it.
-    CLAUDE_PROMPT_TAIL_LINES = 2
+    # context). Unicode separator whitespace is intentional: Claude Code
+    # 2.1.179 emits NBSP after a line-start caret. A caret embedded mid-line
+    # is Claude's own output, not the idle prompt, so it is not matched.
+    CLAUDE_READY_PROMPT_LINE = /\A❯(?:[[:space:]\p{Zs}]|\z)|[[:space:]\p{Zs}]❯\z/u.freeze
+    CLAUDE_MENU_OPTION_LINE = /\A[[:space:]\p{Zs}]*❯[[:space:]\p{Zs}]*\d+\./u.freeze
+    CLAUDE_PROMPT_SCAN_NONBLANK_LINES = 10
+    CLAUDE_PROMPT_SEPARATOR_LINE = /\A[[:space:]─━═╌╍\-_=]+\z/u.freeze
+    # Gem-relative runtime scripts owned by this launcher. Keep every path
+    # consumed by ClaudeLauncher here so wrapper construction and package
+    # verification cannot drift through duplicate literals.
+    LAUNCHER_SCRIPTS = {
+      interactive_claude_wrapper: "lib/hive/scripts/interactive_claude_wrapper.sh"
+    }.freeze
     # Allowed-tool sets shared by every stage that spawns Claude. Keeping
     # them as constants means a policy change lands in one place; previous
     # PRs inlined the string literal across 11 sites and silently drifted
@@ -468,7 +479,7 @@ module Hive
                         mcp_config_path: nil, strict_mcp_config: false)
       command = [
         "bash",
-        File.expand_path("scripts/interactive_claude_wrapper.sh", __dir__),
+        launcher_script_path(:interactive_claude_wrapper),
         "--cwd", cwd
       ]
       Array(add_dirs).each { |dir| command.concat([ "--add-dir", dir ]) }
@@ -486,6 +497,14 @@ module Hive
       command
     end
 
+    def launcher_script_paths
+      LAUNCHER_SCRIPTS.values
+    end
+
+    def launcher_script_path(name)
+      File.expand_path(LAUNCHER_SCRIPTS.fetch(name), File.expand_path("../..", __dir__))
+    end
+
     def mcp_cli_flags(path, strict)
       return [] if path.to_s.strip.empty?
 
@@ -608,34 +627,55 @@ module Hive
     end
 
     def claude_ready_prompt?(pane)
-      current_text = current_prompt_text(pane)
-      current_lines = current_text.each_line.map(&:strip).reject(&:empty?)
+      current_lines = current_prompt_lines(pane)
+      current_text = current_lines.join("\n")
 
       return false if CLAUDE_TRUST_PROMPT_MARKERS.all? { |marker| current_text.include?(marker) }
       return false if current_text.include?(CLAUDE_PERMISSION_PROMPT_MARKER)
+      return false if current_lines.any? { |line| line.match?(CLAUDE_MENU_OPTION_LINE) }
+      # Banner may have scrolled out of the capture; the live status/hint footer
+      # is the alternate presence signal. Use the same Unicode-flexible footer
+      # shape as chrome acceptance so NBSP between ← and "for agents" does not
+      # false-not-ready when the banner is absent (KTD2).
       return false unless pane.include?(CLAUDE_READY_BANNER_MARKER) ||
-                          current_text.include?(CLAUDE_READY_FOOTER_MARKER)
-
-      # The idle caret sits at the bottom of the input box: it is the last
-      # non-blank line, or the line above it when a one-line hint footer
-      # renders beneath it. Limiting the scan to those two lines (rather than
-      # the whole region) keeps a caret Claude printed earlier in its own
-      # output from reading as ready. A numbered menu option (`❯ 1.`) is an
-      # interactive selection, not the idle prompt, so it never counts.
-      current_lines.last(CLAUDE_PROMPT_TAIL_LINES).any? do |line|
-        line.match?(CLAUDE_READY_PROMPT_LINE) && !line.match?(CLAUDE_MENU_OPTION_LINE)
+                          current_lines.any? { |line| ready_footer_line?(line) }
+
+      current_lines.each_index.reverse_each do |index|
+        next unless current_lines[index].match?(CLAUDE_READY_PROMPT_LINE)
+
+        return true if ready_prompt_suffix?(current_lines[(index + 1)..])
       end
+
+      false
     end
 
     def current_prompt_text(pane)
-      raw_lines = pane.each_line.map(&:strip)
-      last_blank_index = raw_lines.rindex("")
+      current_prompt_lines(pane).join("\n")
+    end
+
+    def current_prompt_lines(pane)
+      raw_lines = pane.each_line.map(&:chomp)
+      last_blank_index = raw_lines.rindex { |line| prompt_blank_line?(line) }
       last_banner_index = raw_lines.rindex { |line| line.include?(CLAUDE_READY_BANNER_MARKER) }
       last_blank_start = last_blank_index ? last_blank_index + 1 : nil
       start_index = [ last_banner_index, last_blank_start, 0 ].compact.max
       current_lines = raw_lines[start_index..] || []
 
-      current_lines.reject(&:empty?).last(CLAUDE_PROMPT_CONTEXT_LINES).join("\n")
+      current_lines.reject { |line| prompt_blank_line?(line) }.last(CLAUDE_PROMPT_SCAN_NONBLANK_LINES)
+    end
+
+    def prompt_blank_line?(line)
+      line.match?(/\A[[:space:]\p{Zs}]*\z/u)
+    end
+
+    def ready_footer_line?(line)
+      line.match?(CLAUDE_READY_FOOTER_LINE)
+    end
+
+    def ready_prompt_suffix?(lines)
+      Array(lines).all? do |line|
+        line.match?(CLAUDE_PROMPT_SEPARATOR_LINE) || ready_footer_line?(line)
+      end
     end
 
     def wait_for_terminal_marker(task, runner, timeout)
diff --git a/packaging/verify_gem_contents.rb b/packaging/verify_gem_contents.rb
new file mode 100644
index 00000000..05aefb7e
--- /dev/null
+++ b/packaging/verify_gem_contents.rb
@@ -0,0 +1,53 @@
+#!/usr/bin/env ruby
+
+require "rubygems/package"
+
+$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
+
+require "hive"
+require "hive/claude_launcher"
+
+module Hive
+  module Packaging
+    module GemContentsVerifier
+      module_function
+
+      def run(argv, out: $stdout, err: $stderr)
+        if argv.length != 1
+          err.puts "usage: verify_gem_contents.rb GEM_FILE"
+          return false
+        end
+
+        verify(argv.fetch(0), out: out, err: err)
+      end
+
+      def verify(gem_path, expected_paths: Hive::ClaudeLauncher.launcher_script_paths,
+                 entries: nil, out: $stdout, err: $stderr)
+        unless readable_gem_artifact?(gem_path)
+          err.puts "gem artifact is missing, empty, or unreadable: #{gem_path}"
+          return false
+        end
+
+        entry_paths = entries || Gem::Package.new(gem_path).contents
+        missing_paths = expected_paths - entry_paths
+        unless missing_paths.empty?
+          err.puts "gem artifact is missing Claude launcher scripts:"
+          missing_paths.each { |path| err.puts "  #{path}" }
+          return false
+        end
+
+        out.puts "verified #{gem_path}: #{expected_paths.length} Claude launcher script(s) present"
+        true
+      rescue StandardError => e
+        err.puts "could not inspect gem artifact #{gem_path}: #{e.class}: #{e.message}"
+        false
+      end
+
+      def readable_gem_artifact?(gem_path)
+        gem_path.is_a?(String) && File.file?(gem_path) && File.size?(gem_path) && File.readable?(gem_path)
+      end
+    end
+  end
+end
+
+exit(Hive::Packaging::GemContentsVerifier.run(ARGV) ? 0 : 1) if $PROGRAM_NAME == __FILE__
diff --git a/test/fixtures/claude_panes/menu_selection.txt b/test/fixtures/claude_panes/menu_selection.txt
new file mode 100644
index 00000000..def889a4
--- /dev/null
+++ b/test/fixtures/claude_panes/menu_selection.txt
@@ -0,0 +1,5 @@
+Claude Code v2.1.179
+Choose an option
+❯ 1. Continue
+────────────────────────────────────────────────────────────────────────
+⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents
diff --git a/test/fixtures/claude_panes/permission_prompt.txt b/test/fixtures/claude_panes/permission_prompt.txt
new file mode 100644
index 00000000..726114b5
--- /dev/null
+++ b/test/fixtures/claude_panes/permission_prompt.txt
@@ -0,0 +1,5 @@
+Claude Code v2.1.179
+Do you want to make this edit?
+❯ 1. Yes
+────────────────────────────────────────────────────────────────────────
+⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents
diff --git a/test/fixtures/claude_panes/ready_2_1_133.txt b/test/fixtures/claude_panes/ready_2_1_133.txt
new file mode 100644
index 00000000..e18606ee
--- /dev/null
+++ b/test/fixtures/claude_panes/ready_2_1_133.txt
@@ -0,0 +1,4 @@
+Claude Code v2.1.133
+Tip: try refactor
+
+❯ Try "refactor <filepath>"
diff --git a/test/fixtures/claude_panes/ready_2_1_179_nbsp.txt b/test/fixtures/claude_panes/ready_2_1_179_nbsp.txt
new file mode 100644
index 00000000..839c46d7
--- /dev/null
+++ b/test/fixtures/claude_panes/ready_2_1_179_nbsp.txt
@@ -0,0 +1,5 @@
+Claude Code v2.1.179
+────────────────────────────────────────────────────────────────────────
+❯ 
+────────────────────────────────────────────────────────────────────────
+⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents
diff --git a/test/fixtures/claude_panes/trust_prompt.txt b/test/fixtures/claude_panes/trust_prompt.txt
new file mode 100644
index 00000000..fcb82417
--- /dev/null
+++ b/test/fixtures/claude_panes/trust_prompt.txt
@@ -0,0 +1,6 @@
+Claude Code v2.1.179
+Quick safety check
+❯ 1. Yes, I trust this folder
+Enter to confirm
+────────────────────────────────────────────────────────────────────────
+⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents
diff --git a/test/unit/claude_launcher_test.rb b/test/unit/claude_launcher_test.rb
index e9c71b65..32121bcb 100644
--- a/test/unit/claude_launcher_test.rb
+++ b/test/unit/claude_launcher_test.rb
@@ -501,12 +501,31 @@ class ClaudeLauncherTest < Minitest::Test
     end
   end
 
-  def test_claude_ready_prompt_accepts_observed_prompt_on_last_nonblank_line
-    pane = "Claude Code v2.1.133\nTip: try refactor\n\n❯ Try \"refactor <filepath>\""
+  def test_claude_ready_prompt_accepts_2_1_133_fixture
+    pane = claude_pane_fixture("ready_2_1_133.txt")
 
     assert Hive::ClaudeLauncher.claude_ready_prompt?(pane)
   end
 
+  def test_claude_ready_prompt_accepts_2_1_179_nbsp_fixture
+    pane = claude_pane_fixture("ready_2_1_179_nbsp.txt")
+
+    assert Hive::ClaudeLauncher.claude_ready_prompt?(pane),
+           "the idle NBSP caret remains ready above variable separator/footer chrome"
+  end
+
+  def test_claude_ready_prompt_scans_the_last_ten_nonblank_prompt_lines
+    pane = [
+      "Claude Code v2.1.179",
+      "❯ ",
+      *Array.new(7, "────────────────────────────────────────────────────────────────────────"),
+      "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents"
+    ].join("\n")
+
+    assert Hive::ClaudeLauncher.claude_ready_prompt?(pane),
+           "known prompt chrome may follow the caret anywhere in the ten-line scan window"
+  end
+
   def test_claude_ready_prompt_rejects_stale_prompt_marker_in_scrollback
     pane = "Claude Code v2.1.133\n❯ Try \"refactor <filepath>\"\n\nbackground indexing update"
 
@@ -534,6 +553,32 @@ class ClaudeLauncherTest < Minitest::Test
     refute Hive::ClaudeLauncher.claude_ready_prompt?(pane)
   end
 
+  def test_claude_ready_prompt_rejects_active_prompt_fixtures
+    {
+      "permission_prompt.txt" => "permission prompt",
+      "trust_prompt.txt" => "trust prompt",
+      "menu_selection.txt" => "numbered menu"
+    }.each do |name, label|
+      refute Hive::ClaudeLauncher.claude_ready_prompt?(claude_pane_fixture(name)),
+             "#{label} must not be classified as an idle prompt"
+    end
+  end
+
+  def test_claude_ready_prompt_accepts_unicode_separator_whitespace_after_line_start_caret
+    [ " ", "\u00a0", "\u2007" ].each do |space|
+      pane = "Claude Code v2.1.179\n❯#{space}Type a request"
+
+      assert Hive::ClaudeLauncher.claude_ready_prompt?(pane),
+             "#{space.inspect} must be accepted after a line-start caret"
+    end
+  end
+
+  def test_claude_ready_prompt_rejects_line_start_caret_followed_by_non_whitespace_text
+    pane = "Claude Code v2.1.179\n❯Type a request"
+
+    refute Hive::ClaudeLauncher.claude_ready_prompt?(pane)
+  end
+
   # 2026-05-27 Claude Code build: the input caret moved to the END of a
   # context-prefixed line (`<cwd> <git-status>  ❯`) and a hint footer renders
   # BENEATH it, so the caret is no longer the last line. The previous
@@ -576,9 +621,10 @@ class ClaudeLauncherTest < Minitest::Test
            "a caret embedded mid-line is Claude's own output, not the idle prompt"
   end
 
-  # The idle caret is at the BOTTOM of the input box. A caret with two or
-  # more non-footer lines below it is stale output, not the live prompt, so
-  # restricting the scan to the last two lines must exclude it.
+  # The idle caret is at the BOTTOM of the input box. A caret with ordinary
+  # response/progress lines below it is stale output, not the live prompt.
+  # ready_prompt_suffix? only accepts separator/footer chrome in the ten-line
+  # nonblank scan window, so non-chrome lines below a candidate reject it.
   def test_claude_ready_prompt_rejects_caret_above_the_input_box_tail
     pane = "Claude Code v2.1.133\n\n❯ Try \"refactor <filepath>\"\n" \
            "running build step 1\nrunning build step 2"
@@ -587,6 +633,45 @@ class ClaudeLauncherTest < Minitest::Test
            "a caret with non-footer output below it is not the live idle prompt"
   end
 
+  # Response prose may mention "for agents" without being the Claude Code
+  # hint footer. A loose substring match would treat that prose as chrome and
+  # classify the pane ready (KTD3 false-ready under the ten-line scan).
+  def test_claude_ready_prompt_rejects_prose_containing_for_agents
+    pane = "Claude Code v2.1.179\n❯ npm test\nThis helps for agents"
+
+    refute Hive::ClaudeLauncher.claude_ready_prompt?(pane),
+           "ordinary output containing 'for agents' is not footer chrome"
+  end
+
+  # Unanchored "← for agents" still matches response prose. Footer chrome must
+  # be the real status-bar shape (· before ←, end-anchored), not any line that
+  # merely contains the arrow phrase.
+  def test_claude_ready_prompt_rejects_prose_containing_arrow_for_agents
+    pane = "Claude Code v2.1.179\n❯ npm test\nSee docs ← for agents"
+
+    refute Hive::ClaudeLauncher.claude_ready_prompt?(pane),
+           "prose containing '← for agents' is not the Claude status/hint footer"
+  end
+
+  # Separator + bare arrow phrase is still not the real middle-dot status bar.
+  def test_claude_ready_prompt_rejects_separator_and_bare_arrow_for_agents
+    pane = "Claude Code v2.1.179\n❯ npm test\n---\n← for agents"
+
+    refute Hive::ClaudeLauncher.claude_ready_prompt?(pane),
+           "separator plus bare '← for agents' must not count as ready chrome"
+  end
+
+  # Banner-scrolled presence gate must use the same Unicode-flexible footer
+  # shape as chrome acceptance (NBSP between ← and "for agents").
+  def test_claude_ready_prompt_accepts_banner_scrolled_footer_with_nbsp
+    pane = "?────────────────────────────────────────────────────────────────────────\n" \
+           "  hive-patrol-command-bin-hive-babysitter-stub-gh-5031d524 hive-patrol/command-bin-hive-babysitter-stub-gh-5031d524  ❯\n" \
+           "  ⏵⏵ bypass permissions on (shift+tab to cycle) · PR #316 · ←\u00a0for agents"
+
+    assert Hive::ClaudeLauncher.claude_ready_prompt?(pane),
+           "banner-scrolled ready footer with NBSP after ← must still read ready"
+  end
+
   # A bare caret line is a legitimate idle prompt; lock it as intentional.
   def test_claude_ready_prompt_accepts_bare_caret
     pane = "Claude Code v2.1.133\n\n❯"
@@ -826,6 +911,35 @@ class ClaudeLauncherTest < Minitest::Test
     File.read(File.expand_path("../fixtures/panes/#{name}", __dir__))
   end
 
+  def claude_pane_fixture(name)
+    File.read(File.expand_path("../fixtures/claude_panes/#{name}", __dir__))
+  end
+
+  def unregistered_launcher_script_literals(source)
+    gem_relative = source.scan(%r{["'](lib/hive/scripts/[^"']+\.sh)["']}).flatten
+    # Historical bypasses under lib/hive that resolve to the same gem-relative
+    # path as LAUNCHER_SCRIPTS without writing the lib/hive/scripts/… string.
+    dir_expand = source.scan(%r{File\.expand_path\(\s*["'](scripts/[^"']+\.sh)["']\s*,\s*__dir__\s*\)}).flatten
+                      .map { |rel| "lib/hive/#{rel}" }
+    dir_join = source.scan(%r{File\.join\(\s*__dir__\s*,\s*["'](scripts/[^"']+\.sh)["']\s*\)}).flatten
+                     .map { |rel| "lib/hive/#{rel}" }
+    pathname_join = source.scan(
+      %r{Pathname(?:\.new)?\(\s*__dir__\s*\)\.join\(\s*["'](scripts/[^"']+\.sh)["']\s*\)}
+    ).flatten.map { |rel| "lib/hive/#{rel}" }
+    # %W/%w arrays can smuggle script paths as bare words (quoted or not).
+    word_array_paths = source.scan(/%[Ww][\[(]([^\]\)]*)[\])]/).flat_map do |body|
+      body[0].to_s.scan(%r{(?:lib/hive/)?scripts/[^\s"'\\]+\.sh})
+    end.map { |path| path.start_with?("lib/") ? path : "lib/hive/#{path}" }
+
+    (gem_relative + dir_expand + dir_join + pathname_join + word_array_paths).uniq -
+      Hive::ClaudeLauncher.launcher_script_paths
+  end
+
+  def assert_launcher_script_literals_registered(source)
+    assert_empty unregistered_launcher_script_literals(source),
+                 "ClaudeLauncher scripts must be declared in LAUNCHER_SCRIPTS"
+  end
+
   # Unify on the UnboundMethod capture+rebind stub pattern used in
   # brainstorm_tmux_sentinel_test.rb (Q1 / pr-test-analyzer #9). The
   # earlier `define_singleton_method` lambda-rebind approach could
@@ -874,6 +988,38 @@ class ClaudeLauncherTest < Minitest::Test
     assert_equal %w[--effort medium], command.each_cons(2).find { |a, _| a == "--effort" }
   end
 
+  def test_wrapper_command_resolves_the_registered_script_from_this_checkout
+    profile = Hive::AgentProfiles.lookup(:claude)
+    command = Hive::ClaudeLauncher.send(
+      :wrapper_command,
+      cwd: "/tmp", add_dirs: [ "/project" ], profile: profile,
+      permission_mode: "bypassPermissions"
+    )
+
+    assert_equal "bash", command.first
+    assert_equal Hive::ClaudeLauncher.launcher_script_path(:interactive_claude_wrapper), command[1]
+    assert_path_exists command[1]
+    assert_equal [ "--cwd", "/tmp", "--add-dir", "/project" ], command[2, 4]
+  end
+
+  def test_launcher_script_literals_must_be_registered
+    source = File.read(File.expand_path("../../lib/hive/claude_launcher.rb", __dir__))
+
+    assert_launcher_script_literals_registered(source)
+    {
+      "gem-relative string" => "\"lib/hive/scripts/unregistered.sh\"",
+      "File.expand_path" => "File.expand_path(\"scripts/unregistered_wrapper.sh\", __dir__)",
+      "File.join" => "File.join(__dir__, \"scripts/unregistered_join.sh\")",
+      "Pathname.join" => "Pathname(__dir__).join(\"scripts/unregistered_pathname.sh\")",
+      "%W word array" => "%W[scripts/unregistered_word.sh]",
+      "%w lib-relative word" => "%w[lib/hive/scripts/unregistered_word2.sh]"
+    }.each do |label, snippet|
+      assert_raises(Minitest::Assertion, "#{label} bypass must fail the registry guard") do
+        assert_launcher_script_literals_registered("#{source}\n#{snippet}")
+      end
+    end
+  end
+
   def test_wrapper_command_carries_mcp_config_flags_before_allowed_tools
     profile = Hive::AgentProfiles.lookup(:claude)
     command = Hive::ClaudeLauncher.send(
diff --git a/test/unit/gemspec_test.rb b/test/unit/gemspec_test.rb
index c4cd96dd..dbe3c494 100644
--- a/test/unit/gemspec_test.rb
+++ b/test/unit/gemspec_test.rb
@@ -1,6 +1,15 @@
 require "test_helper"
+require "hive/claude_launcher"
+require "open3"
+require "rbconfig"
+require "rubygems/package"
+require "stringio"
+require "tmpdir"
+require_relative "../../packaging/verify_gem_contents"
 
 class GemspecTest < Minitest::Test
+  include HiveTestHelper
+
   GEMSPEC_PATH = File.expand_path("../../hive.gemspec", __dir__)
 
   def test_gem_package_includes_babysitter_dry_run_stubs
@@ -10,6 +19,100 @@ class GemspecTest < Minitest::Test
     assert_includes spec.files, "bin/hive-babysitter-stub-git"
   end
 
+  def test_gem_package_includes_every_registered_claude_launcher_script
+    spec = Gem::Specification.load(GEMSPEC_PATH)
+
+    assert_launcher_scripts_packaged(spec.files)
+  end
+
+  def test_gem_package_guard_detects_an_omitted_registered_launcher_script
+    assert_raises(Minitest::Assertion) { assert_launcher_scripts_packaged([]) }
+  end
+
+  def test_built_gem_contains_every_registered_claude_launcher_script
+    with_built_gem do |gem_path|
+      out = StringIO.new
+      err = StringIO.new
+
+      assert Hive::Packaging::GemContentsVerifier.verify(gem_path, out: out, err: err)
+      assert_match(/verified .* launcher script/, out.string)
+      assert_empty err.string
+    end
+  end
+
+  def test_gem_contents_verifier_command_requires_one_artifact_and_verifies_it
+    err = StringIO.new
+    refute Hive::Packaging::GemContentsVerifier.run([], out: StringIO.new, err: err)
+    assert_match(/usage:/, err.string)
+
+    with_built_gem do |gem_path|
+      assert Hive::Packaging::GemContentsVerifier.run([ gem_path ], out: StringIO.new, err: StringIO.new)
+    end
+  end
+
+  def test_gem_contents_verifier_script_verifies_a_built_artifact
+    with_built_gem do |gem_path|
+      out, err, status = Open3.capture3(
+        RbConfig.ruby,
+        File.expand_path("../../packaging/verify_gem_contents.rb", __dir__),
+        gem_path
+      )
+
+      assert_predicate status, :success?
+      assert_match(/verified .* launcher script/, out)
+      assert_empty err
+    end
+  end
+
+  def test_gem_contents_verifier_reports_each_missing_launcher_script
+    with_built_gem do |gem_path|
+      expected_paths = Hive::ClaudeLauncher.launcher_script_paths + [ "lib/hive/scripts/future_launcher.sh" ]
+      entries = Gem::Package.new(gem_path).contents - Hive::ClaudeLauncher.launcher_script_paths
+      err = StringIO.new
+
+      refute Hive::Packaging::GemContentsVerifier.verify(
+        gem_path,
+        entries: entries,
+        expected_paths: expected_paths,
+        out: StringIO.new,
+        err: err
+      )
+
+      expected_paths.each { |path| assert_includes err.string, path }
+    end
+  end
+
+  def test_gem_contents_verifier_rejects_missing_empty_and_unreadable_artifacts
+    Dir.mktmpdir("hive-gem") do |dir|
+      missing_path = File.join(dir, "missing.gem")
+      empty_path = File.join(dir, "empty.gem")
+      File.binwrite(empty_path, "")
+
+      [ missing_path, empty_path ].each do |path|
+        refute Hive::Packaging::GemContentsVerifier.verify(path, out: StringIO.new, err: StringIO.new)
+      end
+
+      corrupt_path = File.join(dir, "corrupt.gem")
+      File.binwrite(corrupt_path, "not a gem archive")
+      refute Hive::Packaging::GemContentsVerifier.verify(corrupt_path, out: StringIO.new, err: StringIO.new)
+
+      with_built_gem do |gem_path|
+        with_replaced_singleton_method(File, :readable?, ->(_path) { false }) do
+          refute Hive::Packaging::GemContentsVerifier.verify(gem_path, out: StringIO.new, err: StringIO.new)
+        end
+      end
+    end
+  end
+
+  def test_release_workflow_verifies_the_selected_gem_before_smoke_and_upload
+    workflow = File.read(File.expand_path("../../.github/workflows/release.yml", __dir__))
+
+    assert_includes workflow, 'ruby packaging/verify_gem_contents.rb "$GEM_FILE"'
+    assert_includes workflow, "GEM_FILE: ${{ steps.build_gem.outputs.gem_file }}"
+    assert_operator workflow.index("Verify built gem contents"), :<, workflow.index("Smoke test built gem")
+    assert_operator workflow.index("Smoke test built gem"), :<, workflow.index("actions/upload-artifact@v7")
+  end
+
   def test_gem_executables_exclude_bash_hv_launcher
     spec = Gem::Specification.load(GEMSPEC_PATH)
 
@@ -29,4 +132,24 @@ class GemspecTest < Minitest::Test
     refute spec.files.any? { |f| f.start_with?("public/") },
            "no Sinatra-era static assets should be packaged"
   end
+
+  private
+
+  def assert_launcher_scripts_packaged(files)
+    assert_empty missing_launcher_scripts(files),
+                 "gemspec must include every ClaudeLauncher script"
+  end
+
+  def missing_launcher_scripts(files)
+    Hive::ClaudeLauncher.launcher_script_paths - files
+  end
+
+  def with_built_gem
+    Dir.mktmpdir("hive-gem") do |dir|
+      spec = Gem::Specification.load(GEMSPEC_PATH)
+      gem_path = File.join(dir, spec.file_name)
+      Gem::Package.build(spec, false, false, gem_path)
+      yield gem_path
+    end
+  end
 end
diff --git a/wiki/log.d/20260724T120000Z-fix-claude-tmux-ready-detector.md b/wiki/log.d/20260724T120000Z-fix-claude-tmux-ready-detector.md
new file mode 100644
index 00000000..34029d02
--- /dev/null
+++ b/wiki/log.d/20260724T120000Z-fix-claude-tmux-ready-detector.md
@@ -0,0 +1,53 @@
+---
+title: Claude tmux readiness and gem wrapper package guard
+date: 2026-07-24
+pages: [modules/agent, stages/brainstorm, testing]
+---
+
+**Action:** Updated the Claude tmux operational contract after the ready-prompt
+and package fixes. `Hive::ClaudeLauncher` now scans the last ten nonblank lines
+of the isolated prompt region, accepts Unicode separator whitespace after a
+line-start caret (including NBSP), permits variable separator/footer chrome,
+and retains trust, permission, numbered-menu, embedded-caret, and stale-output
+rejection. Footer chrome is the real Claude hint line matching
+`← for agents` (not the bare phrase `for agents`), so response prose cannot
+false-ready under the ten-line scan. Checked-in hand-authored fixtures cover
+Claude Code 2.1.179, the prior ready shape, and active permission/trust/menu
+panes.
+
+The launcher-owned shell wrapper is declared through `LAUNCHER_SCRIPTS`; the
+gemspec includes `lib/hive/scripts/**/*.sh`; and the built-gem verifier opens
+the archive before the release workflow smoke-installs and uploads that exact
+artifact. This extends the single-rubygem release contract in [[decisions]]
+ADR-032 without changing a version, tag, daemon service, or publication path.
+
+**Validation prerequisite:** Record the invoked Hive/Claude binary versions,
+the daemon service `ExecStart`, and the running daemon binary/version before a
+live Claude tmux smoke. Treat absent service tooling or a daemon/CLI mismatch
+as a separate environment finding; do not edit a service unit in this change.
+
+**Observed isolated-install validation (review fix pass):**
+
+| Item | Result |
+|---|---|
+| Built gem archive verifier | Pass (`hive-cli-0.3.2.gem`, registered wrapper present) |
+| Isolated `GEM_HOME` install | Pass — `hive --version` → `0.3.2` |
+| `wrapper_command` resolves under isolated gem | Pass — `…/gems/hive-cli-0.3.2/lib/hive/scripts/interactive_claude_wrapper.sh` |
+| `hive doctor` (isolated binary + CE plugin + user-local tmux + qmd) | Pass — all rows green (`claude/tmux`, `wiki/qmd`, `/ce-brainstorm`, `/plan`) |
+| Claude binary | `/usr/local/bin/claude` **2.1.191** |
+| tmux | User-local AppImage-extracted **3.3a** on `PATH` (host package absent; no root) |
+| systemctl / daemon `ExecStart` | Unavailable on this host (no systemctl) |
+| Ambient hive | `/usr/local/bin/hive` also reports **0.3.2** (same version string; prefer isolated path for smoke) |
+| Reproduced task folder `2-brainstorm/add-local-hive-web-install-260629-f4ca` | Absent on this host |
+| Live Claude tmux → `WAITING` (AE5) | **Blocked:** interactive Claude in tmux shows first-run theme wizard / not logged in (`claude -p` → "Not logged in · Please run /login"). No OAuth credentials in the validation environment, so a full brainstorm cannot reach idle ready or `WAITING`. Packaging and doctor gates above are not blocked by that auth gap. |
+
+**Earlier validation note (U4):** The first isolated-install attempt on this
+worktree lacked tmux/systemctl/CE plugin paths; those tool gaps were closed in
+the review fix pass for doctor/wrapper checks. The remaining AE5 gap is Claude
+operator auth, not missing gem contents or readiness fixtures.
+
+**Refreshed pages:**
+
+- [[modules/agent]]
+- [[stages/brainstorm]]
+- [[testing]]
diff --git a/wiki/log.d/20260724T150000Z-claude-ready-footer-shape.md b/wiki/log.d/20260724T150000Z-claude-ready-footer-shape.md
new file mode 100644
index 00000000..ef4aad9b
--- /dev/null
+++ b/wiki/log.d/20260724T150000Z-claude-ready-footer-shape.md
@@ -0,0 +1,15 @@
+## [2026-07-24T15:00:00Z] claude-launcher - tighten ready footer chrome shape
+
+**Action:** Pass-2 review fix for Claude tmux readiness. Footer chrome now requires
+the real Claude status/hint bar shape (middle-dot-separated segments ending in
+`← for agents` with Unicode-flexible space after `←`) so unanchored prose such as
+`See docs ← for agents` or separator + bare `← for agents` cannot false-ready under
+the ten-line scan. Banner-scrolled presence uses the same matcher (fixes
+false-not-ready when NBSP appears between `←` and `for agents`). Extended the
+`LAUNCHER_SCRIPTS` registry-convention guard to `File.join(__dir__, …)`,
+`Pathname(__dir__).join(…)`, and `%W`/`%w` word-array bypasses.
+
+**Pages:**
+- [[modules/agent]] — footer shape + banner-scrolled presence
+- [[stages/brainstorm]] — readiness predicate notes
+- [[testing]] — fixture/scan contract wording
diff --git a/wiki/modules/agent.md b/wiki/modules/agent.md
index 2538ebb3..83eb70f6 100644
--- a/wiki/modules/agent.md
+++ b/wiki/modules/agent.md
@@ -3,7 +3,7 @@ title: Hive::Agent
 type: module
 source: lib/hive/agent.rb, lib/hive/agent_limit.rb, lib/hive/claude_launcher.rb, lib/hive/scripts/interactive_claude_wrapper.sh
 created: 2026-04-25
-updated: 2026-06-21
+updated: 2026-07-24
 tags: [agent, claude, subprocess]
 ---
 
@@ -120,6 +120,27 @@ value (fresh init offers `low`, `medium`, and `high`).
 tmux-backed Claude sessions, and the shell wrapper forwards `--model` and
 `--effort` without shell re-parsing.
 
+## Claude tmux readiness and packaged wrapper
+
+`Hive::ClaudeLauncher` isolates the current prompt region and examines its last
+ten nonblank lines for an idle caret. The line-start form accepts ASCII or
+Unicode separator whitespace (including NBSP) and end-of-line; the existing
+context-prefixed line-end form remains valid. Separator lines and the real
+Claude status/hint footer (middle-dot-separated segments ending in
+`← for agents`, with Unicode-flexible space after `←`) may trail the caret at a
+variable distance; ordinary output below a candidate (including prose that
+merely contains "for agents" or an unanchored `← for agents` phrase) invalidates
+it. When the `Claude Code` banner has scrolled out of the capture, the same
+footer shape is the alternate presence signal. Trust, permission, numbered-menu,
+embedded-caret, and stale-output guards still reject unsafe panes.
+
+`LAUNCHER_SCRIPTS` is the launcher-owned, gem-relative shell-script registry.
+`wrapper_command` resolves through it, `hive.gemspec` includes
+`lib/hive/scripts/**/*.sh`, and `packaging/verify_gem_contents.rb` checks the
+actual gem archive. The release workflow uses that verifier on the same
+artifact it smoke-installs and uploads, preserving ADR-032's single-rubygem
+release contract.
+
 ## `spawn_and_wait` (the long part)
 
 1. Open a logfile (`<task.log_dir>/<label>-<UTC-ts>.log`), append a `[hive] <ts> spawn cwd=… cmd=…` line.
diff --git a/wiki/stages/brainstorm.md b/wiki/stages/brainstorm.md
index 87991c5d..7894a51f 100644
--- a/wiki/stages/brainstorm.md
+++ b/wiki/stages/brainstorm.md
@@ -3,7 +3,7 @@ title: 2-brainstorm stage
 type: stage
 source: lib/hive/stages/brainstorm.rb, lib/hive/stages/brainstorm_tmux.rb, lib/hive/tmux_runner.rb, templates/brainstorm_prompt.md.erb
 created: 2026-04-25
-updated: 2026-06-16
+updated: 2026-07-24
 tags: [stage, brainstorm, qa, tmux]
 ---
 
@@ -15,7 +15,7 @@ tags: [stage, brainstorm, qa, tmux]
 - **Prompt**: `templates/brainstorm_prompt.md.erb`, rendered with `project_name`, `task_folder`, `idea_text`. Idea text is wrapped in `<user_supplied content_type="idea_text">…</user_supplied>` per the prompt-injection boundary policy.
 - **Agent invocation**: `cwd = task.folder`, `--add-dir <task.folder>`, `log_label = "brainstorm"`. For `claude.mode: tmux`, `Hive::ClaudeLauncher` starts Claude through `interactive_claude_wrapper.sh`, unsets API-key env vars, maps `claude.permission_mode` (default `bypassPermissions`) to the same flags the headless path uses (`bypassPermissions` → `--dangerously-skip-permissions`, otherwise `--permission-mode <mode>`) plus `--allowedTools Read,Write,Edit,LS`, waits for the TUI prompt, and then asks `Hive::TmuxRunner` to paste the rendered prompt and submit only after the pane tail has settled.
 - **Tmux readiness env vars**: `Hive::ClaudeLauncher` owns `HIVE_CLAUDE_TMUX_*` readiness settings. `SESSION_READY`, `PID_READY`, and `CLAUDE_READY` inherit `HIVE_CLAUDE_TMUX_READY_WAIT_TIMEOUT_SEC` when their specific env var is unset; `CLAUDE_READY` otherwise keeps a 120s bare default for slow Claude TUI startup. Legacy `HIVE_BRAINSTORM_TMUX_*` names remain fallback inputs during the migration window.
-- **Claude TUI readiness predicates**: Trust and ready strings are named constants in `Hive::ClaudeLauncher`, pinned to the Claude Code 2.1.133 TUI observed during the 2026-05-25 tmux dogfood. `claude_ready_prompt?` accepts the idle caret at the bottom of the input box with the current Claude footer, including the patrol-observed case where the captured pane tail has scrolled the `Claude Code` banner out but still shows `... PR #N ... for agents`; it classifies trust and permission prompts from the current prompt block instead of stale scrollback, and rejects numbered menu options as non-ready.
+- **Claude TUI readiness predicates**: `claude_ready_prompt?` isolates the current prompt block and scans its last ten nonblank lines, so the idle caret can appear above variable separator/footer chrome. Its line-start form accepts ASCII or Unicode separator whitespace (including Claude Code 2.1.179's NBSP) or end-of-line, and the context-prefixed line-end form remains supported. Known footer chrome matches the real Claude status/hint bar (middle-dot-separated segments ending in `← for agents`, Unicode-flexible after `←`) rather than bare `for agents` or an unanchored arrow phrase; ordinary output/progress below a candidate, active trust/permission prompts, numbered menus, embedded carets, and stale scrollback are rejected. This keeps the patrol-observed banner-scrolled/footer-present shape valid (including NBSP after `←`) without treating an interactive menu as ready.
 - **Tmux submit/session failures**: `Hive::TmuxRunner#send_prompt` loads and pastes the prompt through a tmux buffer, waits until the pane tail is identical across two consecutive captures (`PROMPT_SETTLE_STABLE_POLLS`) or the bounded `HIVE_TMUX_PROMPT_SETTLE_TIMEOUT_SEC` deadline passes, then sends one explicit Enter. `HIVE_TMUX_PROMPT_SUBMIT_DELAY_SEC` is the poll interval. This avoids the old large-paste race where Enter could fire while a multi-chunk prompt was still rendering and be swallowed by the input box. If tmux disappears before that Enter submit or a tmux command exceeds `HIVE_TMUX_COMMAND_TIMEOUT_SEC`, the typed tmux error propagates immediately. During marker polling, `Hive::ClaudeLauncher.wait_for_terminal_marker` also checks that the tmux session still exists while `brainstorm.md` is stuck on `AGENT_WORKING`; a disappeared session stamps `ERROR reason=tmux_session_terminated` instead of waiting for the full brainstorm timeout.
 - **Tmux cleanup/orphan sweep**: cleanup is shared in `Hive::ClaudeLauncher`. After `/quit` and `kill_session`, the launcher sweeps leftover Claude processes by this task's `--add-dir <task.folder>` while skipping matched `tmux` commands and logging killed/skipped entries to `claude-tmux-orphan-sweep.log`. The skip is load-bearing because the tmux server can keep the first session's full argv and would otherwise match the task-specific sweep pattern.
 - **Profile**: `Hive::Stages::Base.stage_profile(cfg, "brainstorm")` — reads `cfg.dig("brainstorm", "agent")` with `|| "claude"` fallback so legacy configs keep working. Spawn pins `status_mode: :state_file_marker` regardless of profile, because brainstorm's lifecycle contract is the WAITING/COMPLETE marker the agent writes to `brainstorm.md` — codex's profile default `:output_file_exists` would never satisfy that.
diff --git a/wiki/testing.md b/wiki/testing.md
index 259e6e7f..26e1cebb 100644
--- a/wiki/testing.md
+++ b/wiki/testing.md
@@ -3,7 +3,7 @@ title: Testing
 type: reference
 source: test/, Rakefile, bin/hive-eval, .rubocop.yml, .github/workflows/ci.yml, .github/workflows/release.yml, config/brakeman.ignore
 created: 2026-04-25
-updated: 2026-06-25
+updated: 2026-07-24
 tags: [test, minitest, fixtures]
 ---
 
@@ -80,7 +80,7 @@ task default: :test
 | `digest/window_test.rb`, `digest/ship_times_test.rb`, `digest/collector_test.rb` | Digest collection primitives — local-date helpers, git-log ship-time preference (`pr_finalized`, `archived`, approval into `9-done`), registered-project grouping, missing artifact tolerance, and local timezone boundaries. |
 | `digest/categorizer_test.rb`, `digest/renderer_test.rb`, `digest/run_test.rb`, `digest/sender_test.rb` | Digest generation/delivery — model JSON mapping and fallbacks, prompt rendering with PR bodies, Telegram MarkdownV2 escaping/category ordering, empty/success/failed-notice orchestration, dry-run token bypass, chat-id resolution, and Telegram send arguments through an injected client. These are unit seams; no real agent or Telegram Bot API call is exercised. |
 | `daemon/digest_scheduler_test.rb` | `Hive::Daemon::DigestScheduler` — first-run no-history guard, local-midnight due calculation, one-day-at-a-time catch-up, catch-up cap logging, non-zero retry behavior, disabled mode, and DST local-date handling. |
-| `claude_launcher_test.rb` | `Hive::ClaudeLauncher` — headless/tmux delegation, readiness deadlines, prompt submission, pane logging, tmux-session loss before terminal markers and expected-output waits, tight provider-limit menu classification from checked-in pane fixtures, threaded `limit_text` on wait errors, quoted-limit false-positive rejection, signal cleanup, and wrapper argv policy including model/effort pins. |
+| `claude_launcher_test.rb` | `Hive::ClaudeLauncher` — headless/tmux delegation, readiness deadlines, prompt submission, pane logging, a ten-nonblank-line Unicode-aware idle-caret detector (including Claude Code 2.1.179 NBSP/separator/footer fixtures), trust/permission/menu and stale-output rejection, tmux-session loss before terminal markers and expected-output waits, tight provider-limit menu classification from checked-in pane fixtures, threaded `limit_text` on wait errors, quoted-limit false-positive rejection, signal cleanup, and wrapper argv policy including model/effort pins. |
 | `commands/run_test.rb`, `stages/agent_test.rb`, `stages/resolver_test.rb` | Descriptor-backed runner dispatch — `Run#pick_runner` passing `task.workflow`, generic `kind: :agent` prompt rendering, prior-artifact nonce wrapping, marker-to-action mapping, spawn kwargs, coding-name bespoke runner precedence, generic non-coding fallback, `StageError` fallback, and lazy require behavior. |
 | `task_action_test.rb`, `task_action_generic_test.rb`, `daemon/policy_test.rb` | Status action classification and daemon decision coverage — coding action/command invariants, coding action golden matrix, descriptor-generic marker classification, generic `hive approve ... --from <stage>` and `hive run` command shape, and `ready_to_advance` policy dispatch/block/skip behavior. |
 | `stages/brainstorm_tmux_sentinel_test.rb` | Claude/tmux sentinel and cleanup behavior — readiness/sentinel delegation, pgrep pattern shape, missing/failing pgrep logging, oversized orphan-sweep log rotation, and the v0.2.3 invariant that a task cleanup kills matched Claude PIDs individually while skipping a matched tmux server. |
@@ -93,7 +93,7 @@ task default: :test
 | `daemon/status_consumer_test.rb` | `Hive::Daemon::StatusConsumer` — `hive status --json` envelope parsing, schema-version skew handling, strict `live_task_lock` coercion, legacy project filtering, and local `state_file` mtime re-stat so daemon edit-resume decisions keep subsecond precision even though public JSON timestamps are whole-second ISO8601. |
 | `daemon/stale_agent_healer_test.rb` | `Hive::Daemon::StaleAgentHealer` — stale `AGENT_WORKING` healing, wedged `REVIEW_WORKING` lock cleanup, and bounded daemon auto-recovery for `review_agent_died`, reviewer partial failures caused only by Claude/tmux expected-output session death, fix-phase `fix_failed` only when the message is the known Claude stop-hook completion failure, `8-finalize` `ERROR reason=unpushed_commits`, elapsed `limits_reached` cooldown markers (including terminal `ERROR reason=limits_reached` on `4-execute`), and non-review terminal agent-loss errors (`2-brainstorm`, `3-plan`, `4-execute`, `7-artifacts`, `8-finalize` `ERROR reason=tmux_session_terminated` or `reason=agent_orphaned`). Terminal-error coverage pins marker-id guarded clears, live-lock skips, manual repository-state skips, shared budgets across fresh marker ids, per-task budget isolation, pre-clear dispatch-baseline seeding, the `3-plan` `hive plan ... --from 3-plan` dispatch-request requeue / `heal_requeued` trace for both agent-loss and `limits_reached` clears, the distinct `heal_requeue_failed` event when enqueueing fails after a successful clear, one-shot `marker_heal_exhausted` logging, and the load-bearing AgentLimit wire-message → held review marker → cooldown-boundary assertion. |
 | `hv_test.rb` | `bin/hv` — refuses unsafe Apache Hive fallback paths (`/usr/bin/hive`, `/opt/hive/bin/hive`) and verifies `HIVE_BIN_OVERRIDE` can point at a custom Hive CLI install path. |
-| `gemspec_test.rb`, `install_script_test.rb` | RubyGem/install packaging — `hv` stays out of `spec.executables` so RubyGems does not create a broken Ruby binstub for the bash launcher; the bash installer writes its own `hv` wrapper and does not expect a gem-installed `hv` shim. |
+| `gemspec_test.rb`, `install_script_test.rb` | RubyGem/install packaging — `ClaudeLauncher::LAUNCHER_SCRIPTS` must appear in the gemspec source list and in a freshly built archive verified by `packaging/verify_gem_contents.rb`; `hv` stays out of `spec.executables` so RubyGems does not create a broken Ruby binstub for the bash launcher; the bash installer writes its own `hv` wrapper and does not expect a gem-installed `hv` shim. |
 | `babysitter/dry_run_env_test.rb` | `Hive::Babysitter::DryRunEnv` plus `bin/hive-babysitter-stub-git` / `bin/hive-babysitter-stub-gh` — PATH overlay wrapper handoff, relative PATH real-binary canonicalization, non-absolute `HIVE_BABYSITTER_REAL_*` refusal, command-local `HIVE_BABYSITTER_REAL_*` and `HIVE_BABYSITTER_DRY_RUN_LOG` override resistance, recording fake binaries pinned to the current test runner Ruby so git-stub PATH pinning cannot switch fixture interpreters, default-deny skips, read-only passthrough, argv-wide and positional `gh` host-override skips, `GH_HOST` / `GH_REPO` / enterprise-token env scrubbing, `gh` config env scrubbing plus fresh empty `HOME`/`GH_CONFIG_DIR` passthrough roots, `gh api` implicit-POST payload flag blocking plus explicit-GET file/cache guards, host-default non-token `gh auth status` passthrough with token-display and hostname skips, browser-launch flag skips plus `w` inside value-taking `gh` read-option values, git executable/write-option skips, `remote show` without `-n` skipping before repo-configured transport helpers can run, exact read-only `git branch` forms and mixed branch mutation skips, env config/command seam skips including `GIT_EXEC_PATH`, `GIT_ASKPASS`, and `SSH_ASKPASS`, hermetic HOME/XDG/local git config passthrough guards, `--textconv` abbreviation, `cat-file --filters`, and git signature-verification skips, subcommand `-p` passthrough, `grep`/`ls-files` read-option exceptions, grep pager `--open-files-in-pager` abbreviations and `-O` forms including clustered `-nO<cmd>`, value-taking grep short options such as `-eTODO` / `-fNEEDLEFILE.txt`, pathspec separator handling, symlinked skip-log refusal, FIFO skip-log refusal through a timeout-bounded stub capture, and ASCII control-character escaping in skip logs/stderr, and invalid/non-UTF-8 `git`/`gh` argv skip-log regressions. |
 | `bot/router_test.rb`, `bot/slash_handlers_test.rb`, `bot/supervisor_test.rb`, `bot/status_watcher_test.rb`, `bot/format_test.rb`, `bot/notification_builders_test.rb`, `bot/notification_dispatcher_test.rb` | Telegram bot slash/menu/status/notification surface — router classification for supported slash commands including first-contact `/start`, `SlashHandlers#start` welcome copy with concrete next steps, the `setMyCommands` quick-actions list (`/idea`, `/status`, `/queue`, `/answer`, `/approve`, `/autofix`, `/details`, `/done`, `/help`), `StatusWatcher` parsing of status rows including id/display name/`pr_url`, HTML escaping and PR-link formatting, `/status` and `/queue` PR-link rows, ready-for-review push enrichment, parse-mode forwarding, fingerprint stability, live `agent_running` stale-marker notification suppression, and recovery confirmation holds while a retry lock is active. |
 | `openclaw_skills_test.rb` | OpenClaw skill metadata — only the umbrella `hive-cli` listing is published, setup stays visible before the CLI is installed, `/hive` common paths include `wiki compile-log --check`, fragment-first changelog guidance is present, destructive/foreground admin commands require confirmation, and README publish instructions avoid shortcut listings. |
@@ -225,6 +225,16 @@ The live Telegram bot E2E wrapper lives at `test/e2e/tg/run_idea_e2e.sh` and is
 
 The global `claude.mode: tmux` path was manually dogfooded on 2026-05-25 in a disposable git project with a temporary `HIVE_HOME` and private `HIVE_TMUX_SOCKET`. The run used Claude Code 2.1.133 and tmux 3.6a.
 
+Fixture coverage now also records the Claude Code 2.1.179 NBSP caret with
+separator/footer chrome. Footer chrome is the real status/hint bar shape
+(middle-dot segments ending in `← for agents`, Unicode-flexible after `←`) so
+ordinary response text — including bare `for agents` or unanchored
+`← for agents` prose — cannot false-ready under the ten-line scan. Banner-scrolled
+presence uses the same matcher. A live tmux validation must record the
+exact Hive and Claude binaries plus the daemon service `ExecStart`/running
+binary before attributing a result to this detector; a service-binary mismatch
+is a separate daemon recovery finding, not a reason to alter this launch path.
+
 Run shape:
 
 - `hive init .` in non-TTY mode rendered `claude.mode: tmux`.
