diff --git a/hive.gemspec b/hive.gemspec index d1e8c7c..2bf21a2 100644 --- a/hive.gemspec +++ b/hive.gemspec @@ -35,6 +35,12 @@ Gem::Specification.new do |spec| "bin/hive-babysitter-stub-git", "bin/hv", "lib/**/*.rb", + # Shell scripts referenced at runtime by packaged lib code: + # lib/hive/scripts/interactive_claude_wrapper.sh (ClaudeLauncher.wrapper_command) + # and lib/hive/scripts/stop_hook.sh (StopHookInstaller::HOOK_PATH). + # Without this glob an installed gem's wrapper_command points at a + # missing file and every Claude tmux launch dies instantly. + "lib/hive/scripts/**/*", "templates/**/*", "schemas/**/*.json", "examples/systemd/*", diff --git a/lib/hive/claude_launcher.rb b/lib/hive/claude_launcher.rb index 742bbdc..b9d981c 100644 --- a/lib/hive/claude_launcher.rb +++ b/lib/hive/claude_launcher.rb @@ -31,21 +31,38 @@ module Hive CLAUDE_READY_POLL_INTERVAL_SEC = 0.25 MIN_TMUX_VERSION = "3.0" TERMINAL_MARKERS = %i[waiting complete error execute_complete review_complete review_waiting review_error].freeze - # Observed against Claude Code 2.1.133 (2026-05-25 dogfood) and the + # Observed against Claude Code 2.1.133 (2026-05-25 dogfood), the # 2026-05-27 build that moved the input caret to the end of a - # context-prefixed line and added a hint footer beneath it. + # context-prefixed line and added a hint footer beneath it, and the + # 2.1.179 build (incident: task add-local-hive-web-install-260629-f4ca + # stuck in claude_launch_failed) that renders the idle caret with a + # NON-BREAKING SPACE after it inside a separator/footer sandwich: + # `─` row / `❯\u00A0Try "…"` / `─` row / `⏵⏵ … ← for agents` footer. # # 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. + # at line end), what renders BELOW it (hint footers, box-drawing + # separator rows, so the caret is no longer the last or second-to-last + # line), and how many lines up from the pane bottom the live caret sits. + # To tolerate that without treating a `❯` Claude prints in its OWN output + # (shell snippets, prose, bullets) as ready we + # (a) anchor the caret to a line edge — first or last glyph, never + # mid-prose, + # (b) scan only the bottom CLAUDE_PROMPT_TAIL_LINES nonblank lines of + # the current region and take the LOWEST qualifying caret there + # (version-tolerant window; no fixed footer/separa­tor offset), + # (c) apply the below-blocking rule: trust-prompt or permission copy + # rendering strictly BELOW the chosen caret means an ACTIVE modal + # owns the bottom of the pane — the caret above it is dead output, + # not the live prompt, so the pane is not ready. + # Whitespace note: Ruby's ASCII-only `\s` does NOT match NBSP/U+00A0 or + # U+3000 where 2.1.179 pads the caret and menus, so every whitespace + # class below uses `[[:space:]]`, which Onigmo defines over Unicode + # separators (`[[:space:]]` matches \u00A0 and \u3000; `\s` matches + # neither). Pinned by fixtures carrying literal NBSP bytes so any + # engine drift fails loudly in CI instead of in operator runs. # # Copy strings still gate readiness in two places, both version-coupled # and both to update when Claude Code changes them: the positive @@ -58,16 +75,29 @@ 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 caret as the FIRST glyph (`❯ …`, older builds), 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 + # context), or the first glyph followed by Unicode whitespace + # (`❯\u00A0Try …`, the NBSP form 2.1.179 renders before its hint + # text). 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 + CLAUDE_READY_PROMPT_LINE = /\A❯(?:[[:space:]]|\z)|[[:space:]]❯\z/.freeze + # A numbered menu option (`❯ 1.`, optionally padded — including NBSP / + # U+3000 indentation) is an interactive selection, not the idle + # prompt. Same Unicode-whitespace classes as CLAUDE_READY_PROMPT_LINE; + # keep them in step. + CLAUDE_MENU_OPTION_LINE = /\A[[:space:]]*❯[[:space:]]*\d+\./.freeze + # Nonblank lines retained around the bottom for the trust/permission/ + # menu gates. Must satisfy CONTEXT_LINES >= TAIL_LINES or the context + # truncation would silently clip the tail window below — pinned by a + # test asserting that invariant. + CLAUDE_PROMPT_CONTEXT_LINES = 12 + # Bottom-of-pane scan window for the idle caret: wide enough to bridge + # release-dependent decoration (separators, footers, box borders) + # between the live caret and the pane floor without reaching into old + # scrollback output. Clamp to roughly one TUI-screen-bottom band — + # grow only alongside new fixture evidence from real panes. + 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 @@ -616,15 +646,35 @@ 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. - current_lines.last(CLAUDE_PROMPT_TAIL_LINES).any? do |line| + # The idle caret sits at the bottom of the input box, but HOW FAR up + # from the pane floor depends on the release's decoration (separator + # rows, hint footers). Scan the bottom TAIL_LINES nonblank lines and + # take the LOWEST qualified caret there; anything higher inside the + # window stays unexamined while a lower candidate exists. A numbered + # menu option (`❯ 1.`) is an interactive selection, not the idle + # prompt, so it never qualifies. + tail_lines = current_lines.last(CLAUDE_PROMPT_TAIL_LINES) + caret_index = tail_lines.rindex do |line| line.match?(CLAUDE_READY_PROMPT_LINE) && !line.match?(CLAUDE_MENU_OPTION_LINE) end + return false unless caret_index + + # Below-blocking rule: an ACTIVE modal always paints at the very + # bottom of the pane. Trust/permission copy strictly BELOW the chosen + # caret therefore means that caret has scrolled up out of the live + # surface — the pane is waiting on a dialog, not idling at our input + # box. (Numbered menu options without marker copy still read as the + # accepted residual risk; see the plan's risk notes.) + below_caret = tail_lines[(caret_index + 1)..] || [] + below_caret.none? { |line| blocking_marker_line?(line) } + end + + # Trust OR permission marker anywhere on a line is enough: modals can + # lose their header (or their options) past the top of the scan window + # while their body remains visible below a stale caret. + def blocking_marker_line?(line) + CLAUDE_TRUST_PROMPT_MARKERS.any? { |marker| line.include?(marker) } || + line.include?(CLAUDE_PERMISSION_PROMPT_MARKER) end def current_prompt_text(pane) @@ -635,7 +685,11 @@ module Hive 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") + # Truncation order matters with the tail window: this cap feeds + # claude_ready_prompt?'s scan of the bottom TAIL_LINES nonblank lines, + # so CLAUDE_PROMPT_CONTEXT_LINES must stay >= CLAUDE_PROMPT_TAIL_LINES + # or the context cut would silently clip the readiness window. + current_lines.reject(&:empty?).last(CLAUDE_PROMPT_CONTEXT_LINES).join("\n") end def wait_for_terminal_marker(task, runner, timeout) diff --git a/test/integration/run_brainstorm_tmux_test.rb b/test/integration/run_brainstorm_tmux_test.rb index d4d846b..ede749f 100644 --- a/test/integration/run_brainstorm_tmux_test.rb +++ b/test/integration/run_brainstorm_tmux_test.rb @@ -54,6 +54,30 @@ class RunBrainstormTmuxTest < Minitest::Test end end + # E2E regression net for the 2.1.179 incident: the fake renders the exact + # NBSP-caret / separator-footer sandwich layout, so readiness must be + # detected through the real tmux launch path (prepare_claude_session!) and + # the stage still reaches WAITING. This test timed out pre-fix — with the + # old two-line/ASCII-whitespace detector the pane never read as ready. + def test_waiting_marker_reached_through_2_1_179_nbsp_pane_layout + with_tmp_global_config do + with_tmp_git_repo do |dir| + fake = write_fake_interactive_claude(dir) + ENV["HIVE_CLAUDE_BIN"] = fake + ENV["HIVE_FAKE_INTERACTIVE_SCENARIO"] = "waiting_2179" + folder = make_task_at_brainstorm(dir, timeout: 3) + + capture_io { Hive::Commands::Run.new(folder).call } + + marker = Hive::Markers.current(File.join(folder, "brainstorm.md")) + assert_equal :waiting, marker.name, + "the 2.1.179 pane layout must drive a brainstorm stage to WAITING" + assert_empty tmux_sessions + refute File.exist?(File.join(folder, ".claude", "settings.json")) + end + end + end + def test_complete_marker_returns_complete_commit_action with_tmp_global_config do with_tmp_git_repo do |dir| @@ -269,10 +293,33 @@ class RunBrainstormTmuxTest < Minitest::Test exit 0 end - puts "Claude Code v2.1.118" - puts "❯" - STDOUT.flush scenario = ENV.fetch("HIVE_FAKE_INTERACTIVE_SCENARIO") + if scenario == "waiting_2179" + # Claude Code 2.1.179 idle layout: the input caret is followed by a + # NON-BREAKING SPACE (U+00A0) hint and sits inside a separator/ + # footer sandwich three rows above the pane floor. This layout + # drives readiness through Hive::ClaudeLauncher's widened Unicode + # window; it timed out pre-fix. + nbsp = [0xA0].pack("U") + bar = "%c" % 0x2500 + separator = bar * 46 + footer = "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" + box_top = "│╭" + bar * 37 + "╮│" + box_bottom = " ╰" + bar * 37 + "╯" + puts "█▘▛███▜▌ Claude Code v2.1.179" + puts "" + puts box_top + puts " ││ Research mode · sonnet ││" + puts box_bottom + puts separator + puts "❯\#{nbsp}Try \\"fix lint errors in ./lib\\"" + puts separator + puts footer + else + puts "Claude Code v2.1.118" + puts "❯" + end + STDOUT.flush if scenario == "exit_before_submit" system("stty raw -echo") deadline = Time.now + 2 @@ -315,7 +362,7 @@ class RunBrainstormTmuxTest < Minitest::Test end case scenario - when "waiting" + when "waiting", "waiting_2179" File.write(state_file, "## Round 1\\n### Q1. Scope?\\n### A1.\\n\\n\\n") puts "" fire_hook("waiting") diff --git a/test/unit/claude_launcher_test.rb b/test/unit/claude_launcher_test.rb index e9c71b6..55dee4b 100644 --- a/test/unit/claude_launcher_test.rb +++ b/test/unit/claude_launcher_test.rb @@ -576,15 +576,18 @@ 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. - 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" + # REPURPOSED for the widened window (was: caret + 2 running-output lines, + # excluded by the old 2-line tail). The invariant “old carets far above + # the input-box tail are stale” survives, but pushing a caret out of the + # view now takes MORE than TAIL_LINES nonblank lines of fresher output: + # the caret falls out of `current_lines.last(TAIL_LINES)` exactly when + # the trailing activity count reaches the window size. + def test_claude_ready_prompt_rejects_caret_pushed_out_of_the_bottom_window + fresh_activity = Array.new(Hive::ClaudeLauncher::CLAUDE_PROMPT_TAIL_LINES) { |i| "running build step #{i}" } + pane = (["Claude Code v2.1.133", "", "❯ Try \"refactor \""] + fresh_activity).join("\n") refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), - "a caret with non-footer output below it is not the live idle prompt" + "a caret pushed farther than the tail window by fresher output is stale, not ready" end # A bare caret line is a legitimate idle prompt; lock it as intentional. @@ -594,6 +597,154 @@ class ClaudeLauncherTest < Minitest::Test assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) end + SEP2179 = "─" * 46 + # Exact hand-authored regression shape for Claude Code 2.1.179 (incident: + # add-local-hive-web-install-260629-f4ca): the idle caret line carries a + # NON-BREAKING SPACE (U+00A0) after the `❯` glyph and sits inside a + # separator/footer sandwich three lines above the pane floor. + PANE_2_1_179_READY = [ + "▛███▜▌ Claude Code v2.1.179", + "", + " │╭─────────────────────────────────────╮│", + " ││ Research mode · sonnet ││", + " ╰─────────────────────────────────────╯", + SEP2179, + "❯\u00A0Try \"fix lint errors in ./lib\"", + SEP2179, + "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents", + ].join("\n").freeze + + # Accept the 2.1.179 pane shape: NBSP caretted hint inside the + # separator/footer sandwich, three rows above the pane floor. + def test_claude_ready_prompt_accepts_2_1_179_nbsp_caret_in_separator_footer_sandwich + assert Hive::ClaudeLauncher.claude_ready_prompt?(PANE_2_1_179_READY), + "the shipped 2.1.179 pane shape must read as ready" + end + + # Byte-level pin: the fixture really carries U+00A0 after the caret, and + # plain ASCII `\\s` canNOT consume it — the ready regex depends on + # Onigmo's Unicode-aware `[[:space:]]`. If engines drift, these two + # assertions fail loudly in CI instead of breaking operator launches. + def test_2_1_179_fixture_pins_literal_nbsp_bytes_requiring_unicode_space_class + caret_line = PANE_2_1_179_READY.each_line.find { |line| line.include?("❯") } + + assert_operator caret_line.index("\u00A0"), :>, caret_line.index("❯"), + "the fixture must carry literal NBSP bytes after the caret glyph" + refute caret_line.match?(/\A❯\s/), + "ASCII \\s alone cannot match the NBSP after the caret; only [[:space:]] reaches it" + end + + # Widened-window acceptance: a line-start caret still reads as ready when + # a separator row AND a hint footer render below it inside the scan + # window (the old two-line tail view would have missed this shape). + def test_claude_ready_prompt_accepts_caret_within_window_with_separator_and_footer_below + pane = [ + "Claude Code v2.1.179", + SEP2179, + "❯ Try \"continue where you left off\"", + SEP2179, + "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents", + ].join("\n") + + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) + end + + # Rejection: the permission dialog owns the pane floor — standalone and + # when its copy is buried among other busy output inside the scan window. + def test_claude_ready_prompt_rejects_permission_dialog_at_pane_floor + pane = [ + "Claude Code v2.1.179", + "The file config/example.yml has been edited locally.", + "Do you want to reload the config from disk?", + "❯ 1. Yes", + " 2. Yes, and always allow config reloads", + " 3. No, and tell Claude what to do differently (esc)", + ].join("\n") + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "an active permission dialog is not the idle prompt" + end + + def test_claude_ready_prompt_rejects_permission_dialog_buried_inside_scan_window + busy_tail = Array.new(6) { |i| "editing src/auth/token_#{i}.rb…" } + pane = ([ + "Claude Code v2.1.179", + "reading src/auth/session.ts…", + "Do you want to proceed with replacing session.ts?", + "❯ 1. Yes", + " 2. No (esc)", + ] + busy_tail).join("\n") + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "dialog copy anywhere in the scanned band must keep the pane unready" + end + + # Rejection: the folder-trust modal must stay rejected AND classified as + # a trust prompt (not merely unready). + def test_claude_ready_prompt_rejects_standalone_trust_dialog + pane = [ + "Claude Code v2.1.175", + "New folder detected", + "Quick safety check", + "❯ 1. Yes, I trust this folder", + " 2. No, choose different folders", + "Enter to confirm", + ].join("\n") + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane) + assert Hive::ClaudeLauncher.claude_trust_prompt?(pane) + end + + # Rejection: numbered menu selections — including NBSP-padded variants. + # The NBSP-after-caret option is the dangerous case: it is shaped exactly + # like the 2.1.179 idle caret, so only the widened Unicode menu-option + # class excludes it. + def test_claude_ready_prompt_rejects_numbered_menus_with_unicode_whitespace + intro = [ "Claude Code v2.1.179", "Choose how to proceed:" ] + nbsp_after_caret = [ + "❯\u00A01.\u00A0Continue with recommended settings", + " 2. Customize rules manually", + ] + nbsp_indented = [ + "\u00A0❯ 1. Continue with recommended settings", + "\u00A0\u00A02. Customize rules manually", + ] + + [ nbsp_after_caret, nbsp_indented ].each do |options| + refute Hive::ClaudeLauncher.claude_ready_prompt?((intro + options).join("\n")), + "a numbered selection state must never read as the idle prompt (#{options.first.inspect})" + end + end + + # Below-blocking proof: a partially-scrolled trust modal loses its header + # (and its ❯-digit options) past the top of the scan window, leaving only + # copy without any caret-shaped line of its own. The stale idle caret + # ABOVE the modal must still be rejected — an active modal owns the pane + # floor, and typing there silently loses keystrokes. + def test_claude_ready_prompt_rejects_caret_above_numberless_active_modal + pane = [ + "Claude Code v2.1.133", + "❯ Try \"previous task work\"", + "Review the folders Claude will access:", + "Yes, I trust this folder", + "Enter to confirm", + ].join("\n") + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "modal copy strictly below the only caret means the caret is dead output" + end + + # Constants invariant (brainstorm A1): the context cap feeds the tail + # scan, so letting CONTEXT_LINES drop below TAIL_LINES would silently + # clip the readiness window. Also pins the deliberate 8–12 band choice. + def test_prompt_context_window_never_clips_the_tail_scan_window + assert_operator Hive::ClaudeLauncher::CLAUDE_PROMPT_CONTEXT_LINES, :>=, + Hive::ClaudeLauncher::CLAUDE_PROMPT_TAIL_LINES, + "CONTEXT_LINES < TAIL_LINES clips the readiness scan window" + assert_includes 8..12, Hive::ClaudeLauncher::CLAUDE_PROMPT_TAIL_LINES, + "the tail window was sized to the 8–12 band from the brainstorm" + end + def test_claude_trust_prompt_matches_observed_folder_trust_prompt pane = "Quick safety check\n❯ 1. Yes, I trust this folder\nEnter to confirm" diff --git a/test/unit/gemspec_test.rb b/test/unit/gemspec_test.rb index c4cd96d..792ebd1 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 + # Every script referenced at runtime from packaged lib code must ship + # inside the gem. ClaudeLauncher.wrapper_command expands + # scripts/interactive_claude_wrapper.sh relative to its __dir__ and + # StopHookInstaller resolves HOOK_PATH the same way; omitting them made + # `bash ` kill every Claude tmux launch on install. + def test_gem_package_includes_hive_scripts_dir + spec = Gem::Specification.load(GEMSPEC_PATH) + + assert_includes spec.files, "lib/hive/scripts/interactive_claude_wrapper.sh" + assert_includes spec.files, "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/packaging/built_gem_scripts_test.rb b/test/unit/packaging/built_gem_scripts_test.rb new file mode 100644 index 0000000..0e3cd89 --- /dev/null +++ b/test/unit/packaging/built_gem_scripts_test.rb @@ -0,0 +1,96 @@ +require "test_helper" +require "open3" +require "rubygems/package" +require "stringio" +require "tmpdir" +require "zlib" + +# Release gate (U2): fail whenever a script referenced by packaged +# lib/hive/**/*.rb code is missing from the ACTUALLY BUILT .gem artifact. +# +# Unlike GemspecTest (which inspects spec.files before any build), this +# test shells out to `gem build`, opens the resulting tarball, extracts +# data.tar.gz and asserts member names — proving a release build would +# ship every runtime script, not merely that the glob looks right. +# Offline-safe: no install step, no network, all output under a tempdir. +class BuiltGemScriptsTest < Minitest::Test + PROJECT_ROOT = File.expand_path("../../..", __dir__) + GEMSPEC_PATH = File.join(PROJECT_ROOT, "hive.gemspec") + LIB_DIR = File.join(PROJECT_ROOT, "lib") + + # Runtime consumers reference scripts relative to their own __dir__ + # (e.g. File.expand_path("scripts/stop_hook.sh", __dir__)), so packaged + # lib sources carry literal `scripts/.sh` substrings. Scanning for + # that pattern keeps this guard self-extending: a future consumer of a + # new script is picked up without touching this test. + SCRIPT_REF_PATTERN = %r{scripts/[\w.\-]+\.sh}.freeze + + def test_every_script_referenced_by_packaged_lib_ships_inside_the_built_gem + referenced = referenced_scripts + assert referenced.any?, + "sanity guard: script-reference enumeration found nothing under lib/hive/**/*.rb " \ + "(a product-code refactor changed how scripts are located — update this scanner)" + + unpacked_gem_artifact do |member_names| + # Archive-integrity sanity: the obvious always-shipped entry proves we + # read real member names instead of passing against an empty reader. + assert_includes member_names, "lib/hive.rb", + "built artifact does not look like a hive-cli gem" + + referenced.each do |member_path| + assert File.exist?(File.join(PROJECT_ROOT, member_path)), + "#{member_path} is referenced by packaged lib code but missing from the source tree" + assert_includes member_names, member_path, + "#{member_path} is referenced by packaged lib code but was NOT shipped in the built gem" + end + end + end + + private + + # Returns gem-member paths like "lib/hive/scripts/stop_hook.sh". The + # in-code literals are relative to lib/hive (the consumers' __dir__), + # so the resolution mirrors File.expand_path(ref, ""). + def referenced_scripts + refs = Dir.glob(File.join(LIB_DIR, "hive/**/*.rb")).flat_map do |source| + File.read(source).scan(SCRIPT_REF_PATTERN) + end.uniq.sort + flunk "malformed script reference(s): #{refs.inspect}" unless refs.all? { |r| r.start_with?("scripts/") } + + refs.map { |ref| File.join("lib", "hive", ref) } + end + + # Builds the real gem into a private tempdir, reads every data.tar.gz + # member name, and guarantees the tempdir is removed afterwards. + def unpacked_gem_artifact + Dir.mktmpdir("hive-built-gem-guard") do |dir| + gem_path = File.join(dir, "hive-cli.gem") + out, err, status = Open3.capture3("gem", "build", GEMSPEC_PATH, + "--output", gem_path, + chdir: PROJECT_ROOT) + unless status.success? + flunk "gem build failed (exit=#{status.exitstatus}): #{err} #{out}" + end + + yield data_tar_member_names(gem_path) + end + end + + def data_tar_member_names(gem_path) + names = [] + File.open(gem_path, "rb") do |outer| + Gem::Package::TarReader.new(outer) do |tar| + tar.each do |entry| + next unless entry.full_name == "data.tar.gz" + + Zlib::GzipReader.wrap(StringIO.new(entry.read)) do |gz| + Gem::Package::TarReader.new(gz) do |inner| + inner.each { |member| names << member.full_name } + end + end + end + end + end + names + end +end diff --git a/wiki/e2e.md b/wiki/e2e.md index c3c3e05..40df858 100644 --- a/wiki/e2e.md +++ b/wiki/e2e.md @@ -7,7 +7,7 @@ updated: 2026-06-21 tags: [test, e2e, tui, artifacts] --- -**TLDR**: `test/e2e/` is the outer test layer. It drives the real `bin/hive` binary in a copied Ruby sample project, uses tmux for TUI scenarios, validates JSON output against published schemas, and writes versioned run artifacts for later debugging. The `bin/hive-e2e` Thor executable is also a small public harness surface with pinned exit codes and JSON error envelopes for wrapper/CI callers. +**TLDR**: `test/e2e/` is the outer test layer. It drives the real `bin/hive` binary in a copied Ruby sample project, uses tmux for TUI scenarios, validates JSON output against published schemas, and writes versioned run artifacts for later debugging. The `bin/hive-e2e` Thor executable is also a small public harness surface with pinned exit codes and JSON error envelopes for wrapper/CI callers. Inner-layer note: the offline tmux-path launch regression for Claude readiness (scenario-controlled fake interactive claude, including a Claude Code 2.1.179 NBSP-caret layout) lives in `test/integration/run_brainstorm_tmux_test.rb`, not here. ## Commands diff --git a/wiki/log.d/20260701T000000Z-claude-tmux-ready-detector-nbsp.md b/wiki/log.d/20260701T000000Z-claude-tmux-ready-detector-nbsp.md new file mode 100644 index 0000000..189440f --- /dev/null +++ b/wiki/log.d/20260701T000000Z-claude-tmux-ready-detector-nbsp.md @@ -0,0 +1,8 @@ +--- +timestamp: 2026-07-01T00:00:00Z +title: Claude tmux ready detector tolerates 2.1.179 NBSP caret; gem ships scripts +--- + +- `Hive::ClaudeLauncher` ready detector widened: bottom-of-region scan window `CLAUDE_PROMPT_TAIL_LINES=10` (context cap 12, invariant-pinned), `[[:space:]]` Unicode whitespace classes (2.1.179 pads the caret with U+00A0; `\s` matches neither NBSP nor U+3000), lowest-anchored-caret selection plus below-blocking on trust/permission copy. Rejections for permission/trust/menu states preserved; see [[modules/agent]]. +- `hive.gemspec` now packages `lib/hive/scripts/**/*` (wrapper + stop hook were missing from installed gems — root cause of the add-local-hive-web-install-260629-f4ca `claude_launch_failed` incident); new built-artifact guard `test/unit/packaging/built_gem_scripts_test.rb` enumerates every `scripts/*.sh` reference in packaged lib sources and asserts each inside a real built `.gem`. +- Offline integration regression: fake interactive claude gains the exact 2.1.179 pane layout (`waiting_2179` scenario) and drives a brainstorm stage to WAITING through real tmux in `test/integration/run_brainstorm_tmux_test.rb`. diff --git a/wiki/modules/agent.md b/wiki/modules/agent.md index 2538ebb..f6bf818 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-01 tags: [agent, claude, subprocess] --- @@ -40,6 +40,12 @@ Hive::Agent.new( Headless `Hive::Agent#spawn_and_wait` scans each raw stream line for limit text while still preserving the structured final message and bounded plain tail. That raw-stream path catches CLIs that emit usage walls as JSON error events which `MessageExtractor` does not surface as a final assistant message; `handle_exit` then prefers `result[:limit_text]` and falls back to scanning `final_message`. The classifier still only controls failure/timeout handling: a clean `exit_code == 0` result is not reclassified. For `:state_file_marker` spawns it stamps `ERROR reason=limits_reached`; for `:exit_code_only` and `:output_file_exists` spawns it returns the limit message without overwriting the orchestrator-owned marker. `Hive::ClaudeLauncher` uses the same classifier while waiting for tmux readiness, terminal markers, and expected-output files, so a visible provider-limit pane wins over readiness timeout, tmux-session-death, and missing-output fallbacks. +## Tmux readiness detection + +`Hive::ClaudeLauncher.prepare_claude_session!` polls the pane tail until `claude_ready_prompt?` accepts it. Detection keys on the `❯` input caret anchored to a line edge (first glyph, last glyph, or Unicode whitespace after it), scanned across the bottom `CLAUDE_PROMPT_TAIL_LINES` (10) nonblank lines of the region that starts after the last blank line or `Claude Code` banner; the LOWEST anchored, non-menu-option caret wins, and trust (`Quick safety check`, `Yes, I trust this folder`) or permission (`Do you want to…`) copy strictly below that caret blocks readiness, because an active modal owns the pane floor (below-blocking rule). All whitespace classes use `[[:space:]]`, never `\s`: Claude Code 2.1.179 pads the caret hint with a non-breaking space (U+00A0) inside a separator/footer sandwich, and Ruby's ASCII-only `\s` matches neither NBSP nor U+3000 — hand-authored fixtures carry the literal NBSP bytes so engine drift fails in CI rather than in operator launches. Copy anchors (`Claude Code` banner, `for agents` footer, trust/permission strings) remain version-coupled surfaces to refresh when Claude Code renames its chrome. + +Packaging counterpart: `hive.gemspec` ships `lib/hive/scripts/**/*` so `wrapper_command`'s `interactive_claude_wrapper.sh` and `StopHookInstaller::HOOK_PATH` resolve inside installed gems; `test/unit/packaging/built_gem_scripts_test.rb` builds the real `.gem` into a tempdir and asserts every `scripts/*.sh` reference found in packaged lib sources appears among the artifact's `data.tar.gz` members. + ## `run!` (the main entry) 1. `ensure_log_dir`. @@ -164,7 +170,7 @@ The default Claude permission path still uses `--dangerously-skip-permissions` ( ## Tests - `test/unit/agent_test.rb` and `test/fixtures/fake-claude` exercise the spawn/wait/timeout logic without a real claude binary, including configurable Claude permission-mode argv and model/effort `cli_flags` reaching the headless command. -- `test/unit/claude_launcher_test.rb` covers the tmux wrapper argv carrying model/effort pins and omitting them when no flags are configured. +- `test/unit/claude_launcher_test.rb` covers the tmux wrapper argv carrying model/effort pins and omitting them when no flags are configured, plus the version-tolerant ready detector fixtures (2.1.179 NBSP sandwich acceptances; permission/trust/menu/below-blocking rejections). - `test/unit/spawn_agent_test.rb` covers `Stages::Base.spawn_agent` forwarding `claude.permission_mode` from config into headless Claude spawns and the stage permission-scope helper preserving yolo defaults. - `test/smoke/permission_scope_headless_smoke_test.rb` is a live Claude smoke proving a read-only headless write attempt completes without timeout and does not create the file, while yolo creates it.