Skip to content

fix(composer): make prompt detection locale-invariant - #4

Merged
purple-phoenix merged 3 commits into
mainfrom
fm/fm-main-test-fixes-t5
Jul 27, 2026
Merged

purple-phoenix merged 3 commits into
mainfrom
fm/fm-main-test-fixes-t5

Conversation

@purple-phoenix

Copy link
Copy Markdown
Owner

Intent

Fix the five pre-existing Firstmate main-suite test failures that were blocking every PR at the red test gate (including the capacity branch). The failures only reproduced under no-mistakes' LC_ALL=C environment: (1) AFK inject e2e misclassified U+2063 digests as user because bash ${var:0:1} counts bytes; (2-4) composer/placeholder expectations failed because ${var#?} multi-byte glyph stripping and grep multi-byte character classes break under C locale, misreading idle placeholders after ❯/› as pending and promoting box-drawing rows into bare composers; (5) fm-cd-pretool-check shellcheck belt-check failed when shellcheck was absent. Fix the CODE for locale invariance (exact glyph prefix strip, case-based bare-agent-prompt match, full-prefix inject marker classification) and align the shellcheck test skip with the arm-pretool pattern. Keep the change strictly scoped to these five failures; do not touch capacity skill files.

What Changed

  • Make shared composer glyph stripping and Herdr prompt detection locale-invariant, preventing idle prompts and box-drawing rows from being misclassified under LC_ALL=C.
  • Run custom Herdr prompt regexes under a discovered UTF-8 locale and fail closed when none is available, with the configuration contract documented.
  • Update AFK injection fixtures and composer regressions for multibyte prefix matching, and skip the cd-pretool ShellCheck belt-check when ShellCheck is unavailable.

Risk Assessment

✅ Low: Captain, the changes remain narrowly scoped to the five locale-dependent failures, and the follow-up safely resolves the prior custom Herdr regex portability risk by using a discovered UTF-8 locale or failing closed.

Testing

The supplied full-suite baseline passed; focused LC_ALL=C composer, fake-Herdr, real-tmux AFK, and shellcheck-absent checks also passed, with transcripts demonstrating correct placeholder, pending-text, box-row, sentinel, and skip behavior. The optional real-Herdr e2e skipped because Herdr is not installed, but its affected classifier path passed both the fake-Herdr suite and direct runtime verification.

Evidence: Locale-invariant behavior transcript

ambient locale: LC_ALL=C LANG=C Claude idle placeholder after ❯ => empty Codex idle placeholder after › => empty Claude typed content after ❯ => pending Herdr box-drawing row => unknown U+2063 submission prefix=e281a3 classification=injection

