From 200f95d720e0aee4678e960042501051115b3b1e Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Fri, 14 Aug 2026 20:52:26 +0800 Subject: [PATCH 1/4] fix(runtime): isolate Grok hooks and floor Herdr projection --- .agents/skills/harness-adapters/SKILL.md | 4 +- .claude/settings.json | 10 +- AGENTS.md | 2 +- bin/backends/herdr.sh | 185 ++++++++++++++-- bin/fm-config-inherit-lib.sh | 8 +- bin/fm-harness.sh | 8 +- bin/fm-spawn.sh | 14 +- docs/architecture.md | 2 +- docs/configuration.md | 2 +- docs/herdr-backend.md | 27 ++- docs/subagent-guard.md | 5 + docs/turnend-guard.md | 8 +- docs/verification/supervision.md | 2 + tests/fm-backend-herdr.test.sh | 267 ++++++++++++++++++++--- tests/fm-secondmate-harness.test.sh | 19 +- tests/fm-turnend-guard.test.sh | 57 +++++ 16 files changed, 533 insertions(+), 87 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 342f5a517f..8239477a6d 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -324,7 +324,7 @@ For Grok's supported reasoning-effort values and omission behavior, see the [lau | Interrupt | single `Ctrl+C` (cancels the current turn; the footer shows `Ctrl+c:cancel` mid-turn). `Esc` only moves focus to the scrollback, it does NOT interrupt. | | Skill invocation | `/` (e.g. `/no-mistakes`), same as claude. Opens a slash-autocomplete popup, so a too-fast Enter selects the popup entry instead of sending. For an argument-taking command that first Enter does not submit at all - it expands the selection into an argument-hint placeholder in the composer (e.g. `/compact` -> `/compact compaction instructions`, live-verified), leaving real text still sitting there unsubmitted; a genuine second Enter is required. `fm-send`'s retried Enter lands it on BOTH backends, but only because each backend's own submit-verification correctly recognizes that placeholder-filled text as still-pending - see the incident below. | | Autonomy | `--always-approve` (footer shows `· always-approve`); auto-approves every tool execution, verified to run fully unattended. `--permission-mode bypassPermissions` is the stronger equivalent. | -| Env marker | `GROK_AGENT=1`, set for child/tool processes. grok does NOT set `CLAUDECODE` despite Claude compatibility, so the marker is unambiguous. | +| Env marker | `GROK_AGENT=1`, set for child/tool processes on grok 0.2.73. grok does NOT set `CLAUDECODE` despite Claude compatibility, so the marker is unambiguous when present, but it is not guaranteed present: a grok 1.0.0 hook process carries `GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, `GROK_SESSION_ID`, and `GROK_WORKSPACE_ROOT` with no `GROK_AGENT`. Treat it as a fast path only; `bin/fm-harness.sh`'s ancestry walk guarantees grok identification, and a rule that must be reliable under grok has to test the hook markers too (owner: `docs/turnend-guard.md` "Harness integrations"). | | Resume | `grok --resume ` (id printed on exit) or `grok -c` / `--continue` (most recent for the cwd); `--fork-session` branches a new session id. | **Incident (2026-07-03, herdr backend only, grok 0.2.82):** two grok/herdr crewmates were sent `/no-mistakes` via `fm-send`; both left it fully typed but unsubmitted in the composer for minutes (footer still `Enter:send`), and `fm-send` exited 0 with no error. @@ -363,7 +363,7 @@ Secondmate spawns skip the pointer (idle panes are healthy, no stale-pane detect The firstmate PRIMARY's own `.grok/hooks/fm-primary-turnend-guard.json` invokes `bin/fm-turnend-guard-grok.sh`. Grok 0.2.112 exposes native same-process Stop continuation in its running payload, while the genuine pre-native 0.2.73 payload omits that capability and still needs one guarded `grok --resume`. The exact adaptive and malformed-input contract is owned by `docs/turnend-guard.md`. -The tracked Claude Stop hooks skip themselves under `GROK_AGENT`, because Grok also loads Claude-compatible project settings and otherwise creates a second blocking path. +The tracked Claude hook entries whose events Grok already covers through `.grok/hooks/` skip themselves under `GROK_AGENT` or `GROK_HOOK_EVENT`, because Grok also loads Claude-compatible project settings and otherwise creates duplicate paths; `docs/turnend-guard.md` "Harness integrations" owns the exact marker set and exception. Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm. Grok's primary watcher protocol remains background-notify around `bin/fm-watch-arm.sh`; native Stop continuation does not provide Pi-like extension ownership. diff --git a/.claude/settings.json b/.claude/settings.json index 5ecbe4e1d0..2d2e16a017 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-sessionstart-run.sh", + "command": "[ -z \"${GROK_AGENT:-}${GROK_HOOK_EVENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-sessionstart-run.sh", "timeout": 180 } ] @@ -17,11 +17,11 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-arm-pretool-check.sh --claude" + "command": "[ -z \"${GROK_AGENT:-}${GROK_HOOK_EVENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-arm-pretool-check.sh --claude" }, { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" + "command": "[ -z \"${GROK_AGENT:-}${GROK_HOOK_EVENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" } ] }, @@ -40,11 +40,11 @@ "hooks": [ { "type": "command", - "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" + "command": "[ -z \"${GROK_AGENT:-}${GROK_HOOK_EVENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" }, { "type": "command", - "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", + "command": "[ -z \"${GROK_AGENT:-}${GROK_HOOK_EVENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", "asyncRewake": true, "timeout": 28800 } diff --git a/AGENTS.md b/AGENTS.md index 9a77094668..0dc15b4881 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ config/backlog-backend backlog backend override; LOCAL, gitignored; absent or " config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget" -config/herdr-presentation-spaces optional "off" opt-out from Herdr's default-on disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" +config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, whose unconfigured default requires Herdr 0.8.0 or newer; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" config/trace-context optional presence flag enabling default-off native W3C trace-context propagation to spawned agents; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Trace context propagation" and docs/trace-context.md config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 29ef7e5caa..c54b77a9f0 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -33,7 +33,8 @@ # (upstream discussion #1328, fixed by PR #1877), while a pane-death removal # preserves focus exactly when the dying workspace sits behind the focused # one or the focused one is last (upstream issue #1621, fixed by PR #1912); -# both fixes are merged upstream but in no release. Projected cleanup +# both fixes first shipped in Herdr 0.8.0, which is the version floor for +# default-on projection. Projected cleanup # therefore serializes under the session lock, repositions a doomed workspace # behind the focused one when needed, and ends its verified lone idle shell # so Herdr removes the emptied workspace through the focus-preserving @@ -104,6 +105,14 @@ FM_BACKEND_HERDR_MIN_EVENTS_PROTOCOL=16 # presentation path uses one narrowly whitelisted raw-socket request after # verifying the exact method and parameter schema. FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL=16 +# Projection turns every crewmate teardown into a workspace-emptying removal. +# Herdr 0.8.0 is the first release carrying both upstream focus fixes, so a +# home that configured nothing is projected only at or above that release. +# Protocol 19 is the structural signal for the floor: measured 0.7.x releases +# top out at protocol 17, while 0.8.0 reports protocol 19. +FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL=19 +FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION=0.8.0 +FM_BACKEND_HERDR_PRESENTATION_FLOOR_MARKER_PREFIX=".herdr-presentation-floor-" # Per-pane escalation dedupe marker prefix, under the state dir. One marker per # window (keyed like the watcher's own .stale-): set when a ->blocked edge # is enqueued, cleared on any working edge, so exactly one wake fires per @@ -124,34 +133,163 @@ FM_BACKEND_HERDR_SECONDMATE_MARKER=".fm-secondmate-home" # No send, capture, Treehouse, or general task-ownership path reads it. FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation" -# The config item a home writes to opt OUT of the projection. +# The config item a home writes to opt out of, or explicitly in to, the +# projection. FM_BACKEND_HERDR_PRESENTATION_CONFIG="herdr-presentation-spaces" -# fm_backend_herdr_presentation_enabled : true when this home's -# children should be projected into disposable one-task workspaces -# (docs/herdr-backend.md "Presentation spaces" owns the full contract). -# Projection is ON by default, so an absent config file enables it; a home opts -# out by writing "off". Values are read with the whole-file whitespace-stripped -# convention the other scalar config items already use (config/backlog-backend, -# config/crew-harness), plus case folding. An empty file is the historical -# presence-based opt-in form and still means on, so no home that had the -# projection enabled can be turned off by the default flip. An unrecognized -# value warns and keeps the default rather than failing a spawn over a purely -# visual setting, so a typo is visible instead of silently disabling anything. -fm_backend_herdr_presentation_enabled() { # +# fm_backend_herdr_presentation_preference : echoes exactly one of +# off, on, or default. An empty file is the historical explicit opt-in. +fm_backend_herdr_presentation_preference() { # local config_dir=${1:-} file value - [ -n "$config_dir" ] || return 0 + [ -n "$config_dir" ] || { printf 'default\n'; return 0; } file="$config_dir/$FM_BACKEND_HERDR_PRESENTATION_CONFIG" - [ -f "$file" ] || return 0 + [ -f "$file" ] || { printf 'default\n'; return 0; } value=$(tr -d '[:space:]' < "$file" 2>/dev/null | tr '[:upper:]' '[:lower:]') || value="" case "$value" in - off) return 1 ;; - ''|on) return 0 ;; + off) printf 'off\n' ;; + ''|on) printf 'on\n' ;; *) - echo "warning: $file: unrecognized value \"$value\"; herdr presentation spaces stay on (write \"off\" to opt out)" >&2 + echo "warning: $file: unrecognized value \"$value\"; herdr presentation spaces fall back to the default (write \"off\" to opt out, \"on\" to force the projection on)" >&2 + printf 'default\n' + ;; + esac +} + +# Numeric dotted-release comparison. Return 0 when candidate >= floor, 1 when +# candidate < floor, and 2 when candidate is not parseable. +fm_backend_herdr_version_at_least() { # + local candidate=${1:-} floor=${2:-} c f + candidate=${candidate%%[-+]*} + case "$candidate" in ''|*[!0-9.]*) return 2 ;; esac + while [ -n "$floor" ]; do + c=${candidate%%.*} + f=${floor%%.*} + [ -n "$c" ] || c=0 + [ "$c" -gt "$f" ] 2>/dev/null && return 0 + [ "$c" -lt "$f" ] 2>/dev/null && return 1 + case "$candidate" in *.*) candidate=${candidate#*.} ;; *) candidate= ;; esac + case "$floor" in *.*) floor=${floor#*.} ;; *) floor= ;; esac + done + return 0 +} + +# Pure release-floor classifier. Return 0 at or above the floor, 1 when +# provably below it, and 2 when both signals are indeterminate. Either the +# structural protocol or the version can carry a positive verdict. +fm_backend_herdr_release_floor_verdict() { # + local protocol=${1:-} version=${2:-} protocol_known=0 version_status=0 + case "$protocol" in + ''|*[!0-9]*) ;; + *) + protocol_known=1 + [ "$protocol" -ge "$FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL" ] && return 0 + ;; + esac + fm_backend_herdr_version_at_least "$version" "$FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION" \ + || version_status=$? + [ "$version_status" -eq 0 ] && return 0 + { [ "$protocol_known" -eq 1 ] || [ "$version_status" -eq 1 ]; } && return 1 + return 2 +} + +# Classify the installed client and, when present, the selected session's +# running server. Both applicable releases must meet the floor. +fm_backend_herdr_presentation_release_supported() { # [] + local session=${1:-} status running client_protocol client_version client_verdict=0 + local server_protocol server_version server_verdict=0 + FM_BACKEND_HERDR_PRESENTATION_RELEASE="an unreadable release" + command -v herdr >/dev/null 2>&1 || return 2 + command -v jq >/dev/null 2>&1 || return 2 + [ -n "$session" ] || session=$(fm_backend_herdr_session) + status=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null) || return 2 + client_protocol=$(printf '%s' "$status" | jq -r '.client.protocol // empty' 2>/dev/null) || return 2 + client_version=$(printf '%s' "$status" | jq -r '.client.version // empty' 2>/dev/null) || return 2 + fm_backend_herdr_release_floor_verdict "$client_protocol" "$client_version" || client_verdict=$? + running=$(printf '%s' "$status" | jq -r ' + if .server.running == true then "true" + elif .server.running == false then "false" + else "unknown" + end + ' 2>/dev/null) || return 2 + case "$running" in + true) + server_protocol=$(printf '%s' "$status" | jq -r '.server.protocol // empty' 2>/dev/null) || return 2 + server_version=$(printf '%s' "$status" | jq -r '.server.version // empty' 2>/dev/null) || return 2 + fm_backend_herdr_release_floor_verdict "$server_protocol" "$server_version" || server_verdict=$? + if [ "$server_verdict" -eq 1 ]; then + FM_BACKEND_HERDR_PRESENTATION_RELEASE="server version ${server_version:-unknown} (protocol ${server_protocol:-unknown})" + return 1 + fi + if [ "$client_verdict" -eq 1 ]; then + FM_BACKEND_HERDR_PRESENTATION_RELEASE="version ${client_version:-unknown} (protocol ${client_protocol:-unknown})" + return 1 + fi + if [ "$server_verdict" -ne 0 ]; then + FM_BACKEND_HERDR_PRESENTATION_RELEASE="server version ${server_version:-unknown} (protocol ${server_protocol:-unknown})" + return 2 + fi + if [ "$client_verdict" -ne 0 ]; then + FM_BACKEND_HERDR_PRESENTATION_RELEASE="version ${client_version:-unknown} (protocol ${client_protocol:-unknown})" + return 2 + fi return 0 ;; + false) + FM_BACKEND_HERDR_PRESENTATION_RELEASE="version ${client_version:-unknown} (protocol ${client_protocol:-unknown})" + return "$client_verdict" + ;; + *) return 2 ;; + esac +} + +# Warn once per detected release when a usable state directory is supplied. +fm_backend_herdr_presentation_floor_warn() { # + local state_dir=${1:-} verdict=${2:-2} release=${FM_BACKEND_HERDR_PRESENTATION_RELEASE:-an unreadable release} + local key marker reason tmp="" + if [ "$verdict" -eq 1 ]; then + reason="herdr $release is older than the $FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION floor for presentation spaces, where projected cleanup can steal the active workspace" + else + reason="the selected herdr release could not be read, so the $FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION floor for presentation spaces cannot be verified" + fi + if [ -n "$state_dir" ] && [ -d "$state_dir" ] && [ ! -L "$state_dir" ]; then + key=${release//[^a-zA-Z0-9]/-} + marker="$state_dir/$FM_BACKEND_HERDR_PRESENTATION_FLOOR_MARKER_PREFIX$key" + { [ -e "$marker" ] || [ -L "$marker" ]; } && return 0 + tmp=$(umask 077; mktemp "$state_dir/.herdr-presentation-floor.XXXXXX" 2>/dev/null) || tmp="" + if [ -n "$tmp" ]; then + if ln "$tmp" "$marker" 2>/dev/null; then + rm -f -- "$tmp" + else + rm -f -- "$tmp" + { [ -e "$marker" ] || [ -L "$marker" ]; } && return 0 + fi + fi + fi + echo "warning: $reason; using the ordinary flat layout instead. Upgrade herdr to $FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION or newer (herdr update) to restore the projection, or write \"on\" into config/$FM_BACKEND_HERDR_PRESENTATION_CONFIG to force it on this release." >&2 + return 0 +} + +fm_backend_herdr_presentation_default_supported() { # [] + local state_dir=${1:-} session=${2:-} verdict=0 + fm_backend_herdr_presentation_release_supported "$session" || verdict=$? + [ "$verdict" -eq 0 ] && return 0 + fm_backend_herdr_presentation_floor_warn "$state_dir" "$verdict" + return 1 +} + +# The single spawn-time projection gate. Explicit off and on are honored as +# written; an unconfigured home projects only on a verified supported release. +fm_backend_herdr_presentation_enabled() { # [] + local config_dir=${1:-} state_dir=${2:-} preference + preference=$(fm_backend_herdr_presentation_preference "$config_dir") + # bin/fm-spawn.sh reads this out-parameter after sourcing this adapter. + # shellcheck disable=SC2034 + FM_BACKEND_HERDR_PRESENTATION_PREFERENCE=$preference + case "$preference" in + off) return 1 ;; + on) return 0 ;; esac + fm_backend_herdr_presentation_default_supported "$state_dir" } # fm_backend_herdr_workspace_label: the per-firstmate-HOME herdr workspace # label (docs/herdr-backend.md "Default task container shape"). The PRIMARY home (no @@ -605,7 +743,7 @@ fm_backend_herdr_projection_focus_snapshot() { # # explicit pane.close that empties a non-focused workspace moves focus to # that workspace's neighbor (upstream #1328/#1877), and a pane-death removal # before a non-last focused workspace moves focus to the focused workspace's -# right neighbor (upstream #1621/#1912); both fixes are unreleased. +# right neighbor (upstream #1621/#1912); both fixes shipped in Herdr 0.8.0. # A single tab.focus on the exact response-independent pre-operation tab id # restores both the workspace and tab atomically. fm_backend_herdr_projection_focus_restore() { # @@ -744,13 +882,14 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # : python3 for the transport, diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 374ee5078d..a33b87a460 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -9,10 +9,10 @@ # runtime-backend default for future spawns, primary config/startup-memory-budget # bounds that home's startup-memory curation, and primary # config/herdr-presentation-spaces carries the same Herdr presentation-projection -# choice - that item is default-ON, so an absent primary file and an absent -# destination file both mean on and the generic absence mirror below already -# converges a secondmate to the primary's default rather than turning it off; -# only an explicit primary "off" propagates an opt-out, and primary +# preference - an absent primary file and an absent destination file both mean +# the same unconfigured default, so the generic absence mirror below converges +# a secondmate without deciding the release-dependent floor; explicit "on" and +# "off" preferences propagate as files, and primary # config/trace-context is copied at the launch convergence point as part of the # default-off W3C trace-context setup, while live convergence leaves it unchanged. # The primary passes its frozen home-session decision into a newly launched diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 824b95804d..db06d3951f 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -40,9 +40,11 @@ detect_own() { if [ "${FM_PI_HARNESS:-}" = pi-signed ]; then echo pi-signed; else echo pi; fi return fi - # grok sets GROK_AGENT=1 for its child/tool processes (verified, grok 0.2.73). - # It does NOT set CLAUDECODE despite being Claude-Code-compatible, so this marker - # is unambiguous when firstmate runs natively on grok. + # grok set GROK_AGENT=1 for its child/tool processes on 0.2.73. + # It does NOT set CLAUDECODE despite being Claude-Code-compatible, so this + # marker is unambiguous when present, but a grok 1.0.0 hook process carried + # GROK_HOOK_EVENT and related hook markers without GROK_AGENT. Treat this as + # a fast path only; the ancestry walk below guarantees grok identification. [ "${GROK_AGENT:-}" = "1" ] && { echo grok; return; } # Layer 2: walk the parent chain and match the command name. local pid=$$ comm args diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9b75859585..1d38ecdf62 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -67,9 +67,12 @@ # outside herdr has no workspace to inherit and uses this home's own labeled # workspace, which must then match exactly one. --secondmate is the deliberate # exception: it stands up that secondmate home's own workspace. -# Herdr additionally uses a default-on presentation-only layout unless the -# local config/herdr-presentation-spaces file says off. A clean fresh task first -# writes state/.herdr-presentation atomically, then creates a disposable +# Herdr additionally uses a presentation-only layout by default when the +# selected client and running server meet the Herdr 0.8.0 floor. The local +# config/herdr-presentation-spaces file can say off to disable it or on to +# opt in below that floor; an empty file remains the historical opt-in form. +# A clean fresh task first writes state/.herdr-presentation atomically, +# then creates a disposable # workspace containing only the ordinary task pane. A successful clean create # upgrades its attempt journal with exact home, session, workspace, tab, pane, # parent, and label bindings. On a same-identity restart, that complete binding @@ -1660,7 +1663,7 @@ case "$BACKEND" in fi HERDR_PRESENTATION_JOURNAL=$(fm_backend_herdr_projection_journal_path "$STATE" "$ID") HERDR_PROJECTED=0 - if [ "$KIND" != secondmate ] && fm_backend_herdr_presentation_enabled "$CONFIG"; then + if [ "$KIND" != secondmate ] && fm_backend_herdr_presentation_enabled "$CONFIG" "$STATE"; then HERDR_SES=$(fm_backend_herdr_session) HERDR_PARENT_LABEL=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_workspace_label) if [ -e "$HERDR_PRESENTATION_JOURNAL" ] || [ -L "$HERDR_PRESENTATION_JOURNAL" ]; then @@ -1710,6 +1713,9 @@ case "$BACKEND" in # live named-session socket before journal publication. if ! fm_backend_herdr_server_ensure "$HERDR_SES"; then echo "warning: herdr presentation could not ensure its session server; using the ordinary flat layout without projection" >&2 + elif [ "${FM_BACKEND_HERDR_PRESENTATION_PREFERENCE:-default}" = default ] \ + && ! fm_backend_herdr_presentation_default_supported "$STATE" "$HERDR_SES"; then + : elif spawn_herdr_presentation_order_lock_acquire "$HERDR_SES"; then # The projected child is placed and bound UNDER this launcher's exact # parent workspace. Its own herdr pane identity names that workspace diff --git a/docs/architecture.md b/docs/architecture.md index 668489e6a2..2d1ba90991 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,7 +161,7 @@ For capable Herdr sessions, the same watcher replaces its terminal sleep with a The deeper session-start agent-process liveness probe is separate from that busy-state poll: tmux and Herdr have verified classifiers for secondmate recovery, Zellij remains unverified, and Orca and cmux do not support secondmate spawns. Herdr is experimental and can be selected explicitly or by runtime auto-detection: Treehouse remains its worktree provider, [`herdr-backend.md`](herdr-backend.md) owns current setup and safety limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#herdr) owns active empirical evidence. Herdr uses one tab per task; [Watching and task containers](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, and recovery scope. -Its default-on presentation projection may place one clean new task in a disposable workspace without changing endpoint authority or lifecycle ownership; [Presentation spaces](herdr-backend.md#presentation-spaces) owns that conditional design and its narrow home-local restored-shell cleanup at locked session start. +Its default-on presentation projection may place one clean new task in a disposable workspace without changing endpoint authority or lifecycle ownership; [Presentation spaces](herdr-backend.md#presentation-spaces) owns that conditional design, the Herdr version floor behind its unconfigured default, and its narrow home-local restored-shell cleanup at locked session start. Zellij is experimental and selected only explicitly: Treehouse remains its worktree provider, [`zellij-backend.md`](zellij-backend.md) owns current setup and limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#zellij) owns active empirical evidence. Zellij's container shape is simpler than herdr's: one shared `firstmate` session, one tab per task, with no per-home workspace split; visible tab titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path. Orca is experimental and selected only explicitly: Orca owns both worktree and terminal lifecycle, records `orca_worktree_id=` and `terminal=`, and removes worktrees through `orca worktree rm` only after the usual firstmate teardown checks pass. diff --git a/docs/configuration.md b/docs/configuration.md index 055274bca5..d44ab2e6e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,7 +83,7 @@ Missing, empty, duplicate, malformed, backend-inconsistent, or task-mismatched e Legacy tmux metadata remains cleanup-compatible when its exact window name is `fm-`; opaque non-tmux endpoints require their recorded `endpoint_task_id=` binding. `FM_HOME` determines Herdr's home label: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. [`herdr-backend.md`](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, collision handling, and recovery behavior. -The local `config/herdr-presentation-spaces` file instead opts a home out of Herdr's default-on disposable single-task visual projection; [Presentation spaces](herdr-backend.md#presentation-spaces) owns its accepted values, default, migration, behavior, safety limits, recovery contract, and narrow locked session-start cleanup of exact restored idle-shell children. +The local `config/herdr-presentation-spaces` file instead opts a home out of, or explicitly in to, Herdr's default-on disposable single-task visual projection; [Presentation spaces](herdr-backend.md#presentation-spaces) owns its accepted values, default, Herdr version floor, migration, behavior, safety limits, recovery contract, and narrow locked session-start cleanup of exact restored idle-shell children. The setting is inherited into secondmate homes under the primary-authoritative contract owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). For normal herdr operations, `HERDR_SESSION` selects the named session, but destructive test cleanup must not rely on `HERDR_SESSION` alone. Use the explicit guarded cleanup path described in [`docs/herdr-backend.md`](herdr-backend.md) instead of `herdr server stop`. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index c5b77e5afe..4b6ce384da 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -2,6 +2,7 @@ Herdr is an experimental agent-native terminal backend with native per-pane agent state and push events. Firstmate requires Herdr protocol 14 or newer; broad backend verification covers versions 0.7.1, 0.7.3, 0.7.4, and 0.7.5, while the presentation-projection suite is additionally verified on 0.8.0 protocol 19 and protocol-16 features remain gated by availability. +Default-on presentation spaces have a higher floor of Herdr 0.8.0 for the reason given under [Presentation spaces](#presentation-spaces). Herdr provides the terminal session while Treehouse continues to provide task worktrees. [`configuration.md`](configuration.md#runtime-backend-configbackend--fm_backend) owns shared backend selection and metadata semantics. @@ -68,12 +69,24 @@ Closing its last tab can remove the workspace, and the next spawn recreates it. ## Presentation spaces -Each new crewmate or scout is placed in a disposable one-task workspace by default. -A home opts out by writing `off` into local gitignored `config/herdr-presentation-spaces`. -An absent file, an empty file, and the value `on` all keep the projection enabled, values are compared with whitespace stripped and case ignored, and an unrecognized value warns and keeps the projection enabled rather than failing a spawn over a purely visual setting. -The empty file is the historical presence-based opt-in form, so every home that had already enabled the projection stays enabled with no migration step, and no previously enabled home can be turned off by the default. -A home that never created the file gains the projection at its next Herdr spawn; that flip is deliberate, and it reaches only the Herdr backend because no other runtime backend has a projection path. -The setting is inherited into secondmate homes through the normal configuration-convergence owner, and the default needs no special convergence: the primary's absent file and the secondmate's absent file both mean on, so leaving the default converges a secondmate to the same default rather than turning it off, and only an explicit primary `off` propagates the opt-out. +Each new crewmate or scout is placed in a disposable one-task workspace by default on Herdr 0.8.0 and newer. +A home opts out by writing `off` into local gitignored `config/herdr-presentation-spaces`, and forces the projection on by writing `on`. +An absent file leaves the choice to the version floor below, an empty file and the value `on` are both a deliberate opt-in, values are compared with whitespace stripped and case ignored, and an unrecognized value warns and follows the unconfigured default rather than failing a spawn over a purely visual setting. +The empty file is the historical presence-based opt-in form, so every home that had already enabled the projection stays enabled with no migration step, and no previously enabled home can be turned off by the default or by the floor. +A home that never created the file gains the projection at its next Herdr spawn on a supported release; that flip is deliberate, and it reaches only the Herdr backend because no other runtime backend has a projection path. + +Projecting each task into its own workspace makes every task cleanup a workspace-emptying removal, which is the only removal shape Herdr's pre-0.8.0 focus defect touches. +The focus-safe removal plan below can avoid that defect only while the closing pane's shell can be proved lone, childless, and idle. +A persistent child of that shell - a `gitstatusd`, a `zsh-async` worker, or `direnv` - fails that proof permanently and forces the plain explicit close, which on those releases briefly moves the active workspace before the restore backstop pulls it back. +An unconfigured home is therefore projected only on a release at or above the 0.8.0 floor, where every workspace-removal primitive preserves focus and that proof stops being load-bearing. +Below the floor an unconfigured home uses the ordinary flat per-home layout instead and warns once per home per detected release, naming the running release and the upgrade that restores the projection. +That one-warning-per-release record is a `state/.herdr-presentation-floor-` marker; deleting it only makes the same warning appear again, and an upgrade or downgrade re-announces itself because the release is part of the key. +The floor reads both the installed client's protocol and version and the selected named session's server signals while that server is running, requires both applicable releases to pass, and uses only the client when status positively reports no running server because that client will start it. +The unconfigured default is rechecked after the server is started or adopted and before any presentation journal or workspace is created, while an unreadable server state or release is treated as unsupported rather than guessed at. +An explicit `on` is honored below the floor, so a home that deliberately opted in is never silently downgraded; it accepts that documented focus move, and the exact prior-tab restore stays its backstop. +The floor has a single owner, the spawn-time gate, so cleanup for a projection that already exists always runs and never strands a workspace, whatever release the home is on now. +Upgrading Herdr to 0.8.0 or newer is the durable fix; writing `off` is the immediate mitigation for a home that cannot upgrade yet. +The setting is inherited into secondmate homes through the normal configuration-convergence owner, and the default needs no special convergence: the primary's absent file and the secondmate's absent file both mean the same unconfigured default, so leaving it converges a secondmate to that same default rather than turning it off, and only an explicit primary `off` propagates the opt-out. A secondmate agent itself always stays in its ordinary parent workspace; only children launched by that home are eligible. An unconverged opt-out keeps the default projection in that home until convergence. @@ -103,7 +116,7 @@ The worker remains on the ordinary flat or Herdr-current-order path. Normal task metadata remains the sole endpoint authority after creation. Cleanup closes only the exact recorded task pane and never calls `workspace close`. -Herdr 0.7.5's explicit close moves focus to a neighbor whenever it empties a non-focused workspace, while its pane-death removal preserves the focused workspace whenever the dying workspace sits behind it or the focused workspace is last; both behaviors are fixed on the upstream default branch but in no release, and the exact rules live in the adapter header of `bin/backends/herdr.sh`. +Herdr 0.7.5's explicit close moves focus to a neighbor whenever it empties a non-focused workspace, while its pane-death removal preserves the focused workspace whenever the dying workspace sits behind it or the focused workspace is last; both behaviors are fixed in Herdr 0.8.0, and the exact rules live in the adapter header of `bin/backends/herdr.sh`. Projected cleanup therefore runs under the same session lock, captures the exact active tab, refuses to delete the active tab, and treats a workspace-emptying close as a focus-safe removal: it verifies the close would empty the workspace, repositions the doomed workspace behind the focused one through the verified `workspace.move` transport when needed, proves the pane holds one lone idle shell, and ends that shell so Herdr removes the emptied workspace through its focus-preserving pane-death path. The repositioning move-to-last preserves every surviving workspace's relative order, and removal is confirmed against the exact moved workspace rather than inferred from pane disappearance before an unconfirmed removal makes one verified attempt under the same session lock to roll the doomed workspace back to its exact original position. If that rollback cannot restore the verified original order, cleanup warns loudly and leaves the retained records for inspection rather than retrying the shared-layout mutation. diff --git a/docs/subagent-guard.md b/docs/subagent-guard.md index d5bc07eea7..500a251f2d 100644 --- a/docs/subagent-guard.md +++ b/docs/subagent-guard.md @@ -365,6 +365,11 @@ tests/fm-subagent-pretool-check.test.sh ## Known residual gap +The other tracked Claude hook entries in `.claude/settings.json` refuse to run under Grok's Claude-compatible settings loading because Grok already covers each of those events through `.grok/hooks/` registrations and running both creates duplicate paths. +This entry is the deliberate exception and stays unguarded: Grok is "inspected but not wired" above, so no `.grok/hooks/` registration covers the subagent-spawn event and guarding it would remove the guard from Grok rather than deduplicate it. +The coverage it leaves is partial rather than correct because the tracked entry passes `--claude`, which suppresses the stdout decision object Grok consumes; treat this as incidental reach, not as Grok being wired. +Wiring Grok properly still requires the matcher-token verification described above, which is what closes this exception. + This change does not close the deeper harness-agnostic defect. Every firstmate guard's in-flight-work branch keys off `state/.meta`, and only `bin/fm-spawn.sh` writes that record. `bin/fm-supervision-lib.sh` also recognizes an X-mode relay poll as supervision need, but unaccounted primary work still contributes nothing to that predicate. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 3320083190..dc7d14649c 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -55,7 +55,13 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s - OpenCode listens for `session.idle` in `.opencode/plugins/fm-primary-turnend-guard.js`, lets the watcher coordinator act first, and calls `client.session.promptAsync` once when the guard returns 2. - Pi listens for `agent_settled` in `.pi/extensions/fm-primary-turnend-guard.ts`, runs once per logical agent run, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` once when the guard returns 2. - Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and delegates capability selection to `bin/fm-turnend-guard-grok.sh`. - The tracked Claude Stop entries are inert when `GROK_AGENT` is present, so Grok's Claude-compatible settings loading cannot create a second continuation path. + The tracked Claude Stop entries are inert when `GROK_AGENT` or `GROK_HOOK_EVENT` is present, so Grok's Claude-compatible settings loading cannot create a second continuation path. + Both markers are required because Grok does not inject the same variables into every process kind: grok 0.2.73 set `GROK_AGENT` for child and tool processes, while grok 1.0.0 hook processes carried `GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, `GROK_SESSION_ID`, and `GROK_WORKSPACE_ROOT` but no `GROK_AGENT`. + A guard keyed on `GROK_AGENT` alone therefore stopped firing on grok 1.0.0, and the resulting Claude-only auto-arm ran synchronously under Grok for the declared 28800-second timeout. + Do not widen this guard to `GROK_SESSION_ID`: Grok injects that into every child process, so it can survive into a Claude session that Grok launched and silently disable Claude's continuity. + The same marker guard covers both `Stop` entries, the `SessionStart` entry, and the two `PreToolUse` Bash entries in `.claude/settings.json` because Grok already covers those events through `.grok/hooks/` registrations. + `bin/fm-subagent-pretool-check.sh` is the deliberate unguarded exception because no Grok registration covers the subagent-spawn event; [`subagent-guard.md`](subagent-guard.md) "Known residual gap" owns that exception. + `tests/fm-turnend-guard.test.sh` executes every tracked command under Grok and Claude environments so neither the five guarded entries nor the one exception can change silently. Claude and Codex can block a Stop directly with exit status 2 and stderr. Both payloads carry `stop_hook_active`. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 789dba0d14..62049edf2d 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -194,6 +194,8 @@ ok - Grok adaptive Stop real-process matrix passed with exact target cleanup and ``` The same run proved the Claude-compatible Stop entries stay inert under `GROK_AGENT`, the legacy resume carries `GROK_TURNEND_GUARD_ACTIVE=1`, and every replacement root is removed after exact target cleanup while its control window survives. +That inertness result did not establish that `GROK_AGENT` reaches a Grok hook process, and grok 1.0.0 demonstrated that it does not, so the marker set now includes `GROK_HOOK_EVENT`. +`tests/fm-turnend-guard.test.sh` pins every tracked `.claude/settings.json` hook command against a Grok hook environment so this contract is deterministic rather than only an opt-in live result. The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input. The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 0e7fd82904..2a59fc42f7 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -833,67 +833,274 @@ test_create_task_creates_with_no_focus_flag() { # --- default-on disposable presentation projection -------------------------- +# A status-only Herdr stub for the presentation release-floor contract. +# Empty protocol or version values omit those fields, "unreadable" makes the +# whole status call fail, and a server-running value other than true or false +# leaves that state unknown. +make_release_fakebin() { # [ ] + local dir=$1 protocol=$2 version=$3 server_running=${4:-false} server_protocol=${5:-} server_version=${6:-} + local fb="$1/release-fakebin" fields="" server_fields="" + mkdir -p "$fb" + if [ -n "$version" ]; then + fields="\"version\":\"$version\"" + fi + if [ -n "$protocol" ]; then + [ -n "$fields" ] && fields="$fields," + fields="$fields\"protocol\":$protocol" + fi + case "$server_running" in + true|false) server_fields="\"running\":$server_running" ;; + esac + if [ -n "$server_version" ]; then + [ -n "$server_fields" ] && server_fields="$server_fields," + server_fields="$server_fields\"version\":\"$server_version\"" + fi + if [ -n "$server_protocol" ]; then + [ -n "$server_fields" ] && server_fields="$server_fields," + server_fields="$server_fields\"protocol\":$server_protocol" + fi + cat > "$fb/herdr" <> "$fb/herdr" + else + printf 'printf %s\n' "'{\"client\":{$fields},\"server\":{$server_fields}}\\n'" >> "$fb/herdr" + fi + chmod +x "$fb/herdr" + printf '%s\n' "$fb" +} + # fm_backend_herdr_presentation_enabled is the one gate bin/fm-spawn.sh consults -# before projecting a crewmate or scout, so these cases pin the default-on -# contract and its explicit opt-out at that interface. -presentation_enabled_verdict() { # -> "on"/"off" on stdout, warnings on stderr - bash -c ' +# before projecting a crewmate or scout, so these cases pin explicit choices +# and the release-dependent unconfigured default at that interface. +presentation_enabled_verdict() { # [state-dir] [session] -> on|off + HERDR_SESSION="${4:-}" PATH="$2:$PATH" bash -c ' . "$0/bin/backends/herdr.sh" - if fm_backend_herdr_presentation_enabled "$1"; then printf "on\n"; else printf "off\n"; fi - ' "$ROOT" "$1" + if fm_backend_herdr_presentation_enabled "$1" "$2"; then printf "on\n"; else printf "off\n"; fi + ' "$ROOT" "$1" "${3:-}" } -test_presentation_defaults_on_without_config() { - local dir config verdict +AT_FLOOR_PROTOCOL=19 +AT_FLOOR_VERSION=0.8.0 +BELOW_FLOOR_PROTOCOL=17 +BELOW_FLOOR_VERSION=0.7.5 + +test_presentation_defaults_on_at_or_above_the_floor() { + local dir config fb verdict stderr dir="$TMP_ROOT/presentation-default-on"; config="$dir/config"; mkdir -p "$config" - verdict=$(presentation_enabled_verdict "$config" 2>/dev/null) - [ "$verdict" = on ] || fail "an absent presentation config must resolve on, got '$verdict'" - verdict=$(presentation_enabled_verdict "$dir/missing-config-dir" 2>/dev/null) - [ "$verdict" = on ] || fail "a missing config dir must resolve on, got '$verdict'" - pass "herdr presentation: a home that set nothing gets the projection by default" + stderr="$dir/default-on.err" + fb=$(make_release_fakebin "$dir" "$AT_FLOOR_PROTOCOL" "$AT_FLOOR_VERSION") + verdict=$(presentation_enabled_verdict "$config" "$fb" 2>"$stderr") + [ "$verdict" = on ] || fail "an absent presentation config at the floor must resolve on, got '$verdict'" + [ ! -s "$stderr" ] || fail "a supported release must not warn: $(cat "$stderr")" + verdict=$(presentation_enabled_verdict "$dir/missing-config-dir" "$fb" 2>/dev/null) + [ "$verdict" = on ] || fail "a missing config dir at the floor must resolve on, got '$verdict'" + pass "herdr presentation: a home that set nothing gets the projection by default at or above the floor" +} + +test_presentation_default_falls_back_below_the_floor() { + local dir config fb verdict stderr + dir="$TMP_ROOT/presentation-below-floor"; config="$dir/config"; mkdir -p "$config" + stderr="$dir/below-floor.err" + fb=$(make_release_fakebin "$dir" "$BELOW_FLOOR_PROTOCOL" "$BELOW_FLOOR_VERSION") + verdict=$(presentation_enabled_verdict "$config" "$fb" 2>"$stderr") + [ "$verdict" = off ] || fail "an unconfigured home below the floor must fall back flat, got '$verdict'" + assert_contains "$(cat "$stderr")" "$BELOW_FLOOR_VERSION" \ + "the below-floor warning must name the running release" + assert_contains "$(cat "$stderr")" "$AT_FLOOR_VERSION" \ + "the below-floor warning must name the upgrade that fixes it" + pass "herdr presentation: an unconfigured home below the floor falls back flat with a naming warning" +} + +test_presentation_unreadable_release_falls_back() { + local dir config fb verdict stderr + dir="$TMP_ROOT/presentation-unreadable"; config="$dir/config"; mkdir -p "$config" + stderr="$dir/unreadable.err" + fb=$(make_release_fakebin "$dir" unreadable unreadable) + verdict=$(presentation_enabled_verdict "$config" "$fb" 2>"$stderr") + [ "$verdict" = off ] || fail "an unverifiable release must fall back flat, got '$verdict'" + assert_contains "$(cat "$stderr")" "could not be read" \ + "an unverifiable release must say the floor could not be checked" + pass "herdr presentation: an unreadable client release falls back flat instead of guessing" } test_presentation_legacy_opt_in_file_still_resolves_on() { - local dir config verdict stderr + local dir config fb verdict stderr dir="$TMP_ROOT/presentation-legacy-opt-in"; config="$dir/config"; mkdir -p "$config" stderr="$dir/legacy.err" + fb=$(make_release_fakebin "$dir" "$BELOW_FLOOR_PROTOCOL" "$BELOW_FLOOR_VERSION") # The historical opt-in was a bare `touch` of the file, so an empty file must # keep meaning on - and must not warn, or every migrated home warns on every spawn. : > "$config/herdr-presentation-spaces" - verdict=$(presentation_enabled_verdict "$config" 2>"$stderr") - [ "$verdict" = on ] || fail "a legacy empty opt-in file must resolve on, got '$verdict'" + verdict=$(presentation_enabled_verdict "$config" "$fb" 2>"$stderr") + [ "$verdict" = on ] || fail "a legacy empty opt-in file must resolve on below the floor, got '$verdict'" [ ! -s "$stderr" ] || fail "a legacy empty opt-in file must not warn: $(cat "$stderr")" printf '\n \n' > "$config/herdr-presentation-spaces" - verdict=$(presentation_enabled_verdict "$config" 2>"$stderr") - [ "$verdict" = on ] || fail "a whitespace-only opt-in file must resolve on, got '$verdict'" + verdict=$(presentation_enabled_verdict "$config" "$fb" 2>"$stderr") + [ "$verdict" = on ] || fail "a whitespace-only opt-in file must resolve on below the floor, got '$verdict'" [ ! -s "$stderr" ] || fail "a whitespace-only opt-in file must not warn: $(cat "$stderr")" printf 'on\n' > "$config/herdr-presentation-spaces" - verdict=$(presentation_enabled_verdict "$config" 2>/dev/null) - [ "$verdict" = on ] || fail "an explicit on must resolve on, got '$verdict'" - pass "herdr presentation: an already-enabled home keeps the projection with no migration step" + verdict=$(presentation_enabled_verdict "$config" "$fb" 2>"$stderr") + [ "$verdict" = on ] || fail "an explicit on must resolve on below the floor, got '$verdict'" + [ ! -s "$stderr" ] || fail "an explicit opt-in must not warn: $(cat "$stderr")" + pass "herdr presentation: a deliberate opt-in remains on below the floor" } test_presentation_explicit_off_opts_out() { - local dir config verdict value + local dir config fb verdict value dir="$TMP_ROOT/presentation-opt-out"; config="$dir/config"; mkdir -p "$config" + fb=$(make_release_fakebin "$dir" "$AT_FLOOR_PROTOCOL" "$AT_FLOOR_VERSION") for value in 'off' 'off ' ' off ' 'OFF' 'Off'; do printf '%s' "$value" > "$config/herdr-presentation-spaces" - verdict=$(presentation_enabled_verdict "$config" 2>/dev/null) + verdict=$(presentation_enabled_verdict "$config" "$fb" 2>/dev/null) [ "$verdict" = off ] || fail "the opt-out value '$value' must resolve off, got '$verdict'" done pass "herdr presentation: an explicit off opts the home out" } test_presentation_unrecognized_value_warns_and_keeps_default() { - local dir config verdict stderr + local dir config fb verdict stderr dir="$TMP_ROOT/presentation-unrecognized"; config="$dir/config"; mkdir -p "$config" stderr="$dir/unrecognized.err" printf 'disabled\n' > "$config/herdr-presentation-spaces" - verdict=$(presentation_enabled_verdict "$config" 2>"$stderr") + fb=$(make_release_fakebin "$dir" "$AT_FLOOR_PROTOCOL" "$AT_FLOOR_VERSION") + verdict=$(presentation_enabled_verdict "$config" "$fb" 2>"$stderr") [ "$verdict" = on ] || fail "an unrecognized value must keep the default on, got '$verdict'" [ -s "$stderr" ] || fail "an unrecognized value must warn so a typo is visible" - pass "herdr presentation: an unrecognized value warns and keeps the default instead of failing a spawn" + fb=$(make_release_fakebin "$dir/below" "$BELOW_FLOOR_PROTOCOL" "$BELOW_FLOOR_VERSION") + verdict=$(presentation_enabled_verdict "$config" "$fb" 2>"$stderr") + [ "$verdict" = off ] || fail "an unrecognized value below the floor must follow the default, got '$verdict'" + pass "herdr presentation: an unrecognized value warns and follows the release-dependent default" +} + +test_presentation_floor_warning_is_one_per_release() { + local dir config state fb first second third + dir="$TMP_ROOT/presentation-floor-dedupe"; config="$dir/config"; state="$dir/state" + mkdir -p "$config" "$state" + fb=$(make_release_fakebin "$dir" "$BELOW_FLOOR_PROTOCOL" "$BELOW_FLOOR_VERSION") + first=$(presentation_enabled_verdict "$config" "$fb" "$state" 2>&1 >/dev/null) + second=$(presentation_enabled_verdict "$config" "$fb" "$state" 2>&1 >/dev/null) + [ -n "$first" ] || fail "the first below-floor spawn must warn" + [ -z "$second" ] || fail "a repeat spawn on the same release must not warn again: $second" + fb=$(make_release_fakebin "$dir/other" 16 0.7.3) + third=$(presentation_enabled_verdict "$config" "$fb" "$state" 2>&1 >/dev/null) + assert_contains "$third" '0.7.3' "a changed release must re-announce the floor" + pass "herdr presentation: the below-floor warning is one per home per release" +} + +test_presentation_floor_warning_marker_is_atomic_and_symlink_safe() { + local dir config state fb i pid warnings marker outside symlink_warning + local pids=() + dir="$TMP_ROOT/presentation-floor-marker-safety"; config="$dir/config"; state="$dir/state" + mkdir -p "$config" "$state" + fb=$(make_release_fakebin "$dir" "$BELOW_FLOOR_PROTOCOL" "$BELOW_FLOOR_VERSION") + for i in {1..20}; do + presentation_enabled_verdict "$config" "$fb" "$state" \ + >"$dir/concurrent-$i.out" 2>"$dir/concurrent-$i.err" & + pids+=("$!") + done + for pid in "${pids[@]}"; do + wait "$pid" || fail "a concurrent presentation-floor verdict failed" + done + warnings=$(awk '/^warning:/ { count++ } END { print count + 0 }' "$dir"/concurrent-*.err) + [ "$warnings" -eq 1 ] \ + || fail "concurrent below-floor spawns must publish exactly one warning, got $warnings" + + state="$dir/symlink-state" + mkdir -p "$state" + marker="$state/.herdr-presentation-floor-version-0-7-5--protocol-17-" + outside="$dir/symlink-target" + ln -s "$outside" "$marker" + symlink_warning=$(presentation_enabled_verdict "$config" "$fb" "$state" 2>&1 >/dev/null) + [ -z "$symlink_warning" ] \ + || fail "an existing dangling marker symlink must be treated as already claimed: $symlink_warning" + [ ! -e "$outside" ] \ + || fail "publishing the floor marker followed a dangling symlink outside the state directory" + pass "herdr presentation: warning marker publication is atomic and symlink-safe" +} + +test_presentation_running_server_release_is_load_bearing() { + local dir config fb verdict stderr + dir="$TMP_ROOT/presentation-running-server-floor"; config="$dir/config" + mkdir -p "$config" + stderr="$dir/server.err" + + fb=$(make_release_fakebin "$dir/old-server" "$AT_FLOOR_PROTOCOL" "$AT_FLOOR_VERSION" \ + true "$BELOW_FLOOR_PROTOCOL" "$BELOW_FLOOR_VERSION") + verdict=$(presentation_enabled_verdict "$config" "$fb" "" stale-session 2>"$stderr") + [ "$verdict" = off ] \ + || fail "an old running server must keep a new client below the presentation floor, got '$verdict'" + assert_contains "$(cat "$stderr")" "server version $BELOW_FLOOR_VERSION" \ + "the floor warning must name the selected running server release" + + fb=$(make_release_fakebin "$dir/new-server" "$AT_FLOOR_PROTOCOL" "$AT_FLOOR_VERSION" \ + true "$AT_FLOOR_PROTOCOL" "$AT_FLOOR_VERSION") + verdict=$(presentation_enabled_verdict "$config" "$fb" "" current-session 2>"$stderr") + [ "$verdict" = on ] \ + || fail "an at-floor client and running server must project, got '$verdict'" + [ ! -s "$stderr" ] || fail "an at-floor client and running server must not warn: $(cat "$stderr")" + + fb=$(make_release_fakebin "$dir/old-client" "$BELOW_FLOOR_PROTOCOL" "$BELOW_FLOOR_VERSION" \ + true "$AT_FLOOR_PROTOCOL" "$AT_FLOOR_VERSION") + verdict=$(presentation_enabled_verdict "$config" "$fb" "" current-session 2>"$stderr") + [ "$verdict" = off ] \ + || fail "a below-floor client must block projection despite an at-floor server, got '$verdict'" + + printf 'on\n' > "$config/herdr-presentation-spaces" + fb=$(make_release_fakebin "$dir/opt-in" "$AT_FLOOR_PROTOCOL" "$AT_FLOOR_VERSION" \ + true "$BELOW_FLOOR_PROTOCOL" "$BELOW_FLOOR_VERSION") + verdict=$(presentation_enabled_verdict "$config" "$fb" "" stale-session 2>"$stderr") + [ "$verdict" = on ] || fail "an explicit opt-in must survive a below-floor running server" + [ ! -s "$stderr" ] || fail "an explicit opt-in below the server floor must not warn" + rm -f "$config/herdr-presentation-spaces" + + fb=$(make_release_fakebin "$dir/unknown-server" "$AT_FLOOR_PROTOCOL" "$AT_FLOOR_VERSION" unknown) + verdict=$(presentation_enabled_verdict "$config" "$fb" "" unknown-session 2>"$stderr") + [ "$verdict" = off ] || fail "an unreadable selected server state must fail flat, got '$verdict'" + pass "herdr presentation: client and selected server floors compose conservatively" +} + +release_floor_verdict() { # -> above|below|indeterminate + bash -c ' + . "$0/bin/backends/herdr.sh" + status=0 + fm_backend_herdr_release_floor_verdict "$1" "$2" || status=$? + case "$status" in + 0) printf "above\n" ;; + 1) printf "below\n" ;; + *) printf "indeterminate\n" ;; + esac + ' "$ROOT" "$1" "$2" +} + +test_release_floor_verdict_matches_measured_releases_and_signal_loss() { + local protocol version expected got + while IFS=$'\t' read -r protocol version expected; do + [ -n "$expected" ] || continue + got=$(release_floor_verdict "$protocol" "$version") + [ "$got" = "$expected" ] \ + || fail "protocol '$protocol' version '$version' should be $expected, got $got" + done <<'CASES' +16 0.7.3 below +16 0.7.4 below +17 0.7.5 below +18 0.7.5-preview.2026-07-29-44b3adb12552 below +19 0.8.0-preview.2026-08-04-d78e3d3b5126 above +19 0.8.0 above +20 0.9.0 above +CASES + [ "$(release_floor_verdict 19 '')" = above ] || fail "the floor protocol alone must carry an above verdict" + [ "$(release_floor_verdict 17 '')" = below ] || fail "a below-floor protocol alone must carry a below verdict" + [ "$(release_floor_verdict '' 0.8.0)" = above ] || fail "the floor version alone must carry an above verdict" + [ "$(release_floor_verdict '' 0.7.5)" = below ] || fail "a below-floor version alone must carry a below verdict" + [ "$(release_floor_verdict '' '')" = indeterminate ] || fail "losing both signals must be indeterminate" + [ "$(release_floor_verdict 19 0.7.5)" = above ] || fail "the protocol signal must survive a divergent version" + [ "$(release_floor_verdict 16 0.9.0)" = above ] || fail "the version signal must survive a divergent protocol" + pass "herdr presentation floor: measured releases and independent signals classify correctly" } test_projection_journal_is_atomic_and_uses_128_bit_token() { @@ -4035,10 +4242,16 @@ test_create_task_refuses_when_agent_state_ambiguous test_create_task_husk_replacement_creates_before_closing test_create_task_creates_and_parses_ids test_create_task_creates_with_no_focus_flag -test_presentation_defaults_on_without_config +test_presentation_defaults_on_at_or_above_the_floor +test_presentation_default_falls_back_below_the_floor +test_presentation_unreadable_release_falls_back test_presentation_legacy_opt_in_file_still_resolves_on test_presentation_explicit_off_opts_out test_presentation_unrecognized_value_warns_and_keeps_default +test_presentation_floor_warning_is_one_per_release +test_presentation_floor_warning_marker_is_atomic_and_symlink_safe +test_presentation_running_server_release_is_load_bearing +test_release_floor_verdict_matches_measured_releases_and_signal_loss test_projection_journal_is_atomic_and_uses_128_bit_token test_projection_journal_v2_binds_and_advances_exact_endpoint test_projection_create_uses_exact_response_ids_and_leaves_one_task_pane diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index d1955436e8..d503291606 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -19,9 +19,10 @@ # down into each secondmate home's config/, so the secondmate's OWN crewmates, # dispatch profiles, backlog backend, runtime-backend default, Herdr # presentation choice, startup-memory budget, and trace context inherit the -# primary's settings. config/herdr-presentation-spaces is default-ON, so an -# absent primary file and an absent destination file both mean on and the -# generic absence mirror already converges that item correctly. +# primary's settings. For config/herdr-presentation-spaces, an absent +# primary file and an absent destination file both mean the same +# unconfigured default, so the generic absence mirror converges that item +# without deciding its release-dependent floor. # It is primary-authoritative # (re-pushed at secondmate spawn, on the bootstrap secondmate sweep, and by # config push). @@ -1353,14 +1354,16 @@ test_backend_inheritance_present_and_absent() { pass "B12b backend inheritance: present values and primary absence converge exactly" } -# config/herdr-presentation-spaces is default-ON, so this item's convergence is -# asserted through the verdict the spawn gate actually reads in the destination -# home, not through file presence alone: mirroring the primary's absence must -# converge a secondmate to the same default rather than turning its projection off. +# config/herdr-presentation-spaces has an unconfigured default, so inheritance +# is asserted through the preference the spawn gate reads rather than a live +# Herdr release verdict. sm_presentation_verdict() { # -> on|off bash -c ' . "$0/bin/backends/herdr.sh" - if fm_backend_herdr_presentation_enabled "$1"; then printf "on\n"; else printf "off\n"; fi + case "$(fm_backend_herdr_presentation_preference "$1")" in + off) printf "off\n" ;; + *) printf "on\n" ;; + esac ' "$ROOT" "$1" 2>/dev/null } diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index b3c87f4471..e81b0368a0 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -861,6 +861,62 @@ test_grok_adapter_missing_jq_and_no_supervision_allow() { pass "fm-turnend-guard-grok: missing jq and no-supervision-needed stops stay silent and bounded" } +# Grok loads Claude-compatible settings, so a tracked .claude/settings.json +# entry that also has a .grok/hooks/ counterpart must refuse to run under Grok, +# or the home gets a duplicate path. +# The regression this pins is the old GROK_AGENT-only guard: a Grok 1.0.0 hook +# process omitted GROK_AGENT, so Claude's auto-arm ran synchronously under Grok +# and wedged the turn for its declared 28800-second timeout. +# +# bin/fm-subagent-pretool-check.sh is the deliberate exception because Grok has +# no counterpart registration for that event. +test_tracked_claude_entries_inert_under_grok() { + local dir cmd script target guarded=0 unguarded=0 + command -v jq >/dev/null 2>&1 || fail "test host must provide jq" + dir="$TMP_ROOT/claude-entries-grok-inert" + mkdir -p "$dir/bin" + for script in fm-turnend-guard.sh fm-claude-stop-autoarm.sh fm-sessionstart-run.sh \ + fm-arm-pretool-check.sh fm-cd-pretool-check.sh fm-subagent-pretool-check.sh; do + printf '#!/usr/bin/env bash\nprintf ran >> %q\n' "$dir/invoked" > "$dir/bin/$script" + chmod +x "$dir/bin/$script" + done + + ran_under() { + rm -f "$dir/invoked" + env "$@" CLAUDE_PROJECT_DIR="$dir" bash -c "$cmd" /dev/null 2>&1 + [ -e "$dir/invoked" ] + } + + while IFS= read -r cmd; do + [ -n "$cmd" ] || continue + target=$(printf '%s\n' "$cmd" | sed -n 's|.*/bin/\([a-z0-9-]*\.sh\).*|\1|p') + [ -n "$target" ] || fail "could not identify the target script of tracked entry: $cmd" + + ran_under -u GROK_AGENT -u GROK_HOOK_EVENT -u GROK_HOOK_NAME -u GROK_SESSION_ID \ + -u GROK_WORKSPACE_ROOT \ + || fail "tracked entry for $target did not run under a native Claude environment" + + if [ "$target" = fm-subagent-pretool-check.sh ]; then + unguarded=$((unguarded + 1)) + ran_under -u GROK_AGENT GROK_HOOK_EVENT=pre_tool_use GROK_SESSION_ID=grok-test-session \ + || fail "the documented $target exception must stay unguarded; Grok has no counterpart to fall back to" + continue + fi + + guarded=$((guarded + 1)) + ! ran_under -u GROK_AGENT GROK_HOOK_EVENT=stop \ + GROK_HOOK_NAME='project/settings:stop[0].hooks[0]' \ + GROK_SESSION_ID=grok-test-session GROK_WORKSPACE_ROOT="$dir" \ + || fail "tracked entry for $target ran under a Grok 1.0.0 hook environment" + ! ran_under -u GROK_HOOK_EVENT -u GROK_HOOK_NAME GROK_AGENT=1 \ + || fail "tracked entry for $target ran under a legacy GROK_AGENT environment" + done < <(jq -r '.hooks[][].hooks[].command' "$ROOT/.claude/settings.json") + + [ "$guarded" -eq 5 ] || fail "expected 5 Grok-guarded tracked entries, saw $guarded" + [ "$unguarded" -eq 1 ] || fail "expected 1 documented unguarded tracked entry, saw $unguarded" + pass "tracked .claude/settings.json entries: $guarded inert under Grok, the documented subagent exception still armed, all live under Claude" +} + test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root() { local settings command dir expected_root outside payload out status settings="$ROOT/.codex/hooks.json" @@ -1675,6 +1731,7 @@ test_grok_adapter_native_true_allows_without_resume test_grok_adapter_snake_case_native_and_camel_precedence test_grok_adapter_invalid_inputs_start_neither_path test_grok_adapter_missing_jq_and_no_supervision_allow +test_tracked_claude_entries_inert_under_grok test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root test_codex_hook_ignores_nested_git_root_guard test_opencode_plugin_anchors_guard_to_worktree From c61c672282726a74aa6789f89379dbbfb5d30fe4 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Fri, 14 Aug 2026 21:09:48 +0800 Subject: [PATCH 2/4] no-mistakes(review): run herdr projection recovery whenever journal exists --- bin/fm-spawn.sh | 13 +++++++++++-- docs/herdr-backend.md | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1d38ecdf62..85ec24e30c 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -71,6 +71,9 @@ # selected client and running server meet the Herdr 0.8.0 floor. The local # config/herdr-presentation-spaces file can say off to disable it or on to # opt in below that floor; an empty file remains the historical opt-in form. +# That gate decides only whether a FRESH projection is created; recovery of +# an already-journaled projection runs whenever the journal exists, so no +# preference or floor change strands a previously projected workspace. # A clean fresh task first writes state/.herdr-presentation atomically, # then creates a disposable # workspace containing only the ordinary task pane. A successful clean create @@ -1663,9 +1666,14 @@ case "$BACKEND" in fi HERDR_PRESENTATION_JOURNAL=$(fm_backend_herdr_projection_journal_path "$STATE" "$ID") HERDR_PROJECTED=0 - if [ "$KIND" != secondmate ] && fm_backend_herdr_presentation_enabled "$CONFIG" "$STATE"; then + if [ "$KIND" != secondmate ]; then HERDR_SES=$(fm_backend_herdr_session) HERDR_PARENT_LABEL=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_workspace_label) + # An existing presentation journal names a projection that may still + # hold a workspace, so its recovery path runs whenever the journal + # exists; the presentation preference and the release floor govern only + # whether a FRESH projection is created (docs/herdr-backend.md + # "Presentation spaces"). if [ -e "$HERDR_PRESENTATION_JOURNAL" ] || [ -L "$HERDR_PRESENTATION_JOURNAL" ]; then fm_backend_herdr_server_ensure "$HERDR_SES" || { echo "error: herdr presentation recovery could not ensure its exact named session" >&2 @@ -1708,7 +1716,8 @@ case "$BACKEND" in else spawn_herdr_presentation_order_lock_release fi - elif [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ]; then + elif [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ] \ + && fm_backend_herdr_presentation_enabled "$CONFIG" "$STATE"; then # Session lock path resolution and exact parent binding both need a # live named-session socket before journal publication. if ! fm_backend_herdr_server_ensure "$HERDR_SES"; then diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 4b6ce384da..e350638655 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -84,7 +84,7 @@ That one-warning-per-release record is a `state/.herdr-presentation-floor- Date: Fri, 14 Aug 2026 21:20:21 +0800 Subject: [PATCH 3/4] no-mistakes(document): docs: align herdr recovery-refusal reachability with presentation floor --- docs/herdr-backend.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index e350638655..e133d15421 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -159,7 +159,7 @@ Operational compromises: - Grouping is best-effort; only an exact same-identity version 2 binding survives a Herdr restart in place. - A failed journal publication or projected workspace create stops that spawn instead of falling back flat, so a Herdr create failure surfaces as a spawn failure in every Herdr home rather than only in homes that opted in; every earlier degradation on the fresh projected-create path (no session server, contended presentation lock, absent or ambiguous parent) still warns and continues flat. -- Recovery of an existing presentation journal deliberately refuses the spawn when the shared presentation lock is contended rather than falling back flat, and default-on makes that refusal reachable in any Herdr home. +- Recovery of an existing presentation journal deliberately refuses the spawn when the shared presentation lock is contended rather than falling back flat; the unconfigured default makes that refusal reachable without any opt-in on a supported release, and recovery of an already-journaled projection runs whatever release the home is on now. - Existing layouts are not force-renamed or rearranged. - Missing or ambiguous restart bindings fall back to the ordinary home workspace while the old projection remains untouched. - Crashes, lost responses, failed exact-pane cleanup, or human renames can leave quarantined spaces; session start removes only the exact home-local, uniquely journal-correlated, childless idle-shell shape above. From 343d12fcadf43f4d0b6e7881635b1816f8bd5931 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Fri, 14 Aug 2026 21:47:20 +0800 Subject: [PATCH 4/4] no-mistakes(ci): gate default-on presentation E2E on the 0.8.0 release floor The real-Herdr presentation E2E still asserted that an unconfigured home is always projected, but the presentation floor added in this change keeps an absent setting flat below Herdr 0.8.0, so the test failed on CI's herdr 0.7.4. The default-on scenario now composes the same client and running-server evidence as the adapter's gate: at or above the floor it keeps the projection assertions, below it it asserts the flat firstmate placement, the floor warning, and its per-release dedupe marker. Co-Authored-By: Claude Fable 5 --- .../fm-backend-herdr-presentation-e2e.test.sh | 65 +++++++++++++++---- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 0a02a40000..0d0aedc593 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -505,28 +505,67 @@ pass "real Herdr lab: an opted-out spawn retains the Stage 1 Herdr command seque teardown_task shape "$HOME_DIR" > "$TMP_ROOT/off-teardown.out" 2> "$TMP_ROOT/off-teardown.err" \ || fail "opted-out teardown failed: $(cat "$TMP_ROOT/off-teardown.err")" -# A home that configured nothing at all must be projected: this is the default, -# and the only difference from the opted-out spawn above is the removed file. +# A home that configured nothing at all follows the release floor: it is +# projected only on Herdr >= 0.8.0 (the first release carrying both upstream +# focus fixes), and stays flat with one deduplicated warning below the floor. +# The verdict here composes the same client and running-server evidence the +# adapter's own gate reads, so this lab asserts whichever branch the real +# installed release makes reachable. +FLOOR_STATUS_JSON=$(lab status --json) \ + || fail "could not read herdr status for the presentation floor verdict" +FLOOR_SUPPORTED=0 +if printf '%s' "$FLOOR_STATUS_JSON" | PATH="$FAKEBIN:$PATH" HERDR_SESSION="$HERDR_LAB_SESSION" bash -c ' + . "$0/bin/backends/herdr.sh" + status=$(cat) + client_protocol=$(printf "%s" "$status" | jq -r ".client.protocol // empty") + client_version=$(printf "%s" "$status" | jq -r ".client.version // empty") + fm_backend_herdr_release_floor_verdict "$client_protocol" "$client_version" || exit 1 + running=$(printf "%s" "$status" | jq -r ".server.running // empty") + if [ "$running" = true ]; then + server_protocol=$(printf "%s" "$status" | jq -r ".server.protocol // empty") + server_version=$(printf "%s" "$status" | jq -r ".server.version // empty") + fm_backend_herdr_release_floor_verdict "$server_protocol" "$server_version" || exit 1 + fi + ' "$ROOT"; then + FLOOR_SUPPORTED=1 +fi rm -f "$HOME_DIR/config/herdr-presentation-spaces" spawn_task default-on "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/default-on.out" 2> "$TMP_ROOT/default-on.err" \ || fail "default-on spawn failed: $(cat "$TMP_ROOT/default-on.err")" DEFAULT_ON_META="$HOME_DIR/state/default-on.meta" remember_meta_worktree "$DEFAULT_ON_META" >/dev/null DEFAULT_ON_JOURNAL="$HOME_DIR/state/default-on.herdr-presentation" -[ -f "$DEFAULT_ON_JOURNAL" ] \ - || fail "an unconfigured home did not publish a presentation journal by default" -DEFAULT_ON_TOKEN=$(grep '^projection_id=' "$DEFAULT_ON_JOURNAL" | cut -d= -f2-) DEFAULT_ON_WSID=$(grep '^herdr_workspace_id=' "$DEFAULT_ON_META" | cut -d= -f2-) -[ -n "$DEFAULT_ON_WSID" ] && [ "$DEFAULT_ON_WSID" != "$FIRSTMATE_WSID" ] \ - || fail "an unconfigured home reused the flat firstmate workspace instead of projecting" -DEFAULT_ON_LABEL=$(lab workspace get "$DEFAULT_ON_WSID" | jq -r '.result.workspace.label // empty') -[ "$DEFAULT_ON_LABEL" = "└ default-on · p:$DEFAULT_ON_TOKEN" ] \ - || fail "default-on projection used an unexpected workspace label: $DEFAULT_ON_LABEL" -pass "real Herdr lab: a home that configured nothing is projected by default" +if [ "$FLOOR_SUPPORTED" = 1 ]; then + [ -f "$DEFAULT_ON_JOURNAL" ] \ + || fail "an unconfigured home did not publish a presentation journal by default" + DEFAULT_ON_TOKEN=$(grep '^projection_id=' "$DEFAULT_ON_JOURNAL" | cut -d= -f2-) + [ -n "$DEFAULT_ON_WSID" ] && [ "$DEFAULT_ON_WSID" != "$FIRSTMATE_WSID" ] \ + || fail "an unconfigured home reused the flat firstmate workspace instead of projecting" + DEFAULT_ON_LABEL=$(lab workspace get "$DEFAULT_ON_WSID" | jq -r '.result.workspace.label // empty') + [ "$DEFAULT_ON_LABEL" = "└ default-on · p:$DEFAULT_ON_TOKEN" ] \ + || fail "default-on projection used an unexpected workspace label: $DEFAULT_ON_LABEL" + pass "real Herdr lab: a home that configured nothing is projected by default (herdr meets the 0.8.0 floor)" +else + [ ! -f "$DEFAULT_ON_JOURNAL" ] \ + || fail "a below-floor unconfigured home still published a presentation journal" + [ -n "$DEFAULT_ON_WSID" ] && [ "$DEFAULT_ON_WSID" = "$FIRSTMATE_WSID" ] \ + || fail "a below-floor unconfigured home did not land flat in the firstmate workspace" + grep -q "floor for presentation spaces" "$TMP_ROOT/default-on.err" \ + || fail "a below-floor unconfigured home did not warn about the presentation floor: $(cat "$TMP_ROOT/default-on.err")" + ls "$HOME_DIR/state"/.herdr-presentation-floor-* >/dev/null 2>&1 \ + || fail "the below-floor warning did not record its per-release dedupe marker" + pass "real Herdr lab: a home that configured nothing stays flat below the 0.8.0 floor and warns once" +fi teardown_task default-on "$HOME_DIR" > "$TMP_ROOT/default-on-teardown.out" 2> "$TMP_ROOT/default-on-teardown.err" \ || fail "default-on teardown failed: $(cat "$TMP_ROOT/default-on-teardown.err")" -if lab workspace get "$DEFAULT_ON_WSID" >/dev/null 2>&1; then - fail "default-on teardown left its disposable workspace behind" +if [ "$FLOOR_SUPPORTED" = 1 ]; then + if lab workspace get "$DEFAULT_ON_WSID" >/dev/null 2>&1; then + fail "default-on teardown left its disposable workspace behind" + fi +else + lab workspace get "$FIRSTMATE_WSID" >/dev/null 2>&1 \ + || fail "a below-floor flat teardown removed the durable firstmate workspace" fi # The ordering scenarios below read the whole move log cumulatively against the # projected workspaces that are still live, so this retired one starts them clean.