diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39fa61fb..453f8c53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,12 @@ jobs: --bindir "$sandbox/bin" \ --no-document \ --source https://rubygems.org + GEM_HOME="$sandbox" GEM_PATH="$sandbox" ruby -e ' + require "hive" + require "hive/claude_launcher" + missing = Hive::ClaudeLauncher::LAUNCHER_SCRIPTS.each_value.reject { |path| File.file?(path) } + abort "installed hive-cli gem is missing launcher scripts: #{missing.join(", ")}" unless missing.empty? + ' GEM_HOME="$sandbox" GEM_PATH="$sandbox" "$sandbox/bin/hive" --version - uses: actions/upload-artifact@v7 with: @@ -87,6 +93,12 @@ jobs: --bindir "$sandbox/bin" \ --no-document \ --source https://rubygems.org + GEM_HOME="$sandbox" GEM_PATH="$sandbox" ruby -e ' + require "hive" + require "hive/claude_launcher" + missing = Hive::ClaudeLauncher::LAUNCHER_SCRIPTS.each_value.reject { |path| File.file?(path) } + abort "installed hive-cli gem is missing launcher scripts: #{missing.join(", ")}" unless missing.empty? + ' GEM_HOME="$sandbox" GEM_PATH="$sandbox" "$sandbox/bin/hive" --version release-finalize: diff --git a/docs/notes/claude-tmux-launch-mode.md b/docs/notes/claude-tmux-launch-mode.md index 7fd1d5fd..39d8203a 100644 --- a/docs/notes/claude-tmux-launch-mode.md +++ b/docs/notes/claude-tmux-launch-mode.md @@ -17,11 +17,25 @@ 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` treats the idle `❯` as the positive readiness signal, +not a version-pinned footer layout. It captures pane history without byte +slicing for trust and permission guards, then searches only the last 10 nonblank current +prompt lines for the caret. It accepts a bare line-start caret, including ASCII or +Unicode separator whitespace such as U+00A0 NBSP, the legacy quoted +`❯ Try "…"` prompt, +and the context-prefixed line-end caret form. Other textual line-start forms, +such as streamed `❯ npm test` output, are not prompts. The detector requires +the usual Claude banner/current-footer evidence, rejects numbered menu options +under the same Unicode whitespace policy, and only allows recognized input-box +separator/footer chrome after a candidate. A bare caret with trailing chrome +also requires an opening separator immediately above it; the legacy +`Claude Code` / `❯ Try …` / footer layout remains valid without that opening +separator. A context-prefixed line-end caret with trailing chrome requires the +recognized `for agents` footer. A later banner starts a new safety block only +after a numbered safety menu in the preceding block, so internal blank lines, +long previews, or banner-shaped preview text cannot hide an active dialog. +This accepts the hand-authored Claude Code 2.1.179 +separator/caret/separator/footer fixture without assuming a fixed footer depth. ## Scope @@ -60,6 +74,17 @@ and the watchdog keeps waiting. This preserves the manual-intervention model: a human may type in the attached pane, but completion still requires Claude to write the expected terminal marker. +## Packaged Runtime Scripts + +The interactive wrapper and Stop hook are runtime files, not source-checkout +helpers. `hive.gemspec` packages every `lib/hive/scripts/**/*.sh` file; the +launcher resolves its direct scripts through `LAUNCHER_SCRIPTS`; and tests +inspect a genuinely built gem archive. The release workflow installs the built +gem into an isolated gem home and checks every launcher-manifest script is a +regular installed file before it uploads or publishes the artifact. This keeps +the wrapper and hook available to a clean `gem install hive-cli` without a +manual copy from the source tree. + The wrapper resolves `claude.permission_mode` (default `bypassPermissions`) to the same CLI flags the headless `-p` path uses: `bypassPermissions` becomes `--dangerously-skip-permissions`, and any other mode becomes @@ -137,3 +162,46 @@ the runtime can override them in the calling shell: Legacy `HIVE_BRAINSTORM_TMUX_*` names are still honored as fallbacks for one release while projects migrate from the old brainstorm-only runtime. + +## Clean-install validation + +The live Claude smoke remains opt-in because it requires the operator's Claude +login, tmux, and Compound Engineering plugin. Build and install the gem into a +temporary gem home, record the selected executable and version, then run the +same installed binary for `hive doctor` and the reproduced stage: + +```sh +repo_root=$(pwd) +sandbox=$(mktemp -d)/gem-sandbox +mkdir -p "$sandbox" +gem build hive.gemspec --output "$sandbox/hive-cli-test.gem" +GEM_HOME="$sandbox" GEM_PATH="$sandbox" gem install "$sandbox/hive-cli-test.gem" \ + --install-dir "$sandbox" --bindir "$sandbox/bin" --no-document +GEM_HOME="$sandbox" GEM_PATH="$sandbox" "$sandbox/bin/hive" --version +GEM_HOME="$sandbox" GEM_PATH="$sandbox" "$sandbox/bin/hive" doctor +GEM_HOME="$sandbox" GEM_PATH="$sandbox" "$sandbox/bin/hive" run \ + "$repo_root/.hive-state/stages/2-brainstorm/add-local-hive-web-install-260629-f4ca" +``` + +The expected live result is prompt submission followed by `WAITING` or another +terminal marker, with `hive doctor` green for Claude/tmux and the installed +Compound Engineering plugin. If `command -v hive` resolves to `/usr/bin/hive` +instead of the temporary binary (or reports another version), record that +binary drift; daemon/service executable selection is a separate fix and is not +changed by this launch-mode work. + +### Latest validation record (2026-07-15, incomplete) + +- A clean temporary install selected + `/tmp/hive-clean-install-pass02.cTFt21/bin/hive`, reported version `0.3.2`, + and resolved `interactive_claude_wrapper.sh` from that install's gem tree. +- The same installed binary's `hive doctor --json` exited `65`: this runner has + no `tmux`, Compound Engineering Claude plugin, or `/plan` skill. +- `claude --version` reported `2.1.191`, but `claude auth status` reported + `loggedIn: false`; no API or OAuth credential was available to the wrapper. + +This is packaging evidence, not the required live acceptance. No stage prompt +was submitted and no terminal marker or no-manual-copy result was observed. +Repeat the commands above on a logged-in host with tmux and the configured +skills installed, then replace this incomplete record with the selected binary, +version, green doctor output, submitted prompt, and resulting terminal marker. 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..d1b0acd4 100644 --- a/lib/hive/claude_launcher.rb +++ b/lib/hive/claude_launcher.rb @@ -38,14 +38,14 @@ 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 input + # chrome below it. Scan a bounded current-prompt window instead of fixing + # the caret to a footer offset, but only accept separator/footer chrome + # after a candidate so Claude's own busy output stays non-ready. Bare + # carets with trailing chrome require the opening input separator, while + # the legacy quoted `❯ Try "…"` suggestion remains valid with or + # without a footer. Other textual line-start carets are output, not idle + # prompts. # # Copy strings still gate readiness in two places, both version-coupled # and both to update when Claude Code changes them: the positive @@ -57,17 +57,23 @@ module Hive ].freeze CLAUDE_PERMISSION_PROMPT_MARKER = "Do you want to".freeze CLAUDE_READY_BANNER_MARKER = "Claude Code".freeze + CLAUDE_READY_PLAIN_BANNER_LINE = /\AClaude Code(?:[[:space:]\p{Zs}]+v?\d+(?:\.\d+)*)?\z/.freeze + CLAUDE_READY_LOGO_BANNER_LINE = /\A▐▛███▜▌[[:space:]\p{Zs}]+Claude Code(?:[[:space:]\p{Zs}]+v?\d+(?:\.\d+)*)?\z/.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 + # Ruby's `\s` does not include every Unicode separator Claude can render. + # Keep U+00A0 NBSP and the rest of Unicode Zs explicit in both the idle + # caret and numbered-menu matchers so a menu cannot bypass the guard. + CLAUDE_READY_PROMPT_LINE = /\A❯(?:[[:space:]\p{Zs}]|\z)|[[:space:]\p{Zs}]❯\z/.freeze + CLAUDE_MENU_OPTION_LINE = /\A[[:space:]\p{Zs}]*❯[[:space:]\p{Zs}]*\d+\./.freeze + CLAUDE_TEXTUAL_LINE_START_PROMPT = /\A❯[[:space:]\p{Zs}]+[^[:space:]\p{Zs}]/.freeze + CLAUDE_LEGACY_TEXTUAL_PROMPT_LINE = /\A❯[[:space:]\p{Zs}]+Try[[:space:]\p{Zs}]+"[^"]+"\z/.freeze + CLAUDE_PROMPT_SCAN_LINES = 10 + CLAUDE_INPUT_SEPARATOR_LINE = /\A[[:space:]\p{Zs}─━┄┅┈┉╌╍═╭╮╰╯│]+\z/.freeze + CLAUDE_INPUT_FOOTER_LINE = /\A(?:⏵⏵|[←↵⇥⌘]).*for agents.*\z/.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 @@ -82,6 +88,12 @@ module Hive PLANNER_ALLOWED_TOOLS = "Read,Write,Edit,LS".freeze IMPLEMENTER_ALLOWED_TOOLS = "Read,Write,Edit,Bash,LS,Glob,Grep".freeze DEFAULT_ALLOWED_TOOLS = PLANNER_ALLOWED_TOOLS + # Keep every script launched directly by this module in one enumerable + # manifest. Packaging checks consume this same source of truth, so adding + # another launcher script cannot quietly leave its runtime file behind. + LAUNCHER_SCRIPTS = { + interactive_wrapper: File.expand_path("scripts/interactive_claude_wrapper.sh", __dir__) + }.freeze ORPHAN_SWEEP_LOG_MAX_BYTES = 64 * 1024 # Patterns that mark tmux itself as unavailable (binary missing, too @@ -468,7 +480,7 @@ module Hive mcp_config_path: nil, strict_mcp_config: false) command = [ "bash", - File.expand_path("scripts/interactive_claude_wrapper.sh", __dir__), + LAUNCHER_SCRIPTS.fetch(:interactive_wrapper), "--cwd", cwd ] Array(add_dirs).each { |dir| command.concat([ "--add-dir", dir ]) } @@ -561,7 +573,7 @@ module Hive deadline = [ ready_deadline, caller_deadline ].compact.min last_tail = "" loop do - last_tail = runner.capture_pane_tail(bytes: SENTINEL_CAPTURE_BYTES) + last_tail = capture_readiness_pane(runner) if claude_trust_prompt?(last_tail) runner.send_keys("Enter") @@ -602,40 +614,94 @@ module Hive end def claude_trust_prompt?(pane) - current_prompt_text(pane).then do |text| + current_claude_screen_text(pane).then do |text| CLAUDE_TRUST_PROMPT_MARKERS.all? { |marker| text.include?(marker) } end 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") + safety_text = current_claude_screen_text(pane) - 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 CLAUDE_TRUST_PROMPT_MARKERS.all? { |marker| safety_text.include?(marker) } + return false if safety_text.include?(CLAUDE_PERMISSION_PROMPT_MARKER) 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) + # Keep trust and permission guards over the screen from the latest + # banner-shaped line, then scan only the last ten nonblank lines of the + # current prompt for the idle caret. + # A numbered menu option (`❯ 1.`) is interactive, not idle, and all + # lines after an idle candidate must be TUI chrome rather than Claude's + # substantive output. + scan_lines = current_lines.last(CLAUDE_PROMPT_SCAN_LINES) + scan_start = current_lines.length - scan_lines.length + scan_lines.each_index.any? do |index| + line = scan_lines.fetch(index) + current_index = scan_start + index + tail = scan_lines[(index + 1)..] + line.match?(CLAUDE_READY_PROMPT_LINE) && + !line.match?(CLAUDE_MENU_OPTION_LINE) && + idle_prompt_head?(current_lines[...current_index], line, tail) && + idle_prompt_tail?(tail) end end - def current_prompt_text(pane) + def current_claude_screen_text(pane) + lines = pane.each_line.map(&:strip) + boundary_index = 0 + lines.each_index do |index| + next unless claude_banner_line?(lines.fetch(index)) + + preceding_block = lines[boundary_index...index] + safety_copy = preceding_block.any? { |line| line.include?(CLAUDE_PERMISSION_PROMPT_MARKER) } || + CLAUDE_TRUST_PROMPT_MARKERS.all? do |marker| + preceding_block.any? { |line| line.include?(marker) } + end + completed_menu = preceding_block.any? { |line| line.match?(CLAUDE_MENU_OPTION_LINE) } + boundary_index = index unless safety_copy && !completed_menu + end + + (lines[boundary_index..] || []).join("\n") + end + + def current_prompt_lines(pane) raw_lines = pane.each_line.map(&:strip) last_blank_index = raw_lines.rindex("") - last_banner_index = raw_lines.rindex { |line| line.include?(CLAUDE_READY_BANNER_MARKER) } + last_banner_index = raw_lines.rindex { |line| claude_banner_line?(line) } 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(&:empty?) + end + + def claude_banner_line?(line) + line.match?(CLAUDE_READY_LOGO_BANNER_LINE) || line.match?(CLAUDE_READY_PLAIN_BANNER_LINE) + end + + def idle_prompt_tail?(lines) + Array(lines).all? do |line| + line.match?(CLAUDE_INPUT_SEPARATOR_LINE) || line.match?(CLAUDE_INPUT_FOOTER_LINE) + end + end + + def idle_prompt_head?(lines, candidate, tail) + if candidate.match?(CLAUDE_TEXTUAL_LINE_START_PROMPT) + return candidate.match?(CLAUDE_LEGACY_TEXTUAL_PROMPT_LINE) + end + + return true if tail.empty? + return tail.any? { |line| line.match?(CLAUDE_INPUT_FOOTER_LINE) } unless candidate.start_with?("❯") + + lines.last&.match?(CLAUDE_INPUT_SEPARATOR_LINE) + end + + def capture_readiness_pane(runner) + return runner.capture_pane if runner.respond_to?(:capture_pane) + + runner.capture_pane_tail(bytes: SENTINEL_CAPTURE_BYTES) end def wait_for_terminal_marker(task, runner, timeout) @@ -760,7 +826,7 @@ module Hive return { status: :ok, log_label: log_label } if File.exist?(done_path(task)) begin - pane = runner.capture_pane_tail(bytes: SENTINEL_CAPTURE_BYTES) + pane = capture_readiness_pane(runner) tmux_error_streak = 0 return { status: :ok, log_label: log_label } if claude_ready_prompt?(pane) rescue Hive::TmuxError => e diff --git a/lib/hive/tmux_runner.rb b/lib/hive/tmux_runner.rb index 0133c160..d65ca797 100644 --- a/lib/hive/tmux_runner.rb +++ b/lib/hive/tmux_runner.rb @@ -121,6 +121,14 @@ module Hive tail.scrub end + # Read pane history without byte slicing for safety-sensitive TUI + # classification. The readiness parser separates stale dialogs from the + # current prompt; keeping the history prevents a long dialog from clipping + # its permission or trust copy. + def capture_pane + run_tmux("capture-pane", "-t", target_pane, "-p", "-S", "-").scrub.rstrip + end + # Active pane's process PID. The wrapper script execs into claude # (`exec "$@"` in interactive_claude_wrapper.sh), preserving the PID # across the exec, so this is the claude PID we record into the task diff --git a/test/fixtures/panes/claude_menu_prompt.txt b/test/fixtures/panes/claude_menu_prompt.txt new file mode 100644 index 00000000..a40d1647 --- /dev/null +++ b/test/fixtures/panes/claude_menu_prompt.txt @@ -0,0 +1,5 @@ +Claude Code v2.1.179 +What do you want to do? +❯ 1. Stop and wait for limit to reset +────────────────────────────────────────────────────────────────────────────── +⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents diff --git a/test/fixtures/panes/claude_permission_prompt.txt b/test/fixtures/panes/claude_permission_prompt.txt new file mode 100644 index 00000000..46195cf8 --- /dev/null +++ b/test/fixtures/panes/claude_permission_prompt.txt @@ -0,0 +1,6 @@ +Claude Code v2.1.179 +Do you want to make this edit? +────────────────────────────────────────────────────────────────────────────── +❯ +────────────────────────────────────────────────────────────────────────────── +⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents diff --git a/test/fixtures/panes/claude_ready_2_1_179.txt b/test/fixtures/panes/claude_ready_2_1_179.txt new file mode 100644 index 00000000..7b7d911d --- /dev/null +++ b/test/fixtures/panes/claude_ready_2_1_179.txt @@ -0,0 +1,5 @@ +Claude Code v2.1.179 +────────────────────────────────────────────────────────────────────────────── +❯  +────────────────────────────────────────────────────────────────────────────── +⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents diff --git a/test/fixtures/panes/claude_ready_legacy.txt b/test/fixtures/panes/claude_ready_legacy.txt new file mode 100644 index 00000000..e18606ee --- /dev/null +++ b/test/fixtures/panes/claude_ready_legacy.txt @@ -0,0 +1,4 @@ +Claude Code v2.1.133 +Tip: try refactor + +❯ Try "refactor " diff --git a/test/fixtures/panes/claude_trust_prompt.txt b/test/fixtures/panes/claude_trust_prompt.txt new file mode 100644 index 00000000..b356f0c4 --- /dev/null +++ b/test/fixtures/panes/claude_trust_prompt.txt @@ -0,0 +1,4 @@ +Claude Code v2.1.179 +Quick safety check +❯ 1. Yes, I trust this folder +Enter to confirm diff --git a/test/integration/gem_package_test.rb b/test/integration/gem_package_test.rb new file mode 100644 index 00000000..d6342558 --- /dev/null +++ b/test/integration/gem_package_test.rb @@ -0,0 +1,54 @@ +require "test_helper" +require "open3" +require "rubygems/package" +require "hive/claude_launcher" + +class GemPackageTest < Minitest::Test + ROOT = File.expand_path("../..", __dir__) + + def test_built_gem_contains_launcher_manifest_and_runtime_shell_scripts + Dir.mktmpdir("hive-gem-package") do |dir| + gem_path = File.join(dir, "hive-cli-test.gem") + output, status = Open3.capture2e("gem", "build", "hive.gemspec", "--output", gem_path, chdir: ROOT) + + assert status.success?, "gem build failed:\n#{output}" + archive_paths = Gem::Package.new(gem_path).contents + expected_paths = launcher_script_paths | runtime_shell_script_paths + + assert_archive_contains_paths(expected_paths, archive_paths) + assert_includes archive_paths, "lib/hive/scripts/interactive_claude_wrapper.sh" + assert_includes archive_paths, "lib/hive/scripts/stop_hook.sh" + end + end + + def test_missing_launcher_manifest_path_diagnostic_names_gem_relative_path + missing_path = "lib/hive/scripts/missing_wrapper.sh" + + error = assert_raises(Minitest::Assertion) do + assert_archive_contains_paths([ missing_path ], []) + end + + assert_includes error.message, missing_path + end + + private + + def launcher_script_paths + Hive::ClaudeLauncher::LAUNCHER_SCRIPTS.each_value.map do |path| + path.delete_prefix("#{ROOT}/") + end + end + + def runtime_shell_script_paths + Dir[File.join(ROOT, "lib/hive/scripts/**/*.sh")].map do |path| + path.delete_prefix("#{ROOT}/") + end + end + + def assert_archive_contains_paths(expected_paths, archive_paths) + missing_paths = expected_paths - archive_paths + + assert_empty missing_paths, + "built gem is missing runtime scripts: #{missing_paths.join(', ')}" + end +end diff --git a/test/unit/claude_launcher_test.rb b/test/unit/claude_launcher_test.rb index e9c71b65..000b7b70 100644 --- a/test/unit/claude_launcher_test.rb +++ b/test/unit/claude_launcher_test.rb @@ -507,6 +507,210 @@ class ClaudeLauncherTest < Minitest::Test assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) end + def test_capture_readiness_pane_prefers_the_untruncated_visible_screen + screen = "Claude Code v2.1.179\nDo you want to make this edit?\n#{'x' * 9_000}\n❯" + runner = Object.new + runner.define_singleton_method(:capture_pane) { screen } + runner.define_singleton_method(:capture_pane_tail) do |bytes:| + raise "tail capture would clip safety copy at #{bytes} bytes" + end + + assert_equal screen, Hive::ClaudeLauncher.capture_readiness_pane(runner) + end + + def test_claude_ready_prompt_accepts_2_1_179_separator_caret_footer_fixture_with_literal_nbsp + pane = pane_fixture("claude_ready_2_1_179.txt") + + assert_includes pane, "\u00A0" + assert_includes pane.codepoints, 0x00A0 + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) + end + + def test_claude_ready_prompt_accepts_legacy_fixture + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane_fixture("claude_ready_legacy.txt")) + end + + def test_claude_ready_prompt_scans_the_last_ten_nonblank_lines + separator = "────────────────────────────────────────────────────────────────────────" + at_scan_boundary = "Claude Code v2.1.179\n#{separator}\n❯\n" + ([ separator ] * 9).join("\n") + outside_scan_window = "Claude Code v2.1.179\n#{separator}\n❯\n" + ([ separator ] * 10).join("\n") + + assert Hive::ClaudeLauncher.claude_ready_prompt?(at_scan_boundary) + refute Hive::ClaudeLauncher.claude_ready_prompt?(outside_scan_window) + end + + def test_claude_ready_prompt_accepts_variable_separator_and_footer_depth + separator = "────────────────────────────────────────────────────────────────────────" + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + panes = [ + "Claude Code v2.1.179\n❯", + "Claude Code v2.1.179\n#{separator}\n❯\n#{separator}", + "Claude Code v2.1.179\n#{separator}\n❯\n#{separator}\n#{footer}" + ] + + panes.each { |pane| assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) } + end + + def test_claude_ready_prompt_accepts_boxed_textual_prompt_with_opening_separator + separator = "────────────────────────────────────────────────────────────────────────" + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + pane = "Claude Code v2.1.179\n#{separator}\n❯ Try \"refactor \"\n" \ + "#{separator}\n#{footer}" + + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) + end + + def test_claude_ready_prompt_accepts_legacy_textual_prompt_with_footer_only + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + pane = "Claude Code v2.1.133\n❯ Try \"refactor \"\n#{footer}" + + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "the legacy textual prompt does not render an opening separator" + end + + def test_claude_ready_prompt_rejects_streamed_prompt_shaped_output_before_input_chrome + separator = "────────────────────────────────────────────────────────────────────────" + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + pane = "Claude Code v2.1.179\n❯ npm test\n" \ + "#{separator}\n#{footer}" + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "prompt-shaped streamed output without an opening input separator is still busy" + end + + def test_claude_ready_prompt_rejects_streamed_prompt_shaped_output_at_pane_bottom + panes = [ + "Claude Code v2.1.179\n❯ npm test", + "Claude Code v2.1.179\n❯ Try npm test", + "Claude Code v2.1.179\n❯ Try again after the build" + ] + + panes.each do |pane| + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "a final streamed command line is not an idle textual prompt" + end + end + + def test_claude_ready_prompt_rejects_bare_caret_with_output_shaped_tail + separator = "────────────────────────────────────────────────────────────────────────" + panes = [ + "Claude Code v2.1.179\n❯\n← still compiling", + "Claude Code v2.1.179\n❯\n#{separator}" + ] + + panes.each do |pane| + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "a bare output caret needs opening input-box chrome before a trailing line" + end + end + + def test_claude_ready_prompt_rejects_output_shaped_tail_after_line_end_caret + separator = "────────────────────────────────────────────────────────────────────────" + panes = [ + "Claude Code v2.1.179\nworktree dirty ❯\n← still compiling", + "Claude Code v2.1.179\nworktree dirty ❯\n?────────", + "Claude Code v2.1.179\nstill compiling ❯\n#{separator}", + "Claude Code v2.1.179\nstill compiling ❯\n⏵⏵ still compiling" + ] + + panes.each do |pane| + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "output-shaped tail text is not recognized input chrome" + end + end + + def test_claude_ready_prompt_accepts_recognized_footer_after_line_end_caret + pane = "Claude Code v2.1.179\nworktree clean ❯\n← for agents" + + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) + end + + def test_claude_ready_prompt_rejects_permission_fixture_with_ready_like_caret + pane = pane_fixture("claude_permission_prompt.txt") + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane) + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane.sub("Do you want to make this edit?", "Review the edit")), + "without permission copy, the fixture must otherwise be ready" + end + + def test_claude_ready_prompt_checks_permission_guard_before_the_scan_window + separator = "────────────────────────────────────────────────────────────────────────" + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + dialog = ([ "Edit preview line" ] * Hive::ClaudeLauncher::CLAUDE_PROMPT_SCAN_LINES).join("\n") + pane = "Claude Code v2.1.179\nDo you want to make this edit?\n#{dialog}\n" \ + "#{separator}\n❯\n#{separator}\n#{footer}" + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "permission copy outside the caret scan window must still block readiness" + end + + def test_claude_ready_prompt_checks_permission_guard_before_an_internal_blank_line + separator = "────────────────────────────────────────────────────────────────────────" + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + pane = "Claude Code v2.1.179\nDo you want to make this edit?\n\n" \ + "README: Claude Code supports tmux\n" \ + "#{separator}\n❯\n#{separator}\n#{footer}" + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "blank lines inside a permission dialog must not hide its safety copy" + end + + def test_claude_ready_prompt_does_not_treat_banner_shaped_preview_as_a_new_screen + separator = "───────────────────────────────────────────────────────────────────────" + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + pane = "Claude Code v2.1.179\nDo you want to make this edit?\n" \ + "▐▛███▜▌ Claude Code v2.1.170\n" \ + "#{separator}\n❯\n#{separator}\n#{footer}" + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "banner-shaped edit content must not reset an active permission guard" + end + + def test_claude_ready_prompt_rejects_trust_fixture_before_the_trust_flow + pane = pane_fixture("claude_trust_prompt.txt") + + assert Hive::ClaudeLauncher.claude_trust_prompt?(pane) + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane) + end + + def test_claude_ready_prompt_checks_trust_guard_before_the_scan_window + separator = "────────────────────────────────────────────────────────────────────────" + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + dialog = ([ "Folder detail line" ] * Hive::ClaudeLauncher::CLAUDE_PROMPT_SCAN_LINES).join("\n") + pane = "Claude Code v2.1.179\nQuick safety check\nYes, I trust this folder\n#{dialog}\n" \ + "#{separator}\n❯\n#{separator}\n#{footer}" + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "trust copy outside the caret scan window must still block readiness" + end + + def test_claude_ready_prompt_checks_trust_guard_before_an_internal_blank_line + separator = "───────────────────────────────────────────────────────────────────────" + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + pane = "Claude Code v2.1.179\nQuick safety check\nYes, I trust this folder\n\n" \ + "README: Claude Code supports tmux\n#{separator}\n❯\n#{separator}\n#{footer}" + + assert Hive::ClaudeLauncher.claude_trust_prompt?(pane) + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "blank lines inside a trust dialog must not hide its safety copy" + end + + def test_claude_ready_prompt_does_not_reset_trust_guard_at_banner_shaped_content + separator = "───────────────────────────────────────────────────────────────────────" + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + pane = "Claude Code v2.1.179\nQuick safety check\nYes, I trust this folder\n" \ + "▐▛███▜▌ Claude Code v2.1.170\n" \ + "#{separator}\n❯\n#{separator}\n#{footer}" + + assert Hive::ClaudeLauncher.claude_trust_prompt?(pane) + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "banner-shaped folder content must not reset an active trust guard" + end + + def test_claude_ready_prompt_rejects_numbered_menu_with_nbsp_and_footer + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane_fixture("claude_menu_prompt.txt")) + 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" @@ -523,7 +727,7 @@ class ClaudeLauncherTest < Minitest::Test def test_claude_ready_prompt_accepts_current_ready_prompt_with_stale_permission_scrollback pane = "Claude Code v2.1.133\nDo you want to make this edit?\n❯ 1. Yes\n" \ - "Claude Code v2.1.133\n❯ Try \"refactor \"" + "▐▛███▜▌ Claude Code v2.1.133\n❯ Try \"refactor \"" assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) end @@ -576,9 +780,9 @@ 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. + # A caret candidate inside the 10-line scan remains busy when substantive + # output follows it; only recognized input separator/footer chrome may + # appear after the live prompt. 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" @@ -874,6 +1078,19 @@ class ClaudeLauncherTest < Minitest::Test assert_equal %w[--effort medium], command.each_cons(2).find { |a, _| a == "--effort" } end + def test_wrapper_command_uses_launcher_script_manifest + profile = Hive::AgentProfiles.lookup(:claude) + command = Hive::ClaudeLauncher.send( + :wrapper_command, + cwd: "/tmp", add_dirs: [], profile: profile, + permission_mode: "bypassPermissions" + ) + + wrapper = Hive::ClaudeLauncher::LAUNCHER_SCRIPTS.fetch(:interactive_wrapper) + assert_equal "interactive_claude_wrapper.sh", File.basename(wrapper) + assert_equal wrapper, command.fetch(1) + 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..7df25769 100644 --- a/test/unit/gemspec_test.rb +++ b/test/unit/gemspec_test.rb @@ -10,6 +10,18 @@ class GemspecTest < Minitest::Test assert_includes spec.files, "bin/hive-babysitter-stub-git" end + def test_gem_package_includes_all_runtime_shell_scripts + spec = Gem::Specification.load(GEMSPEC_PATH) + source_scripts = Dir[File.expand_path("../../lib/hive/scripts/**/*.sh", __dir__)] + .map { |path| path.delete_prefix("#{File.expand_path('../..', __dir__)}/") } + .sort + + assert_equal source_scripts, (source_scripts & spec.files).sort, + "missing runtime shell scripts: #{(source_scripts - spec.files).join(', ')}" + assert_includes source_scripts, "lib/hive/scripts/interactive_claude_wrapper.sh" + assert_includes source_scripts, "lib/hive/scripts/stop_hook.sh" + end + def test_gem_executables_exclude_bash_hv_launcher spec = Gem::Specification.load(GEMSPEC_PATH) diff --git a/test/unit/tmux_runner_test.rb b/test/unit/tmux_runner_test.rb index 005bccda..d3175504 100644 --- a/test/unit/tmux_runner_test.rb +++ b/test/unit/tmux_runner_test.rb @@ -282,6 +282,19 @@ class TmuxRunnerTest < Minitest::Test end end + def test_capture_pane_returns_untruncated_history + with_tmp_dir do |dir| + screen = "Do you want to make this edit?\n#{([ 'x' * 80 ] * 120).join("\n")}\n❯" + fake = write_fake_tmux(dir, <<~RUBY) + #!/usr/bin/env ruby + STDOUT.write(#{screen.dump}) if ARGV.include?("capture-pane") + RUBY + runner = Hive::TmuxRunner.new(name: unique_name("screen"), cwd: dir, tmux_bin: fake) + + assert_equal screen, runner.capture_pane + end + end + def test_session_exists_returns_false_when_tmux_missing with_tmp_dir do |dir| runner = Hive::TmuxRunner.new(name: unique_name("missing-exists"), cwd: dir, tmux_bin: "missing-tmux-for-hive") diff --git a/wiki/gaps.md b/wiki/gaps.md index 2d71cc61..f5b0fa44 100644 --- a/wiki/gaps.md +++ b/wiki/gaps.md @@ -3,7 +3,7 @@ title: Gaps type: gaps source: wiki/* vs lib/, templates/, test/, bin/ created: 2026-04-25 -updated: 2026-06-25 +updated: 2026-07-15 tags: [gap, todo] --- @@ -142,6 +142,8 @@ completed real GitHub/Codex/Claude provider login plus a daemon-owned PR push. 49. **Screenote live capture test-token endpoint is blocked on Screenote-side availability.** The OAuth/MCP implementation has opt-in live discovery, dynamic-registration, and preseeded auth-code exchange coverage in `test/integration/screenote_oauth_live_test.rb`. The live `create_screenshot_upload` round-trip is written in `test/integration/screenote_capture_live_test.rb`, but it skips until the Screenote non-interactive test-token endpoint URL, secret, and project id are provided. As of 2026-06-22 the upload tool's request/response contract is covered in CI by `test_call_tool_create_screenshot_upload_round_trips_through_http_seam` (`test/unit/screenote/mcp_client_test.rb`) through the FakeHttp seam; the remaining gap is narrowed to proving the *signed upload* against a real Screenote bearer, which still requires the blocked live endpoint. +50. **The post-detector clean-install/live Claude acceptance is still incomplete.** On 2026-07-15 the review runner built and installed `hive-cli` into a clean temporary gem home, selected `/tmp/hive-clean-install-pass02.cTFt21/bin/hive`, observed version `0.3.2`, and resolved `interactive_claude_wrapper.sh` from the installed gem. The same installed binary's `hive doctor --json` exited `65` because the runner had no tmux, Compound Engineering Claude plugin, or `/plan` skill. Claude Code `2.1.191` was present, but `claude auth status` reported `loggedIn: false` and no API/OAuth credential was available. Consequently this run produced no live prompt submission, terminal marker, or no-manual-copy evidence. R8/R9/R11 acceptance must be rerun on a logged-in host with tmux and the configured skills, recording the selected binary/version, green doctor output, submitted prompt, and resulting marker. See [[stages/brainstorm]] and [[testing]]. + ## 2026-06-16/17 refresh uncertainty The 2026-06-17 audit rechecked recent source, tests, git history, project wiki diff --git a/wiki/log.d/20260715-claude-tmux-ready-detector.md b/wiki/log.d/20260715-claude-tmux-ready-detector.md new file mode 100644 index 00000000..d21e35a0 --- /dev/null +++ b/wiki/log.d/20260715-claude-tmux-ready-detector.md @@ -0,0 +1,9 @@ +## [2026-07-15T00:00:00Z] fix — package Claude tmux scripts and widen guarded readiness detection + +**Action:** Updated the Claude tmux runtime contract after source inspection showed that `hive.gemspec` omitted `lib/hive/scripts/**/*.sh`, even though `Hive::ClaudeLauncher` directly starts `interactive_claude_wrapper.sh` and `Hive::StopHookInstaller` needs `stop_hook.sh`. The gemspec now packages the whole runtime-script directory; `Hive::ClaudeLauncher::LAUNCHER_SCRIPTS` is the enumerable source of truth for direct launcher script paths; a unit test checks the evaluated gemspec; `test/integration/gem_package_test.rb` builds a temporary gem and inspects its archive; and the release jobs check the launcher manifest after an isolated gem install and before artifact upload/publication. + +`ClaudeLauncher` now evaluates trust and permission guards from untruncated pane history. A later banner starts a new safety block only after a numbered safety menu in the preceding block, preserving active guards across long dialogs, internal blanks, and banner-shaped preview content while allowing completed dialogs to become stale. It then scans only the last 10 nonblank current-prompt lines for the caret rather than using a fixed two-line tail. It accepts bare and context-prefixed caret forms with Unicode separator whitespace, including U+00A0 NBSP, plus the known legacy quoted `❯ Try "…"` textual prompt. Other textual line-start carets remain busy output. A bare caret with trailing chrome needs the opening separator immediately above it; a line-end caret with trailing chrome needs a recognized `for agents` footer; and the legacy footer-only textual layout remains ready. Hand-authored legacy, Claude Code 2.1.179, long, blank-separated, and banner-shaped permission/trust, menu, and busy-stream fixtures pin the contract. The built-gem test also proves a missing manifest path is named gem-relatively in its assertion. [[stages/brainstorm]] and [[testing]] document the clean-install/live-validation procedure and the boundary that `/usr/bin/hive` versus service/user-local binary drift is diagnosed, not changed here. + +**Refreshed pages:** +- [[stages/brainstorm]] +- [[testing]] diff --git a/wiki/log.d/20260715T143614Z-claude-tmux-ready-review-hardening.md b/wiki/log.d/20260715T143614Z-claude-tmux-ready-review-hardening.md new file mode 100644 index 00000000..ac674af9 --- /dev/null +++ b/wiki/log.d/20260715T143614Z-claude-tmux-ready-review-hardening.md @@ -0,0 +1,10 @@ +## [2026-07-15T14:36:14Z] fix — close Claude tmux readiness safety gaps + +**Action:** Hardened `Hive::ClaudeLauncher` after review found that its 10-line caret window also truncated permission/trust guards and that output-shaped text could satisfy broad prompt-chrome matchers. Readiness now captures untruncated pane history for safety guards, and a later banner starts a new safety block only after a numbered menu in the preceding block; long dialogs, internal blanks, and banner-shaped preview content therefore cannot erase active safety copy while completed dialogs become stale. Only the caret search is bounded. Arbitrary textual line-start carets, bare carets followed by unpaired chrome, and line-end carets without a recognized footer remain busy; the known legacy quoted `❯ Try "…"` footer-only prompt and boxed Claude Code 2.1.179 form remain ready. Focused regressions cover long, blank-separated, and banner-shaped permission/folder-trust dialogs, final streamed command output, arrow/separator-shaped tails, and the prior footer-only layout. [[stages/brainstorm]] and [[testing]] describe the resulting contract. + +The same pass produced clean-install packaging evidence for `hive-cli` 0.3.2 but could not complete the live acceptance because the review runner lacked tmux, configured Claude skills, and Claude authentication. [[gaps]] records the exact incomplete result so it is not mistaken for green doctor or terminal-marker evidence. + +**Refreshed pages:** +- [[stages/brainstorm]] +- [[testing]] +- [[gaps]] diff --git a/wiki/stages/brainstorm.md b/wiki/stages/brainstorm.md index 87991c5d..b4d3709b 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-15 tags: [stage, brainstorm, qa, tmux] --- @@ -13,9 +13,9 @@ tags: [stage, brainstorm, qa, tmux] - **State file**: `brainstorm.md` (touched empty if absent so the marker write has a target). - **Prompt**: `templates/brainstorm_prompt.md.erb`, rendered with `project_name`, `task_folder`, `idea_text`. Idea text is wrapped in `` per the prompt-injection boundary policy. -- **Agent invocation**: `cwd = task.folder`, `--add-dir `, `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 `) 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. +- **Agent invocation**: `cwd = task.folder`, `--add-dir `, `log_label = "brainstorm"`. For `claude.mode: tmux`, `Hive::ClaudeLauncher` starts Claude through its `LAUNCHER_SCRIPTS` manifest entry `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 `) 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. `hive.gemspec` packages every runtime `lib/hive/scripts/**/*.sh` file (including the wrapper and Stop hook); tests inspect a built archive, and release smoke checks the isolated installed artifact. - **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**: Trust and ready strings are named constants in `Hive::ClaudeLauncher`. `Hive::TmuxRunner#capture_pane` supplies untruncated pane history for trust and permission guards; a later banner starts a new safety block only after a numbered safety menu in the preceding block, so long dialogs, internal blanks, or banner-shaped preview content cannot erase an active guard while completed dialogs become stale. The detector searches only the last 10 nonblank current-prompt lines for a candidate. It accepts the bare line-start caret with ASCII/Unicode separator whitespace (including U+00A0 NBSP), the legacy quoted `❯ Try "…"` textual prompt, and the context-prefixed line-end form. Other textual line-start forms such as streamed `❯ npm test` or `❯ Try npm test` output remain busy. Only recognized separator/footer chrome may follow a candidate; a bare caret with trailing chrome also needs the opening separator immediately above it, while a line-end caret with trailing chrome needs the recognized `for agents` footer. The legacy footer-only textual layout remains valid. The detector retains banner/current-footer positive evidence, including the patrol-observed banner-scrolled case, and rejects ASCII- or NBSP-separated numbered menus, embedded carets, and substantive busy output. The fixture suite covers the Claude Code 2.1.179 separator/caret/separator/footer shape without tying readiness to a version or footer offset. - **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 ` 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..62d83660 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-15 tags: [test, minitest, fixtures] --- @@ -223,7 +223,7 @@ The live Telegram bot E2E wrapper lives at `test/e2e/tg/run_idea_e2e.sh` and is ## Live Claude tmux dogfood -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. +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. The detector is no longer pinned to that layout: `claude_launcher_test.rb` carries hand-authored legacy and Claude Code 2.1.179 panes, including literal U+00A0 NBSP, a 10-nonblank-line caret scan, variable separator/footer depth, and trust/permission/menu/stale/busy rejection cases. Trust and permission copy comes from untruncated pane history; boundary regressions cover long and blank-separated dialogs plus arbitrary, exact plain, and exact logo-bearing banner-shaped preview content. Final textual lines such as `❯ npm test` and `❯ Try npm test`, bare carets followed by unpaired chrome, and line-end carets followed only by separators or generic `⏵⏵` output remain busy. Only the known quoted legacy `❯ Try "…"` suggestion accepts a footer without an opening separator. `test/integration/gem_package_test.rb` builds a temporary `.gem` and reads its archive to require every launcher-manifest script plus all runtime shell scripts; the release workflow repeats the launcher-manifest assertion against its isolated installed gem, and the test suite pins that a missing manifest entry's diagnostic names its gem-relative path. Run shape: @@ -238,6 +238,9 @@ argv pins, so it proves tmux-mode launch/cleanup but not the new `--model default` or `--effort ` behavior against a live Claude Code binary. +For a post-change live validation, build and install the gem into a temporary +`GEM_HOME`, record `GEM_HOME="$sandbox" GEM_PATH="$sandbox" "$sandbox/bin/hive" --version`, run that same environment's `hive doctor`, and reproduce `hive run /.hive-state/stages/2-brainstorm/add-local-hive-web-install-260629-f4ca` with Claude tmux mode and the Compound Engineering plugin installed. The expected outcome is stage-prompt submission and `WAITING` or another terminal marker, while doctor remains green. A different `/usr/bin/hive` or service-selected binary is recorded as the separate binary-drift issue; this work does not alter daemon or systemd selection. See [[stages/brainstorm]]. + ## Eval suite (`test/eval/`) The Telegram bot eval harness is opt-in and separate from the default suite: