From 9adc2b377c32312b99438c7de2487659639b31f4 Mon Sep 17 00:00:00 2001 From: Patrick Charlier Date: Sat, 15 Aug 2026 11:13:59 -0700 Subject: [PATCH 1/8] feat(herdr): add per-project workspaces --- AGENTS.md | 1 + bin/backends/herdr.sh | 171 +++++++++++++++++- bin/fm-config-inherit-lib.sh | 5 +- bin/fm-spawn.sh | 40 +++- docs/configuration.md | 6 +- docs/herdr-backend.md | 28 +++ .../fm-backend-herdr-presentation-e2e.test.sh | 4 +- tests/fm-backend-herdr.test.sh | 108 +++++++++++ tests/fm-secondmate-harness.test.sh | 10 +- 9 files changed, 358 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d06b4229b..b34421bf3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +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-project-spaces optional empty or "on" opt-in to exact-id-bound per-project Herdr workspaces; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Project workspaces" config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, which is unconfigured-default-on only at or above a Herdr version floor; 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") diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 7367a8db5c..b76bb4f420 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -15,9 +15,10 @@ # herdr-verification-p2.md "Task container shape", refined by # docs/herdr-backend.md "Default task container shape"): ONE herdr workspace PER # FIRSTMATE HOME (the primary, and each secondmate, gets its own), ONE herdr TAB -# per task inside its home's workspace. The default-on presentation projection -# creates a disposable workspace for a clean fresh task instead unless the home -# opts out. That +# per task inside its home's workspace. An opt-in project-space layout instead +# groups each project's task tabs in one exact-id-bound workspace. The +# default-on presentation projection creates a disposable workspace for a clean +# fresh task unless project grouping wins or the home opts out. That # workspace is a non-authoritative visual projection containing only the normal # task pane. Its random token and mutable label never authorize lookup, # adoption, reuse, closure, deletion, task ownership, or endpoint selection. @@ -145,6 +146,28 @@ FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation" # 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_PROJECT_SPACES_CONFIG="herdr-project-spaces" +FM_BACKEND_HERDR_PROJECT_BINDING_PREFIX=".herdr-project-space-" + +# fm_backend_herdr_project_spaces_enabled : presence-compatible +# opt-in parsing for project workspaces. An empty file or "on" enables the +# layout, "off" and absence disable it, and an unrecognized value warns and +# disables it so a visual preference never fails dispatch. +fm_backend_herdr_project_spaces_enabled() { # + local config_dir=${1:-} file value + [ -n "$config_dir" ] || return 1 + file="$config_dir/$FM_BACKEND_HERDR_PROJECT_SPACES_CONFIG" + [ -f "$file" ] || return 1 + value=$(tr -d '[:space:]' < "$file" 2>/dev/null | tr '[:upper:]' '[:lower:]') || value="" + case "$value" in + ''|on) return 0 ;; + off) return 1 ;; + *) + echo "warning: $file: unrecognized value \"$value\"; herdr project spaces stay disabled (write \"on\" or leave the file empty to opt in)" >&2 + return 1 + ;; + esac +} # fm_backend_herdr_presentation_preference : the single owner of # config/herdr-presentation-spaces parsing. Echoes exactly one of "off", "on" @@ -1623,6 +1646,148 @@ fm_backend_herdr_launcher_identity() { # return 0 } +# fm_backend_herdr_project_binding_path : resolve the +# home-local exact-id binding record for one registered project name. Project +# names become filenames, so only the registry's safe identifier shape is +# accepted; an unsafe name makes project placement fall back flat. +fm_backend_herdr_project_binding_path() { # + local state=$1 project_name=$2 + case "$project_name" in + ''|.|..|*[!A-Za-z0-9._-]*) return 1 ;; + esac + printf '%s/%s%s' "$state" "$FM_BACKEND_HERDR_PROJECT_BINDING_PREFIX" "$project_name" +} + +# Read one complete binding and reject malformed, duplicate, cross-project, or +# cross-session fields. The record is placement authority only for the exact +# workspace id it carries; its label is an expected cosmetic property, never a +# discovery key. +fm_backend_herdr_project_binding_snapshot() { # + local file=$1 project_dir=$2 project_name=$3 session=$4 parsed + FM_BACKEND_HERDR_PROJECT_BOUND_WORKSPACE_ID="" + [ ! -L "$file" ] || return 1 + if [ -e "$file" ] && [ ! -f "$file" ]; then + return 1 + fi + [ -f "$file" ] || return 2 + parsed=$(awk -F= ' + BEGIN { required["version"]; required["project_dir"]; required["project_name"]; required["session"]; required["workspace_id"] } + !($1 in required) || seen[$1]++ { bad=1; next } + { value[$1]=substr($0, index($0, "=") + 1) } + END { + for (key in required) if (!(key in seen)) bad=1 + if (NR != 5 || bad) exit 1 + printf "%s\034%s\034%s\034%s\034%s", value["version"], value["project_dir"], value["project_name"], value["session"], value["workspace_id"] + } + ' "$file" 2>/dev/null) || return 1 + IFS=$'\034' read -r binding_version binding_dir binding_name binding_session binding_workspace < + local session=$1 workspace_id=$2 expected_label=$3 out match_count actual_label + out=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1 + match_count=$(printf '%s' "$out" | jq -r --arg workspace "$workspace_id" ' + select((.result.workspaces | type) == "array") + | [.result.workspaces[] | select(.workspace_id == $workspace)] | length + ' 2>/dev/null) || return 1 + case "$match_count" in + 0) return 2 ;; + 1) ;; + *) return 1 ;; + esac + actual_label=$(printf '%s' "$out" | jq -r --arg workspace "$workspace_id" ' + .result.workspaces[] | select(.workspace_id == $workspace) | .label // empty + ' 2>/dev/null) || return 1 + [ "$actual_label" = "$expected_label" ] || return 2 + return 0 +} + +# Atomically publish one exact create-response id. A binding is never derived +# from a label search, and an existing symlink is never replaced. +fm_backend_herdr_project_binding_publish() { # + local file=$1 project_dir=$2 project_name=$3 session=$4 workspace_id=$5 dir tmp + dir=${file%/*} + mkdir -p "$dir" || return 1 + [ ! -L "$file" ] || return 1 + tmp=$(umask 077; mktemp "$dir/.herdr-project-space.XXXXXX" 2>/dev/null) || return 1 + { + printf 'version=1\n' + printf 'project_dir=%s\n' "$project_dir" + printf 'project_name=%s\n' "$project_name" + printf 'session=%s\n' "$session" + printf 'workspace_id=%s\n' "$workspace_id" + } > "$tmp" || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$file" || { rm -f "$tmp"; return 1; } +} + +# Resolve or create the project workspace while the caller holds the named +# session lock. Existing bindings are used only after exact live-id and label +# validation. Stale bindings create a fresh workspace from the create response; +# malformed or unreadable bindings fall back flat without guessing. +fm_backend_herdr_project_workspace_ensure() { # + local session=$1 project_dir=$2 state=$3 project_name=$4 binding status out wsid seeded + FM_BACKEND_HERDR_PROJECT_WS_ID="" + FM_BACKEND_HERDR_PROJECT_WS_SEEDED_TAB_ID="" + case "$project_dir$session" in + *$'\n'*|*$'\r'*|*$'\034'*) + echo "warning: herdr project-space placement cannot encode the exact project or session identity; using the ordinary flat layout" >&2 + return 1 + ;; + esac + binding=$(fm_backend_herdr_project_binding_path "$state" "$project_name") || { + echo "warning: herdr project-space placement cannot represent project name '$project_name'; using the ordinary flat layout" >&2 + return 1 + } + fm_backend_herdr_project_binding_snapshot "$binding" "$project_dir" "$project_name" "$session" && status=0 || status=$? + if [ "$status" -eq 0 ]; then + fm_backend_herdr_project_binding_live "$session" "$FM_BACKEND_HERDR_PROJECT_BOUND_WORKSPACE_ID" "$project_name" && status=0 || status=$? + case "$status" in + 0) + FM_BACKEND_HERDR_PROJECT_WS_ID=$FM_BACKEND_HERDR_PROJECT_BOUND_WORKSPACE_ID + return 0 + ;; + 2) ;; + *) + echo "warning: herdr project-space binding for '$project_name' is ambiguous or unreadable; using the ordinary flat layout" >&2 + return 1 + ;; + esac + elif [ "$status" -eq 1 ]; then + echo "warning: herdr project-space binding for '$project_name' is malformed or unreadable; using the ordinary flat layout" >&2 + return 1 + fi + out=$(fm_backend_herdr_cli "$session" workspace create --cwd "$project_dir" --label "$project_name" --no-focus 2>/dev/null) || { + echo "warning: herdr could not create a project workspace for '$project_name'; using the ordinary flat layout" >&2 + return 1 + } + wsid=$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id // empty' 2>/dev/null) + seeded=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null) + if [ -z "$wsid" ] || [ -z "$seeded" ] \ + || [[ "$wsid$seeded" == *$'\n'* || "$wsid$seeded" == *$'\r'* || "$wsid$seeded" == *$'\034'* ]]; then + echo "warning: herdr project workspace create for '$project_name' returned incomplete exact ids; using the ordinary flat layout" >&2 + return 1 + fi + fm_backend_herdr_project_binding_publish "$binding" "$project_dir" "$project_name" "$session" "$wsid" || { + echo "warning: herdr could not publish the exact project-space binding for '$project_name'; using the ordinary flat layout" >&2 + return 1 + } + FM_BACKEND_HERDR_PROJECT_WS_ID=$wsid + FM_BACKEND_HERDR_PROJECT_WS_SEEDED_TAB_ID=$seeded + return 0 +} + # fm_backend_herdr_workspace_prune_seeded_default_tab: close EXACTLY # , the auto-created default tab id that THIS SAME # fm_backend_herdr_workspace_ensure call captured straight from its own diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 0b3ec94f09..b08b49baaa 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -8,7 +8,8 @@ # hand-edit backlog files too, primary config/backend pins that home's local # 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 +# config/herdr-project-spaces carries the same opt-in project-grouping choice, +# and config/herdr-presentation-spaces carries the same Herdr presentation-projection # 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 @@ -63,7 +64,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget trace-context}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-project-spaces herdr-presentation-spaces startup-memory-budget trace-context}" # Items whose value is a home-SESSION enablement decision rather than durable # local configuration. They are inherited at the launch convergence point, where diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index cfb25f0058..78006c87d4 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -67,6 +67,13 @@ # 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. +# A home can opt into per-project grouping with config/herdr-project-spaces. +# That path binds the canonical project directory and registered basename to +# one exact workspace id in state/.herdr-project-space-, validates +# that id live before every use, and never adopts by label. It wins over the +# presentation projection for project-resolvable crewmate and scout spawns. +# Any ambiguous grouping placement warns and falls back to the ordinary flat +# layout. --secondmate placement is unchanged. # 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 @@ -1638,6 +1645,7 @@ if [ "$KIND" = secondmate ]; then fi else PROJ_ABS="$(cd "$(resolve_project_dir_arg "$PROJ")" && pwd)" + PROJ_NAME=$(basename "$PROJ_ABS") WT="" BRIEF="$DATA/$ID/brief.md" fi @@ -1657,7 +1665,6 @@ delivery_rigor_rank() { # -> 3 (most rigor) .. 1 (least); 0 = not a task # line. A spawn that disagrees would launch a worker whose instructions and whose # recorded task delivery differ, which is the exact drift this contract prevents. if [ "$KIND" = ship ]; then - PROJ_NAME=$(basename "$PROJ_ABS") BRIEF_MODE=$(sed -n 's/^Delivery contract: mode=\([^ ]*\).*$/\1/p' "$BRIEF" | head -n 1) if [ -z "$BRIEF_MODE" ]; then echo "warning: $BRIEF records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode $MODE - confirm its definition of done matches" >&2 @@ -1898,7 +1905,12 @@ 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 + HERDR_PROJECT_GROUPING=0 + if [ "$KIND" != secondmate ] && fm_backend_herdr_project_spaces_enabled "$CONFIG"; then + HERDR_PROJECT_GROUPING=1 + fi + if [ "$KIND" != secondmate ] && [ "$HERDR_PROJECT_GROUPING" -ne 1 ] \ + && 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 @@ -2016,7 +2028,29 @@ case "$BACKEND" in fi fi if [ "$HERDR_PROJECTED" -ne 1 ]; then - HERDR_CONTAINER_RAW=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure "$PROJ_ABS" "$HERDR_LAUNCHER_RELATIONSHIP") || exit 1 + HERDR_CONTAINER_RAW="" + if [ "$HERDR_PROJECT_GROUPING" -eq 1 ]; then + HERDR_SES=$(fm_backend_herdr_session) + if fm_backend_herdr_version_check \ + && fm_backend_herdr_server_ensure "$HERDR_SES" \ + && spawn_herdr_presentation_order_lock_acquire "$HERDR_SES"; then + set +e + FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_project_workspace_ensure \ + "$HERDR_SES" "$PROJ_ABS_REAL" "$STATE" "$PROJ_NAME" + HERDR_PROJECT_STATUS=$? + set -e + spawn_herdr_presentation_order_lock_release + if [ "$HERDR_PROJECT_STATUS" -eq 0 ]; then + HERDR_CONTAINER_RAW="$HERDR_SES:$FM_BACKEND_HERDR_PROJECT_WS_ID"$'\t'"$FM_BACKEND_HERDR_PROJECT_WS_SEEDED_TAB_ID" + fi + else + echo "warning: herdr project-space placement could not acquire an exact named-session placement context; using the ordinary flat layout" >&2 + spawn_herdr_presentation_order_lock_release + fi + fi + if [ -z "$HERDR_CONTAINER_RAW" ]; then + HERDR_CONTAINER_RAW=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure "$PROJ_ABS" "$HERDR_LAUNCHER_RELATIONSHIP") || exit 1 + fi # fm_backend_herdr_container_ensure echoes ":\t" # (the second field empty when this call ADOPTED a pre-existing workspace # rather than creating a fresh one). Split on the guaranteed single tab diff --git a/docs/configuration.md b/docs/configuration.md index e0466d80d3..5458a03c4a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -84,8 +84,10 @@ 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-project-spaces` file opts a home into exact-id-bound per-project Herdr workspaces when it is empty or contains `on`; absence or `off` preserves the existing layout. +[`herdr-backend.md`](herdr-backend.md#project-workspaces) owns its binding record, precedence, fallback, lifecycle, and label-collision safety contract. 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). +Both Herdr layout settings are 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`. For normal zellij operations, `FM_ZELLIJ_SESSION` selects the named session and defaults to `firstmate`. @@ -326,7 +328,7 @@ When a running home advances and its loaded instruction surface (`AGENTS.md`, `b If that send fails, bootstrap keeps an idempotent retry marker and emits `NUDGE_SECONDMATES:` with the failure reason. The same bootstrap run emits `SECONDMATE_LIVENESS:` only when a registered secondmate is skipped or its relaunch fails; already-live and successfully relaunched secondmates are handled silently. For a mid-session inherited local-material edit where tracked-file sync is not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `backend`, `herdr-presentation-spaces`, `startup-memory-budget`, `trace-context`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `backend`, `herdr-project-spaces`, `herdr-presentation-spaces`, `startup-memory-budget`, `trace-context`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. When an allowlisted config item changes for an already-running local home, it sends the literal-content reread pointer described in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md); unchanged allowlisted config sends no pointer unless a previous delivery is pending. A changed remote home instead receives one durably recorded marked re-read instruction after the allowlisted bytes have transferred because primary-local generation paths are not meaningful on another host. The locked bootstrap inheritance pass uses the same placement-specific behavior; see `secondmate-provisioning` for the single contract owner. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 4c75fd8bc5..4cceda53e4 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -36,6 +36,7 @@ Real harness credential tests remain opt-in rather than part of default CI. ## Watching and task containers The ordinary topology puts one task tab per endpoint in the exact workspace of the Firstmate or secondmate that launches it. +The opt-in project topology described below instead puts each crewmate or scout task tab in its project's bound workspace. When the launcher has no Herdr workspace to inherit, the adapter maintains one durable home-labeled workspace instead. The primary home label is `firstmate`. A secondmate home label is `2ndmate-`, derived from its validated `.fm-secondmate-home` marker. @@ -67,9 +68,36 @@ Existing task operations use recorded endpoint ids and do not move a live task w The per-home workspace is reused while it has task tabs. Closing its last tab can remove the workspace, and the next spawn recreates it. +## Project workspaces + +A home opts into per-project grouping by creating local gitignored `config/herdr-project-spaces` as an empty file or with the value `on`. +The value `off` and an absent file preserve the existing flat or presentation behavior byte for byte. +Values are compared with whitespace stripped and case ignored, and an unrecognized value warns and stays disabled rather than failing dispatch. +The setting reaches only the Herdr backend and is inherited into secondmate homes through the normal configuration-convergence owner. + +When enabled, each project-resolvable crewmate or scout spawn uses the canonical spawn project directory and its registered project or repository basename. +The workspace carries that project name as its cosmetic label and contains the ordinary `fm-` task tabs for that project. +Two tasks for one project therefore share one workspace as separate tabs, while different projects receive different workspaces. +When both Herdr layout flags are enabled, `config/herdr-project-spaces` wins for project-resolvable crewmate and scout spawns, so no disposable presentation workspace is created for those tasks. +A `--secondmate` spawn keeps its existing dedicated-home workspace behavior unchanged. + +Placement authority is the durable home-local `state/.herdr-project-space-` binding, which records the canonical project directory, registered project name, named session, and exact workspace id. +Before every reuse, Firstmate validates that exact id live in the recorded named session and confirms its expected project label. +A label is never searched, matched, or adopted, because Herdr does not enforce label uniqueness. +If the exact workspace is missing, dead, renamed away from the registered project name, or bound to another project or named session, Firstmate creates a fresh workspace with `--no-focus` and records only the exact id returned by that create response. +Two or more unrelated workspaces may carry the same label without affecting this decision; none is adopted without the exact durable binding. +An unreadable or malformed binding, ambiguous live response, failed create, incomplete create response, unavailable placement lock, or failed atomic binding publication emits a warning and falls back to the existing flat placement instead of failing dispatch. + +The project binding chooses placement only and never becomes task, send, recovery, or cleanup authority. +Normal task metadata continues to record the exact pane, tab, workspace, and named session for each worker. +Cleanup and recovery therefore retain their existing behavior: they address only recorded panes, close only the exact task pane, never call `workspace close`, and never sweep or close a project workspace. +Workspace and task-tab creation both use `--no-focus`, so grouping does not intentionally steal focus. + ## Presentation spaces Each new crewmate or scout is placed in a disposable one-task workspace by default, on Herdr 0.8.0 and newer. +A project-resolvable spawn with `config/herdr-project-spaces` enabled uses its project workspace instead, even when presentation spaces are also enabled. +A `--secondmate` spawn remains exempt from both child layouts and keeps its existing dedicated-workspace behavior. 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. diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 39b0e13b51..09d0ebbdff 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -949,10 +949,10 @@ printf 'alpha\n' > "$SECOND_HOME_A/.fm-secondmate-home" printf 'bravo\n' > "$SECOND_HOME_B/.fm-secondmate-home" touch "$SECOND_HOME_A/state/.last-watcher-beat" "$SECOND_HOME_B/state/.last-watcher-beat" # Ensure the secondmate homes look like gitignored firstmate homes so inheritance -# may write config/herdr-presentation-spaces. +# may write the inherited Herdr layout settings. git -C "$SECOND_HOME_A" init -q git -C "$SECOND_HOME_B" init -q -printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\nconfig/backend\nconfig/startup-memory-budget\n' \ +printf 'config/herdr-project-spaces\nconfig/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\nconfig/backend\nconfig/startup-memory-budget\n' \ > "$SECOND_HOME_A/.gitignore" cp "$SECOND_HOME_A/.gitignore" "$SECOND_HOME_B/.gitignore" git -C "$SECOND_HOME_A" add .gitignore diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 1adeed3645..1f14bb778a 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -831,6 +831,110 @@ test_create_task_creates_with_no_focus_flag() { pass "fm_backend_herdr_create_task: tab create passes --no-focus" } +# --- opt-in per-project workspaces ----------------------------------------- + +test_project_spaces_flag_is_opt_in_and_presence_compatible() { + local dir config status + dir="$TMP_ROOT/project-spaces-config"; config="$dir/config"; mkdir -p "$config" + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_project_spaces_enabled "$1"' "$ROOT" "$config" + status=$? + expect_code 1 "$status" "an absent project-spaces flag must preserve today's placement" + : > "$config/herdr-project-spaces" + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_project_spaces_enabled "$1"' "$ROOT" "$config" \ + || fail "an empty project-spaces file must opt in" + printf 'on\n' > "$config/herdr-project-spaces" + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_project_spaces_enabled "$1"' "$ROOT" "$config" \ + || fail "an explicit on must opt in" + printf 'off\n' > "$config/herdr-project-spaces" + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_project_spaces_enabled "$1"' "$ROOT" "$config" + status=$? + expect_code 1 "$status" "an explicit off must disable project spaces" + pass "herdr project spaces: absence preserves existing placement while empty/on opt in" +} + +test_project_workspace_create_reuse_and_project_separation() { + local dir state project_a project_b log fb out ws_a1 ws_a2 ws_b + dir="$TMP_ROOT/project-space-reuse"; state="$dir/home-state"; project_a="$dir/mf-website"; project_b="$dir/soul-site" + mkdir -p "$state" "$project_a" "$project_b"; log="$dir/log"; : > "$log" + fb=$(make_herdr_statefake "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_FAKE_HERDR_STATE="$dir/state.json" \ + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_project_workspace_ensure fmtest "$1" "$3" mf-website || exit 1 + ws_a1=$FM_BACKEND_HERDR_PROJECT_WS_ID + fm_backend_herdr_create_task "fmtest:$ws_a1" fm-task-a1 "$1" "$FM_BACKEND_HERDR_PROJECT_WS_SEEDED_TAB_ID" >/dev/null || exit 1 + fm_backend_herdr_project_workspace_ensure fmtest "$1" "$3" mf-website || exit 1 + ws_a2=$FM_BACKEND_HERDR_PROJECT_WS_ID + fm_backend_herdr_create_task "fmtest:$ws_a2" fm-task-a2 "$1" "$FM_BACKEND_HERDR_PROJECT_WS_SEEDED_TAB_ID" >/dev/null || exit 1 + fm_backend_herdr_project_workspace_ensure fmtest "$2" "$3" soul-site || exit 1 + ws_b=$FM_BACKEND_HERDR_PROJECT_WS_ID + fm_backend_herdr_create_task "fmtest:$ws_b" fm-task-b1 "$2" "$FM_BACKEND_HERDR_PROJECT_WS_SEEDED_TAB_ID" >/dev/null || exit 1 + printf "%s %s %s\n" "$ws_a1" "$ws_a2" "$ws_b" + ' "$ROOT" "$project_a" "$project_b" "$state") || fail "project workspace create/reuse sequence failed" + read -r ws_a1 ws_a2 ws_b < "$log" + fb=$(make_herdr_statefake "$dir") + first=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_FAKE_HERDR_STATE="$dir/state.json" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_project_workspace_ensure fmtest "$1" "$2" mf-website || exit 1; printf "%s\n" "$FM_BACKEND_HERDR_PROJECT_WS_ID"' \ + "$ROOT" "$project" "$state") || fail "initial project workspace create failed" + tmp="$dir/state.json.tmp" + jq --arg w "$first" '.workspaces |= [.[] | select(.workspace_id != $w)] | .tabs |= [.[] | select(.workspace_id != $w)]' \ + "$dir/state.json" > "$tmp" && mv "$tmp" "$dir/state.json" + second=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_FAKE_HERDR_STATE="$dir/state.json" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_project_workspace_ensure fmtest "$1" "$2" mf-website || exit 1; printf "%s\n" "$FM_BACKEND_HERDR_PROJECT_WS_ID"' \ + "$ROOT" "$project" "$state") || fail "stale binding did not create a fresh project workspace" + [ "$first" != "$second" ] || fail "a dead exact workspace binding was reused" + [ "$(sed -n 's/^workspace_id=//p' "$state/.herdr-project-space-mf-website")" = "$second" ] \ + || fail "the stale binding was not atomically replaced with the fresh response id" + jq --arg w "$second" '(.workspaces[] | select(.workspace_id == $w) | .label) = "renamed-by-human"' \ + "$dir/state.json" > "$tmp" && mv "$tmp" "$dir/state.json" + third=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_FAKE_HERDR_STATE="$dir/state.json" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_project_workspace_ensure fmtest "$1" "$2" mf-website || exit 1; printf "%s\n" "$FM_BACKEND_HERDR_PROJECT_WS_ID"' \ + "$ROOT" "$project" "$state") || fail "renamed binding did not create a fresh project workspace" + [ "$second" != "$third" ] || fail "a renamed-away exact workspace binding was reused" + pass "herdr project spaces: dead and renamed-away exact bindings create and record fresh workspaces" +} + +test_project_workspace_never_adopts_by_colliding_label() { + local dir state project log fb out existing1 existing2 bound + dir="$TMP_ROOT/project-space-label-collision"; state="$dir/home-state"; project="$dir/mf-website" + mkdir -p "$state" "$project"; log="$dir/log"; : > "$log" + fb=$(make_herdr_statefake "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_FAKE_HERDR_STATE="$dir/state.json" \ + bash -c ' + . "$0/bin/backends/herdr.sh" + one=$(fm_backend_herdr_cli fmtest workspace create --cwd "$1" --label mf-website --no-focus) || exit 1 + two=$(fm_backend_herdr_cli fmtest workspace create --cwd "$1" --label mf-website --no-focus) || exit 1 + existing1=$(printf "%s" "$one" | jq -r .result.workspace.workspace_id) + existing2=$(printf "%s" "$two" | jq -r .result.workspace.workspace_id) + fm_backend_herdr_project_workspace_ensure fmtest "$1" "$2" mf-website || exit 1 + printf "%s %s %s\n" "$existing1" "$existing2" "$FM_BACKEND_HERDR_PROJECT_WS_ID" + ' "$ROOT" "$project" "$state") || fail "label-collision project create failed" + read -r existing1 existing2 bound < "$src/crew-harness" printf 'manual\n' > "$src/backlog-backend" printf 'tmux\n' > "$src/backend" + : > "$src/herdr-project-spaces" : > "$src/herdr-presentation-spaces" : > "$src/trace-context" stdout="$d/clean-copy.out" @@ -302,6 +304,7 @@ test_propagate_lib() { [ "$(cat "$dest/crew-harness")" = codex ] || fail "crew-harness not propagated" [ "$(cat "$dest/backlog-backend")" = manual ] || fail "backlog-backend not propagated" [ "$(cat "$dest/backend")" = tmux ] || fail "backend not propagated" + [ -f "$dest/herdr-project-spaces" ] || fail "herdr-project-spaces not propagated" [ -f "$dest/herdr-presentation-spaces" ] || fail "herdr-presentation-spaces not propagated" printf 'herdr\n' > "$dest/backend" propagate_inheritable_config "$src" "$dest" @@ -343,12 +346,13 @@ test_propagate_lib() { # 4. removing the source mirrors absence downstream (primary-authoritative) printf 'herdr\n' > "$dest/backend" rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" \ - "$src/backend" "$src/herdr-presentation-spaces" "$src/trace-context" + "$src/backend" "$src/herdr-project-spaces" "$src/herdr-presentation-spaces" "$src/trace-context" propagate_inheritable_config "$src" "$dest" [ -e "$dest/crew-dispatch.json" ] && fail "dispatch profile absence not mirrored downstream" [ -e "$dest/crew-harness" ] && fail "absence not mirrored downstream" [ -e "$dest/backlog-backend" ] && fail "backlog-backend absence not mirrored downstream" [ -e "$dest/backend" ] && fail "backend absence not mirrored downstream" + [ -e "$dest/herdr-project-spaces" ] && fail "herdr-project-spaces absence not mirrored downstream" [ -e "$dest/herdr-presentation-spaces" ] && fail "herdr-presentation-spaces absence not mirrored downstream" [ -e "$dest/trace-context" ] && fail "trace-context absence not mirrored downstream" @@ -997,7 +1001,7 @@ new_world() { printf 'projects/\nstate/\ndata/\n.no-mistakes/\n' [ "$dispatch_ignore" = no ] || printf 'config/crew-dispatch.json\n' printf 'config/crew-harness\nconfig/secondmate-harness\nconfig/backlog-backend\n' - printf 'config/backend\nconfig/herdr-presentation-spaces\nconfig/startup-memory-budget\n' + printf 'config/backend\nconfig/herdr-project-spaces\nconfig/herdr-presentation-spaces\nconfig/startup-memory-budget\n' } > "$w/main/.gitignore" printf 'v1\n' > "$w/main/AGENTS.md" printf 'r1\n' > "$w/main/README.md" From f94e03609d8cef5c9cd9dfc81ff95b6c1b6d5093 Mon Sep 17 00:00:00 2001 From: Patrick Charlier Date: Sat, 15 Aug 2026 11:39:29 -0700 Subject: [PATCH 2/8] no-mistakes(review): preserve journal recovery guard; path-hash project bindings --- bin/backends/herdr.sh | 38 +++++++++++++++++++++++------- bin/fm-spawn.sh | 20 +++++++++------- docs/herdr-backend.md | 6 +++-- tests/fm-backend-herdr.test.sh | 43 ++++++++++++++++++++++++++++++++-- 4 files changed, 87 insertions(+), 20 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index b76bb4f420..6dc3b37da0 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1646,16 +1646,38 @@ fm_backend_herdr_launcher_identity() { # return 0 } -# fm_backend_herdr_project_binding_path : resolve the -# home-local exact-id binding record for one registered project name. Project -# names become filenames, so only the registry's safe identifier shape is -# accepted; an unsafe name makes project placement fall back flat. -fm_backend_herdr_project_binding_path() { # - local state=$1 project_name=$2 +# fm_backend_herdr_project_dir_hash : a stable short digest of the +# canonical project directory, used only to key the binding filename. +fm_backend_herdr_project_dir_hash() { # + local dir=$1 digest + if command -v shasum >/dev/null 2>&1; then + digest=$(printf '%s' "$dir" | shasum -a 256 2>/dev/null | awk '{print substr($1, 1, 16)}') + elif command -v sha256sum >/dev/null 2>&1; then + digest=$(printf '%s' "$dir" | sha256sum 2>/dev/null | awk '{print substr($1, 1, 16)}') + else + digest=$(printf '%s' "$dir" | cksum 2>/dev/null | awk '{printf "%08x%08x", $1, $2}') + fi + case "$digest" in + *[!0-9a-f]*|'') return 1 ;; + esac + printf '%s' "$digest" +} + +# fm_backend_herdr_project_binding_path : +# resolve the home-local exact-id binding record for one project. The filename +# is keyed by the registered name PLUS a stable digest of the canonical project +# directory, so two same-named projects in different directories keep +# independent durable bindings; the name itself stays a cosmetic label only. +# Project names become filenames, so only the registry's safe identifier shape +# is accepted; an unsafe name makes project placement fall back flat. +fm_backend_herdr_project_binding_path() { # + local state=$1 project_name=$2 project_dir=$3 dir_hash case "$project_name" in ''|.|..|*[!A-Za-z0-9._-]*) return 1 ;; esac - printf '%s/%s%s' "$state" "$FM_BACKEND_HERDR_PROJECT_BINDING_PREFIX" "$project_name" + [ -n "$project_dir" ] || return 1 + dir_hash=$(fm_backend_herdr_project_dir_hash "$project_dir") || return 1 + printf '%s/%s%s-%s' "$state" "$FM_BACKEND_HERDR_PROJECT_BINDING_PREFIX" "$project_name" "$dir_hash" } # Read one complete binding and reject malformed, duplicate, cross-project, or @@ -1746,7 +1768,7 @@ fm_backend_herdr_project_workspace_ensure() { # &2 return 1 } diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 78006c87d4..52c567c2cc 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -69,11 +69,15 @@ # exception: it stands up that secondmate home's own workspace. # A home can opt into per-project grouping with config/herdr-project-spaces. # That path binds the canonical project directory and registered basename to -# one exact workspace id in state/.herdr-project-space-, validates -# that id live before every use, and never adopts by label. It wins over the -# presentation projection for project-resolvable crewmate and scout spawns. -# Any ambiguous grouping placement warns and falls back to the ordinary flat -# layout. --secondmate placement is unchanged. +# one exact workspace id in +# state/.herdr-project-space-- (the hash keys the +# canonical project directory so same-named projects stay independent), +# validates that id live before every use, and never adopts by label. It wins +# over creating a NEW presentation projection for project-resolvable crewmate +# and scout spawns, but a pending presentation journal still runs the full +# recovery guard first - a stale journal never grants launch authority, with +# or without grouping. Any ambiguous grouping placement warns and falls back +# to the ordinary flat layout. --secondmate placement is unchanged. # 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 @@ -1909,8 +1913,7 @@ case "$BACKEND" in if [ "$KIND" != secondmate ] && fm_backend_herdr_project_spaces_enabled "$CONFIG"; then HERDR_PROJECT_GROUPING=1 fi - if [ "$KIND" != secondmate ] && [ "$HERDR_PROJECT_GROUPING" -ne 1 ] \ - && fm_backend_herdr_presentation_enabled "$CONFIG" "$STATE"; 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 @@ -1955,7 +1958,8 @@ case "$BACKEND" in else spawn_herdr_presentation_order_lock_release fi - elif [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ]; then + elif [ "$HERDR_PROJECT_GROUPING" -ne 1 ] \ + && [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ]; 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 4cceda53e4..c3432501e3 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -78,10 +78,12 @@ The setting reaches only the Herdr backend and is inherited into secondmate home When enabled, each project-resolvable crewmate or scout spawn uses the canonical spawn project directory and its registered project or repository basename. The workspace carries that project name as its cosmetic label and contains the ordinary `fm-` task tabs for that project. Two tasks for one project therefore share one workspace as separate tabs, while different projects receive different workspaces. -When both Herdr layout flags are enabled, `config/herdr-project-spaces` wins for project-resolvable crewmate and scout spawns, so no disposable presentation workspace is created for those tasks. +When both Herdr layout flags are enabled, `config/herdr-project-spaces` wins for project-resolvable crewmate and scout spawns, so no new disposable presentation workspace is created for those tasks. +A task that still has a pending presentation journal from an earlier projected spawn runs the full journal recovery guard first, exactly as without grouping: a live projected pane is reclaimed in place, a dead one is retired, and an ambiguous one refuses the spawn, so a stale journal never admits a duplicate agent into a project workspace. A `--secondmate` spawn keeps its existing dedicated-home workspace behavior unchanged. -Placement authority is the durable home-local `state/.herdr-project-space-` binding, which records the canonical project directory, registered project name, named session, and exact workspace id. +Placement authority is the durable home-local `state/.herdr-project-space--` binding, which records the canonical project directory, registered project name, named session, and exact workspace id. +The `` filename component is a stable short digest of the canonical project directory, so two projects that share a basename in different directories keep independent durable bindings instead of overwriting each other; the registered name remains only the cosmetic workspace label. Before every reuse, Firstmate validates that exact id live in the recorded named session and confirms its expected project label. A label is never searched, matched, or adopted, because Herdr does not enforce label uniqueness. If the exact workspace is missing, dead, renamed away from the registered project name, or bound to another project or named session, Firstmate creates a fresh workspace with `--no-focus` and records only the exact id returned by that create response. diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 1f14bb778a..77c156031d 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -884,8 +884,40 @@ EOF pass "herdr project spaces: one project reuses one exact workspace while another project gets a separate workspace" } +test_project_workspace_same_basename_projects_keep_stable_separate_bindings() { + local dir state project_a project_b log fb out ws_a1 ws_b1 ws_a2 ws_b2 + dir="$TMP_ROOT/project-space-same-basename"; state="$dir/home-state" + project_a="$dir/clients/a/site"; project_b="$dir/clients/b/site" + mkdir -p "$state" "$project_a" "$project_b"; log="$dir/log"; : > "$log" + fb=$(make_herdr_statefake "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_FAKE_HERDR_STATE="$dir/state.json" \ + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_project_workspace_ensure fmtest "$1" "$3" site || exit 1 + ws_a1=$FM_BACKEND_HERDR_PROJECT_WS_ID + fm_backend_herdr_project_workspace_ensure fmtest "$2" "$3" site || exit 1 + ws_b1=$FM_BACKEND_HERDR_PROJECT_WS_ID + fm_backend_herdr_project_workspace_ensure fmtest "$1" "$3" site || exit 1 + ws_a2=$FM_BACKEND_HERDR_PROJECT_WS_ID + fm_backend_herdr_project_workspace_ensure fmtest "$2" "$3" site || exit 1 + ws_b2=$FM_BACKEND_HERDR_PROJECT_WS_ID + printf "%s %s %s %s\n" "$ws_a1" "$ws_b1" "$ws_a2" "$ws_b2" + ' "$ROOT" "$project_a" "$project_b" "$state") || fail "same-basename alternating ensure sequence failed" + read -r ws_a1 ws_b1 ws_a2 ws_b2 < "$log" fb=$(make_herdr_statefake "$dir") @@ -899,7 +931,13 @@ test_project_workspace_stale_binding_creates_fresh_workspace() { bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_project_workspace_ensure fmtest "$1" "$2" mf-website || exit 1; printf "%s\n" "$FM_BACKEND_HERDR_PROJECT_WS_ID"' \ "$ROOT" "$project" "$state") || fail "stale binding did not create a fresh project workspace" [ "$first" != "$second" ] || fail "a dead exact workspace binding was reused" - [ "$(sed -n 's/^workspace_id=//p' "$state/.herdr-project-space-mf-website")" = "$second" ] \ + binding=$(bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_project_binding_path "$1" mf-website "$2"' \ + "$ROOT" "$state" "$project") || fail "binding path resolution failed" + case "$binding" in + "$state/.herdr-project-space-mf-website-"*) ;; + *) fail "binding filename is not keyed by project name plus directory hash: $binding" ;; + esac + [ "$(sed -n 's/^workspace_id=//p' "$binding")" = "$second" ] \ || fail "the stale binding was not atomically replaced with the fresh response id" jq --arg w "$second" '(.workspaces[] | select(.workspace_id == $w) | .label) = "renamed-by-human"' \ "$dir/state.json" > "$tmp" && mv "$tmp" "$dir/state.json" @@ -4463,6 +4501,7 @@ test_create_task_creates_and_parses_ids test_create_task_creates_with_no_focus_flag test_project_spaces_flag_is_opt_in_and_presence_compatible test_project_workspace_create_reuse_and_project_separation +test_project_workspace_same_basename_projects_keep_stable_separate_bindings test_project_workspace_stale_binding_creates_fresh_workspace test_project_workspace_never_adopts_by_colliding_label test_presentation_defaults_on_at_or_above_the_floor From f307af04a32c29b569441cc7485cbadfc697ba32 Mon Sep 17 00:00:00 2001 From: Patrick Charlier Date: Sat, 15 Aug 2026 12:04:25 -0700 Subject: [PATCH 3/8] no-mistakes(document): add herdr-project-spaces to secondmate inherited-config lists --- .agents/skills/secondmate-provisioning/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index b878c6f765..cc71a6e6ff 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -104,7 +104,7 @@ That no-fetch path is a purely local fast-forward of tracked files, never an ori A remote launch and the deferred bootstrap sweep ask the configured host to fast-forward its persistent home to that host's code-root commit under the same clean and ancestry guards. `/updatefirstmate` first updates the remote code root from its own origin, then runs that guarded home sync. SSH exit 255 preserves the route and reports unknown completion; it never triggers local respawn or failover. -The same placement-specific launch and deferred bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`, and the one shared captain-preference file `data/captain-shared.md`. +The same placement-specific launch and deferred bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-project-spaces`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`, and the one shared captain-preference file `data/captain-shared.md`. Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. @@ -125,7 +125,7 @@ Keep every `data/learnings.md` fully local by captain decision; route fleet-gene No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. -For a local home, after each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`), in deterministic allowlist order. +For a local home, after each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-project-spaces`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`), in deterministic allowlist order. Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. `data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. From d479f5264238d19fb2b725909cf18c7409e70c7f Mon Sep 17 00:00:00 2001 From: Patrick Charlier Date: Sat, 15 Aug 2026 12:20:21 -0700 Subject: [PATCH 4/8] no-mistakes(review): run presentation-journal recovery guard whenever a journal exists --- bin/fm-spawn.sh | 4 +- docs/herdr-backend.md | 2 +- .../fm-backend-herdr-presentation-e2e.test.sh | 52 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 52c567c2cc..9c1bed6620 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1913,7 +1913,9 @@ case "$BACKEND" in if [ "$KIND" != secondmate ] && fm_backend_herdr_project_spaces_enabled "$CONFIG"; then HERDR_PROJECT_GROUPING=1 fi - if [ "$KIND" != secondmate ] && fm_backend_herdr_presentation_enabled "$CONFIG" "$STATE"; then + if [ "$KIND" != secondmate ] \ + && { [ -e "$HERDR_PRESENTATION_JOURNAL" ] || [ -L "$HERDR_PRESENTATION_JOURNAL" ] \ + || 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 diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index c3432501e3..207ace07cb 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -79,7 +79,7 @@ When enabled, each project-resolvable crewmate or scout spawn uses the canonical The workspace carries that project name as its cosmetic label and contains the ordinary `fm-` task tabs for that project. Two tasks for one project therefore share one workspace as separate tabs, while different projects receive different workspaces. When both Herdr layout flags are enabled, `config/herdr-project-spaces` wins for project-resolvable crewmate and scout spawns, so no new disposable presentation workspace is created for those tasks. -A task that still has a pending presentation journal from an earlier projected spawn runs the full journal recovery guard first, exactly as without grouping: a live projected pane is reclaimed in place, a dead one is retired, and an ambiguous one refuses the spawn, so a stale journal never admits a duplicate agent into a project workspace. +A task that still has a pending presentation journal from an earlier projected spawn runs the full journal recovery guard first, with or without grouping and whatever the current `config/herdr-presentation-spaces` value: a positively dead or agent-free projected endpoint proceeds under the existing recovery contract (exact bound reclaim when its metadata still binds it, ordinary placement otherwise), while a live or ambiguous one refuses the spawn, so a stale journal never admits a duplicate agent into a project workspace. A `--secondmate` spawn keeps its existing dedicated-home workspace behavior unchanged. Placement authority is the durable home-local `state/.herdr-project-space--` binding, which records the canonical project directory, registered project name, named session, and exact workspace id. diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 09d0ebbdff..ae0d271b29 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -1223,6 +1223,58 @@ for RESTART_ID in fm-hibit-resume-r1 wheelhouse-healing-r1; do done pass "real Herdr lab: Hi Bit and Wheelhouse-style same-identity restarts reclaim one nested space with exact focus and idempotence" +# A pending journal keeps its duplicate-agent recovery guard when the home has +# since opted into project grouping and turned presentation explicitly off: +# a live journal-bound endpoint refuses duplicate launch, and a safely +# agent-free one proceeds through the existing exact recovery contract. +GUARD_OFF_ID=guard-off-r1 +mkdir -p "$HOME_DIR/data/$GUARD_OFF_ID" +printf 'Presentation-off recovery guard fixture.\n' > "$HOME_DIR/data/$GUARD_OFF_ID/brief.md" +spawn_task "$GUARD_OFF_ID" "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/guard-off-first.out" 2> "$TMP_ROOT/guard-off-first.err" \ + || fail "presentation-off guard fixture's projected spawn failed: $(cat "$TMP_ROOT/guard-off-first.err")" +GUARD_OFF_META="$HOME_DIR/state/$GUARD_OFF_ID.meta" +GUARD_OFF_OLD_WT=$(remember_meta_worktree "$GUARD_OFF_META") +GUARD_OFF_WSID=$(grep '^herdr_workspace_id=' "$GUARD_OFF_META" | cut -d= -f2-) +GUARD_OFF_OLD_PANE=$(grep '^herdr_pane_id=' "$GUARD_OFF_META" | cut -d= -f2-) +GUARD_OFF_JOURNAL="$HOME_DIR/state/$GUARD_OFF_ID.herdr-presentation" +[ -f "$GUARD_OFF_JOURNAL" ] || fail "presentation-off guard fixture published no journal" +printf 'off\n' > "$HOME_DIR/config/herdr-presentation-spaces" +: > "$HOME_DIR/config/herdr-project-spaces" +GUARD_OFF_START=$(log_line_count) +if spawn_task "$GUARD_OFF_ID" "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/guard-off-live.out" 2> "$TMP_ROOT/guard-off-live.err"; then + fail "a live journal-bound endpoint admitted a duplicate launch under presentation-off grouping" +fi +grep -F "refusing duplicate launch" "$TMP_ROOT/guard-off-live.err" >/dev/null 2>&1 \ + || fail "presentation-off refusal did not come from the journal recovery guard: $(cat "$TMP_ROOT/guard-off-live.err")" +if sed -n "$((GUARD_OFF_START + 1)),\$p" "$HERDR_CALL_LOG" | grep -E $'^(workspace\tcreate|tab\tcreate)' >/dev/null 2>&1; then + fail "presentation-off live refusal still created a workspace or tab" +fi +lab pane get "$GUARD_OFF_OLD_PANE" >/dev/null 2>&1 \ + || fail "presentation-off live refusal disturbed the journal-bound pane" +PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" stop "$HERDR_LAB_SESSION" >/dev/null \ + || fail "could not stop the isolated session for the presentation-off guard" +PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" \ + || fail "could not reprovision the isolated session for the presentation-off guard" +spawn_task "$GUARD_OFF_ID" "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/guard-off-husk.out" 2> "$TMP_ROOT/guard-off-husk.err" \ + || fail "agent-free journal recovery failed under presentation-off grouping: $(cat "$TMP_ROOT/guard-off-husk.err")" +GUARD_OFF_NEW_WT=$(remember_meta_worktree "$GUARD_OFF_META") +GUARD_OFF_NEW_PANE=$(grep '^herdr_pane_id=' "$GUARD_OFF_META" | cut -d= -f2-) +[ "$(grep '^herdr_workspace_id=' "$GUARD_OFF_META" | cut -d= -f2-)" = "$GUARD_OFF_WSID" ] \ + || fail "presentation-off recovery flattened into a different workspace instead of the exact reclaim" +[ "$GUARD_OFF_NEW_PANE" != "$GUARD_OFF_OLD_PANE" ] \ + || fail "presentation-off recovery reused the old husk pane" +[ "$(grep '^pane_id=' "$GUARD_OFF_JOURNAL" | cut -d= -f2-)" = "$GUARD_OFF_NEW_PANE" ] \ + || fail "presentation-off recovery did not advance the exact journal binding" +teardown_task "$GUARD_OFF_ID" "$HOME_DIR" > "$TMP_ROOT/guard-off-teardown.out" 2> "$TMP_ROOT/guard-off-teardown.err" \ + || fail "presentation-off recovery teardown failed: $(cat "$TMP_ROOT/guard-off-teardown.err")" +[ ! -e "$GUARD_OFF_JOURNAL" ] \ + || fail "presentation-off reclaimed teardown did not retire its journal" +rm -f "$HOME_DIR/config/herdr-project-spaces" +: > "$HOME_DIR/config/herdr-presentation-spaces" +"$REAL_TREEHOUSE" return --force "$GUARD_OFF_OLD_WT" >/dev/null 2>&1 || true +"$REAL_TREEHOUSE" return --force "$GUARD_OFF_NEW_WT" >/dev/null 2>&1 || true +pass "real Herdr lab: a pending journal runs the recovery guard under presentation-off project grouping - live refuses, agent-free reclaims exactly" + # A secondmate child binds and reclaims only inside its own home and parent. CROSS_RESTART_ID=wheel-child-resume mkdir -p "$SECOND_HOME_A/data/$CROSS_RESTART_ID" From eda5aaba9a499a8e059a2699cab8ff660b3ef21f Mon Sep 17 00:00:00 2001 From: Patrick Charlier Date: Sat, 15 Aug 2026 13:09:49 -0700 Subject: [PATCH 5/8] no-mistakes(test): register live agent so guard e2e refusal check fires --- .../fm-backend-herdr-presentation-e2e.test.sh | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index ae0d271b29..6edb399419 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -1240,6 +1240,11 @@ GUARD_OFF_JOURNAL="$HOME_DIR/state/$GUARD_OFF_ID.herdr-presentation" [ -f "$GUARD_OFF_JOURNAL" ] || fail "presentation-off guard fixture published no journal" printf 'off\n' > "$HOME_DIR/config/herdr-presentation-spaces" : > "$HOME_DIR/config/herdr-project-spaces" +# The fixture pane runs a plain sleep, which reads positively agent-free, so +# register a live agent on the exact journal-bound pane to model the +# crashed-spawn-with-live-worker case the guard must refuse. +lab pane report-agent "$GUARD_OFF_OLD_PANE" --source fm-projection-e2e --agent test-agent --state working >/dev/null \ + || fail "could not register the live-agent fixture on the journal-bound pane" GUARD_OFF_START=$(log_line_count) if spawn_task "$GUARD_OFF_ID" "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/guard-off-live.out" 2> "$TMP_ROOT/guard-off-live.err"; then fail "a live journal-bound endpoint admitted a duplicate launch under presentation-off grouping" @@ -1275,6 +1280,47 @@ rm -f "$HOME_DIR/config/herdr-project-spaces" "$REAL_TREEHOUSE" return --force "$GUARD_OFF_NEW_WT" >/dev/null 2>&1 || true pass "real Herdr lab: a pending journal runs the recovery guard under presentation-off project grouping - live refuses, agent-free reclaims exactly" +# Project grouping wins over presentation for fresh project-resolvable spawns: +# with presentation left on (empty file) and project spaces opted in, two fresh +# tasks for one project skip the disposable projection, publish no journal, and +# land as ordinary task tabs in one exact-id-bound project-labeled workspace. +: > "$HOME_DIR/config/herdr-project-spaces" +GROUP_START=$(log_line_count) +for GROUP_ID in group-fresh-a group-fresh-b; do + mkdir -p "$HOME_DIR/data/$GROUP_ID" + printf 'Project grouping precedence fixture.\n' > "$HOME_DIR/data/$GROUP_ID/brief.md" + spawn_task "$GROUP_ID" "$HOME_DIR" "$PROJECT_DIR" > "$TMP_ROOT/$GROUP_ID.out" 2> "$TMP_ROOT/$GROUP_ID.err" \ + || fail "project-grouped spawn $GROUP_ID failed: $(cat "$TMP_ROOT/$GROUP_ID.err")" + [ ! -e "$HOME_DIR/state/$GROUP_ID.herdr-presentation" ] \ + || fail "project grouping still published a presentation journal for $GROUP_ID" +done +[ -z "$(projection_labels_from_log "$GROUP_START")" ] \ + || fail "project grouping created a disposable presentation workspace anyway" +GROUP_WS_A=$(grep '^herdr_workspace_id=' "$HOME_DIR/state/group-fresh-a.meta" | cut -d= -f2-) +GROUP_WS_B=$(grep '^herdr_workspace_id=' "$HOME_DIR/state/group-fresh-b.meta" | cut -d= -f2-) +[ -n "$GROUP_WS_A" ] || fail "grouped spawn A recorded no workspace id" +[ "$GROUP_WS_A" = "$GROUP_WS_B" ] \ + || fail "two grouped tasks did not share one exact project workspace: $GROUP_WS_A vs $GROUP_WS_B" +GROUP_PROJECT_NAME=$(basename "$PROJECT_DIR") +[ "$(lab workspace get "$GROUP_WS_A" | jq -r '.result.workspace.label')" = "$GROUP_PROJECT_NAME" ] \ + || fail "the project workspace label is not the registered project name" +GROUP_TABS=$(lab tab list --workspace "$GROUP_WS_A" | jq -r '[.result.tabs[].label] | sort | join(",")') +[ "$GROUP_TABS" = "fm-group-fresh-a,fm-group-fresh-b" ] \ + || fail "the project workspace does not hold exactly the two ordinary task tabs: $GROUP_TABS" +GROUP_BINDING=$(find "$HOME_DIR/state" -name ".herdr-project-space-$GROUP_PROJECT_NAME-*" -type f) +[ "$(printf '%s\n' "$GROUP_BINDING" | grep -c .)" = 1 ] \ + || fail "expected exactly one durable project-space binding: $GROUP_BINDING" +[ "$(sed -n 's/^workspace_id=//p' "$GROUP_BINDING")" = "$GROUP_WS_A" ] \ + || fail "the durable binding does not carry the exact bound workspace id" +for GROUP_ID in group-fresh-a group-fresh-b; do + GROUP_WT=$(remember_meta_worktree "$HOME_DIR/state/$GROUP_ID.meta") + teardown_task "$GROUP_ID" "$HOME_DIR" > "$TMP_ROOT/$GROUP_ID.teardown.out" 2> "$TMP_ROOT/$GROUP_ID.teardown.err" \ + || fail "grouped task $GROUP_ID teardown failed: $(cat "$TMP_ROOT/$GROUP_ID.teardown.err")" + "$REAL_TREEHOUSE" return --force "$GROUP_WT" >/dev/null 2>&1 || true +done +rm -f "$HOME_DIR/config/herdr-project-spaces" +pass "real Herdr lab: fresh grouped spawns share one exact project workspace and skip the presentation projection" + # A secondmate child binds and reclaims only inside its own home and parent. CROSS_RESTART_ID=wheel-child-resume mkdir -p "$SECOND_HOME_A/data/$CROSS_RESTART_ID" From 5699f3e79d0c820e5f64054cc81f4c0dabb92441 Mon Sep 17 00:00:00 2001 From: Patrick Charlier Date: Sat, 15 Aug 2026 13:27:42 -0700 Subject: [PATCH 6/8] no-mistakes(document): sync architecture herdr layout summary; fix stale section pointers --- bin/backends/herdr.sh | 4 ++-- docs/architecture.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 6dc3b37da0..87e7cada4f 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -13,7 +13,7 @@ # # Default container shape (D4, decided empirically - see # herdr-verification-p2.md "Task container shape", refined by -# docs/herdr-backend.md "Default task container shape"): ONE herdr workspace PER +# docs/herdr-backend.md "Watching and task containers"): ONE herdr workspace PER # FIRSTMATE HOME (the primary, and each secondmate, gets its own), ONE herdr TAB # per task inside its home's workspace. An opt-in project-space layout instead # groups each project's task tabs in one exact-id-bound workspace. The @@ -361,7 +361,7 @@ fm_backend_herdr_presentation_enabled() { # [] } # fm_backend_herdr_workspace_label: the per-firstmate-HOME herdr workspace -# label (docs/herdr-backend.md "Default task container shape"). The PRIMARY home (no +# label (docs/herdr-backend.md "Watching and task containers"). The PRIMARY home (no # secondmate marker) resolves to the constant "firstmate", byte-identical to # every pre-existing task's recorded label - no forced migration. A SECONDMATE # home resolves to "2ndmate-", so its tasks land in their own diff --git a/docs/architecture.md b/docs/architecture.md index ee6749827f..723c58bf94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -144,7 +144,8 @@ 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, the Herdr version floor its unconfigured default is gated behind, and its narrow home-local restored-shell cleanup at locked session start. +An opt-in `config/herdr-project-spaces` layout instead groups each project's crewmate and scout tabs in one exact-id-bound workspace; [Project workspaces](herdr-backend.md#project-workspaces) owns its binding record, its precedence over the projection, and its flat fallback. +Herdr's 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 its unconfigured default is gated behind, 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. From 5b673e25867a5ce8006c5d82c81cadc19867a5a5 Mon Sep 17 00:00:00 2001 From: Patrick Charlier Date: Sun, 16 Aug 2026 13:12:30 -0700 Subject: [PATCH 7/8] no-mistakes(document): document herdr project-space state binding in AGENTS inventory --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index b34421bf3a..4dac78d668 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,7 @@ state/ runtime records and signals; gitignored .cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown .meta task metadata; each producer script's header owns its exact fields and mutation contract, with docs/configuration.md routing operator-facing backend and trace-context details .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Presentation spaces" + .herdr-project-space-- durable exact-id binding for the opt-in per-project Herdr workspace layout; placement authority only, never task or endpoint authority; see docs/herdr-backend.md "Project workspaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified Relay shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check .pr-poll private validated data sidecar for the byte-static PR merge poll From 961c9fb037cfe76b1dd855ea3d63776bd289fcf2 Mon Sep 17 00:00:00 2001 From: Patrick Charlier Date: Mon, 17 Aug 2026 19:09:16 -0700 Subject: [PATCH 8/8] fix(herdr): consume project workspace outputs --- bin/backends/herdr.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 87e7cada4f..de3b761987 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1778,6 +1778,7 @@ fm_backend_herdr_project_workspace_ensure() { #