ambient locale: LC_ALL=C LANG=C
Claude idle placeholder after ❯ => empty
Codex idle placeholder after › => empty
Claude typed content after ❯ => pending
Herdr row [╭────────────╮] => unknown
Herdr row [❯ Type a message...]      => empty
Herdr row [› Type a message...]      => empty
Herdr row [❯ fix the login bug]      => pending
Submission prefix=e281a3 classification=injection
Submission prefix=636170 classification=user
Evidence: Focused LC_ALL=C regression tests
== tests/fm-composer-lib.test.sh ==
ok - fm_composer_classify_content: a bare shell prompt glyph (>/$/%/#) reads unknown, never empty
ok - fm_composer_classify_content: stripped unbordered content is unknown except verified agent glyphs
ok - fm_composer_classify_content: a bare shell prompt carrying a command is not empty
ok - fm_composer_classify_content: a bare prompt glyph inside a bordered composer box reads empty (claude's own idle composer)
ok - fm_composer_classify_content: agent prompt glyphs (❯ claude, › codex) read empty bordered or bare
ok - fm_composer_classify_content: an empty composer reads empty
ok - fm_composer_classify_content: a known idle placeholder reads empty, before and after glyph stripping
ok - fm_composer_classify_content: idle-after-glyph and bare agent glyphs are locale-invariant under LC_ALL=C
ok - fm_composer_classify_content: idle matching preserves the caller's case mode
ok - fm_composer_classify_content: real unsubmitted text reads pending (including a popup argument-hint fill)
== tests/fm-backend-herdr.test.sh ==
ok - fm_backend_herdr_version_check: accepts the current protocol (14)
ok - fm_backend_herdr_version_check: refuses an old protocol loudly
ok - fm_backend_herdr_version_check: refuses loudly when herdr is not installed
ok - fm_backend_herdr_workspace_label: a primary home (no marker) resolves to 'firstmate'
ok - fm_backend_herdr_workspace_label: a secondmate home (.fm-secondmate-home) resolves to '2ndmate-<id>'
ok - fm_backend_herdr_workspace_label: trims whitespace around the marker's secondmate id
ok - fm_backend_herdr_workspace_label: an empty marker file falls back to the primary label 'firstmate'
ok - fm_backend_herdr_workspace_label: two different secondmate homes get two different, non-colliding labels
ok - fm_backend_herdr_cli: sets HERDR_SESSION AND appends a trailing --session flag on every call
ok - fm_backend_herdr_container_ensure: version-gates, starts the server, ensures the firstmate workspace, echoes session:workspace_id + the seeded default tab id
ok - fm_backend_herdr_container_ensure: reuses an existing firstmate workspace without recreating it, and reports no seeded default tab (adopted, not created)
ok - fm_backend_herdr_container_ensure: workspace create passes --no-focus
ok - fm_backend_herdr_container_ensure: creates the workspace under the SECONDMATE home's own label, not 'firstmate'
ok - fm_backend_herdr_create_task: prunes exactly the seeded default tab container_ensure identified, once the first real task tab exists
ok - herdr repeated spawn/teardown: one persistent firstmate workspace reused, zero orphans, default tab pruned, create ran once
ok - fm_backend_herdr_create_task: an ADOPTED workspace's pre-existing tab is never pruned (the created-vs-adopted gate)
ok - fm_backend_herdr_create_task: the label-collision startup-workspace scenario (2026-07-02 incident) leaves the captain's live tab untouched
ok - fm_backend_herdr_workspace_prune_seeded_default_tab: refuses to close the seeded default tab when its pane reports a working agent (defense in depth)
ok - no bin/ jq filter names a --arg/--argjson variable after a jq reserved keyword
ok - fm_backend_herdr_create_task: refuses a duplicate tab label (herdr's own tab create has no uniqueness check)
ok - fm_backend_herdr_create_task: a same-labeled tab with a live (even idle) registered agent still refuses exactly as before
ok - fm_backend_herdr_create_task: scans every same-labeled tab and refuses if any duplicate is live
ok - fm_backend_herdr_create_task: closes and replaces a same-labeled tab whose pane is dead (pane_not_found)
ok - fm_backend_herdr_create_task: closes and replaces a same-labeled tab whose pane is alive but hosts no registered agent (a restored plain shell)
ok - fm_backend_herdr_create_task: closes every confirmed same-labeled husk only after creating the replacement
ok - fm_backend_herdr_create_task: refuses success when a preexisting husk tab remains after replacement
ok - fm_backend_herdr_create_task: refuses (fail-safe) rather than guessing when the duplicate's agent state cannot be classified confidently
ok - fm_backend_herdr_create_task: creates the replacement tab BEFORE closing the husk tab, never the reverse
ok - fm_backend_herdr_create_task: creates a tab and parses tab_id/pane_id from the JSON response, prunes nothing when no seeded tab id is given
ok - fm_backend_herdr_create_task: tab create passes --no-focus
ok - fm_backend_herdr_workspace_find: matches only THIS home's own label among several coexisting workspaces
ok - fm_backend_herdr_list_live: scoped to this home's own workspace, never a sibling home's
ok - fm_backend_herdr_parse_target: splits '<session>:<pane_id>' on the FIRST colon (pane_id itself contains one)
ok - fm_backend_herdr_normalize_key: Enter/Escape/C-c map to herdr's verified enter/escape/ctrl+c
ok - fm_backend_herdr_capture: calls 'pane read <pane> --source recent --lines N' with the session set
ok - fm_backend_herdr_capture: works around the verified small-N '--lines' bug by over-fetching and trimming locally
ok - fm_backend_herdr_capture: ensures the session and preserves pane read failure
ok - fm_backend_herdr_send_key: normalizes the key and targets the right pane
ok - fm_backend_herdr_kill: calls pane close and stays best-effort on failure
ok - fm_backend_herdr_current_path: reads pane foreground_cwd (the live running process), not the frozen creation-time cwd
ok - fm_backend_herdr_busy_state: working -> busy
ok - fm_backend_herdr_busy_state: done -> idle, blocked -> idle (surfaced like a stale pane, not suppressed as busy)
ok - fm_backend_herdr_busy_state: unparseable/absent agent state reports unknown, the regex-fallback cue
ok - fm_backend_herdr_composer_state: a bare '❯' composer row reads empty
ok - fm_backend_herdr_composer_state: the ghost placeholder text reads empty, not pending
ok - fm_backend_herdr_composer_state: real composer text reads pending
ok - fm_backend_herdr_composer_state: a slash-command popup's argument-hint placeholder still reads pending (the incident fix)
ok - fm_backend_herdr_composer_state: reports unknown when the pane cannot be captured
ok - fm_backend_herdr_composer_state: reports unknown for bare shell prompts with no composer row
ok - fm_backend_herdr_composer_state: a native idle Pi separator composer reads empty
ok - fm_backend_herdr_composer_state: real Pi composer text remains pending
ok - fm_backend_herdr_composer_state: an incomplete lower Pi separator cannot inherit a stale empty row
ok - fm_backend_herdr_composer_state: Pi separators never authorize working, non-Pi, unreadable, or over-tall targets
ok - fm_backend_herdr_composer_state: a real-claude unbordered '❯' prompt row (no border box in view) reads empty
ok - fm_backend_herdr_composer_state: a real-claude unbordered '❯ <text>' prompt row reads pending
ok - fm_backend_herdr_composer_state: custom prompts use a discovered UTF-8 locale
ok - fm_backend_herdr_composer_state: custom prompts fail closed without a UTF-8 locale
ok - fm_backend_herdr_composer_state: a live unbordered prompt row below a stale bordered decorative box still wins (not misread as the box's own row)
ok - fm_backend_herdr_composer_state: claude's dim prompt-suggestion ghost (the overnight wedge shape) reads empty
ok - fm_backend_herdr_composer_state: real typed text on the same claude prompt row still reads pending
ok - fm_backend_herdr_composer_state: grok's dark-truecolor placeholder (the TRUECOLOR gap) reads empty
ok - fm_backend_herdr_composer_state: grok's real bright typed input still reads pending
ok - fm_backend_herdr_composer_state: a real-codex unbordered '›' prompt row reads empty
ok - fm_backend_herdr_composer_state: a faint real-codex ghost suggestion reads empty
ok - fm_backend_herdr_composer_state: non-faint codex prompt text still reads pending
ok - fm_backend_herdr_wait_for_working: reports 'busy' immediately on the first poll, without spending the rest of the budget
ok - fm_backend_herdr_wait_for_working: a slow transition landing on a later sample within one window is still caught (robust against the 'slow transition' failure direction)
ok - fm_backend_herdr_wait_for_working: spreads six samples across the full budget endpoint without a final trailing sleep
ok - fm_backend_herdr_send_text_submit: applies the herdr minimum confirmation budget before polling agent-state
ok - fm_backend_herdr_wait_for_working: reports 'idle' (readable, genuinely not yet working) when 'busy' never appears
ok - fm_backend_herdr_wait_for_working: reports 'unknown' (a hard read failure, not a timing race) only when EVERY poll in the window fails
ok - fm_backend_herdr_wait_for_working: treats blocked as submit-active for confirmation without changing watcher busy-state semantics
ok - fm_backend_herdr_send_text_submit: reports 'empty' once agent_status reports working after one Enter, without ever reading the composer
ok - fm_backend_herdr_send_text_submit: reports 'pending' when agent_status never reports working after retried Enters (swallowed)
ok - fm_backend_herdr_send_text_submit: a slash-command popup's placeholder fill on Enter #1 never flips agent_status to working, so it does not short-circuit as submitted; Enter #2 is retried and lands it
ok - fm_backend_herdr_send_text_submit: a post-Enter blocked state confirms delivery without retrying into the prompt
ok - fm_backend_herdr_send_text_submit: preexisting working is not accepted as submit proof when the composer still holds the message
ok - fm_backend_herdr_send_text_submit: confirms submission via native agent-state alone, immune to a codex-style dynamic idle-tip composer that would have misread as 'pending' under the old composer-based confirmation
ok - fm_backend_herdr_composer_state: a faint real-codex dynamic idle-tip composer row reads empty
ok - fm_backend_composer_state (herdr): the pre-injection empty-box guard still refuses a genuinely non-empty composer, unaffected by the submit-confirmation change
ok - fm_backend_herdr_send_text_submit: a slow transition landing on a later sample within one Enter's budget is confirmed WITHOUT sending a needless extra Enter
ok - fm_backend_herdr_send_text_submit: reports 'send-failed' when the literal send-text call itself errors
ok - fm_backend_herdr_send_text_submit: reports 'unknown' when the post-Enter agent-get read fails (never retries past an unreadable target)
ok - fm_backend_validate: herdr is a known backend (P2)
ok - fm_backend_busy_state: tmux (no native primitive) always reports unknown, preserving the P1 regex-only path
ok - fm_backend_composer_state dispatches tmux/herdr/orca to their named classifiers, unknown for zellij/unrecognized backends
ok - fm-peek/fm-send: explicit stale targets matching metadata use the recorded backend
ok - fm_backend_herdr_normalize_event routes through the shared record with an empty from_status
ok - fm_backend_herdr_escalation_marker keys the dedupe marker exactly like the watcher's .stale-<key>
ok - fm_backend_herdr_apply_transition: blocked dedupe starts only after explicit commit
ok - fm_backend_herdr_apply_transition: a working edge clears the marker so the next ->blocked re-escalates
ok - fm_backend_herdr_clear_transition removes task-owned dedupe state
ok - fm_backend_herdr_apply_transition: idle/done (defer) and unknown/empty (fallback) take no fast action
ok - fm_backend_herdr_wait_transition: a home with no herdr panes falls back to polling (rc 2)
ok - fm_backend_herdr_wait_transition: below-capability protocol/schema falls back to polling (rc 2)
ok - fm_backend_herdr_wait_transition: reconnect level-reconcile returns an uncommitted blocked pane
ok - fm_backend_herdr_wait_transition: subscribes before reconnect level-reconcile
ok - fm_backend_herdr_wait_transition: a still-blocked, already-escalated pane is not re-delivered on reconnect
ok - fm_backend_herdr_wait_transition: a streamed ->blocked edge returns the record sub-poll
ok - fm_backend_herdr_wait_transition: streamed working clears the marker, idle/done are deferred (clean timeout)
ok - fm_backend_herdr_wait_transition: a reader/subscribe failure falls back to polling (rc 2)
ok - fm_backend_herdr_wait_transition: Bash 3.2-safe bad-ack path closes fd 9 and removes its FIFO
ok - fm_backend_herdr_wait_transition: stock macOS Bash clean timeout closes fd 9 and returns 1
== tests/fm-afk-inject-e2e.test.sh ==
ok - Scenario A: partial input defers injection; digest arrives clean after idle
ok - Scenario B: swallowed Enter produces exactly one clean digest
ok - Scenario C: a normal captain status injects exactly one clean single-line sentinel digest
all e2e injection tests passed
== tests/fm-afk-inject-herdr-e2e.test.sh ==
skip: herdr not found
Evidence: Shellcheck-absent behavior

The complete cd-guard test passed with shellcheck deliberately absent from PATH, ending with: ok - shellcheck not installed, skipping

ok - cd-guard acceptance matrix: 63 cases x 5 harness entry forms, block/allow all correct
ok - cd-guard: fires in a secondmate home (its own primary session is a primary)
ok - cd-guard: inert in a crewmate/scout task worktree (linked git worktree)
ok - cd-guard: inert in a non-firstmate repo (no AGENTS.md)
ok - cd-guard: inert when not inside a git repo
ok - cd-guard: reproduces the cwd leak and denies the exact command that causes it
ok - cd-guard: fails open on empty stdin
ok - cd-guard: fails open on unparseable stdin JSON
ok - cd-guard: fails open (never blocks) when node is missing
ok - cd-guard: fails open on the stdin path when jq is missing
ok - cd-guard: prefilter fast-allows (skips node) when no cd/pushd/popd substring is present
ok - cd-guard: fm-cd-command-policy.mjs CLI honors the deny/allow output contract
ok - .claude/settings.json: PreToolUse invokes the cd-guard alongside the arm guard
ok - .codex/hooks.json: PreToolUse invokes the cd-guard alongside the arm guard
ok - .grok primary cd hook: PreToolUse invokes the cd-guard
ok - .opencode cd plugin: tool.execute.before invokes the cd-guard and blocks by throwing
ok - .pi primary extension: tool_call runs the cd-guard alongside the watcher-arm check
ok - shellcheck not installed, skipping

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed ✅
  • ⚠️ bin/backends/herdr.sh:839 - A custom FM_BACKEND_HERDR_BARE_PROMPT_RE no longer replaces the default matcher: ❯ and › are always accepted before the configured expression is consulted. This breaks the documented override contract and could authorize injection into a prompt an operator intentionally excluded. Apply the case matcher only when the configured value is the default, or confirm that the setting is now intentionally additive.
  • ⚠️ bin/backends/herdr.sh:846 - Custom prompt matching now depends on either C.UTF-8 or en_US.UTF-8 existing. Neither locale name is guaranteed; if both are unavailable, grep can fall back to bytewise matching with errors hidden, recreating the false-positive behavior this change fixes. Select an available UTF-8 locale or implement custom glyph matching without optional locale dependencies.

🔧 Fix: Discover available UTF-8 locale for custom Herdr prompts
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"
  • Configured full-suite baseline already passed: command -v tmux &gt;/dev/null || { echo &#34;tmux is required for e2e tests&#34; &gt;&amp;2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo &#34;== $t ==&#34;; bash &#34;$t&#34; || rc=1; done; exit &#34;$rc&#34;
  • LC_ALL=C LANG=C bash tests/fm-composer-lib.test.sh
  • LC_ALL=C LANG=C bash tests/fm-backend-herdr.test.sh
  • LC_ALL=C LANG=C bash tests/fm-afk-inject-e2e.test.sh
  • LC_ALL=C LANG=C bash tests/fm-afk-inject-herdr-e2e.test.sh (skipped: Herdr binary unavailable)
  • PATH=<temporary PATH without shellcheck> LC_ALL=C LANG=C bash tests/fm-cd-pretool-check.test.sh
  • Manual LC_ALL=C runtime exercise of fm_composer_classify_content and fm_backend_herdr_composer_state for ❯/› placeholders, typed input, box-drawing rows, and U+2063 classification
  • Reviewed ab9cd7e...afd0644 changed paths and confirmed no capacity skill files were touched.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

No-mistakes runs the suite with LC_ALL=C, where bash ${var#?} and
${var:0:1} count bytes and grep multi-byte character classes become
raw-byte sets. That misread idle placeholders after ❯/› as pending,
promoted box-drawing rows into bare composers, and classified U+2063
injections as user. Strip glyphs by exact prefix, match bare agent
prompts with case, classify inject markers by full prefix, and skip
the cd-guard shellcheck belt-check when shellcheck is absent.
@purple-phoenix
purple-phoenix merged commit d225b87 into main Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant