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: ` ❯` - # 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: ` ❯`) 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 " 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 \"" + 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 \"\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 (` ❯`) 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 \"\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 (`/