diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39fa61fba..05d2740b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,17 @@ jobs: --no-document \ --source https://rubygems.org GEM_HOME="$sandbox" GEM_PATH="$sandbox" "$sandbox/bin/hive" --version + - name: Guard: every runtime script is packaged in the built gem + # Prevents the live bug where lib/**/*.rb-only globbing dropped + # lib/hive/scripts/*.sh, so a clean `gem install` shipped a launcher + # that could not find its wrapper / stop-hook. Runs the same checker + # the PR suite runs (test/integration/packaged_gem_test.rb) against + # THIS release artifact, and also asserts direct-invocation scripts + # (stop_hook.sh) stay +x. + run: | + gem_file="$(ls hive-cli-*.gem | head -n 1)" + [[ -s "$gem_file" ]] || { echo "no hive-cli-*.gem in workspace" >&2; exit 1; } + ruby packaging/check_packaged_scripts.rb "$gem_file" - uses: actions/upload-artifact@v7 with: name: hive-cli-gem diff --git a/hive.gemspec b/hive.gemspec index d1e8c7cf4..05ee399dd 100644 --- a/hive.gemspec +++ b/hive.gemspec @@ -35,6 +35,13 @@ Gem::Specification.new do |spec| "bin/hive-babysitter-stub-git", "bin/hv", "lib/**/*.rb", + # Runtime shell scripts (interactive_claude_wrapper.sh is invoked by + # ClaudeLauncher via `bash`; stop_hook.sh is invoked directly as the + # generated Stop-hook command). The `lib/**/*.rb` glob alone omits *.sh, + # so a clean `gem install` would ship a launcher that fails to find its + # wrapper. The packaging guard (test/integration/packaged_gem_test.rb) + # asserts every runtime-referenced script is present in the built gem. + "lib/hive/scripts/**/*", "templates/**/*", "schemas/**/*.json", "examples/systemd/*", diff --git a/lib/hive/claude_launcher.rb b/lib/hive/claude_launcher.rb index 742bbdc50..6579e40f2 100644 --- a/lib/hive/claude_launcher.rb +++ b/lib/hive/claude_launcher.rb @@ -8,6 +8,7 @@ require "hive/agent_limit" require "hive/config" require "hive/lock" require "hive/markers" +require "hive/packaged_scripts" require "hive/permission_scope" require "hive/stop_hook_installer" require "hive/tmux_runner" @@ -43,9 +44,11 @@ module Hive # 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. + # last glyph of its line — never mid-prose — and (b) scan only the bottom + # CLAUDE_PROMPT_TAIL_LINES non-blank lines of the input box, with no fixed + # footer offset: across builds the caret sits on the last line, one line + # up behind a one-line hint footer, or a couple of lines up behind a + # separator (2.1.179 renders separator / caret / separator / footer). # # Copy strings still gate readiness in two places, both version-coupled # and both to update when Claude Code changes them: the positive @@ -58,16 +61,32 @@ module Hive CLAUDE_PERMISSION_PROMPT_MARKER = "Do you want to".freeze CLAUDE_READY_BANNER_MARKER = "Claude Code".freeze CLAUDE_READY_FOOTER_MARKER = "for agents".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 + # The caret as the FIRST glyph (`❯ …`) or the LAST glyph (`… ? ❯`, the + # line-end caret newer builds render after the cwd/git context). The + # separator after a line-start `❯` may be a plain ASCII space OR a + # Unicode separator (Claude Code 2.1.179 renders a non-breaking space, + # U+00A0, which Ruby's `\s` does NOT match) — `\p{Zs}` covers both. + # 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❯(?:[\p{Zs}\s]|\z)|[\p{Zs}\s]❯\z/.freeze + # Numbered menu options (`❯ 1. …`). Must use the SAME separator class as + # CLAUDE_READY_PROMPT_LINE — `\s` alone misses the U+00A0 NBSP Claude Code + # 2.1.179 renders after `❯`, so an NBSP-numbered menu would match the ready + # regex yet pass this guard, misclassifying an interactive selection as the + # idle prompt (a dangerous auto-Enter false positive). + CLAUDE_MENU_OPTION_LINE = /\A[\p{Zs}\s]*❯[\p{Zs}\s]*\d+\./.freeze + # Number of non-blank lines of the input-box tail extracted for + # inspection. Slightly larger than the scan window below so the banner / + # footer markers that gate readiness stay inside the extracted region. + CLAUDE_PROMPT_CONTEXT_LINES = 12 + # Number of bottom non-blank lines of the extracted prompt region to scan + # for the idle caret. No fixed footer offset is assumed: across Claude + # Code builds the caret sits on the last line, one line up (older hint + # footer), or a couple of lines up behind a separator (2.1.179 renders + # separator / caret / separator / footer). A window of ~10 comfortably + # covers every observed shape. A caret further up with more non-blank + # lines below it is stale output, not the live prompt. + CLAUDE_PROMPT_TAIL_LINES = 10 # 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 +487,7 @@ module Hive mcp_config_path: nil, strict_mcp_config: false) command = [ "bash", - File.expand_path("scripts/interactive_claude_wrapper.sh", __dir__), + Hive::PackagedScripts.path("scripts/interactive_claude_wrapper.sh"), "--cwd", cwd ] Array(add_dirs).each { |dir| command.concat([ "--add-dir", dir ]) } @@ -616,12 +635,14 @@ module Hive 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. + # The idle caret sits at the bottom of the input box, but its exact + # offset from the box's last line varies by Claude Code build (last + # line; above a one-line hint footer; or two lines up behind separator / + # caret / separator / footer in 2.1.179). Scan the last + # CLAUDE_PROMPT_TAIL_LINES non-blank lines of the extracted region + # (no fixed footer offset). A caret with MORE non-blank output lines + # below it than the window is stale scrollback, and a numbered menu + # option (`❯ 1.`) is an interactive selection, so neither counts. current_lines.last(CLAUDE_PROMPT_TAIL_LINES).any? do |line| line.match?(CLAUDE_READY_PROMPT_LINE) && !line.match?(CLAUDE_MENU_OPTION_LINE) end diff --git a/lib/hive/packaged_scripts.rb b/lib/hive/packaged_scripts.rb new file mode 100644 index 000000000..63b815a02 --- /dev/null +++ b/lib/hive/packaged_scripts.rb @@ -0,0 +1,43 @@ +module Hive + # Single source of truth for every runtime-invoked shell script under + # lib/hive/scripts/. The gemspec packages lib/hive/scripts/**/* and the + # packaging guard (test/integration/packaged_gem_test.rb) asserts each + # entry here is present inside the ACTUALLY built gem, so a script + # referenced at runtime can never be silently omitted from a clean + # `gem install` (the historical bug where only lib/**/*.rb shipped). + # + # Add a new script that the runtime invokes by (1) placing it under + # lib/hive/scripts/, (2) listing it here, and (3) referencing it through + # Hive::PackagedScripts.path — then the built-gem guard fails loudly if it + # is not packaged, instead of breaking silently at launch time. + module PackagedScripts + # Relative paths (from lib/hive/) of every script the runtime invokes. + # Details of who references each: + # scripts/interactive_claude_wrapper.sh — ClaudeLauncher.wrapper_command + # invokes it via `bash ` (readability suffices, but it ships +x). + # scripts/stop_hook.sh — StopHookInstaller::HOOK_PATH, invoked directly + # as the generated Stop-hook command, so it must stay executable. + SCRIPTS = [ + "scripts/interactive_claude_wrapper.sh", + "scripts/stop_hook.sh" + ].freeze + + # Subset of SCRIPTS invoked directly (not via `bash