From cf84b0d7a792bba5354c6d7e8212d913afc977cb Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:28:52 -0700 Subject: [PATCH 1/9] feat: add campaign task dispatch --- AGENTS.md | 36 +++++++--- bin/fm-brief.sh | 94 ++++++++++++++++++++++++- bin/fm-crew-state.sh | 8 +-- bin/fm-send.sh | 4 +- bin/fm-spawn.sh | 45 ++++++------ tests/fm-brief.test.sh | 28 ++++++++ tests/fm-crew-state.test.sh | 17 +++++ tests/fm-spawn-dispatch-profile.test.sh | 35 +++++++++ tests/fm-teardown.test.sh | 21 ++++++ 9 files changed, 253 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6fdaa675..d7fe594a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,10 +263,11 @@ Bootstrap reports this as a `CREW_DISPATCH` diagnostic when it can see the inval Secondmates can run on a different harness than crewmates. `config/secondmate-harness` (local, gitignored) is the harness the primary uses to launch SECONDMATE agents; resolve it with `bin/fm-harness.sh secondmate`, which follows the fallback chain `config/secondmate-harness` -> `config/crew-harness` -> your own harness. So an absent or `default` `config/secondmate-harness` behaves exactly as before this knob existed - secondmates launch on the crew harness - and setting it splits the two: e.g. primary `config/crew-harness=codex` with `config/secondmate-harness=claude` runs the secondmate AGENTS on claude while all crewmates (the primary's and the secondmates' own) run on codex. -`bin/fm-spawn.sh` resolves a `--secondmate` launch through `secondmate` mode and a crewmate/scout launch through `crew` mode; an explicit per-spawn `--harness` flag or positional harness arg still overrides either kind. +`bin/fm-spawn.sh` resolves a `--secondmate` launch through `secondmate` mode and a ship/scout/campaign launch through `crew` mode; an explicit per-spawn `--harness` flag or positional harness arg still overrides either kind. The split is durable: every secondmate respawn (recovery, `/updatefirstmate`, restart) re-resolves from `config/secondmate-harness`, so it survives restarts without being recorded per-task. `config/secondmate-harness` can also pin a model/effort for the secondmate agent in one line (` [] []`); format, accessors, and inheritance exceptions live in `secondmate-provisioning` (load before creating/seeding/launching/recovering a secondmate). +This is secondmate-only: ship/scout/campaign model resolution is untouched by this file. `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend` are inherited into every secondmate home; `config/secondmate-harness` is not, because secondmates never spawn secondmates. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when `config/crew-harness` names a concrete adapter, such as `codex`; an unset or `default` value has nothing concrete to inherit, so the secondmate's own crewmates fall back to the secondmate's own/detected harness instead. @@ -449,6 +450,7 @@ Then classify the shape: - **Ship** (the default): the deliverable is a change to the project. It ships through the project's delivery mode: `no-mistakes`, `direct-PR`, or `local-only`. - **Scout:** the deliverable is knowledge - an investigation, a plan, a bug reproduction, an audit. It ends in a report at `data//report.md`, never a PR. When the captain asks "what's wrong", "how would we", or "find out why" about a project, that is a scout task; dispatch it instead of doing the digging yourself. +- **Campaign:** the deliverable is a serially-dependent multi-slice feature driven from a finished committed roadmap or upstream spec by one long-lived crewmate. Choose it when splitting the work into parallel ship tasks would lose necessary context or create artificial handoff risk. Then classify readiness: @@ -476,23 +478,24 @@ bin/fm-spawn.sh projects/ --backend zellij # experimental zellij back bin/fm-spawn.sh projects/ --backend orca # experimental Orca backend (docs/orca-backend.md); Orca owns worktree + terminal; Escape unsupported bin/fm-spawn.sh projects/ --backend cmux # experimental cmux backend (docs/cmux-backend.md); GUI-first macOS-only, treehouse still owns worktree; requires a one-time socket-access setup (docs/cmux-backend.md "Setup") bin/fm-spawn.sh projects/ --scout # scout task; records kind=scout in meta +bin/fm-spawn.sh projects/ --campaign # campaign task; records kind=campaign in meta bin/fm-spawn.sh --secondmate # launch a registered persistent secondmate in its home bin/fm-spawn.sh --secondmate # launch or recover an explicit secondmate home -bin/fm-spawn.sh =projects/ =projects/ [--scout] # batch: one call, several tasks +bin/fm-spawn.sh =projects/ =projects/ [--scout|--campaign] # batch: one call, several tasks ``` -Dispatch several tasks in one call by passing `id=repo` pairs instead of a single ` `; each pair is spawned through the same single-task path, shared `--scout`, `--harness`, `--model`, `--effort`, and `--backend` flags apply to all, and the looping happens inside the script so you never hand-write a multi-task shell loop. +Dispatch several tasks in one call by passing `id=repo` pairs instead of a single ` `; each pair is spawned through the same single-task path, shared `--scout`, `--campaign`, `--harness`, `--model`, `--effort`, and `--backend` flags apply to all, and the looping happens inside the script so you never hand-write a multi-task shell loop. If one pair fails, the rest still run and the batch exits non-zero. -When `config/crew-dispatch.json` exists, include a shared `--harness` for every crewmate or scout batch after consulting the dispatch rules. +When `config/crew-dispatch.json` exists, include a shared `--harness` for every ship, scout, or campaign batch after consulting the dispatch rules. -The script resolves the harness (`fm-harness.sh crew` for crewmate/scout tasks only when `config/crew-dispatch.json` is absent, `fm-harness.sh secondmate` for `kind=secondmate`; section 4), resolves the runtime backend (`--backend`, then `FM_BACKEND`, then `config/backend`, then runtime auto-detection - the runtime firstmate itself is executing inside, from `$TMUX`/`HERDR_ENV=1`/cmux runtime signals, nesting resolved innermost-first (`$TMUX`, then `HERDR_ENV=1`, then cmux's primary `CMUX_WORKSPACE_ID` marker and documented macOS-only fallbacks last, since cmux is a terminal application rather than a nestable multiplexer; docs/cmux-backend.md "Runtime auto-detection") - then `tmux`; an auto-detected herdr or cmux spawn prints a loud stderr notice, auto-detected tmux stays silent; zellij and orca are never auto-detected, only explicit `--backend `/`FM_BACKEND=`/`config/backend`), validates the requested backend against spawn-capable adapters, owns the verified launch templates, resolves the project's delivery mode (`fm-project-mode.sh`) for ship/scout tasks, and records `harness=`, `model=`, `effort=`, `kind=`, `mode=`, and `yolo=` in the task's meta; only a non-default runtime backend is recorded as `backend=` because absent means tmux. +The script resolves the harness (`fm-harness.sh crew` for ship/scout/campaign tasks only when `config/crew-dispatch.json` is absent, `fm-harness.sh secondmate` for `kind=secondmate`; section 4), resolves the runtime backend (`--backend`, then `FM_BACKEND`, then `config/backend`, then runtime auto-detection - the runtime firstmate itself is executing inside, from `$TMUX`/`HERDR_ENV=1`/cmux runtime signals, nesting resolved innermost-first (`$TMUX`, then `HERDR_ENV=1`, then cmux's primary `CMUX_WORKSPACE_ID` marker and documented macOS-only fallbacks last, since cmux is a terminal application rather than a nestable multiplexer; docs/cmux-backend.md "Runtime auto-detection") - then `tmux`; an auto-detected herdr or cmux spawn prints a loud stderr notice, auto-detected tmux stays silent; zellij and orca are never auto-detected, only explicit `--backend `/`FM_BACKEND=`/`config/backend`), validates the requested backend against spawn-capable adapters, owns the verified launch templates, resolves the project's delivery mode (`fm-project-mode.sh`) for ship/scout/campaign tasks, and records `harness=`, `model=`, `effort=`, `kind=`, `mode=`, and `yolo=` in the task's meta; only a non-default runtime backend is recorded as `backend=` because absent means tmux. A backend spawn refusal - a missing dependency, an unauthenticated socket, or a version gate - must be surfaced to the captain as a blocker; never silently retry the spawn on a different backend to work around it. A non-flag third argument containing whitespace is treated as a raw launch command (only for verifying new adapters). -When `config/crew-dispatch.json` exists, the script refuses crewmate or scout launches without an explicit harness because firstmate must have already resolved the profile choice at intake. +When `config/crew-dispatch.json` exists, the script refuses ship, scout, or campaign launches without an explicit harness because firstmate must have already resolved the profile choice at intake. When `--model` or `--effort` is omitted, the corresponding meta value is `default` and no launch flag is passed for that axis, except that a `kind=secondmate` spawn can fill the omitted axis from the optional tokens in `config/secondmate-harness`. For `kind=secondmate`, the same script launches in the registered or explicit firstmate home instead of running `treehouse get` for a project, records `home=` and `projects=`, and uses the charter brief as the launch prompt. -For ship and scout tasks, tmux/herdr/zellij/cmux create a runtime endpoint and run `treehouse get`; Orca creates an Orca-owned worktree, validates it, then creates the terminal. In all cases, the script asserts the resolved worktree is a genuine isolated worktree distinct from the primary checkout (aborting the spawn otherwise, to prevent the worktree tangle of section 8), installs the turn-end hook, records `state/.meta`, and launches the agent with the brief. +For ship, scout, and campaign tasks, tmux/herdr/zellij/cmux create a runtime endpoint and run `treehouse get`; Orca creates an Orca-owned worktree, validates it, then creates the terminal. In all cases, the script asserts the resolved worktree is a genuine isolated worktree distinct from the primary checkout (aborting the spawn otherwise, to prevent the worktree tangle of section 8), installs the turn-end hook, records `state/.meta`, and launches the agent with the brief. For grok, the turn-end hook is one firstmate-owned global hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, activated only when the worktree holds the per-task `.fm-grok-turnend` token pointer that matches `state/.grok-turnend-token`; teardown removes the pointer and token. For `kind=secondmate`, the script creates the same kind of runtime endpoint but starts directly in the persistent home. With herdr, ordinary crewmate and scout spawns use the current `FM_HOME` workspace; a primary `--secondmate` spawn uses the secondmate target home's workspace, so secondmate-owned tabs do not mix into the primary `firstmate` space. @@ -504,6 +507,7 @@ The spawn also propagates the primary's inheritable config into the secondmate h No nudge is needed at spawn because the agent reads `AGENTS.md` fresh on launch. For already-live secondmates, use `bin/fm-config-push.sh` when only this inherited config needs to be pushed. Project worktrees start at detached HEAD on a clean default branch; ship briefs tell the crewmate to create its branch, while scout briefs keep the worktree scratch. +Campaign briefs tell the crewmate to treat that detached launch commit as the roadmap base, create slice or batch branches directly from it, and never attempt to check out the default branch held by the pooled clone. After spawning, peek the endpoint to confirm the crewmate is processing the brief and handle any trust dialog with `harness-adapters`. Add the task to `data/backlog.md` under In flight. @@ -616,6 +620,20 @@ The crewmate keeps its worktree, loaded context, and repro, but the ship branch The repro becomes the regression test. From there the task is an ordinary ship task through its mode-specific validation, PR or local merge, and Teardown. +### Campaign tasks (roadmap instead of one slice) + +A campaign task is for serially-dependent multi-slice work where one crewmate should keep continuous context across the whole feature. Use it when the slices share design decisions, refactor context, or sequencing risk that would make parallel ship tasks brittle. Do not use it for independent backlog items that can safely run as normal ship tasks. + +Scaffold the brief with `bin/fm-brief.sh --campaign`, then spawn with `bin/fm-spawn.sh projects/ --campaign`. The crewmate receives a finished execution artifact: a committed roadmap under `docs/plans/.md` with unchecked slices and optional `[gate]` / `[risk:high]` markers, or a single upstream-produced spec. Planning is not part of the campaign crewmate's job. + +The campaign worktree is persistent for the whole roadmap. It starts detached at the default-branch commit supplied by `treehouse get`; that commit is the roadmap base. The default branch itself is owned by the pooled clone and must not be checked out inside the task worktree. Slice or batch branches are created directly from the launch commit, and if the captain-provided execution skill expects a named default branch during preflight, the detached base commit stands in for it. + +The campaign crewmate drives the captain-provided execution skill in roadmap mode: slice -> verify -> review -> commit with `[S]` -> batch PR -> roadmap-tick. For `no-mistakes` projects, the per-slice verify/review loop still runs inside the execution skill, and the batch PR also goes through the no-mistakes pipeline as the final gate before the PR is reported ready. + +Every execution-skill stop is an escalation stop: `[gate]`, `[risk:high]`, off-spec/off-blueprint UI stops, structured clarification questions, and visual approvals all become `needs-decision: ` in the task status file, then the crewmate waits. It never pushes past the stop; firstmate relays the question to the captain and sends the decision back. + +Merge authority follows the project's recorded delivery mode and yolo flag passed in the brief. On `+yolo` projects, the execution skill's land-pr close may merge through its own readiness gates. On all other projects, the campaign stops at "PR ready, checks green" for each batch PR and reports `done: PR checks green`; the captain's merge word is still required. Do not tear down a campaign worktree after a batch PR. Teardown is only after roadmap close and landed-work confirmation, and `kind=campaign` is protected like `kind=ship`, not scratch like `kind=scout`. + ## 8. Supervision protocol The watcher is the backbone. @@ -798,7 +816,7 @@ If the primary leaves the file absent, each home uses the default tasks-axi back Keep Done to the 10 most recent entries. With the active compatible tasks-axi backend, `tasks-axi done` auto-prunes Done and archives pruned entries to `data/done-archive.md`, so do not hand-prune. When hand-editing, prune older Done entries manually whenever you add to the section. -Pruning loses nothing: finished PR-based ship tasks live on as GitHub PRs, local-only ship tasks live on in local `main`, and scout tasks live on as report files. +Pruning loses nothing: finished PR-based ship and campaign tasks live on as GitHub PRs, local-only ship tasks live on in local `main`, and scout tasks live on as report files. Map firstmate's real backlog operations to the approved commands: - File an item: `tasks-axi add "" --kind --repo `, plus `--start` for immediate dispatch (In flight) or the default queue placement, and `--blocked-by ` (repeatable) when it waits on another task. @@ -827,6 +845,8 @@ The scaffold reads the mode via `fm-project-mode.sh`, so you do not pass it. Ship briefs also include the project-memory contract: run `bin/fm-ensure-agents-md.sh` when the project already has agent-memory files or when the task produced durable project-intrinsic knowledge, then record proportionate learnings in `AGENTS.md`. For scout tasks add `--scout`: the scaffold swaps the definition of done for the report contract (findings to `data//report.md`, no branch, no push, no PR) and declares the worktree scratch; scout is mode-agnostic. Scout briefs do not include the project-memory step, because their deliverable is a report rather than a committed project change. +For campaign tasks add `--campaign`: the scaffold writes the long-lived roadmap contract (one persistent worktree, finished committed roadmap/spec, detached-base branch adaptation, execution-skill stop mapping, mode/yolo merge authority, and no-mistakes final gate when applicable). +Campaign briefs are for serially-dependent multi-slice features, not independent work that should be parallel ship tasks. For secondmates use `bin/fm-brief.sh --secondmate ...`. The scaffold writes a charter brief instead of a task brief. Set `FM_SECONDMATE_CHARTER=''` to fill the charter text and `FM_SECONDMATE_SCOPE=''` when the routing scope differs. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index e0f2ec78..b2739c9d 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -6,10 +6,13 @@ # description, acceptance criteria, and context, and may adjust other sections # when the task genuinely deviates (e.g. working an existing external PR instead # of shipping a new one). -# Usage: fm-brief.sh [--scout] +# Usage: fm-brief.sh [--scout|--campaign] # fm-brief.sh --secondmate ... # --scout writes the scout contract instead: the deliverable is a report at # data//report.md (no branch, no push, no PR) and the worktree is scratch. +# --campaign writes the campaign contract instead: one long-lived crewmate +# drives a committed roadmap or upstream spec in one persistent worktree. +# The worktree is protected like a ship task; teardown waits for roadmap close. # --secondmate writes a persistent secondmate charter. The project list # is cloned into the secondmate home, while the natural-language scope # tells the main firstmate when to route work there; routine churn stays in its own home; @@ -43,6 +46,7 @@ POS=() for a in "$@"; do case "$a" in --scout) KIND=scout ;; + --campaign) KIND=campaign ;; --secondmate) KIND=secondmate ;; *) POS+=("$a") ;; esac @@ -165,6 +169,94 @@ echo "scaffolded: $BRIEF (scout; replace {TASK})" exit 0 fi +if [ "$KIND" = campaign ]; then +read -r MODE YOLO < "$BRIEF" <.md\` with \`- [ ]\` slices and optional \`[gate]\` / \`[risk:high]\` markers, or a single upstream-produced spec. +Planning is out of scope. If the artifact is missing, unfinished, or asks you to plan the feature, append \`blocked: missing finished campaign artifact\` to the status file and stop. + +Project delivery mode: \`$MODE\` +Project yolo flag: \`$YOLO\` + +# Setup +You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default-branch commit. + +**Verify isolation before anything else.** Run \`pwd -P\` and \`git rev-parse --show-toplevel\`; both must resolve to the disposable task worktree you were launched in, such as a treehouse pool path or an Orca-managed worktree, not the primary checkout firstmate operates from. +The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` can help inspect the repo, but they do not prove you are outside the primary checkout. +If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. + +1. Record the launch commit: \`git rev-parse HEAD\`. Treat this commit as the roadmap base. +2. Do not attempt to check out the default branch. The pooled clone holds it, so this worktree starts detached by design. Create slice or batch branches directly from the launch commit with \`git switch -c \`; when the execution skill preflight expects a named default branch, the current detached base commit stands in for it.$NO_MISTAKES_SETUP + +# Execution loop +Drive the captain-provided execution skill in roadmap mode. Invoke \`/autopilot\`; if the harness needs plain language instead, ask it to run the captain-provided execution skill against the committed roadmap/spec in roadmap mode. + +Use this loop: +1. Pick the next unchecked slice from the roadmap/spec. +2. Implement only that slice. +3. Verify the slice. +4. Review the slice. +5. Commit it with a message containing \`[S]\`, where \`\` is the slice number. +6. Open or update the batch PR when the batch is ready. +7. Run roadmap-tick after the batch PR state changes as required by the execution artifact. + +For \`no-mistakes\` projects, keep the execution skill's normal inner verify/review loop per slice, then run the no-mistakes pipeline as the final batch-PR gate before reporting the PR ready. The no-mistakes evidence trail is part of the fleet contract. + +# Escalation mapping +Every stop raised by the execution skill maps to the status file: + +- \`[gate]\` slice: append \`needs-decision: [gate] \`, then wait. +- \`[risk:high]\` slice: append \`needs-decision: [risk:high] \`, then wait. +- Off-spec or off-blueprint UI stop: append \`needs-decision: off-spec UI \`, then wait. +- Structured clarification question: append \`needs-decision: clarification \`, then wait. +- Visual approval: append \`needs-decision: visual approval \`, then wait. + +Never push past a stop. Firstmate relays the decision to the captain and replies with the answer. + +# Rules +1. Never push to the default branch. Never merge a PR unless this project's yolo flag is \`on\` and the execution skill's own readiness gates say the PR may land. +2. Stay inside this worktree; modify nothing outside it. +3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations. +4. Report status by appending one line: + \`echo "{state}: {one short line}" >> $STATUS_FILE\` + States: working, needs-decision, blocked, done, failed. + Each append wakes firstmate, so report sparingly: setup complete, each stop that needs a decision, batch PR ready, roadmap close, blocked, or failed. +5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help. +6. If a decision belongs to a human, append \`needs-decision: {exact stop reason + options}\` and stop. Firstmate will reply with the decision. + +# Merge authority +If \`yolo=$YOLO\` is \`on\`, the execution skill's land-pr close may merge through its own readiness gates. +If \`yolo=$YOLO\` is \`off\`, stop at "PR ready, checks green" for each batch PR and append \`done: PR {url} checks green\`. Wait for firstmate to relay the captain's merge word and continuation instruction. +Never merge a red PR. + +# Project memory +If \`AGENTS.md\` or \`CLAUDE.md\` already exists, or if this campaign produced durable project-intrinsic knowledge, run \`$FM_ROOT/bin/fm-ensure-agents-md.sh .\` in the worktree. +If this campaign produced durable project-intrinsic knowledge, record it in \`AGENTS.md\` as part of your change. + +# Definition of done +The campaign is complete only when the roadmap/spec is fully closed, every batch PR has passed its required gates, and the final work has landed or is waiting at the mode-specific merge stop above. +On final roadmap close, append \`done: campaign complete {PR url or summary}\` to the status file and stop. Firstmate tears down this worktree only after it confirms the campaign work has landed. +EOF +echo "scaffolded: $BRIEF (campaign; replace {TASK})" +exit 0 +fi + # Ship task: shape Setup / Rule 1 / Definition of done by the project's delivery mode. # yolo does not affect the brief (it governs firstmate's approval behaviour), so discard it. read -r MODE _ </dev/null 2>&1; then +# Ship and campaign tasks can drive a no-mistakes validation for their branch. +# Scouts and secondmates do not, so they read state from pane/log directly. +if { [ "$KIND" = ship ] || [ "$KIND" = campaign ]; } && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/null 2>&1; then RUN_OUT=$(nm_run axi status) if [ -n "$RUN_OUT" ]; then run_branch=$(strip_quotes "$(nm_field branch)") diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 70e21529..ef2107d5 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -23,7 +23,7 @@ # records kind=secondmate, the text is prefixed with the from-firstmate marker # (bin/fm-marker-lib.sh) so the secondmate routes its reply via its status file # or a status-pointed doc instead of stranding it in chat the main firstmate -# never reads. A crewmate/scout target, an explicit backend-target escape-hatch +# never reads. A ship/scout/campaign target, an explicit backend-target escape-hatch # target, and the --key path are never marked - their behavior is unchanged. # After a successful text submit fm-send pauses FM_SEND_SETTLE seconds (default 1, # 0 disables) before returning: a cleared composer only proves the text was @@ -53,7 +53,7 @@ shift # resolved through this home's meta and recording kind=secondmate, is marked: the # secondmate then routes its reply via the status path (see fm-marker-lib.sh). # An explicit backend target (the escape hatch for endpoints outside this home) -# and any crewmate/scout target are left unmarked, and so is the --key path. +# and any ship/scout/campaign target are left unmarked, and so is the --key path. MARK_PREFIX="" case "$RAW_TARGET" in fm-*) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index bd760060..34ad4542 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a # secondmate in its isolated firstmate home. -# Usage: fm-spawn.sh [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--scout] +# Usage: fm-spawn.sh [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--scout|--campaign] # fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] --secondmate # --harness is the explicit per-spawn harness/profile adapter. The old # positional harness arg still works for back-compat. @@ -17,8 +17,8 @@ # then tmux. # Spawn-capable backends are the reference tmux adapter and experimental # herdr, zellij, orca, and cmux. Orca owns both the task worktree and -# terminal, so ship/scout Orca spawns do not run treehouse get; cmux is a -# session provider only, exactly like herdr/zellij, so it does. An +# terminal, so ship/scout/campaign Orca spawns do not run treehouse get; +# cmux is a session provider only, exactly like herdr/zellij, so it does. An # auto-detected herdr or cmux spawns print a loud stderr notice; # auto-detected tmux stays silent; zellij and orca are never auto-detected # (always explicit). Default tmux spawns do not write backend= to meta; @@ -26,10 +26,10 @@ # A backend spawn refusal (missing dependency, version gate, unauthenticated # socket, or unsupported secondmate mode) is terminal for that selected backend; # callers must surface it instead of silently retrying another backend. -# With no harness arg, a crewmate/scout spawn resolves the CREW harness only when -# config/crew-dispatch.json is absent. When that file exists, crewmate/scout -# spawns require an explicit harness so firstmate cannot silently skip dispatch -# profile consultation. A --secondmate spawn is exempt and resolves the SECONDMATE +# With no harness arg, an ordinary crewmate spawn resolves the CREW harness only +# when config/crew-dispatch.json is absent. When that file exists, ship/scout/ +# campaign spawns require an explicit harness so firstmate cannot silently skip +# dispatch profile consultation. A --secondmate spawn is exempt and resolves the SECONDMATE # harness (config/secondmate-harness -> config/crew-harness -> own), so the # secondmate-vs-crewmate split is DURABLE across every respawn (recovery, # /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|grok) @@ -50,18 +50,20 @@ # dispatch profiles, and backlog backend inherit the primary's settings # (fm-config-inherit-lib.sh). # --scout records kind=scout in the task's meta (report deliverable, scratch worktree; -# see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a -# provisioned firstmate home; the default is kind=ship. +# see AGENTS.md task lifecycle); --campaign records kind=campaign in the task's +# meta (long-lived roadmap worktree, teardown protected like ship); --secondmate +# records kind=secondmate and launches in a provisioned firstmate home; the default +# is kind=ship. # Before a secondmate launch, the home is locally fast-forwarded to the primary # default-branch commit when safe; skipped syncs warn and launch unchanged. # Ship/scout spawns refuse to launch unless the resolved task path is a real # git worktree root distinct from the primary project checkout. # Batch dispatch: pass one or more `id=repo` pairs instead of a single , e.g. -# fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout] +# fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout|--campaign] # Each pair re-execs this script in single-task mode, so the single path stays the only -# source of truth; shared --scout/--harness/--model/--effort/--backend applies to every pair. -# If config/crew-dispatch.json exists, shared --harness is required for crewmate -# and scout batches. The loop lives here, in bash, so callers never hand-write a +# source of truth; shared --scout/--campaign/--harness/--model/--effort/--backend applies to every pair. +# If config/crew-dispatch.json exists, shared --harness is required for ship, +# scout, and campaign batches. The loop lives here, in bash, so callers never hand-write a # multi-task shell loop (the tool shell is zsh, which does not word-split unquoted # $vars and silently breaks ad-hoc `for ... in $pairs` loops). # Launch templates live in launch_template() below; placeholders replaced before launch: @@ -73,8 +75,8 @@ # Per-harness turn-end hooks are installed automatically; some live outside the worktree. # grok uses a firstmate-owned global hook under ${GROK_HOME:-$HOME/.grok}/hooks # plus a gitignored .fm-grok-turnend worktree pointer and a state token. -# On success prints: spawned harness= kind= mode= yolo= window= worktree= -# mode/yolo are resolved per-project from data/projects.md for ship/scout tasks; +# On success prints: spawned harness= kind= mode= yolo= window= worktree= +# mode/yolo are resolved per-project from data/projects.md for ship/scout/campaign tasks; # secondmate spawns record mode=secondmate, yolo=off, home=, and projects=. set -eu @@ -123,6 +125,7 @@ for a in "$@"; do fi case "$a" in --scout) KIND=scout ;; + --campaign) KIND=campaign ;; --secondmate) KIND=secondmate ;; --harness) want_value=harness ;; --harness=*) HARNESS_ARG=${a#--harness=}; HARNESS_SET=1 ;; @@ -254,6 +257,8 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * continue elif [ "$KIND" = scout ]; then if FM_SPAWN_NO_GUARD=1 "$FM_ROOT/bin/fm-spawn.sh" "${pair%%=*}" "${pair#*=}" ${shared_args[@]+"${shared_args[@]}"} --scout; then :; else echo "batch: FAILED to spawn ${pair%%=*} (${pair#*=})" >&2; rc=1; fi + elif [ "$KIND" = campaign ]; then + if FM_SPAWN_NO_GUARD=1 "$FM_ROOT/bin/fm-spawn.sh" "${pair%%=*}" "${pair#*=}" ${shared_args[@]+"${shared_args[@]}"} --campaign; then :; else echo "batch: FAILED to spawn ${pair%%=*} (${pair#*=})" >&2; rc=1; fi else if FM_SPAWN_NO_GUARD=1 "$FM_ROOT/bin/fm-spawn.sh" "${pair%%=*}" "${pair#*=}" ${shared_args[@]+"${shared_args[@]}"}; then :; else echo "batch: FAILED to spawn ${pair%%=*} (${pair#*=})" >&2; rc=1; fi fi @@ -326,7 +331,7 @@ launch_template() { # crewmate needs; it is the targeted equivalent of claude's # --dangerously-skip-permissions. grok's turn-end signal does NOT ride the # launch command - it is a Stop-event hook installed below (global hook + - # per-task pointer), so the template is identical for ship/scout/secondmate. + # per-task pointer), so the template is identical for ship/scout/campaign/secondmate. grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; *) return 1 ;; esac @@ -666,8 +671,8 @@ case "$BACKEND" in herdr) # fm_backend_herdr_workspace_label resolves the target workspace from # FM_HOME. For every KIND except secondmate, this process's own FM_HOME is - # already the right home (the primary spawning its own crewmate/scout, or - # a secondmate spawning ITS OWN crewmate/scout from its own process's + # already the right home (the primary spawning its own crewmate/scout/campaign, or + # a secondmate spawning ITS OWN crewmate/scout/campaign from its own process's # FM_HOME - the latter needs no glue at all). A --secondmate spawn is the # one case that does: it is the PRIMARY's own fm-spawn.sh process # launching a DIFFERENT home (PROJ_ABS, already validated above as the @@ -918,8 +923,8 @@ fi # Per-project delivery mode + yolo flag (bin/fm-project-mode.sh; AGENTS.md project management and task lifecycle). # Recorded in meta so fm-teardown's safety check and the validate/merge stages can -# branch on them. Mode governs ship tasks; a scout's deliverable is a report, not a -# merge, so scout teardown ignores mode. +# branch on them. Mode governs ship and campaign tasks; a scout's deliverable is a +# report, not a merge, so scout teardown ignores mode. SECONDMATE_PROJECTS= if [ "$KIND" = secondmate ]; then MODE=secondmate diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index be1df2be..91a1aa13 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -32,6 +32,7 @@ write_registry() { cat > "$home/data/projects.md" <<'EOF' - direct-proj [direct-PR] - fixture for direct-PR mode (added 2026-07-01) - local-proj [local-only] - fixture for local-only mode (added 2026-07-01) +- campaign-proj [direct-PR +yolo] - fixture for campaign mode+yolo wording (added 2026-07-01) EOF } @@ -76,6 +77,33 @@ test_no_mistakes_dod_wording() { pass "fm-brief.sh: no-mistakes DOD wording avoids the apostrophe regression" } +test_campaign_brief_contract() { + local home id brief + home="$TMP_ROOT/campaign-home" + write_registry "$home" + id="brief-campaign-c1" + + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" campaign-proj --campaign >/dev/null 2>&1 + brief="$home/data/$id/brief.md" + assert_present "$brief" "campaign brief was not scaffolded" + assert_grep "# Campaign contract" "$brief" "campaign brief missing Campaign contract section" + assert_grep "ONE persistent worktree" "$brief" "campaign brief missing persistent worktree contract" + assert_grep "docs/plans/.md" "$brief" "campaign brief missing committed roadmap path contract" + assert_grep "Planning is out of scope" "$brief" "campaign brief must exclude planning" + assert_grep "Project delivery mode: \`direct-PR\`" "$brief" "campaign brief did not include project delivery mode" + assert_grep "Project yolo flag: \`on\`" "$brief" "campaign brief did not include project yolo flag" + assert_grep "Treat this commit as the roadmap base" "$brief" "campaign brief missing detached-base adaptation" + assert_grep "Do not attempt to check out the default branch" "$brief" "campaign brief missing no-default-checkout instruction" + assert_grep "Invoke \`/autopilot\`" "$brief" "campaign brief missing execution-skill invocation" + assert_grep "needs-decision: [gate]" "$brief" "campaign brief missing gate escalation mapping" + assert_grep "needs-decision: [risk:high]" "$brief" "campaign brief missing high-risk escalation mapping" + assert_grep "no-mistakes pipeline as the final batch-PR gate" "$brief" "campaign brief missing no-mistakes final gate" + assert_grep "done: PR {url} checks green" "$brief" "campaign brief missing non-yolo merge stop report" + assert_grep "Teardown happens only after the roadmap is closed" "$brief" "campaign brief missing teardown timing" + pass "fm-brief.sh: --campaign generates the roadmap-mode campaign contract" +} + test_script_parses test_ship_modes_generate_clean_briefs test_no_mistakes_dod_wording +test_campaign_brief_contract diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 7e88bbc6..bef37d63 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -699,6 +699,22 @@ test_scout_skips_run_lookup() { pass "scout skips the run lookup" } +# Campaigns are ship-like for validation: they keep one long-lived worktree, but +# their batch PR still runs through no-mistakes when the project mode requires it. +test_campaign_uses_run_lookup() { + reset_fakes + local d; d=$(new_case campaign) + make_repo_on_branch "$d/wt" fm/campaign-j + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/campaign-j.meta" "window=fm:fm-campaign-j" "worktree=$d/wt" "kind=campaign" + FM_FAKE_AXI_STATUS="$(run_running fm/campaign-j)" + FM_FAKE_BUSY=0 + local out; out=$(run_crew_state "$d" campaign-j) + assert_contains "$out" "state: working" "campaign active run -> working" + assert_contains "$out" "source: run-step" "campaign active run -> run-step source" + pass "campaign uses the ship-like no-mistakes run lookup" +} + # (j) torn-down worktree and missing meta are graceful (unknown/none, exit 0) test_torn_down_worktree() { reset_fakes @@ -800,6 +816,7 @@ test_dead_window_still_reports_terminal_run_step test_dead_window_still_reports_active_run_step test_no_timeout_uses_perl_bound test_scout_skips_run_lookup +test_campaign_uses_run_lookup test_torn_down_worktree test_missing_meta test_provably_working_via_runs_list_fallback diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 5b0bad7a..d7d7d9a7 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -155,6 +155,39 @@ test_active_dispatch_profile_requires_explicit_harness_for_scout() { pass "active crew-dispatch profile requires an explicit harness for scout spawns" } +test_active_dispatch_profile_requires_explicit_harness_for_campaign() { + local rec id out status + id=profile-required-campaign-z17 + rec=$(make_spawn_case profile-required-campaign claude "$id") + read_case_record "$rec" + enable_dispatch_profile "$HOME_DIR" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --campaign) + status=$? + expect_code 1 "$status" "campaign spawn without explicit harness should fail when dispatch profiles are active" + assert_contains "$out" "config/crew-dispatch.json is active - pass an explicit harness resolved from the dispatch rules" \ + "campaign refusal did not explain the dispatch-profile backstop" + assert_absent "$HOME_DIR/state/$id.meta" "campaign refusal should happen before meta is written" + pass "active crew-dispatch profile requires an explicit harness for campaign spawns" +} + +test_campaign_spawn_records_campaign_kind() { + local rec id out status + id=profile-campaign-z18 + rec=$(make_spawn_case profile-campaign codex "$id") + read_case_record "$rec" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --campaign) + status=$? + expect_code 0 "$status" "campaign spawn should succeed through the ordinary worktree path" + assert_contains "$out" "spawned $id harness=codex kind=campaign" "spawn did not report kind=campaign" + assert_grep "kind=campaign" "$HOME_DIR/state/$id.meta" "campaign meta missing kind=campaign" + assert_grep "mode=no-mistakes" "$HOME_DIR/state/$id.meta" "campaign meta missing project delivery mode" + assert_grep "yolo=off" "$HOME_DIR/state/$id.meta" "campaign meta missing project yolo flag" + assert_meta_profile "$HOME_DIR/state/$id.meta" codex default default + pass "campaign spawn records kind=campaign while preserving normal crewmate metadata" +} + test_active_dispatch_profile_allows_explicit_harness() { local rec id out status launch id=profile-explicit-z13 @@ -367,6 +400,8 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { test_no_profile_keeps_claude_launch_unchanged test_active_dispatch_profile_requires_explicit_harness_for_ship test_active_dispatch_profile_requires_explicit_harness_for_scout +test_active_dispatch_profile_requires_explicit_harness_for_campaign +test_campaign_spawn_records_campaign_kind test_active_dispatch_profile_allows_explicit_harness test_active_dispatch_profile_allows_positional_harness test_active_dispatch_profile_allows_raw_launch_command diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index a3e8b7ed..770f17da 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -34,6 +34,7 @@ # (o) fm-pr-check rerun after HEAD moved -> no stale pr_head # (p) fm-pr-check when local HEAD lags -> record remote PR head # (q) no-mistakes + NO pr= recorded, PR discovered by branch -> ALLOW (yolo/no-CI merge) +# (r) campaign + unpushed, no PR, content not in default -> REFUSE (ship-like protection) set -u # shellcheck source=tests/lib.sh @@ -396,6 +397,25 @@ test_no_mistakes_truly_unpushed_refuses() { pass "no-mistakes worktree with genuinely unlanded work is refused (safety preserved)" } +test_campaign_truly_unpushed_refuses() { + local case_dir rc + case_dir=$(make_case campaign-unpushed) + write_meta "$case_dir" no-mistakes campaign + # Campaigns are long-lived, but they are not scratch. Until their work lands, + # teardown must protect them exactly like ship tasks. + wt_commit_file "$case_dir" feature.txt hello "campaign work" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "campaign-unpushed: teardown should refuse" + grep -q REFUSED "$case_dir/stderr" || fail "campaign-unpushed: no REFUSED line in stderr" + grep -q "not landed" "$case_dir/stderr" || fail "campaign-unpushed: refusal did not cite unlanded work" + pass "campaign worktree with genuinely unlanded work is refused like ship work" +} + test_squash_merged_branch_deleted_allows() { local case_dir rc pr_head case_dir=$(make_case squash-merged) @@ -678,6 +698,7 @@ test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses +test_campaign_truly_unpushed_refuses test_local_only_force_overrides_unpushed test_squash_merged_branch_deleted_allows test_squash_merged_pr_allows_when_head_ancestor_of_pr_head From b317904bce822f5217eee4b3e586f2b535ff4550 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:10:21 -0700 Subject: [PATCH 2/9] no-mistakes(review): Fix campaign merge handoff --- AGENTS.md | 2 +- bin/fm-brief.sh | 29 ++++++++++++++++++++--------- tests/fm-brief.test.sh | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d7fe594a..9f973b2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -632,7 +632,7 @@ The campaign crewmate drives the captain-provided execution skill in roadmap mod Every execution-skill stop is an escalation stop: `[gate]`, `[risk:high]`, off-spec/off-blueprint UI stops, structured clarification questions, and visual approvals all become `needs-decision: ` in the task status file, then the crewmate waits. It never pushes past the stop; firstmate relays the question to the captain and sends the decision back. -Merge authority follows the project's recorded delivery mode and yolo flag passed in the brief. On `+yolo` projects, the execution skill's land-pr close may merge through its own readiness gates. On all other projects, the campaign stops at "PR ready, checks green" for each batch PR and reports `done: PR checks green`; the captain's merge word is still required. Do not tear down a campaign worktree after a batch PR. Teardown is only after roadmap close and landed-work confirmation, and `kind=campaign` is protected like `kind=ship`, not scratch like `kind=scout`. +Merge authority follows the project's recorded delivery mode and yolo flag passed in the brief, but the campaign crewmate never merges. For PR-based projects, the campaign stops at "PR ready, checks green" for each batch PR and reports `done: PR checks green`; firstmate then merges through `bin/fm-pr-merge.sh` after the captain's word, or under `+yolo` when the PR is green and no escalation rule applies. For `local-only` projects, the campaign stops at `done: ready in branch `; firstmate reviews and merges locally with `bin/fm-merge-local.sh` after the captain's word, or under `+yolo` when the branch is approved and no escalation rule applies. Do not tear down a campaign worktree after a batch stop. Teardown is only after roadmap close and landed-work confirmation, and `kind=campaign` is protected like `kind=ship`, not scratch like `kind=scout`. ## 8. Supervision protocol diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index b2739c9d..b3913709 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -178,6 +178,20 @@ if [ "$MODE" = no-mistakes ]; then NO_MISTAKES_SETUP=" 3. Run \`no-mistakes doctor\`; if it reports the repo is not initialized here, run \`no-mistakes init\`." fi +case "$MODE" in + local-only) + CAMPAIGN_BATCH_STEP="6. When the batch is ready, leave it committed on its branch. Do not push, do not open a PR, and do not merge. +7. Update roadmap state only as required by the execution artifact after the local batch state changes." + CAMPAIGN_RULE1="1. Never push to any remote, never open a PR, and never merge. Stop at ready in branch; firstmate handles review and local merge." + CAMPAIGN_MERGE_AUTHORITY="For \`local-only\` projects, stop at \"ready in branch\" for each batch and append \`done: ready in branch {branch}\`. Wait for firstmate to review, merge locally with \`bin/fm-merge-local.sh\`, and send the continuation instruction. If \`yolo=$YOLO\` is \`on\`, firstmate may approve that local merge without the captain's word, but you still never merge." + ;; + *) + CAMPAIGN_BATCH_STEP="6. Open or update the batch PR when the batch is ready. +7. Run roadmap-tick after the batch PR state changes as required by the execution artifact." + CAMPAIGN_RULE1="1. Never push to the default branch. Never merge a PR." + CAMPAIGN_MERGE_AUTHORITY="Stop at \"PR ready, checks green\" for each batch PR and append \`done: PR {url} checks green\`. Wait for firstmate to relay the captain's merge word or, when \`yolo=$YOLO\` is \`on\`, firstmate's yolo merge decision and continuation instruction. Firstmate performs every PR merge through \`bin/fm-pr-merge.sh\` so task metadata records the landed PR. Never merge a red PR." + ;; +esac cat > "$BRIEF" <]\`, where \`\` is the slice number. -6. Open or update the batch PR when the batch is ready. -7. Run roadmap-tick after the batch PR state changes as required by the execution artifact. +$CAMPAIGN_BATCH_STEP For \`no-mistakes\` projects, keep the execution skill's normal inner verify/review loop per slice, then run the no-mistakes pipeline as the final batch-PR gate before reporting the PR ready. The no-mistakes evidence trail is part of the fleet contract. @@ -230,28 +243,26 @@ Every stop raised by the execution skill maps to the status file: Never push past a stop. Firstmate relays the decision to the captain and replies with the answer. # Rules -1. Never push to the default branch. Never merge a PR unless this project's yolo flag is \`on\` and the execution skill's own readiness gates say the PR may land. +$CAMPAIGN_RULE1 2. Stay inside this worktree; modify nothing outside it. 3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations. 4. Report status by appending one line: \`echo "{state}: {one short line}" >> $STATUS_FILE\` States: working, needs-decision, blocked, done, failed. - Each append wakes firstmate, so report sparingly: setup complete, each stop that needs a decision, batch PR ready, roadmap close, blocked, or failed. + Each append wakes firstmate, so report sparingly: setup complete, each stop that needs a decision, batch ready, roadmap close, blocked, or failed. 5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help. 6. If a decision belongs to a human, append \`needs-decision: {exact stop reason + options}\` and stop. Firstmate will reply with the decision. # Merge authority -If \`yolo=$YOLO\` is \`on\`, the execution skill's land-pr close may merge through its own readiness gates. -If \`yolo=$YOLO\` is \`off\`, stop at "PR ready, checks green" for each batch PR and append \`done: PR {url} checks green\`. Wait for firstmate to relay the captain's merge word and continuation instruction. -Never merge a red PR. +$CAMPAIGN_MERGE_AUTHORITY # Project memory If \`AGENTS.md\` or \`CLAUDE.md\` already exists, or if this campaign produced durable project-intrinsic knowledge, run \`$FM_ROOT/bin/fm-ensure-agents-md.sh .\` in the worktree. If this campaign produced durable project-intrinsic knowledge, record it in \`AGENTS.md\` as part of your change. # Definition of done -The campaign is complete only when the roadmap/spec is fully closed, every batch PR has passed its required gates, and the final work has landed or is waiting at the mode-specific merge stop above. -On final roadmap close, append \`done: campaign complete {PR url or summary}\` to the status file and stop. Firstmate tears down this worktree only after it confirms the campaign work has landed. +The campaign is complete only when the roadmap/spec is fully closed, every batch has passed its required gates, and the final work has landed or is waiting at the mode-specific merge stop above. +On final roadmap close, append \`done: campaign complete {PR url, branch, or summary}\` to the status file and stop. Firstmate tears down this worktree only after it confirms the campaign work has landed. EOF echo "scaffolded: $BRIEF (campaign; replace {TASK})" exit 0 diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 91a1aa13..b4d80258 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -99,11 +99,33 @@ test_campaign_brief_contract() { assert_grep "needs-decision: [risk:high]" "$brief" "campaign brief missing high-risk escalation mapping" assert_grep "no-mistakes pipeline as the final batch-PR gate" "$brief" "campaign brief missing no-mistakes final gate" assert_grep "done: PR {url} checks green" "$brief" "campaign brief missing non-yolo merge stop report" + assert_grep "Firstmate performs every PR merge through \`bin/fm-pr-merge.sh\`" "$brief" "campaign brief must reserve PR merge authority for firstmate" + assert_grep "firstmate's yolo merge decision" "$brief" "campaign brief must route yolo merge decisions through firstmate" + assert_no_grep "may merge through its own readiness gates" "$brief" "campaign brief delegated yolo merge authority to the crewmate" assert_grep "Teardown happens only after the roadmap is closed" "$brief" "campaign brief missing teardown timing" pass "fm-brief.sh: --campaign generates the roadmap-mode campaign contract" } +test_campaign_local_only_brief_contract() { + local home id brief + home="$TMP_ROOT/campaign-local-home" + write_registry "$home" + id="brief-campaign-local-c2" + + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" local-proj --campaign >/dev/null 2>&1 + brief="$home/data/$id/brief.md" + assert_present "$brief" "local-only campaign brief was not scaffolded" + assert_grep "Project delivery mode: \`local-only\`" "$brief" "local-only campaign brief did not include project delivery mode" + assert_grep "Do not push, do not open a PR, and do not merge" "$brief" "local-only campaign brief allowed PR or merge delivery" + assert_grep "done: ready in branch {branch}" "$brief" "local-only campaign brief missing ready-in-branch stop" + assert_grep "firstmate handles review and local merge" "$brief" "local-only campaign brief missing firstmate local merge ownership" + assert_grep "bin/fm-merge-local.sh" "$brief" "local-only campaign brief missing local merge helper" + assert_no_grep "Open or update the batch PR" "$brief" "local-only campaign brief still asks for a batch PR" + pass "fm-brief.sh: local-only campaigns stop ready in branch" +} + test_script_parses test_ship_modes_generate_clean_briefs test_no_mistakes_dod_wording test_campaign_brief_contract +test_campaign_local_only_brief_contract From e9e820a88375f420245c4d181940491c2f498ae1 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:27:14 -0700 Subject: [PATCH 3/9] fix: align local campaign branch handoff --- AGENTS.md | 2 +- bin/fm-brief.sh | 6 +++--- tests/fm-brief.test.sh | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f973b2a..0d0c3499 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -632,7 +632,7 @@ The campaign crewmate drives the captain-provided execution skill in roadmap mod Every execution-skill stop is an escalation stop: `[gate]`, `[risk:high]`, off-spec/off-blueprint UI stops, structured clarification questions, and visual approvals all become `needs-decision: ` in the task status file, then the crewmate waits. It never pushes past the stop; firstmate relays the question to the captain and sends the decision back. -Merge authority follows the project's recorded delivery mode and yolo flag passed in the brief, but the campaign crewmate never merges. For PR-based projects, the campaign stops at "PR ready, checks green" for each batch PR and reports `done: PR checks green`; firstmate then merges through `bin/fm-pr-merge.sh` after the captain's word, or under `+yolo` when the PR is green and no escalation rule applies. For `local-only` projects, the campaign stops at `done: ready in branch `; firstmate reviews and merges locally with `bin/fm-merge-local.sh` after the captain's word, or under `+yolo` when the branch is approved and no escalation rule applies. Do not tear down a campaign worktree after a batch stop. Teardown is only after roadmap close and landed-work confirmation, and `kind=campaign` is protected like `kind=ship`, not scratch like `kind=scout`. +Merge authority follows the project's recorded delivery mode and yolo flag passed in the brief, but the campaign crewmate never merges. For PR-based projects, the campaign stops at "PR ready, checks green" for each batch PR and reports `done: PR checks green`; firstmate then merges through `bin/fm-pr-merge.sh` after the captain's word, or under `+yolo` when the PR is green and no escalation rule applies. For `local-only` projects, the campaign works on the fixed `fm/` task branch and stops at `done: ready in branch fm/`; firstmate reviews and merges locally with `bin/fm-merge-local.sh` after the captain's word, or under `+yolo` when the branch is approved and no escalation rule applies. Do not tear down a campaign worktree after a batch stop. Teardown is only after roadmap close and landed-work confirmation, and `kind=campaign` is protected like `kind=ship`, not scratch like `kind=scout`. ## 8. Supervision protocol diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index b3913709..69e26403 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -180,10 +180,10 @@ if [ "$MODE" = no-mistakes ]; then fi case "$MODE" in local-only) - CAMPAIGN_BATCH_STEP="6. When the batch is ready, leave it committed on its branch. Do not push, do not open a PR, and do not merge. + CAMPAIGN_BATCH_STEP="6. When the batch is ready, leave it committed on \`fm/$ID\`. Do not push, do not open a PR, and do not merge. 7. Update roadmap state only as required by the execution artifact after the local batch state changes." - CAMPAIGN_RULE1="1. Never push to any remote, never open a PR, and never merge. Stop at ready in branch; firstmate handles review and local merge." - CAMPAIGN_MERGE_AUTHORITY="For \`local-only\` projects, stop at \"ready in branch\" for each batch and append \`done: ready in branch {branch}\`. Wait for firstmate to review, merge locally with \`bin/fm-merge-local.sh\`, and send the continuation instruction. If \`yolo=$YOLO\` is \`on\`, firstmate may approve that local merge without the captain's word, but you still never merge." + CAMPAIGN_RULE1="1. Never push to any remote, never open a PR, and never merge. Work only on \`fm/$ID\`; firstmate handles review and local merge." + CAMPAIGN_MERGE_AUTHORITY="For \`local-only\` projects, stop at \"ready in branch\" for each batch and append \`done: ready in branch fm/$ID\`. Wait for firstmate to review, merge locally with \`bin/fm-merge-local.sh\`, and send the continuation instruction. If \`yolo=$YOLO\` is \`on\`, firstmate may approve that local merge without the captain's word, but you still never merge." ;; *) CAMPAIGN_BATCH_STEP="6. Open or update the batch PR when the batch is ready. diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index b4d80258..157a2ba1 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -117,7 +117,8 @@ test_campaign_local_only_brief_contract() { assert_present "$brief" "local-only campaign brief was not scaffolded" assert_grep "Project delivery mode: \`local-only\`" "$brief" "local-only campaign brief did not include project delivery mode" assert_grep "Do not push, do not open a PR, and do not merge" "$brief" "local-only campaign brief allowed PR or merge delivery" - assert_grep "done: ready in branch {branch}" "$brief" "local-only campaign brief missing ready-in-branch stop" + assert_grep "done: ready in branch fm/$id" "$brief" "local-only campaign brief missing fixed ready-in-branch stop" + assert_no_grep "done: ready in branch {branch}" "$brief" "local-only campaign brief allowed arbitrary branch reporting" assert_grep "firstmate handles review and local merge" "$brief" "local-only campaign brief missing firstmate local merge ownership" assert_grep "bin/fm-merge-local.sh" "$brief" "local-only campaign brief missing local merge helper" assert_no_grep "Open or update the batch PR" "$brief" "local-only campaign brief still asks for a batch PR" From 21f85862affb9d78868e09add827c8f7ffb44561 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:34:06 -0700 Subject: [PATCH 4/9] no-mistakes(review): Constrain campaign execution handoff --- bin/fm-brief.sh | 8 +++++++- tests/fm-brief.test.sh | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 69e26403..24a6d2c6 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -180,14 +180,18 @@ if [ "$MODE" = no-mistakes ]; then fi case "$MODE" in local-only) + CAMPAIGN_BRANCH_SETUP="2. Do not attempt to check out the default branch. The pooled clone holds it, so this worktree starts detached by design. Create the fixed campaign branch directly from the launch commit with \`git switch -c fm/$ID\`; when the execution skill preflight expects a named default branch, the current detached base commit stands in for it. Do not create slice or batch branches." CAMPAIGN_BATCH_STEP="6. When the batch is ready, leave it committed on \`fm/$ID\`. Do not push, do not open a PR, and do not merge. 7. Update roadmap state only as required by the execution artifact after the local batch state changes." + CAMPAIGN_EXECUTION_OVERRIDE="This campaign contract overrides any normal branch, PR, or landing behavior inside the execution skill. For \`local-only\` projects, stay on the fixed \`fm/$ID\` task branch for the whole campaign. If the execution skill would create another branch, push, open a PR, run PR shipping, run \`land-pr\`, call a merge helper, or merge directly, do not do that step. Stop with \`done: ready in branch fm/$ID\` so firstmate can run \`bin/fm-merge-local.sh\` after review." CAMPAIGN_RULE1="1. Never push to any remote, never open a PR, and never merge. Work only on \`fm/$ID\`; firstmate handles review and local merge." CAMPAIGN_MERGE_AUTHORITY="For \`local-only\` projects, stop at \"ready in branch\" for each batch and append \`done: ready in branch fm/$ID\`. Wait for firstmate to review, merge locally with \`bin/fm-merge-local.sh\`, and send the continuation instruction. If \`yolo=$YOLO\` is \`on\`, firstmate may approve that local merge without the captain's word, but you still never merge." ;; *) + CAMPAIGN_BRANCH_SETUP="2. Do not attempt to check out the default branch. The pooled clone holds it, so this worktree starts detached by design. Create slice or batch branches directly from the launch commit with \`git switch -c \`; when the execution skill preflight expects a named default branch, the current detached base commit stands in for it." CAMPAIGN_BATCH_STEP="6. Open or update the batch PR when the batch is ready. 7. Run roadmap-tick after the batch PR state changes as required by the execution artifact." + CAMPAIGN_EXECUTION_OVERRIDE="This campaign contract overrides any normal landing behavior inside the execution skill. The execution skill may help open or update the batch PR, but if it would run \`land-pr\`, call \`bin/fm-pr-merge.sh\`, call \`gh-axi pr merge\`, merge directly, or treat \`yolo=$YOLO\` as permission for you to merge, do not do that step. Stop before landing at \`done: PR {url} checks green\` so firstmate can run \`bin/fm-pr-merge.sh\` after the captain's word or firstmate's yolo decision." CAMPAIGN_RULE1="1. Never push to the default branch. Never merge a PR." CAMPAIGN_MERGE_AUTHORITY="Stop at \"PR ready, checks green\" for each batch PR and append \`done: PR {url} checks green\`. Wait for firstmate to relay the captain's merge word or, when \`yolo=$YOLO\` is \`on\`, firstmate's yolo merge decision and continuation instruction. Firstmate performs every PR merge through \`bin/fm-pr-merge.sh\` so task metadata records the landed PR. Never merge a red PR." ;; @@ -216,11 +220,13 @@ The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. 1. Record the launch commit: \`git rev-parse HEAD\`. Treat this commit as the roadmap base. -2. Do not attempt to check out the default branch. The pooled clone holds it, so this worktree starts detached by design. Create slice or batch branches directly from the launch commit with \`git switch -c \`; when the execution skill preflight expects a named default branch, the current detached base commit stands in for it.$NO_MISTAKES_SETUP +$CAMPAIGN_BRANCH_SETUP$NO_MISTAKES_SETUP # Execution loop Drive the captain-provided execution skill in roadmap mode. Invoke \`/autopilot\`; if the harness needs plain language instead, ask it to run the captain-provided execution skill against the committed roadmap/spec in roadmap mode. +$CAMPAIGN_EXECUTION_OVERRIDE + Use this loop: 1. Pick the next unchecked slice from the roadmap/spec. 2. Implement only that slice. diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 157a2ba1..d3ead2ec 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -95,6 +95,9 @@ test_campaign_brief_contract() { assert_grep "Treat this commit as the roadmap base" "$brief" "campaign brief missing detached-base adaptation" assert_grep "Do not attempt to check out the default branch" "$brief" "campaign brief missing no-default-checkout instruction" assert_grep "Invoke \`/autopilot\`" "$brief" "campaign brief missing execution-skill invocation" + assert_grep "This campaign contract overrides any normal landing behavior inside the execution skill" "$brief" "campaign brief missing execution-skill landing override" + assert_grep "if it would run \`land-pr\`" "$brief" "campaign brief must explicitly stop before land-pr" + assert_grep "Stop before landing at \`done: PR {url} checks green\`" "$brief" "campaign brief must stop at PR-ready status before firstmate merge" assert_grep "needs-decision: [gate]" "$brief" "campaign brief missing gate escalation mapping" assert_grep "needs-decision: [risk:high]" "$brief" "campaign brief missing high-risk escalation mapping" assert_grep "no-mistakes pipeline as the final batch-PR gate" "$brief" "campaign brief missing no-mistakes final gate" @@ -116,6 +119,10 @@ test_campaign_local_only_brief_contract() { brief="$home/data/$id/brief.md" assert_present "$brief" "local-only campaign brief was not scaffolded" assert_grep "Project delivery mode: \`local-only\`" "$brief" "local-only campaign brief did not include project delivery mode" + assert_grep "Create the fixed campaign branch directly from the launch commit with \`git switch -c fm/$id\`" "$brief" "local-only campaign brief must create only the fixed task branch" + assert_no_grep "Create slice or batch branches" "$brief" "local-only campaign brief allowed non-task branches" + assert_grep "This campaign contract overrides any normal branch, PR, or landing behavior inside the execution skill" "$brief" "local-only campaign brief missing execution-skill branch override" + assert_grep "If the execution skill would create another branch, push, open a PR, run PR shipping, run \`land-pr\`, call a merge helper, or merge directly, do not do that step" "$brief" "local-only campaign brief must override execution-skill branch and landing steps" assert_grep "Do not push, do not open a PR, and do not merge" "$brief" "local-only campaign brief allowed PR or merge delivery" assert_grep "done: ready in branch fm/$id" "$brief" "local-only campaign brief missing fixed ready-in-branch stop" assert_no_grep "done: ready in branch {branch}" "$brief" "local-only campaign brief allowed arbitrary branch reporting" From 1e7e5f30f3b06443a4966860e7c9556c9dcefaa7 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:27:08 -0700 Subject: [PATCH 5/9] no-mistakes(test): fix treehouse worktree-discovery race and isolate gotmp test env --- bin/fm-spawn.sh | 67 ++++++++++++++++++++++++++++++------------ tests/fm-gotmp.test.sh | 18 ++++++++++-- 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 34ad4542..a13b37a4 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -640,22 +640,30 @@ fi # herdr-sm-spaces-k4). Both branches converge on the same $T ("target") string # that every downstream operation (send/capture/kill) already treats as opaque # per-backend routing (fm_backend_resolve_selector). +# worktree_is_isolated : 0 if resolves to a settled, +# isolated git worktree whose own toplevel is itself and which is distinct from +# the primary project checkout ($PROJ_ABS); non-zero otherwise. Used both to gate +# the post-`treehouse get` discovery poll below - so a transient intermediate cwd +# (e.g. treehouse's own staging root ~/.treehouse while it materializes the leaf +# worktree) is skipped rather than latched and then hard-refused - and by +# validate_spawn_worktree as the final guard. +worktree_is_isolated() { # + local cand=$1 cand_real proj_real cand_top cand_top_real + cand_real=$(cd "$cand" 2>/dev/null && pwd -P) || cand_real= + [ -n "$cand_real" ] || return 1 + proj_real=$(cd "$PROJ_ABS" 2>/dev/null && pwd -P) || proj_real= + cand_top=$(git -C "$cand" rev-parse --show-toplevel 2>/dev/null || true) + cand_top_real=$(cd "$cand_top" 2>/dev/null && pwd -P) || cand_top_real= + [ -n "$cand_top_real" ] || return 1 + [ "$cand_real" = "$cand_top_real" ] || return 1 + [ "$cand_real" != "$proj_real" ] || return 1 + return 0 +} + validate_spawn_worktree() { # - local source=$1 inspect_target=$2 wt_real proj_real wt_top wt_top_real - wt_real= - if ! wt_real=$(cd "$WT" 2>/dev/null && pwd -P); then - wt_real= - fi - proj_real= - if ! proj_real=$(cd "$PROJ_ABS" 2>/dev/null && pwd -P); then - proj_real= - fi - wt_top=$(git -C "$WT" rev-parse --show-toplevel 2>/dev/null || true) - wt_top_real= - if ! wt_top_real=$(cd "$wt_top" 2>/dev/null && pwd -P); then - wt_top_real= - fi - if [ -z "$wt_real" ] || [ -z "$wt_top_real" ] || [ "$wt_real" != "$wt_top_real" ] || [ "$wt_real" = "$proj_real" ]; then + local source=$1 inspect_target=$2 wt_top + if ! worktree_is_isolated "$WT"; then + wt_top=$(git -C "$WT" rev-parse --show-toplevel 2>/dev/null || true) echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${wt_top:-none}'; primary '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2 exit 1 fi @@ -793,12 +801,35 @@ spawn_send_key() { # if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then spawn_send_text_line "$T" 'treehouse get' - # Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree. + # Wait for the treehouse subshell: the pane's cwd moves from the project to the + # worktree. Accept an isolated worktree as soon as it appears. A non-isolated + # cwd is ambiguous: while treehouse materializes the leaf worktree its own + # foreground process transiently sits in its staging root (~/.treehouse), so + # latching that intermediate cwd would hard-refuse the spawn a moment before the + # real leaf subshell is entered. But a non-isolated cwd that STAYS put is a + # genuine tangle (treehouse landed in the primary checkout or a non-worktree + # dir), which must be refused promptly. So we skip a non-isolated cwd while it is + # still changing, and only latch it - to let validate_spawn_worktree emit the + # isolation error - once it has settled (seen unchanged across a couple of polls). + prev_cwd= + same_cwd_streak=0 for _ in $(seq 1 60); do p=$(spawn_current_path "$T" || true) if [ -n "$p" ] && [ "$p" != "$PROJ_ABS" ]; then - WT="$p" - break + if worktree_is_isolated "$p"; then + WT="$p" + break + fi + if [ "$p" = "$prev_cwd" ]; then + same_cwd_streak=$((same_cwd_streak + 1)) + else + same_cwd_streak=0 + prev_cwd=$p + fi + if [ "$same_cwd_streak" -ge 2 ]; then + WT="$p" + break + fi fi sleep 1 done diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 8ea2a70c..12fa1e78 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -129,7 +129,11 @@ test_teardown_removes_tasktmp_dir() { # Sanity: dir + contents exist before teardown. [ -d "$task_tmp/gotmp" ] || fail "precondition: gotmp missing before teardown" # Run the REAL teardown against the fake root. - bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ + # Neutralize any ambient FM_HOME/override so the real teardown resolves its + # state dir from the fake FM_ROOT (via BASH_SOURCE), as this test's design + # intends; an exported FM_HOME would otherwise redirect it to the live home. + env -u FM_HOME -u FM_ROOT_OVERRIDE -u FM_STATE_OVERRIDE -u FM_DATA_OVERRIDE \ + -u FM_CONFIG_OVERRIDE bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ || fail "teardown exited non-zero with a valid tasktmp" [ ! -e "$task_tmp" ] \ || fail "teardown did not remove the tasktmp dir ($task_tmp still exists)" @@ -169,7 +173,11 @@ kind=ship mode=no-mistakes yolo=off META - bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ + # Neutralize any ambient FM_HOME/override so the real teardown resolves its + # state dir from the fake FM_ROOT (via BASH_SOURCE), as this test's design + # intends; an exported FM_HOME would otherwise redirect it to the live home. + env -u FM_HOME -u FM_ROOT_OVERRIDE -u FM_STATE_OVERRIDE -u FM_DATA_OVERRIDE \ + -u FM_CONFIG_OVERRIDE bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ || fail "teardown exited non-zero when tasktmp= was absent" pass "fm-teardown skips gracefully when tasktmp= is absent (backward compat)" } @@ -182,7 +190,11 @@ test_teardown_skips_gracefully_when_dir_missing() { [ ! -e "$task_tmp" ] || fail "precondition: task_tmp should not exist yet" local fake fake=$(make_fake_root "$id" "$task_tmp") - bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ + # Neutralize any ambient FM_HOME/override so the real teardown resolves its + # state dir from the fake FM_ROOT (via BASH_SOURCE), as this test's design + # intends; an exported FM_HOME would otherwise redirect it to the live home. + env -u FM_HOME -u FM_ROOT_OVERRIDE -u FM_STATE_OVERRIDE -u FM_DATA_OVERRIDE \ + -u FM_CONFIG_OVERRIDE bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ || fail "teardown exited non-zero when tasktmp dir was missing" [ ! -e "$task_tmp" ] || fail "teardown created/left the tasktmp dir unexpectedly" pass "fm-teardown skips gracefully when tasktmp= points to a nonexistent dir" From b757278ed29dc75488347a5537dd4b33985bb9c5 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:38:22 -0700 Subject: [PATCH 6/9] no-mistakes(review): Protect and prune all campaign branches at teardown --- AGENTS.md | 4 +- bin/fm-brief.sh | 5 +- bin/fm-teardown.sh | 104 +++++++++++++++++++++++++++++--------- tests/fm-brief.test.sh | 4 ++ tests/fm-teardown.test.sh | 100 ++++++++++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d0c3499..6ef9d720 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -507,7 +507,7 @@ The spawn also propagates the primary's inheritable config into the secondmate h No nudge is needed at spawn because the agent reads `AGENTS.md` fresh on launch. For already-live secondmates, use `bin/fm-config-push.sh` when only this inherited config needs to be pushed. Project worktrees start at detached HEAD on a clean default branch; ship briefs tell the crewmate to create its branch, while scout briefs keep the worktree scratch. -Campaign briefs tell the crewmate to treat that detached launch commit as the roadmap base, create slice or batch branches directly from it, and never attempt to check out the default branch held by the pooled clone. +Campaign briefs tell the crewmate to treat that detached launch commit as the roadmap base, create slice or batch branches named under the task's `fm/` prefix (the first from the launch commit, later ones from the updated default branch after each landed batch), and never attempt to check out the default branch held by the pooled clone. After spawning, peek the endpoint to confirm the crewmate is processing the brief and handle any trust dialog with `harness-adapters`. Add the task to `data/backlog.md` under In flight. @@ -626,7 +626,7 @@ A campaign task is for serially-dependent multi-slice work where one crewmate sh Scaffold the brief with `bin/fm-brief.sh --campaign`, then spawn with `bin/fm-spawn.sh projects/ --campaign`. The crewmate receives a finished execution artifact: a committed roadmap under `docs/plans/.md` with unchecked slices and optional `[gate]` / `[risk:high]` markers, or a single upstream-produced spec. Planning is not part of the campaign crewmate's job. -The campaign worktree is persistent for the whole roadmap. It starts detached at the default-branch commit supplied by `treehouse get`; that commit is the roadmap base. The default branch itself is owned by the pooled clone and must not be checked out inside the task worktree. Slice or batch branches are created directly from the launch commit, and if the captain-provided execution skill expects a named default branch during preflight, the detached base commit stands in for it. +The campaign worktree is persistent for the whole roadmap. It starts detached at the default-branch commit supplied by `treehouse get`; that commit is the roadmap base. The default branch itself is owned by the pooled clone and must not be checked out inside the task worktree. Every campaign branch is named under the task's `fm/` prefix: the fixed `fm/` branch for `local-only`, per-batch `fm//` branches for PR-based projects. The first slice or batch branch is created directly from the launch commit; after each landed batch, the next batch branch starts from the updated default branch so each PR carries only its own batch. If the captain-provided execution skill expects a named default branch during preflight, the detached base commit stands in for it. At teardown, that prefix is the protection boundary: `bin/fm-teardown.sh` verifies every `fm/`-prefixed local branch has landed - refusing by branch name otherwise - and removes them all so the pooled clone does not accumulate orphaned refs. The campaign crewmate drives the captain-provided execution skill in roadmap mode: slice -> verify -> review -> commit with `[S]` -> batch PR -> roadmap-tick. For `no-mistakes` projects, the per-slice verify/review loop still runs inside the execution skill, and the batch PR also goes through the no-mistakes pipeline as the final gate before the PR is reported ready. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 24a6d2c6..dc79a74f 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -188,9 +188,10 @@ case "$MODE" in CAMPAIGN_MERGE_AUTHORITY="For \`local-only\` projects, stop at \"ready in branch\" for each batch and append \`done: ready in branch fm/$ID\`. Wait for firstmate to review, merge locally with \`bin/fm-merge-local.sh\`, and send the continuation instruction. If \`yolo=$YOLO\` is \`on\`, firstmate may approve that local merge without the captain's word, but you still never merge." ;; *) - CAMPAIGN_BRANCH_SETUP="2. Do not attempt to check out the default branch. The pooled clone holds it, so this worktree starts detached by design. Create slice or batch branches directly from the launch commit with \`git switch -c \`; when the execution skill preflight expects a named default branch, the current detached base commit stands in for it." + CAMPAIGN_BRANCH_SETUP="2. Do not attempt to check out the default branch. The pooled clone holds it, so this worktree starts detached by design. Create the first slice or batch branch directly from the launch commit with \`git switch -c fm/$ID/\`; when the execution skill preflight expects a named default branch, the current detached base commit stands in for it. Name EVERY campaign branch under the \`fm/$ID/\` prefix - firstmate's teardown protection and ref cleanup track that prefix, and a branch outside it can strand work." CAMPAIGN_BATCH_STEP="6. Open or update the batch PR when the batch is ready. -7. Run roadmap-tick after the batch PR state changes as required by the execution artifact." +7. Run roadmap-tick after the batch PR state changes as required by the execution artifact. +8. After firstmate reports a batch PR merged, start the next batch branch from the updated default branch, not the launch commit: \`git fetch origin\` then \`git switch -c fm/$ID/ origin/\`, so each batch PR contains only its own batch's diff." CAMPAIGN_EXECUTION_OVERRIDE="This campaign contract overrides any normal landing behavior inside the execution skill. The execution skill may help open or update the batch PR, but if it would run \`land-pr\`, call \`bin/fm-pr-merge.sh\`, call \`gh-axi pr merge\`, merge directly, or treat \`yolo=$YOLO\` as permission for you to merge, do not do that step. Stop before landing at \`done: PR {url} checks green\` so firstmate can run \`bin/fm-pr-merge.sh\` after the captain's word or firstmate's yolo decision." CAMPAIGN_RULE1="1. Never push to the default branch. Never merge a PR." CAMPAIGN_MERGE_AUTHORITY="Stop at \"PR ready, checks green\" for each batch PR and append \`done: PR {url} checks green\`. Wait for firstmate to relay the captain's merge word or, when \`yolo=$YOLO\` is \`on\`, firstmate's yolo merge decision and continuation instruction. Firstmate performs every PR merge through \`bin/fm-pr-merge.sh\` so task metadata records the landed PR. Never merge a red PR." diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index b52c4e75..4be02756 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -24,6 +24,12 @@ # local-only projects additionally accept work merged into the local default # branch (firstmate performs that merge on the captain's approval) as a fallback # for the common case where there is no remote at all. +# Campaign tasks (kind=campaign in meta) are the sanctioned multi-branch case: +# a PR-based campaign accumulates one branch per batch, all named under the +# task's fm/ prefix per the campaign brief. Teardown verifies EVERY +# fm/-prefixed local branch by the same landed-work standard, refuses +# naming any branch that holds unlanded commits, and deletes all of them at +# cleanup so the pooled clone does not accumulate orphaned refs. # Scout tasks (kind=scout in meta) carve out of that check: their worktree is # declared scratch and the report at data//report.md is the work # product - teardown proceeds once the report exists, and refuses without it. @@ -183,8 +189,8 @@ patch_id_for_commit() { } unpushed_patches_are_in_pr_head() { - local pr_head=$1 current base pr_patch_ids commit patch_id unpushed - current=$(git -C "$WT" rev-parse --verify HEAD 2>/dev/null) || return 1 + local pr_head=$1 tip=${2:-HEAD} current base pr_patch_ids commit patch_id unpushed + current=$(git -C "$WT" rev-parse --verify "$tip" 2>/dev/null) || return 1 base=$(git -C "$WT" merge-base "$current" "$pr_head" 2>/dev/null) || return 1 pr_patch_ids=$( git -C "$WT" log --format=%H "$base..$pr_head" -- 2>/dev/null \ @@ -195,7 +201,7 @@ unpushed_patches_are_in_pr_head() { | sort -u ) || return 1 [ -n "$pr_patch_ids" ] || return 1 - unpushed=$(git -C "$WT" log --format=%H HEAD --not --remotes -- 2>/dev/null) || return 1 + unpushed=$(git -C "$WT" log --format=%H "$tip" --not --remotes -- 2>/dev/null) || return 1 [ -n "$unpushed" ] || return 1 while IFS= read -r commit; do [ -n "$commit" ] || continue @@ -213,7 +219,7 @@ EOF # current work is not contained in the PR head, no PR is found, or any gh error # occurs - the caller then falls back to the content check. pr_is_merged() { - local branch=$1 target view state head current + local branch=$1 tip=${2:-HEAD} target view state head current if [ -n "$PR_URL" ]; then target=$PR_URL else @@ -230,9 +236,9 @@ pr_is_merged() { esac [ -n "$head" ] || return 1 ensure_commit_object "$target" "$head" || return 1 - current=$(git -C "$WT" rev-parse --verify HEAD 2>/dev/null) || return 1 + current=$(git -C "$WT" rev-parse --verify "$tip" 2>/dev/null) || return 1 git -C "$WT" merge-base --is-ancestor "$current" "$head" 2>/dev/null && return 0 - unpushed_patches_are_in_pr_head "$head" + unpushed_patches_are_in_pr_head "$head" "$tip" } # Is the branch's content already present in the up-to-date default branch? Fetches @@ -243,7 +249,7 @@ pr_is_merged() { # "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), # so the caller refuses rather than guesses. content_in_default() { - local name ref default_tree merged_tree + local tip=${1:-HEAD} name ref default_tree merged_tree name=$(default_branch) || return 1 if git -C "$WT" remote get-url origin >/dev/null 2>&1; then git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 @@ -255,7 +261,7 @@ content_in_default() { fi default_tree=$(git -C "$WT" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 [ -n "$default_tree" ] || return 1 - merged_tree=$(git -C "$WT" merge-tree --write-tree "$ref" HEAD 2>/dev/null) || return 1 + merged_tree=$(git -C "$WT" merge-tree --write-tree "$ref" "$tip" 2>/dev/null) || return 1 merged_tree=$(printf '%s\n' "$merged_tree" | head -1) [ "$merged_tree" = "$default_tree" ] } @@ -266,9 +272,68 @@ content_in_default() { # default branch (fallback, which also covers the no-PR and gh-error paths). False # only for genuinely unlanded work. work_is_landed() { - local branch=$1 - pr_is_merged "$branch" && return 0 - content_in_default + local branch=$1 tip=${2:-HEAD} + pr_is_merged "$branch" "$tip" && return 0 + content_in_default "$tip" +} + +# All local branches under the task's fm/ prefix: the fixed fm/ branch +# (ship and local-only campaigns) plus fm//* batch branches (PR-based +# campaigns, per the campaign brief's naming convention). +task_prefixed_branches() { + git -C "$WT" for-each-ref --format='%(refname:short)' \ + "refs/heads/fm/$ID" "refs/heads/fm/$ID/*" 2>/dev/null || true +} + +# Detach the worktree, then drop the local task branch(es) so the shared repo +# does not accumulate refs. Campaigns drop every fm/-prefixed branch, not +# just the checked-out one; safety was already verified per branch above. +drop_local_task_branches() { + local branch cb + branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) + if [ "$branch" != "HEAD" ]; then + if git -C "$WT" checkout --detach -q 2>/dev/null; then + git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true + fi + fi + if [ "$KIND" = campaign ]; then + while IFS= read -r cb; do + [ -n "$cb" ] || continue + git -C "$WT" branch -D "$cb" >/dev/null 2>&1 || true + done <-prefixed local branch must be +# landed by the same standard the HEAD check applies, or teardown refuses naming +# the branch. HEAD alone is not enough for campaigns, whose earlier batch +# branches can hold unlanded commits while HEAD sits elsewhere. +refuse_unlanded_campaign_branches() { + local cb cb_unpushed cb_unmerged + while IFS= read -r cb; do + [ -n "$cb" ] || continue + cb_unpushed=$(git -C "$WT" log --oneline "$cb" --not --remotes -- 2>/dev/null | head -5 || true) + [ -n "$cb_unpushed" ] || continue + if [ "$MODE" = local-only ]; then + DEFAULT=$(default_branch) || { echo "REFUSED: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master." >&2; exit 1; } + cb_unmerged=$(git -C "$WT" log --oneline "$cb" --not "$DEFAULT" -- 2>/dev/null | head -5 || true) + if [ -n "$cb_unmerged" ]; then + echo "REFUSED: campaign branch $cb in $WT has work not yet merged into $DEFAULT and not on any remote." >&2 + printf 'commits not yet on %s:\n%s\n' "$DEFAULT" "$cb_unmerged" >&2 + echo "Merge the branch into local $DEFAULT first (bin/fm-merge-local.sh after the captain approves), or push to a fork/remote, or get the captain's explicit OK to discard, then --force." >&2 + exit 1 + fi + elif ! work_is_landed "$cb" "$cb"; then + echo "REFUSED: campaign branch $cb in $WT has work not on any remote and not landed." >&2 + printf 'unpushed commits:\n%s\n' "$cb_unpushed" >&2 + echo "Push the branch, land its PR, or get the captain's explicit OK to discard, then --force." >&2 + exit 1 + fi + done </dev/null || echo HEAD) - if [ "$branch" != "HEAD" ]; then - if git -C "$WT" checkout --detach -q 2>/dev/null; then - git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true - fi - fi + drop_local_task_branches rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" "$WT/.fm-grok-turnend" fi [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID" elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then - branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) - if [ "$branch" != "HEAD" ]; then - if git -C "$WT" checkout --detach -q 2>/dev/null; then - git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true - fi - fi + drop_local_task_branches # Remove our hook file so a reused pool worktree cannot fire signals for a dead task. rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" "$WT/.fm-grok-turnend" # Kills remaining processes in the worktree (including the agent), resets, returns diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index d3ead2ec..160f3576 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -94,6 +94,10 @@ test_campaign_brief_contract() { assert_grep "Project yolo flag: \`on\`" "$brief" "campaign brief did not include project yolo flag" assert_grep "Treat this commit as the roadmap base" "$brief" "campaign brief missing detached-base adaptation" assert_grep "Do not attempt to check out the default branch" "$brief" "campaign brief missing no-default-checkout instruction" + assert_grep "git switch -c fm/$id/" "$brief" "campaign brief missing fm// batch-branch naming convention" + assert_grep "Name EVERY campaign branch under the \`fm/$id/\` prefix" "$brief" "campaign brief missing branch-prefix teardown contract" + assert_grep "start the next batch branch from the updated default branch" "$brief" "campaign brief missing post-merge base update" + assert_grep "git switch -c fm/$id/ origin/" "$brief" "campaign brief missing post-merge base-update command" assert_grep "Invoke \`/autopilot\`" "$brief" "campaign brief missing execution-skill invocation" assert_grep "This campaign contract overrides any normal landing behavior inside the execution skill" "$brief" "campaign brief missing execution-skill landing override" assert_grep "if it would run \`land-pr\`" "$brief" "campaign brief must explicitly stop before land-pr" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 770f17da..a56edbb6 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -35,6 +35,11 @@ # (p) fm-pr-check when local HEAD lags -> record remote PR head # (q) no-mistakes + NO pr= recorded, PR discovered by branch -> ALLOW (yolo/no-CI merge) # (r) campaign + unpushed, no PR, content not in default -> REFUSE (ship-like protection) +# (s) campaign + landed batch branch + unlanded batch branch, +# HEAD detached off both -> REFUSE naming the branch +# (t) campaign + all batch branches landed, HEAD detached -> ALLOW + all fm//* refs deleted +# (u) local-only campaign + unmerged fm/ branch, +# HEAD detached at origin/main -> REFUSE (not HEAD-scoped) set -u # shellcheck source=tests/lib.sh @@ -416,6 +421,98 @@ test_campaign_truly_unpushed_refuses() { pass "campaign worktree with genuinely unlanded work is refused like ship work" } +# Reshape the worktree from make_case's ship layout (checked out on fm/task-x1) +# into a PR-campaign layout: detached HEAD at the launch commit with no fixed +# fm/task-x1 branch, so hierarchical fm/task-x1/ refs can exist. +detach_wt_as_campaign_base() { + local case_dir=$1 + git -C "$case_dir/wt" checkout -q --detach + git -C "$case_dir/wt" branch -q -D fm/task-x1 +} + +# Create a campaign batch branch from the given start point and commit one file +# change on it. Args: case_dir branch start_point file content +add_campaign_batch_branch() { + local case_dir=$1 branch=$2 start=$3 file=$4 content=$5 + git -C "$case_dir/wt" switch -q -c "$branch" "$start" + wt_commit_file "$case_dir" "$file" "$content" "add $file" +} + +test_campaign_unlanded_batch_branch_refuses_even_when_head_clean() { + local case_dir rc launch + case_dir=$(make_case campaign-batch-unlanded) + write_meta "$case_dir" no-mistakes campaign + detach_wt_as_campaign_base "$case_dir" + launch=$(git -C "$case_dir/wt" rev-parse HEAD) + # Batch 1: landed on origin/main via an equivalent squash commit. + add_campaign_batch_branch "$case_dir" fm/task-x1/b1 "$launch" batch1.txt one + land_on_origin_main "$case_dir" batch1.txt one + # Batch 2: genuinely unlanded work on its own branch. + add_campaign_batch_branch "$case_dir" fm/task-x1/b2 "$launch" batch2.txt two + # Park HEAD back on the launch commit, off every batch branch: the old + # HEAD-scoped check saw nothing unpushed here and allowed teardown. + git -C "$case_dir/wt" checkout -q --detach "$launch" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "campaign-batch-unlanded: teardown should refuse for the unlanded batch branch" + grep -q REFUSED "$case_dir/stderr" || fail "campaign-batch-unlanded: no REFUSED line in stderr" + grep -q "fm/task-x1/b2" "$case_dir/stderr" || fail "campaign-batch-unlanded: refusal did not name the unlanded branch" + git -C "$case_dir/project" show-ref --verify --quiet refs/heads/fm/task-x1/b2 \ + || fail "campaign-batch-unlanded: refusal deleted the unlanded batch branch" + pass "campaign teardown refuses an unlanded batch branch even with HEAD parked elsewhere" +} + +test_campaign_all_batches_landed_allows_and_prunes_refs() { + local case_dir rc launch + case_dir=$(make_case campaign-batches-landed) + write_meta "$case_dir" no-mistakes campaign + detach_wt_as_campaign_base "$case_dir" + launch=$(git -C "$case_dir/wt" rev-parse HEAD) + add_campaign_batch_branch "$case_dir" fm/task-x1/b1 "$launch" batch1.txt one + land_on_origin_main "$case_dir" batch1.txt one + add_campaign_batch_branch "$case_dir" fm/task-x1/b2 "$launch" batch2.txt two + land_on_origin_main "$case_dir" batch2.txt two + git -C "$case_dir/wt" checkout -q --detach "$launch" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "campaign-batches-landed: teardown should succeed when every batch branch has landed" + ! grep -q REFUSED "$case_dir/stderr" || fail "campaign-batches-landed: teardown printed a REFUSED line" + git -C "$case_dir/project" show-ref --verify --quiet refs/heads/fm/task-x1/b1 \ + && fail "campaign-batches-landed: teardown left batch branch fm/task-x1/b1 behind" + git -C "$case_dir/project" show-ref --verify --quiet refs/heads/fm/task-x1/b2 \ + && fail "campaign-batches-landed: teardown left batch branch fm/task-x1/b2 behind" + pass "campaign teardown with all batches landed proceeds and prunes every fm//* ref" +} + +test_local_only_campaign_unmerged_branch_refuses_when_head_detached() { + local case_dir rc + case_dir=$(make_case campaign-local-detached) + write_meta "$case_dir" local-only campaign + # Unmerged campaign work sits on the fixed fm/task-x1 branch... + wt_commit_file "$case_dir" feature.txt hello "campaign work" + # ...while HEAD is parked on the already-landed baseline, so the HEAD-scoped + # check alone would see nothing to protect. + git -C "$case_dir/wt" checkout -q --detach origin/main + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "campaign-local-detached: teardown should refuse for the unmerged fixed branch" + grep -q REFUSED "$case_dir/stderr" || fail "campaign-local-detached: no REFUSED line in stderr" + grep -q "fm/task-x1" "$case_dir/stderr" || fail "campaign-local-detached: refusal did not name the branch" + pass "local-only campaign teardown refuses the unmerged fixed branch with HEAD detached" +} + test_squash_merged_branch_deleted_allows() { local case_dir rc pr_head case_dir=$(make_case squash-merged) @@ -699,6 +796,9 @@ test_local_only_merged_to_local_main_allows test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_campaign_truly_unpushed_refuses +test_campaign_unlanded_batch_branch_refuses_even_when_head_clean +test_campaign_all_batches_landed_allows_and_prunes_refs +test_local_only_campaign_unmerged_branch_refuses_when_head_detached test_local_only_force_overrides_unpushed test_squash_merged_branch_deleted_allows test_squash_merged_pr_allows_when_head_ancestor_of_pr_head From 6804f00af73cd43934662d0357a1704cade3649a Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:32:35 -0700 Subject: [PATCH 7/9] no-mistakes(review): Verify campaign branches against their own PRs; document campaign filing --- AGENTS.md | 45 +++++++++++++++---------- bin/fm-brief.sh | 4 +-- bin/fm-teardown.sh | 70 +++++++++++++++++++++++++++++---------- tests/fm-teardown.test.sh | 70 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ef9d720..67d6d972 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,7 @@ Otherwise it prints one line per problem or capability fact; handle each: It prints only when `config/backlog-backend` is absent or set to `tasks-axi` and the compatibility probe accepts `tasks-axi --version` as 0.1.1 or newer. If the backend is not opted out and `tasks-axi` is missing or incompatible, bootstrap reports `MISSING: tasks-axi (install: npm install -g tasks-axi)` but still falls back to hand-editing and never blocks work. If `config/backlog-backend=manual`, bootstrap hand-edits and does not suggest installing `tasks-axi`. -- `NUDGE_SECONDMATES: ` - the secondmate sweep fast-forwarded one or more *running* secondmate homes to firstmate's current version and their instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) actually changed; for each listed window, send a one-line re-read nudge with `bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.'` so that secondmate picks up its new instructions. +- `NUDGE_SECONDMATES: ` - the secondmate sweep fast-forwarded one or more _running_ secondmate homes to firstmate's current version and their instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) actually changed; for each listed window, send a one-line re-read nudge with `bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.'` so that secondmate picks up its new instructions. This mirrors `/updatefirstmate`'s `nudge-secondmates:` report: it is a gentle steer, never an interruption, and the fast-forward already landed safely. A secondmate that was skipped, already current, or whose advance changed no instructions is not listed and must not be disturbed. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts; follow section 14 for watcher cadence restart only when a running watcher needs the transition applied immediately. @@ -218,11 +218,19 @@ Schema: "rules": [ { "when": "", - "use": { "harness": "", "model": "", "effort": "" }, + "use": { + "harness": "", + "model": "", + "effort": "" + }, "why": "" } ], - "default": { "harness": "", "model": "", "effort": "" } + "default": { + "harness": "", + "model": "", + "effort": "" + } } ``` @@ -375,14 +383,14 @@ Do not eagerly backfill every project. Route each piece of durable knowledge to its most specific home: -| Kind of knowledge | Home | -| --- | --- | -| Captain preferences and working style | `data/captain.md` | -| Project-intrinsic knowledge | that project's own `AGENTS.md`, via normal crewmate delivery, never hand-written by firstmate | -| Fleet-local operational facts and gotchas | `data/learnings.md` | -| Knowledge generalizable to every firstmate user | the shared `AGENTS.md`, shipped via PR through the pipeline | -| Task-scoped notes | backlog item notes (`tasks-axi update --append ""`, or hand-edit per the active backend) | -| Investigation findings | scout reports at `data//report.md` | +| Kind of knowledge | Home | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Captain preferences and working style | `data/captain.md` | +| Project-intrinsic knowledge | that project's own `AGENTS.md`, via normal crewmate delivery, never hand-written by firstmate | +| Fleet-local operational facts and gotchas | `data/learnings.md` | +| Knowledge generalizable to every firstmate user | the shared `AGENTS.md`, shipped via PR through the pipeline | +| Task-scoped notes | backlog item notes (`tasks-axi update --append ""`, or hand-edit per the active backend) | +| Investigation findings | scout reports at `data//report.md` | When the captain invokes `/stow`, load the `stow` skill. It sweeps the current session for uncaptured durable knowledge, routes findings with this table, files undone next steps to the backlog, and reports whether the session is safe to reset. @@ -556,7 +564,7 @@ Use chat for yes/no decisions; use lavish-axi when there are multiple findings o Judge a validating crewmate by the run's step status, never by whether its shell is still running. Read its current state with `bin/fm-crew-state.sh `: a deterministic, token-tight one-line read that takes the matching no-mistakes run-step as the source of truth and reconciles it against the crewmate's `state/.status` log. Because the run-step is authoritative before pane liveness, a crewmate whose window closed after or during validation can still report `done` or `working` from its run; a missing pane becomes `unknown` only when no matching run exists. -That log is an append-only wake-*event* log, not a current-state field, and it goes stale the moment a resolved gate lets the run resume: after you answer a `needs-decision`/`blocked` and the crewmate silently resumes (responds to the gate, the pipeline fixes, it re-validates), the log's last line still reads `needs-decision`/`blocked` while the run-step has moved on. +That log is an append-only wake-_event_ log, not a current-state field, and it goes stale the moment a resolved gate lets the run resume: after you answer a `needs-decision`/`blocked` and the crewmate silently resumes (responds to the gate, the pipeline fixes, it re-validates), the log's last line still reads `needs-decision`/`blocked` while the run-step has moved on. So never infer current state from a `tail` of that log; `bin/fm-crew-state.sh` reports the live run-step state and explicitly flags the stale log line superseded, where a raw `tail` would mislead you into re-escalating settled work. The fields below name the run-step states and outcomes it reads from `no-mistakes axi status`; run that command directly when you want the full gate findings. @@ -589,7 +597,7 @@ The script refuses if the worktree holds uncommitted changes or committed work t "Landed" is broader than remote-reachable: for a normal ship task whose commits are not reachable from any remote-tracking branch, the script also accepts the work when its PR is merged and GitHub reports a PR head that contains the current local work, or when its content is already present in the up-to-date default branch. Containment means local `HEAD` is the PR head, local `HEAD` is an ancestor of the PR head, or the unpushed local patches have matching patch IDs in that PR head after no-mistakes replayed the branch. This recognizes the common squash-merge-then-delete-branch flow, where the branch's own commits live nowhere on a remote yet the change is fully in `main`; a merged-and-deleted branch now tears down cleanly instead of false-refusing. -The PR is looked up from the task's recorded `pr=` when present, or, when no `pr=` was ever recorded, by finding a merged PR whose head branch matches the worktree's branch and fetching its head via `refs/pull//head` if the branch itself was deleted - so a task whose merge skipped `bin/fm-pr-check.sh` (typically a yolo-authorized merge on a repo with no PR CI, where the "checks green" trigger never fires) still tears down cleanly instead of false-refusing. +The PR is looked up from the task's recorded `pr=` when present and its head branch matches the branch being verified, or otherwise - including when no `pr=` was ever recorded, and for campaign batch branches whose own PR is not the last-recorded one - by finding a merged PR whose head branch matches that branch and fetching its head via `refs/pull//head` if the branch itself was deleted - so a task whose merge skipped `bin/fm-pr-check.sh` (typically a yolo-authorized merge on a repo with no PR CI, where the "checks green" trigger never fires) still tears down cleanly instead of false-refusing, and each campaign batch branch is verified against its own PR rather than the task's last-recorded one. Genuinely unlanded work (no merged PR head containing the local work and content not in the default branch) and dirty worktrees still refuse, and a gh lookup error falls back to the content check rather than silently allowing. Known benign case: after an external-PR task, a squash merge leaves the branch commits reachable only on the contributor's fork; add the fork as a remote and fetch (`git remote add fork && git fetch fork`), then retry - never reach for `--force`. After a successful PR-based teardown, it also runs `bin/fm-fleet-sync.sh` for that project, best-effort, so safe clone states catch up to the merge, clean detached ancestor drift self-heals, and the just-merged branch, now gone on the remote and free of its worktree, is pruned immediately. @@ -646,7 +654,7 @@ For a fresh `stale` pane, the watcher checks the same positive evidence before t The watcher reads that evidence with `bin/fm-crew-state.sh` (run-step first, then pane), so a finish that wrote no `done:` status - for example one reported only through interactive pane menus - is no longer swallowed. A `heartbeat` with no captain-relevant change is likewise absorbed. Absorbed wakes are advanced past their suppression marker and logged to `state/.watch-triage.log` while the watcher keeps blocking - no queue entry, no exit, no LLM turn. -It exits with one reason line on an *actionable* wake: a `signal` carrying a captain-relevant verb (`needs-decision:`/`blocked:`/`failed:`/`done:`/`PR ready`/`checks green`/`ready in branch`/`merged`); a no-verb `signal` whose crewmate is NOT provably working (it stopped its turn with no running pipeline and no busy pane, so it may be done, waiting on a decision, or wedged); any `check`; a `stale` whose crewmate is not provably working, whether or not its status log's last line is captain-relevant (surfaced at once, never left to wait out the timer); a provably-working `stale` that stays idle past the wedge threshold (`FM_STALE_ESCALATE_SECS`, default 240s); or the heartbeat fleet-scan's fail-safe backstop catching a captain-relevant status the per-wake path missed. +It exits with one reason line on an _actionable_ wake: a `signal` carrying a captain-relevant verb (`needs-decision:`/`blocked:`/`failed:`/`done:`/`PR ready`/`checks green`/`ready in branch`/`merged`); a no-verb `signal` whose crewmate is NOT provably working (it stopped its turn with no running pipeline and no busy pane, so it may be done, waiting on a decision, or wedged); any `check`; a `stale` whose crewmate is not provably working, whether or not its status log's last line is captain-relevant (surfaced at once, never left to wait out the timer); a provably-working `stale` that stays idle past the wedge threshold (`FM_STALE_ESCALATE_SECS`, default 240s); or the heartbeat fleet-scan's fail-safe backstop catching a captain-relevant status the per-wake path missed. A captain-relevant status-log line does not by itself make a stale pane terminal: a crewmate gets no new status entry once firstmate hands it to a no-mistakes validation, so its last line can still read `done:` from BEFORE that validation started for the run's entire duration; a provably-working crew therefore always wins over that stale line and is absorbed (with the same wedge-escalation safety net), and only a crewmate that is NOT provably working has its status log trusted to decide terminal-vs-non-terminal. Only an actionable wake is written to the durable queue at `state/.wake-queue` - before advancing suppression markers such as `.seen-*`, `.stale-*`, `.last-check`, or `.last-heartbeat` - and only an actionable wake ends the background task, so you re-arm exactly once per actionable event instead of once per wake. That is what eliminates the quiet-stretch churn without swallowing a finish: during a long crew validation the run is actively running, so the crewmate's `turn-ended`/`working:`/stale wakes (and no-change heartbeats) are absorbed in bash, the liveness beacon (`state/.last-watcher-beat`) stays fresh the whole time so `fm-guard.sh` never false-alarms, and your LLM is woken only when something genuinely needs you - including the moment that crewmate stops with no running pipeline, which now surfaces immediately. @@ -690,7 +698,7 @@ On wake, in order of cheapness: 1. Read the reason line and drain queued wake records with `bin/fm-wake-drain.sh`. 2. `signal:` read the listed status files first; a wake lists every signal that landed within the coalescing grace window (e.g. a status write plus the same turn's turn-end marker), and each is ~30 tokens and usually sufficient. - A status line is the wake *event*, not the crewmate's current state; when you need the live state - especially to confirm a `needs-decision`/`blocked` is still real and not already resolved-and-resumed - read it with `bin/fm-crew-state.sh `, which reconciles the authoritative run-step over the possibly-stale log line, and never `tail` the status log as the current-state source. + A status line is the wake _event_, not the crewmate's current state; when you need the live state - especially to confirm a `needs-decision`/`blocked` is still real and not already resolved-and-resumed - read it with `bin/fm-crew-state.sh `, which reconciles the authoritative run-step over the possibly-stale log line, and never `tail` the status log as the current-state source. 3. `stale:` the crewmate stopped without reporting; peek the pane (`bin/fm-peek.sh `) to diagnose. If the pane is waiting, looping, confused, or unresponsive, load `stuck-crewmate-recovery`. 4. `check:` a per-task poll fired (usually a merge, or X mode when enabled); act on it. @@ -790,12 +798,15 @@ Update it on every dispatch, completion, and decision. ```markdown ## In flight + - [ ] - (repo: , since ) ## Queued + - [ ] - (repo: ) blocked-by: - ## Done + - [x] - - (merged ) - [x] - - local main (merged ) - [x] - - data//report.md (reported ) @@ -819,9 +830,9 @@ When hand-editing, prune older Done entries manually whenever you add to the sec Pruning loses nothing: finished PR-based ship and campaign tasks live on as GitHub PRs, local-only ship tasks live on in local `main`, and scout tasks live on as report files. Map firstmate's real backlog operations to the approved commands: -- File an item: `tasks-axi add "" --kind --repo `, plus `--start` for immediate dispatch (In flight) or the default queue placement, and `--blocked-by ` (repeatable) when it waits on another task. +- File an item: `tasks-axi add "" --kind --repo `, plus `--start` for immediate dispatch (In flight) or the default queue placement, and `--blocked-by ` (repeatable) when it waits on another task. - Start an existing queued item: `tasks-axi start ` before dispatching work from Queued, after checking that blockers are gone and any time/date gate has arrived. -- Move a finished task to Done: `tasks-axi done --pr ` for a PR-based ship, `--report ` for a scout, or `--note "local main"` for a local-only merge. +- Move a finished task to Done: `tasks-axi done --pr ` for a PR-based ship or campaign (a campaign records its final batch PR), `--report ` for a scout, or `--note "local main"` for a local-only merge. - Append a status note: `tasks-axi update --append ""`; replace fields with `--title`, `--body`, or `--body-file `. - Manage dependencies: `tasks-axi block --by ` and `tasks-axi unblock --by `, then `tasks-axi ready` to list queued work with no unresolved blockers. This is a dependency check only; future-dated items still stay queued until their date arrives. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index dc79a74f..e74b21b3 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -214,9 +214,9 @@ Project delivery mode: \`$MODE\` Project yolo flag: \`$YOLO\` # Setup -You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default-branch commit. +You are in a persistent, isolated git worktree of $REPO, at a detached HEAD on a clean default-branch commit. It stays yours for the whole roadmap; firstmate tears it down only after the campaign closes. -**Verify isolation before anything else.** Run \`pwd -P\` and \`git rev-parse --show-toplevel\`; both must resolve to the disposable task worktree you were launched in, such as a treehouse pool path or an Orca-managed worktree, not the primary checkout firstmate operates from. +**Verify isolation before anything else.** Run \`pwd -P\` and \`git rev-parse --show-toplevel\`; both must resolve to the isolated task worktree you were launched in, such as a treehouse pool path or an Orca-managed worktree, not the primary checkout firstmate operates from. The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` can help inspect the repo, but they do not prove you are outside the primary checkout. If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 4be02756..138e07f0 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -12,12 +12,15 @@ # already present in the up-to-date default branch. This recognizes the common # squash-merge-then-delete-branch flow, where the branch's own commits live nowhere # on a remote yet the change is fully in main. -# The PR itself is resolved from the task's recorded pr= when present, or - when -# no pr= was ever recorded (e.g. a yolo-authorized merge on a repo with no PR CI, -# where the usual "checks green" fm-pr-check.sh trigger never fires) - by looking -# up a merged PR whose head branch matches the worktree's branch, fetching its head -# via refs/pull//head when the branch itself was deleted. So a missing pr= never -# by itself causes a false refusal of landed work. +# The PR itself is resolved from the task's recorded pr= when present AND its head +# branch matches the branch being verified, or otherwise - including when no pr= +# was ever recorded (e.g. a yolo-authorized merge on a repo with no PR CI, where +# the usual "checks green" fm-pr-check.sh trigger never fires), and for campaign +# batch branches whose own PR is not the last-recorded one - by looking up a +# merged PR whose head branch matches that branch, fetching its head via +# refs/pull//head when the branch itself was deleted. So a missing pr= never by +# itself causes a false refusal of landed work, and each campaign branch is +# verified against its own PR rather than the task's last-recorded PR. # A gh lookup error falls back to the content check; if that is also inconclusive, # teardown refuses rather than risk discarding unlanded work. # Uncommitted changes are never landed. @@ -213,23 +216,56 @@ $unpushed EOF } -# Is the worktree's PR merged for local work contained in that PR? Resolves the -# PR from the recorded pr= URL first, then from the branch name, and asks GitHub -# for both the PR state and head. Returns non-zero when the PR is not merged, the -# current work is not contained in the PR head, no PR is found, or any gh error -# occurs - the caller then falls back to the content check. +# Query one PR's state, head oid, and head branch name via gh. Echoes +# "stateheadheadref" (headref may be empty when gh does not report it, +# e.g. older gh or a mock without headRefName). Returns non-zero on any gh error +# or an unparseable response. +pr_view_fields() { + local target=$1 view state rest head headref + [ -n "$target" ] || return 1 + view=$(cd "$WT" && gh pr view "$target" --json state,headRefOid,headRefName -q '.state + "\t" + .headRefOid + "\t" + .headRefName' 2>/dev/null) || return 1 + state=${view%%$'\t'*} + rest=${view#*$'\t'} + [ "$rest" != "$view" ] || return 1 + if [ "${rest#*$'\t'}" != "$rest" ]; then + head=${rest%%$'\t'*} + headref=${rest#*$'\t'} + else + head=$rest + headref= + fi + printf '%s\t%s\t%s' "$state" "$head" "$headref" +} + +# Is the branch's PR merged with the local work contained in that PR? Uses the +# recorded pr= URL only when its head branch matches (or cannot be disproven to +# match) the branch being verified; otherwise discovers that branch's own PR by +# name. Campaigns record only the LAST batch PR in pr=, so earlier batch branches +# must be verified against their own PRs, never the recorded one. Returns +# non-zero when the PR is not merged, the current work is not contained in the +# PR head, no PR is found, or any gh error occurs - the caller then falls back +# to the content check. pr_is_merged() { - local branch=$1 tip=${2:-HEAD} target view state head current + local branch=$1 tip=${2:-HEAD} target view state head headref rest current + view= + target= if [ -n "$PR_URL" ]; then target=$PR_URL - else + view=$(pr_view_fields "$target") || view= + if [ -n "$view" ] && [ "$branch" != HEAD ]; then + headref=${view##*$'\t'} + if [ -n "$headref" ] && [ "$headref" != "$branch" ]; then + view= + fi + fi + fi + if [ -z "$view" ]; then target=$(pr_number_from_branch "$branch") || return 1 + view=$(pr_view_fields "$target") || return 1 fi - [ -n "$target" ] || return 1 - view=$(cd "$WT" && gh pr view "$target" --json state,headRefOid -q '.state + "\t" + .headRefOid' 2>/dev/null) || return 1 state=${view%%$'\t'*} - head=${view#*$'\t'} - [ "$state" != "$view" ] || return 1 + rest=${view#*$'\t'} + head=${rest%%$'\t'*} case "$state" in MERGED|merged) ;; *) return 1 ;; diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index a56edbb6..87f628fd 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -40,6 +40,8 @@ # (t) campaign + all batch branches landed, HEAD detached -> ALLOW + all fm//* refs deleted # (u) local-only campaign + unmerged fm/ branch, # HEAD detached at origin/main -> REFUSE (not HEAD-scoped) +# (v) campaign + recorded pr= is the LAST batch PR, earlier batch branch has +# its own merged PR and an inconclusive content check -> ALLOW (per-branch PR lookup) set -u # shellcheck source=tests/lib.sh @@ -246,6 +248,38 @@ land_equivalent_patch_on_origin_branch() { git -C "$case_dir/project" rev-parse "refs/remotes/origin/$branch" } +# Override GitHub lookups with one merged PR per campaign batch branch: +# PR 7 (head branch fm/task-x1/b1) and PR 8 (head branch fm/task-x1/b2), each +# reporting its own head oid and headRefName. Args: case_dir b1_head b2_head +add_gh_prs_merged_per_batch_branch() { + local case_dir=$1 b1_head=$2 b2_head=$3 + cat > "$case_dir/fakebin/gh-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = pr ] && [ "${2:-}" = list ]; then + case " $* " in + *" --head fm/task-x1/b1 "*) + printf '%s\n' "count: 1 (showing first 1)" "pull_requests[1]{number,state}:" " 7,merged" ; exit 0 ;; + *" --head fm/task-x1/b2 "*) + printf '%s\n' "count: 1 (showing first 1)" "pull_requests[1]{number,state}:" " 8,merged" ; exit 0 ;; + esac + printf '%s\n' "count: 0 (showing first 0)" "pull_requests[]: []" ; exit 0 +fi +exit 0 +SH + cat > "$case_dir/fakebin/gh" <&2 +exit 1 +SH + chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh" +} + # Override gh-axi so every call fails, simulating an API/network error. add_gh_axi_error() { local case_dir=$1 @@ -492,6 +526,41 @@ test_campaign_all_batches_landed_allows_and_prunes_refs() { pass "campaign teardown with all batches landed proceeds and prunes every fm//* ref" } +test_campaign_earlier_batch_verified_against_own_pr_allows() { + local case_dir rc launch b1_head b2_head + case_dir=$(make_case campaign-per-branch-pr) + write_meta "$case_dir" no-mistakes campaign + detach_wt_as_campaign_base "$case_dir" + launch=$(git -C "$case_dir/wt" rev-parse HEAD) + # Batch 1: merged via its own PR 7, but the content check is inconclusive + # because origin/main later rewrote batch1.txt to conflicting content - the + # squash replay of b1 no longer merges cleanly. Only b1's own PR proves it + # landed. + add_campaign_batch_branch "$case_dir" fm/task-x1/b1 "$launch" batch1.txt one + b1_head=$(git -C "$case_dir/wt" rev-parse HEAD) + land_on_origin_main "$case_dir" batch1.txt one-rewritten-later + # Batch 2: merged via PR 8, which is the PR recorded in the task's pr= line + # (fm-pr-check only ever records the LAST batch PR). + add_campaign_batch_branch "$case_dir" fm/task-x1/b2 "$launch" batch2.txt two + b2_head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/wt" checkout -q --detach "$launch" + printf '%s\n' 'pr=https://github.com/example/repo/pull/8' >> "$case_dir/state/task-x1.meta" + add_gh_prs_merged_per_batch_branch "$case_dir" "$b1_head" "$b2_head" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "campaign-per-branch-pr: teardown should verify b1 against its own merged PR, not the recorded last-batch PR" + ! grep -q REFUSED "$case_dir/stderr" || fail "campaign-per-branch-pr: teardown printed a REFUSED line" + git -C "$case_dir/project" show-ref --verify --quiet refs/heads/fm/task-x1/b1 \ + && fail "campaign-per-branch-pr: teardown left batch branch fm/task-x1/b1 behind" + git -C "$case_dir/project" show-ref --verify --quiet refs/heads/fm/task-x1/b2 \ + && fail "campaign-per-branch-pr: teardown left batch branch fm/task-x1/b2 behind" + pass "campaign teardown verifies each batch branch against its own merged PR (per-branch lookup)" +} + test_local_only_campaign_unmerged_branch_refuses_when_head_detached() { local case_dir rc case_dir=$(make_case campaign-local-detached) @@ -798,6 +867,7 @@ test_no_mistakes_truly_unpushed_refuses test_campaign_truly_unpushed_refuses test_campaign_unlanded_batch_branch_refuses_even_when_head_clean test_campaign_all_batches_landed_allows_and_prunes_refs +test_campaign_earlier_batch_verified_against_own_pr_allows test_local_only_campaign_unmerged_branch_refuses_when_head_detached test_local_only_force_overrides_unpushed test_squash_merged_branch_deleted_allows From 3c92fe12ce2936c13e5e5eadf5798f7750ac056f Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:49:29 -0700 Subject: [PATCH 8/9] no-mistakes(test): Unset inherited FM_HOME in turnend-guard test for hermetic runs --- tests/fm-turnend-guard.test.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 5edbc3ac..76068e74 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -16,6 +16,11 @@ set -u # shellcheck source=bin/fm-supervision-lib.sh . "$ROOT/bin/fm-supervision-lib.sh" +# The hook honors ambient FM_HOME/FM_STATE_OVERRIDE/FM_ROOT_OVERRIDE by design; +# drop any inherited from the invoking shell so hermetic scenarios read only +# their own temp dirs. Tests that exercise these knobs set them inline. +unset FM_HOME FM_STATE_OVERRIDE FM_ROOT_OVERRIDE + TMP_ROOT=$(fm_test_tmproot fm-turnend-guard) fm_git_identity fmtest fmtest@example.invalid From 61930e6be5a5a52c4433995fd6801c323c556a1b Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:51:27 -0700 Subject: [PATCH 9/9] no-mistakes(document): Extend campaign-task docs coverage across README/architecture/config/scripts --- .agents/skills/harness-adapters/SKILL.md | 78 ++++++------- .../skills/secondmate-provisioning/SKILL.md | 2 +- AGENTS.md | 6 +- CONTRIBUTING.md | 8 +- README.md | 14 ++- bin/fm-turnend-guard.sh | 4 +- docs/architecture.md | 13 ++- docs/configuration.md | 12 +- docs/herdr-backend.md | 34 +++--- docs/orca-backend.md | 2 +- docs/scripts.md | 104 +++++++++--------- docs/turnend-guard.md | 6 +- docs/zellij-backend.md | 38 +++---- 13 files changed, 162 insertions(+), 159 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 42d0ea4e..24258dac 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -11,7 +11,7 @@ metadata: Use this reference before any harness-specific firstmate operation: spawn, recovery, trust-dialog handling, skill invocation, interrupt, exit, resume, or adapter verification. Crewmates default to the same harness firstmate is running on unless `config/crew-harness` records an adapter name. -Optional dispatch profiles in `config/crew-dispatch.json` can override that static default for one crewmate or scout dispatch by selecting concrete harness, model, and effort axes at intake. +Optional dispatch profiles in `config/crew-dispatch.json` can override that static default for one ship, scout, or campaign dispatch by selecting concrete harness, model, and effort axes at intake. The captain may override that file at session start or later; a per-task instruction such as "run this one on codex" overrides it for that dispatch only. `default` means mirror firstmate's own harness. @@ -37,7 +37,7 @@ If the captain asks for a new harness, propose verifying it first: spawn a trivi `bin/fm-harness.sh` prints firstmate's own harness, using verified env markers first and then process ancestry. `bin/fm-harness.sh crew` resolves the effective crewmate harness from `config/crew-harness` (absent or `default` -> own). `bin/fm-harness.sh secondmate` resolves the secondmate-launch harness through the chain `config/secondmate-harness` -> `config/crew-harness` -> own, so an unset `config/secondmate-harness` matches the crew harness. -`bin/fm-spawn.sh` uses `crew` mode for a crewmate/scout launch and `secondmate` mode for a `--secondmate` launch, re-resolving on every spawn so the split is durable across respawns; an explicit per-spawn harness arg overrides either. +`bin/fm-spawn.sh` uses `crew` mode for a ship/scout/campaign launch and `secondmate` mode for a `--secondmate` launch, re-resolving on every spawn so the split is durable across respawns; an explicit per-spawn harness arg overrides either. On `unknown`, ask the captain instead of guessing. A captain override always beats detection. When verifying a new adapter, record its env marker and command name in `bin/fm-harness.sh`. @@ -51,13 +51,13 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. Do not make the shell scripts parse or match natural-language dispatch rules. The supported launch-profile flags below were verified locally on 2026-06-30 with each CLI's help and parser path. -| Harness | Model flag | Effort flag | Notes | -|---|---|---|---| -| claude | `--model ` | `--effort ` | Verified on Claude Code 2.1.196. | -| codex | `--model ` | `-c 'model_reasoning_effort=""'` | Verified on codex-cli 0.142.1. The installed binary schema contains `model_reasoning_effort`, the active config uses it, and the bundled model catalog advertises only low/medium/high/xhigh. `max` is omitted. | -| grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.73. `--effort` parses too, but firstmate's profile axis is reasoning effort. `--reasoning-effort max` is rejected, so `max` is omitted. | -| pi | `--model ` | `--thinking ` | Verified on pi 0.80.2. `max` prints an invalid-thinking warning, so firstmate omits Pi effort when the requested effort is `max`. | -| opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | +| Harness | Model flag | Effort flag | Notes | +| -------- | -------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| claude | `--model ` | `--effort ` | Verified on Claude Code 2.1.196. | +| codex | `--model ` | `-c 'model_reasoning_effort=""'` | Verified on codex-cli 0.142.1. The installed binary schema contains `model_reasoning_effort`, the active config uses it, and the bundled model catalog advertises only low/medium/high/xhigh. `max` is omitted. | +| grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.73. `--effort` parses too, but firstmate's profile axis is reasoning effort. `--reasoning-effort max` is rejected, so `max` is omitted. | +| pi | `--model ` | `--thinking ` | Verified on pi 0.80.2. `max` prints an invalid-thinking warning, so firstmate omits Pi effort when the requested effort is `max`. | +| opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. This preserves launch success instead of passing a known-bad value. @@ -75,12 +75,12 @@ Natural language is acceptable if uncertain. ## claude (VERIFIED) -| Fact | Value | -|---|---| -| Busy-pane signature | `esc to interrupt` | -| Exit command | `/exit` | -| Interrupt | single Escape | -| Skill invocation | `/` (e.g. `/no-mistakes`) | +| Fact | Value | +| ------------------- | -------------------------------- | +| Busy-pane signature | `esc to interrupt` | +| Exit command | `/exit` | +| Interrupt | single Escape | +| Skill invocation | `/` (e.g. `/no-mistakes`) | First launch in a fresh worktree, or first ever on a machine, may show a trust or bypass-permissions confirmation. After every spawn, peek the pane within about 20 seconds. @@ -103,12 +103,12 @@ After those settings are loaded, hook command resolution is still cwd-sensitive ## codex (VERIFIED 2026-06-11, codex-cli 0.139.0) -| Fact | Value | -|---|---| -| Busy-pane signature | `esc to interrupt` (shown as `• Working (Xs • esc to interrupt)`) | -| Exit command | `/quit` (slash popup needs about 1 second between text and Enter; `fm-send` handles it) | -| Interrupt | single Escape | -| Skill invocation | `$` (e.g. `$no-mistakes`); `/` is claude-only and codex rejects it as "Unrecognized command" | +| Fact | Value | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | +| Busy-pane signature | `esc to interrupt` (shown as `• Working (Xs • esc to interrupt)`) | +| Exit command | `/quit` (slash popup needs about 1 second between text and Enter; `fm-send` handles it) | +| Interrupt | single Escape | +| Skill invocation | `$` (e.g. `$no-mistakes`); `/` is claude-only and codex rejects it as "Unrecognized command" | A `$` invocation opens a `$`-autocomplete (skill) popup, the same hazard as the `/` slash popup: submitting too fast lets the popup swallow the Enter, so the invocation never lands. `fm-send` handles it the same way it handles `/` - it gives the popup a longer settle (1.2s) between typing and the first Enter, with the target backend's submit retry as the safety net - but the `$` settle is scoped to `harness=codex`, read from the target's `state/.meta`. @@ -125,11 +125,11 @@ The session id is printed on quit. ## opencode (VERIFIED 2026-06-11, v1.15.7-1.17.3) -| Fact | Value | -|---|---| -| Busy-pane signature | `esc interrupt` (dotted spinner footer; note no "to") | -| Exit command | `/exit` | -| Interrupt | double Escape; known flaky while a long shell command runs, so a wedged pane may need `/exit` and relaunch | +| Fact | Value | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | +| Busy-pane signature | `esc interrupt` (dotted spinner footer; note no "to") | +| Exit command | `/exit` | +| Interrupt | double Escape; known flaky while a long shell command runs, so a wedged pane may need `/exit` and relaunch | No trust dialog. Opencode can auto-upgrade itself in the background and the running TUI can exit mid-task, observed live from 1.15.7 to 1.17.3. @@ -138,11 +138,11 @@ If a pane shows the exit banner, relaunch with `--continue` to resume the sessio ## pi (VERIFIED 2026-06-11) -| Fact | Value | -|---|---| +| Fact | Value | +| ------------------- | ----------------------------------------------------------------- | | Busy-pane signature | `Working...` (braille spinner prefix; no `esc to interrupt` text) | -| Exit command | `/quit` | -| Interrupt | single Escape | +| Exit command | `/quit` | +| Interrupt | single Escape | Pi has no permission system, so crewmates are always autonomous. Keep the brief as one positional argument. @@ -161,15 +161,15 @@ Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection e Grok Build TUI (`grok`), a Claude-Code-compatible CLI from xAI. Launch with a positional prompt: `grok --always-approve "$(cat )"`. -| Fact | Value | -|---|---| -| Busy-pane signature | `Ctrl+c:cancel` (the mid-turn cancel hint in grok's keybind bar, shown iff a turn is running; the spinner line is a braille glyph + `… N.Ns` + `[stop]`, e.g. `⠹ Thinking… 1.1s … [stop]`). Idle keybind bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. The ASCII `Ctrl+c:cancel` is the busy regex (avoids locale fragility of matching braille). | -| Exit command | `Ctrl+Q` double-press within 1000ms (it is a confirmed destructive action). Prints `Resume this session with: grok --resume `. `Ctrl+D` is the quit key in VS Code family terminals. NOT `/exit` and NOT `Ctrl+C`. | -| 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. | -| Resume | `grok --resume ` (id printed on exit) or `grok -c` / `--continue` (most recent for the cwd); `--fork-session` branches a new session id. | +| Fact | Value | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Busy-pane signature | `Ctrl+c:cancel` (the mid-turn cancel hint in grok's keybind bar, shown iff a turn is running; the spinner line is a braille glyph + `… N.Ns` + `[stop]`, e.g. `⠹ Thinking… 1.1s … [stop]`). Idle keybind bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. The ASCII `Ctrl+c:cancel` is the busy regex (avoids locale fragility of matching braille). | +| Exit command | `Ctrl+Q` double-press within 1000ms (it is a confirmed destructive action). Prints `Resume this session with: grok --resume `. `Ctrl+D` is the quit key in VS Code family terminals. NOT `/exit` and NOT `Ctrl+C`. | +| 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. | +| 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. Reproduced live: the herdr adapter's submit-verification at the time treated ANY pane-content change after Enter as "submitted", and the popup-close-with-placeholder-fill described above IS a visible content change even though nothing was actually sent. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 7fed501d..b284643b 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -62,7 +62,7 @@ For a `--secondmate` spawn, `bin/fm-spawn.sh` populates `MODEL`/`EFFORT` from th An explicit per-spawn `--harness` flag, positional harness arg, or raw launch command starts clean on model and effort too, unless the caller also passes explicit `--model` or `--effort`. When the file's tokens do apply, an explicit per-spawn `--model` or `--effort` flag always wins over the file's token for that axis. Because this resolves from the file on every spawn, the pin is durable across every respawn (recovery, `/updatefirstmate`, restart) exactly like the harness axis itself - e.g. `config/secondmate-harness` containing `claude opus` keeps a secondmate pinned to Opus even if the primary's own default model later changes. -This is secondmate-only: crewmate/scout model resolution is untouched by this file. +This is secondmate-only: ship/scout/campaign model resolution is untouched by this file. Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The same launch also propagates the primary's declared inheritable local config, currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`, into the secondmate home's `config/`. diff --git a/AGENTS.md b/AGENTS.md index 67d6d972..80d9eae8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,7 @@ Otherwise it prints one line per problem or capability fact; handle each: - `CREW_HARNESS_OVERRIDE: ` - record and use the override silently; surface a harness fact only if it actually blocks work or the captain asks. - `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; continue with the normal fallback chain, resolve and pass the chosen fallback harness explicitly while the file remains present, fix the JSON, unverified harness name, or invalid harness/effort pair when convenient, and do not select a bad profile. - `CREW_DISPATCH: active config/crew-dispatch.json` - bootstrap validated the optional dispatch profile file and printed its active rules as `rule: -> ` lines, plus `default:` when present. - Keep this block top-of-mind during intake; it is the reminder that every crewmate or scout dispatch must consult the rules before spawning. + Keep this block top-of-mind during intake; it is the reminder that every ship, scout, or campaign dispatch must consult the rules before spawning. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. - `FLEET_SYNC: : recovered: ` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible. - `FLEET_SYNC: : STUCK: on , N commits behind - needs attention` - the clone is dirty, on a non-default branch, detached with unique commits, or diverged, so the sync left it untouched (never forcing or discarding); it will keep falling behind until you look. A loud STUCK, especially a growing N across bootstraps, means that clone needs hands-on attention; dispatch a crewmate or resolve it before it strands work. @@ -239,7 +239,7 @@ Per rule, `when` and `use` are required, and `use.harness` is required. `default` is optional. An omitted model or effort means the selected harness uses its own default for that axis. -When `config/crew-dispatch.json` is present, read it during intake before every crewmate or scout dispatch. +When `config/crew-dispatch.json` is present, read it during intake before every ship, scout, or campaign dispatch. Pick the single best-fit rule using your own judgment. This is explicitly not first-match: weigh all rules, their `when` text, and their `why` rationales against the actual task. Resolve the chosen rule's `use` object into a concrete profile `(harness, model, effort)` and pass it to `bin/fm-spawn.sh` with explicit `--harness`, `--model`, and `--effort` flags for the axes that are set. @@ -737,7 +737,7 @@ Two further guards prevent the tangle upstream: `fm-spawn` refuses to launch unl On the `claude` harness, "no turn ends blind" has a structural backstop beyond the pull-based `fm-guard.sh` banner: `bin/fm-turnend-guard.sh`, a Claude Code Stop hook registered in the tracked `.claude/settings.json`, fires on every primary turn end and, when tasks are in flight without a live identity-matched watcher lock and fresh beacon, blocks the stop (exit 2 with a reason) so the turn cannot end without acting on it first. It shares status fields with `fm-guard.sh` via `bin/fm-supervision-lib.sh`, uses `bin/fm-wake-lib.sh` for live watcher lock health, and never blocks more than once per turn (Claude Code's own `stop_hook_active` loop-guard field lets it always allow the retry). -It is scoped to fire only in the actual primary checkout - never in a crewmate/scout worktree or a secondmate home - and stays silent when supervision is healthy. +It is scoped to fire only in the actual primary checkout - never in a crewmate/scout/campaign worktree or a secondmate home - and stays silent when supervision is healthy. See `docs/turnend-guard.md` for the verified Stop-hook mechanism and the scoping details. This backstop exists only for `claude` today; other harnesses still rely on the pull-based guard alone (future work, same empirical-verification-first pattern as every other harness fact in `harness-adapters`). Watcher liveness is not enough if you are foreground-blocked. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e066fa8..b274d8a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,18 +88,18 @@ tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, and bootstrap relay tests/fm-x-mode.test.sh # X-mode poll, inbox context round-trip, reply threading, dismiss, completion follow-up counters/caps, dry-run preview, and .env-presence activation tests tests/fm-tangle-guard.test.sh # primary-checkout tangle detection, read-only remediation suppression, and spawn/brief isolation tests -tests/fm-brief.test.sh # fm-brief.sh bash -n parse regression guard (issue #166) and clean no-mistakes/direct-PR/local-only brief generation tests +tests/fm-brief.test.sh # fm-brief.sh bash -n parse regression guard (issue #166), clean no-mistakes/direct-PR/local-only brief generation, and PR-based/local-only --campaign roadmap-contract generation tests tests/fm-spawn-batch.test.sh # batch dispatch and FM_HOME project-path scoping tests -tests/fm-spawn-dispatch-profile.test.sh # concrete dispatch profile flags: active-profile backstop, harness/model/effort meta, launch templates, batch forwarding, and secondmate exemption +tests/fm-spawn-dispatch-profile.test.sh # concrete dispatch profile flags: active-profile backstop for ship/scout/campaign, harness/model/effort meta including kind=campaign recording, launch templates, batch forwarding, and secondmate exemption tests/fm-update.test.sh # fast-forward-only self-update, reread, nudge, dedup, and skip-safety tests tests/fm-secondmate-sync.test.sh # local-HEAD secondmate sync, no-fetch, bootstrap nudge gating, and spawn hook tests tests/fm-secondmate-harness.test.sh # secondmate-vs-crewmate harness resolution, optional secondmate model/effort pins, primary-to-secondmate config inheritance, and config-push tests tests/fm-secondmate-lifecycle-e2e.test.sh # persistent secondmate routing, seeding, backlog handoff, spawn, recovery, teardown, and FM_HOME flow tests tests/fm-secondmate-safety.test.sh # secondmate home safety, idle charter, handoff validation, and teardown boundary tests -tests/fm-teardown.test.sh # fm-teardown.sh landed-work safety and reminder checks: fork-remote allow, squash/content landings, dirty and unlanded refusals, PR-head metadata, no-pr= branch discovery, tasks-axi/manual backlog reminder, --force override +tests/fm-teardown.test.sh # fm-teardown.sh landed-work safety and reminder checks: fork-remote allow, squash/content landings, dirty and unlanded refusals, PR-head metadata, no-pr= branch discovery, tasks-axi/manual backlog reminder, --force override, and campaign multi-branch protection/pruning with per-branch PR verification tests/fm-review-diff.test.sh # fm-review-diff.sh authoritative review diff coverage: recorded pr_head=, fetched refs/pull//head, no-pr local branch behavior, and warning fallback tests/fm-pr-merge.test.sh # fm-pr-merge.sh records pr= and available pr_head= before merging, parses PR URLs into gh-axi number/--repo calls, defaults to squash, preserves explicit merge methods, rejects malformed URLs and repo overrides, and propagates real merge failures -tests/fm-crew-state.test.sh # fm-crew-state.sh current-state reconciliation: run-step authority including closed panes, stale needs-decision/blocked superseded by a resumed run, genuine-parked, cross-branch runs-list attribution, pane/status-log fallback, scout skip, torn-down/missing-meta graceful +tests/fm-crew-state.test.sh # fm-crew-state.sh current-state reconciliation: run-step authority including closed panes, stale needs-decision/blocked superseded by a resumed run, genuine-parked, cross-branch runs-list attribution, pane/status-log fallback, scout skip, campaign run lookup, torn-down/missing-meta graceful tests/fm-backend.test.sh # runtime-backend abstraction: fm-backend.sh selection/meta/dispatch helpers, shell-portable sourced backend matching, and old-vs-new fake-tool command-log conformance for fm-send/fm-peek/fm-spawn/fm-teardown tests/fm-backend-tmux-smoke.test.sh # real (private-socket) tmux smoke test for the tmux adapter: create/duplicate-refuse, send text + Enter, send literal + key, bounded capture, live-window resolve, kill tests/fm-backend-herdr.test.sh # fake herdr CLI unit tests for the experimental herdr adapter, including version/tool gates, target parsing, send/capture, structural composer-state verification, slash-submit retry regression coverage, native busy state, per-home workspace-label resolution, default-tab prune safety, restored-layout husk replacement, and verified CLI bug workarounds diff --git a/README.md b/README.md index d6d31cd0..7c6f65ff 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ This is.. a directory that turns any agent into your firstmate, and you the capt - **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes. - **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles. - **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. -- **Two task shapes** - ship tasks deliver a change; scout tasks investigate, plan, reproduce, or audit and leave a report. +- **Three task shapes** - ship tasks deliver a change; scout tasks investigate, plan, reproduce, or audit and leave a report; campaign tasks drive a committed multi-slice roadmap from one long-lived crewmate. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent domain supervisors that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, kept on the primary firstmate version by guarded local fast-forwards. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; on Claude, a primary turn-end hook also blocks a blind stop when work is in flight and supervision is not live. @@ -103,7 +103,9 @@ Setup guides for tmux (the default) and every other supported backend (herdr, ze │ ├─ ship: project mode ► PR/local merge ► teardown │ - └─ scout: report at data//report.md ► relay findings ► teardown + ├─ scout: report at data//report.md ► relay findings ► teardown + │ + └─ campaign: roadmap slices ► batch PR/local merge ► teardown at roadmap close ``` You chat with the first mate. @@ -117,11 +119,11 @@ Full architecture - the supervision engine, worktree isolation, secondmates, dis Firstmate ships these user-invocable built-in skills. Claude and grok use the slash form shown here; codex uses the same names with `$`, such as `$afk`. -| Skill | What it does | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Skill | What it does | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine wakes in bash and escalates only captain-relevant events as one batched digest, cutting supervision cost while you step away | -| `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | -| `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | +| `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | +| `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index b49e3e1e..b2b39cfe 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -18,7 +18,7 @@ # # Ships as the TRACKED .claude/settings.json at the repo root, so this file is # checked out into every worktree of this repo: the primary checkout, any -# crewmate/scout task worktree spawned to work on firstmate itself (the +# crewmate/scout/campaign task worktree spawned to work on firstmate itself (the # recursive "firstmate improving itself" case), and every secondmate home # (treehouse-leased or git-cloned). It must therefore scope itself to the # PRIMARY at runtime and stay a silent, fast no-op everywhere else. @@ -56,7 +56,7 @@ STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r '.stop_hook_active // false' 2 # --- scope precisely to the PRIMARY checkout -------------------------------- # Excludes secondmate homes (the .fm-secondmate-home marker is written at seed # time regardless of whether the home was treehouse-leased or git-cloned; see -# bin/fm-home-seed.sh) and ordinary crewmate/scout task worktrees of +# bin/fm-home-seed.sh) and ordinary crewmate/scout/campaign task worktrees of # firstmate-on-itself (bin/fm-spawn.sh only ever hands those out as genuine # linked `git worktree`s - it aborts the spawn otherwise - so a plain, # non-worktree checkout is never one of those). A linked worktree's git-dir diff --git a/docs/architecture.md b/docs/architecture.md index 05972d29..aebcac44 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness. It leads with prominent bordered banners for the tangle and no-watcher cases so they cannot be skimmed past. On the `claude` harness, a tracked project Stop hook (`bin/fm-turnend-guard.sh`) gives the primary session a push-based backstop: when work is in flight and no identity-matched watcher lock with a fresh beacon is live, Claude Code receives the hook's exit-2 reason and must continue the turn before it can stop. -The hook is scoped out of secondmate homes and crewmate/scout worktrees, allows Claude's own `stop_hook_active` retry, and is documented in [turnend-guard.md](turnend-guard.md). +The hook is scoped out of secondmate homes and crewmate/scout/campaign worktrees, allows Claude's own `stop_hook_active` retry, and is documented in [turnend-guard.md](turnend-guard.md). A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill activates it, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs and status-scan primitives. @@ -63,7 +63,7 @@ cmux's container shape is one workspace per task with one surface, no per-home c ## Worktrees, not branches in your checkout Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux tasks, while Orca creates its own worktrees for `backend=orca`. -For ship and scout work, `fm-spawn.sh` refuses to launch unless the resolved task path is a real git worktree root that is distinct from the project primary checkout. +For ship, scout, and campaign work, `fm-spawn.sh` refuses to launch unless the resolved task path is a real git worktree root that is distinct from the project primary checkout. The firstmate repo has one extra exposure because it can dispatch crewmates to work on itself. Its operating checkout (`FM_ROOT`) and the disposable crewmate worktrees are all linked git worktrees of the same repository, so the valid discriminator is branch state, not whether the checkout is linked. @@ -75,17 +75,17 @@ Only a named non-default branch checked out in `FM_ROOT` is a worktree tangle. If another live session holds the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command. Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show-toplevel` before creating `fm/`, then stop with a blocked status if it landed in the primary checkout. -## Two task shapes +## Three task shapes -Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks investigate, plan, reproduce bugs, or audit, then leave a report at `data//report.md` and never push. +Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks investigate, plan, reproduce bugs, or audit, then leave a report at `data//report.md` and never push; campaign tasks drive one long-lived crewmate through a finished committed roadmap or upstream spec across several serially-dependent slices or batches, and are teardown-protected like ship tasks rather than scratch like scout tasks. ## Dispatch profiles -Crewmate and scout dispatch can stay on the static crewmate harness resolved by `config/crew-harness`, or it can use local dispatch profiles in `config/crew-dispatch.json`. +Ship, scout, and campaign dispatch can stay on the static crewmate harness resolved by `config/crew-harness`, or it can use local dispatch profiles in `config/crew-dispatch.json`. The dispatch file is intentionally judgment-based: firstmate reads the natural-language rules at intake, chooses the best matching profile, and passes only concrete `--harness`, `--model`, and `--effort` axes to `fm-spawn.sh`. The shell scripts validate the JSON shape and verified harness/effort combinations, but they do not parse task intent or match the natural-language rules. The session-start bootstrap step surfaces either the active rule block or a concise invalid-config line at startup. -When the file exists, `fm-spawn.sh` refuses crewmate and scout launches without an explicit harness, so `config/crew-harness` is only automatic when no dispatch profile file is active. +When the file exists, `fm-spawn.sh` refuses ship, scout, or campaign launches without an explicit harness, so `config/crew-harness` is only automatic when no dispatch profile file is active. Secondmate launches are exempt because they resolve the secondmate harness and any optional secondmate model or effort tokens instead. Unsupported effort values are still recorded in task meta when passed to `fm-spawn.sh`, but the launch template omits any effort flag that the selected harness does not accept. That keeps spawn launch compatible across claude, codex, grok, pi, and opencode while preserving the requested profile for later audit. @@ -147,6 +147,7 @@ PR-head containment covers an exact PR head match, a local `HEAD` that is an anc GitHub lookup errors fall back to the content check and still refuse if that check is inconclusive. If no `pr=` was ever recorded, teardown can still discover a merged PR by matching the worktree branch name and fetching `refs/pull//head` when the head branch was deleted. Those PR-head and content checks let a squash-merged PR whose head branch was deleted tear down cleanly without using `--force`; `local-only` work instead tears down after the approved local default-branch merge or after the branch is pushed to any remote. +Campaign tasks (`kind=campaign`) get the same fail-closed protection extended across every local branch under the task's `fm/` prefix, not just `HEAD`: teardown verifies each `fm/`-prefixed branch by the same landed-work standard - a PR-based campaign's earlier batch branches are looked up against their own merged PR rather than the task's last-recorded `pr=`, since `fm-pr-check.sh` only ever records the final batch's PR - refuses by branch name if any batch is unlanded, and deletes every `fm/`-prefixed branch once all of them have landed so the pooled clone accumulates no orphaned refs. ## Optional X mode diff --git a/docs/configuration.md b/docs/configuration.md index 516475f8..519d8d2a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,7 +27,7 @@ See [`docs/cmux-backend.md`](cmux-backend.md#runtime-auto-detection) for why cmu Auto-detected herdr or cmux prints a stderr notice naming `config/backend` and `--backend tmux` as opt-outs; auto-detected tmux stays silent to preserve existing default behavior. Zellij and Orca are never auto-detected; select them by putting the name in a local `config/backend` file, by exporting `FM_BACKEND=`, or by telling the first mate in chat. Any value other than `tmux`, `herdr`, `zellij`, `orca`, or `cmux` is rejected until another adapter is implemented and verified. -`fm-spawn.sh` accepts `tmux`, `herdr`, `zellij`, `orca`, and `cmux` for ship and scout tasks; `backend=orca` and `backend=cmux` both still refuse `--secondmate` until secondmate launch semantics are designed for each. +`fm-spawn.sh` accepts `tmux`, `herdr`, `zellij`, `orca`, and `cmux` for ship, scout, and campaign tasks; `backend=orca` and `backend=cmux` both still refuse `--secondmate` until secondmate launch semantics are designed for each. A herdr spawn additionally version-gates against the installed `herdr` binary's protocol and requires `jq`, refusing loudly on an incompatible or missing installation. A zellij spawn additionally version-gates against the installed `zellij` binary's version and requires `jq`, refusing loudly when either is missing or the version is older than 0.44. A cmux spawn additionally version-gates against the installed `cmux` binary's version, requires `jq`, and requires the control socket to be reachable and accessible (see [`docs/cmux-backend.md`](cmux-backend.md) "Setup" for the one-time socket-access configuration this needs; Automation mode is the recommended socket control mode, with Password mode supported via `config/cmux-socket-password`), refusing loudly and non-retryably on a `cmuxOnly`/unauthenticated socket. @@ -107,7 +107,7 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa claude, codex, opencode, pi, and grok are all empirically verified; new harnesses get verified through a supervised trial task before joining the set. The verified adapter knowledge - busy signatures, interrupt and exit commands, skill-invocation syntax, and per-harness quirks - lives in [`.agents/skills/harness-adapters/SKILL.md`](../.agents/skills/harness-adapters/SKILL.md). Launch mechanics, including the verified command templates, live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh). -`config/crew-harness` is a local, gitignored file containing one adapter name for crewmate and scout launches. +`config/crew-harness` is a local, gitignored file containing one adapter name for ship, scout, and campaign launches. When it is absent or contains `default`, crewmates mirror the firstmate's own harness. `config/secondmate-harness` is a separate local, gitignored file containing the adapter the primary uses to launch secondmate agents, optionally followed by model and effort tokens on the same line. The first non-empty, non-comment line is parsed as ` [] []`. @@ -116,16 +116,16 @@ When the harness token is absent or `default`, secondmate launch falls back thro `fm-harness.sh secondmate-model` and `fm-harness.sh secondmate-effort` expose only the optional tokens from `config/secondmate-harness`; `config/crew-harness` remains a bare adapter-name file. An explicit harness argument to `fm-spawn.sh` still overrides either config file for that spawn only. An explicit `--model` or `--effort` overrides the matching token from `config/secondmate-harness`; an explicit harness or raw launch command starts with clean model and effort defaults unless those flags are also passed. -When `config/crew-dispatch.json` exists, crewmate and scout spawns require an explicit resolved harness instead of automatically falling back to `config/crew-harness`. +When `config/crew-dispatch.json` exists, ship, scout, and campaign spawns require an explicit resolved harness instead of automatically falling back to `config/crew-harness`. The primary propagates `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend` into secondmate homes at secondmate spawn, during the locked session-start bootstrap secondmate sweep, and during explicit `bin/fm-config-push.sh` runs, so a secondmate's own crewmates, dispatch profiles, and backlog backend use the primary values. `config/secondmate-harness` is not inherited because secondmates do not launch secondmates. For grok, `fm-spawn.sh` installs one firstmate-owned global turn-end hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, and drops a per-task `.fm-grok-turnend` pointer in the worktree, with teardown removing the task token and pointer. ## Crew dispatch profiles (config/crew-dispatch.json) -`config/crew-dispatch.json` is an optional local, gitignored file containing natural-language rules that firstmate reads before dispatching a crewmate or scout. +`config/crew-dispatch.json` is an optional local, gitignored file containing natural-language rules that firstmate reads before dispatching a ship, scout, or campaign task. The shell scripts do not match those rules; firstmate chooses the best profile with judgment and passes only concrete `--harness`, `--model`, and `--effort` flags to `fm-spawn.sh`. -When the file exists, `fm-spawn.sh` enforces that contract by refusing crewmate and scout spawns that lack an explicit harness (`--harness`, a positional adapter, or a raw launch command). +When the file exists, `fm-spawn.sh` enforces that contract by refusing ship, scout, or campaign spawns that lack an explicit harness (`--harness`, a positional adapter, or a raw launch command). Batch spawns satisfy the same requirement with a shared `--harness`. Secondmate spawns are exempt and still resolve through `config/secondmate-harness` and its optional model and effort tokens. Each rule has `when`, `use.harness`, optional `use.model`, optional `use.effort`, and optional `why`; an optional `default` profile uses the same `use` shape without `when`. @@ -220,7 +220,7 @@ FM_STATE_OVERRIDE= # alternate state dir, mainly for tests FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests -FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout spawns +FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout/campaign spawns HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned to locate the composer row for submit verification FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after border/prompt stripping diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 21645fc3..33dfb2a5 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -24,7 +24,7 @@ Prerequisites: Select herdr by putting `herdr` in a local `config/backend` file - the durable way to pick it - or by exporting `FM_BACKEND=herdr` when you launch your harness for a one-off session; telling the first mate in chat to use herdr also works. It can also be auto-detected: when firstmate itself is running natively inside herdr (`HERDR_ENV=1`) and no explicit backend is set, firstmate auto-selects herdr and prints a one-time opt-out notice; running inside tmux nested in herdr always resolves to tmux instead. -A herdr spawn refuses loudly before creating a session container or acquiring a ship/scout worktree if `herdr` or `jq` is missing or the installed herdr's protocol is older than verified. +A herdr spawn refuses loudly before creating a session container or acquiring a ship/scout/campaign worktree if `herdr` or `jq` is missing or the installed herdr's protocol is older than verified. For `--secondmate` launches, secondmate home sync and inherited-config propagation happen before this spawn-time backend gate. No first-run provisioning is needed beyond having `herdr` and `jq` on `PATH`; firstmate creates the workspace and tab it needs on first spawn. @@ -66,7 +66,7 @@ This refines, but does not reverse, P2's original decision (AGENTS.md task herdr P2 established workspace-per-TASK vs. tab-per-task-in-one-shared-workspace and picked tab-per-task on the human-watching axis (below); that axis is untouched here and workspace-per-task stays rejected. What changed is the container's OWNER: P2 assumed a single firstmate instance per herdr session, so one shared `firstmate` workspace was enough. With secondmates now spawning their own herdr tasks, jamming every home's tabs into that one shared workspace made a captain's tab bar an unlabeled mix of primary and secondmate work with no visual way to tell them apart. -Workspace-per-HOME fixes that while keeping tab-per-task's original human-watching win intact **within** each home: attaching to a home's own workspace (`herdr`, then switching to its space) still shows every one of *that home's* tasks as a tab in one tab bar, switchable with `ctrl+b `; the ADDITIONAL win is that a captain juggling several homes on one herdr session now sees them as clearly labeled, separate spaces in herdr's spaces sidebar instead of one undifferentiated pile. +Workspace-per-HOME fixes that while keeping tab-per-task's original human-watching win intact **within** each home: attaching to a home's own workspace (`herdr`, then switching to its space) still shows every one of _that home's_ tasks as a tab in one tab bar, switchable with `ctrl+b `; the ADDITIONAL win is that a captain juggling several homes on one herdr session now sees them as clearly labeled, separate spaces in herdr's spaces sidebar instead of one undifferentiated pile. ### Label derivation (stable, derived from the home itself) @@ -83,7 +83,7 @@ So a secondmate's own recovery/duplicate-check calls are automatically scoped to ### The one wrinkle: a `--secondmate` spawn is launched BY the primary -For every other spawn kind, `$FM_HOME` at spawn time already names the right home: the primary spawning its own crewmate/scout, or a secondmate spawning a crewmate/scout FROM ITS OWN `fm-spawn.sh` process (its own `$FM_HOME` already IS that secondmate's home). +For every other spawn kind, `$FM_HOME` at spawn time already names the right home: the primary spawning its own crewmate/scout/campaign, or a secondmate spawning a crewmate/scout/campaign FROM ITS OWN `fm-spawn.sh` process (its own `$FM_HOME` already IS that secondmate's home). The one exception is `bin/fm-spawn.sh --secondmate`: this command runs IN THE PRIMARY's own process, so the primary's OWN `$FM_HOME` is what the label-resolution helpers would see by default, even though the tab being created belongs to the SECONDMATE. `fm-spawn.sh`'s herdr case arm handles this with a narrow, targeted shadow: it computes `HERDR_LABEL_HOME` (the secondmate's own home, `PROJ_ABS`, for `KIND = secondmate`; the process's own `$FM_HOME` otherwise) and passes it as a bash temporary-assignment prefix - `FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure ...` and `FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_create_task ...` - which scopes the override to exactly those two calls and is automatically restored afterward (verified: bash's temporary-assignment-before-a-simple-command form applies for the duration of a shell FUNCTION call too, not only external commands). Nothing else in `fm-spawn.sh` reads `$FM_HOME` again after this point, so no explicit restore is needed. @@ -174,21 +174,21 @@ Herdr tasks additionally record: ## Verified CLI facts -| Operation | Verified herdr call | What was verified | -|---|---|---| -| Version/protocol gate | `herdr status --json` -> `.client.protocol` | Session-independent; `.server.*` fields ARE session-dependent. | -| Headless server start | `HERDR_SESSION= herdr server --session ` (backgrounded) | A bare socket call does NOT auto-start the server; the adapter always starts-then-polls before any workspace/tab/pane call. This fact is for start only, not cleanup, and the explicit `--session` flag is intentional because `HERDR_SESSION` alone is not safe session targeting. | -| Duplicate task check | `herdr tab list --workspace `, match by `.label` | Herdr does NOT enforce tab-label uniqueness itself; two tabs can share a label. The adapter's own duplicate check is required. | -| Send literal (unsubmitted) | `herdr pane send-text ` | Does NOT auto-submit, contrary to the original design addendum's guess. Verified directly: a unique marker sent this way sits unexecuted in the composer until a separate Enter. Behaves exactly like tmux's `send-keys -l`. | -| Send + submit atomically | `herdr pane run ` | Runs and submits a command in one call; used for the two fixed spawn-time commands (`treehouse get`, the `GOTMPDIR` export) exactly where tmux used one `send-keys ... Enter` call. | -| Send key | `herdr pane send-keys ` | Verified names: `enter`, `escape` (alias `esc`), `ctrl+c` (aliases `C-c`, `c-c`). `ctrl+c` verified to interrupt a running foreground process immediately. | -| Bounded capture | `herdr pane read --source recent --lines N` | See "Verified bug" below - N is never passed through directly. | -| Busy state | `herdr agent get ` -> `.result.agent.agent_status` | Verified live against an interactive `claude` session: reports `working` while generating, `done` once idle. Mapped: `working` -> busy; `idle`/`done` -> idle; `blocked` -> idle (surfaced like a stale pane, not suppressed as busy - a blocked agent is stuck waiting on the human, not grinding); anything else -> unknown (the cue for the shared tail-regex fallback). | -| Kill | `herdr pane close ` | Closing a tab's only (root) pane also closes the tab - no separate tab-close call needed for this adapter's one-pane-per-tab shape. Best-effort: closing an already-closed pane exits non-zero, matching tmux's `kill-window \|\| true` contract. Teardown itself only ever closes the task's own pane/tab, never the workspace - but closing a workspace's LAST tab (verified real-herdr behavior) deletes the workspace as a side effect, so a home's own workspace persists only while at least one task tab remains; see "Workspace lifecycle" above. | +| Operation | Verified herdr call | What was verified | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Version/protocol gate | `herdr status --json` -> `.client.protocol` | Session-independent; `.server.*` fields ARE session-dependent. | +| Headless server start | `HERDR_SESSION= herdr server --session ` (backgrounded) | A bare socket call does NOT auto-start the server; the adapter always starts-then-polls before any workspace/tab/pane call. This fact is for start only, not cleanup, and the explicit `--session` flag is intentional because `HERDR_SESSION` alone is not safe session targeting. | +| Duplicate task check | `herdr tab list --workspace `, match by `.label` | Herdr does NOT enforce tab-label uniqueness itself; two tabs can share a label. The adapter's own duplicate check is required. | +| Send literal (unsubmitted) | `herdr pane send-text ` | Does NOT auto-submit, contrary to the original design addendum's guess. Verified directly: a unique marker sent this way sits unexecuted in the composer until a separate Enter. Behaves exactly like tmux's `send-keys -l`. | +| Send + submit atomically | `herdr pane run ` | Runs and submits a command in one call; used for the two fixed spawn-time commands (`treehouse get`, the `GOTMPDIR` export) exactly where tmux used one `send-keys ... Enter` call. | +| Send key | `herdr pane send-keys ` | Verified names: `enter`, `escape` (alias `esc`), `ctrl+c` (aliases `C-c`, `c-c`). `ctrl+c` verified to interrupt a running foreground process immediately. | +| Bounded capture | `herdr pane read --source recent --lines N` | See "Verified bug" below - N is never passed through directly. | +| Busy state | `herdr agent get ` -> `.result.agent.agent_status` | Verified live against an interactive `claude` session: reports `working` while generating, `done` once idle. Mapped: `working` -> busy; `idle`/`done` -> idle; `blocked` -> idle (surfaced like a stale pane, not suppressed as busy - a blocked agent is stuck waiting on the human, not grinding); anything else -> unknown (the cue for the shared tail-regex fallback). | +| Kill | `herdr pane close ` | Closing a tab's only (root) pane also closes the tab - no separate tab-close call needed for this adapter's one-pane-per-tab shape. Best-effort: closing an already-closed pane exits non-zero, matching tmux's `kill-window \|\| true` contract. Teardown itself only ever closes the task's own pane/tab, never the workspace - but closing a workspace's LAST tab (verified real-herdr behavior) deletes the workspace as a side effect, so a home's own workspace persists only while at least one task tab remains; see "Workspace lifecycle" above. | | Default-tab prune (create_task, first task in a fresh workspace only) | `herdr workspace create`'s own response (`.result.tab.tab_id`) identifies the seeded tab; `herdr tab list` + `herdr agent get ` re-verify it; `herdr pane close ` closes exactly that tab id | `herdr workspace create` seeds the new workspace with one auto-created default tab (label `1`, id captured straight from the create response) firstmate never uses. `fm_backend_herdr_create_task` closes EXACTLY that captured tab id right after creating the first real task tab in a freshly created workspace - never right after `workspace create` itself (see Kill row), and never re-derived from a tab's label or the workspace's tab count at create_task time (see "Default-tab prune" above for the created-vs-adopted safety gate and the 2026-07-02 incident it fixes). Best-effort; an ADOPTED workspace (not freshly created by this same call) is never a prune candidate at all. | -| Recovery / list-live | `herdr tab list --workspace `, filter labels starting with `fm-` | Label-based, never trusts a stored id blindly - see "ID stability" below. `` is always THIS home's own workspace (`fm_backend_herdr_workspace_find`), so recovery never sees a sibling home's tabs. | -| Workspace create / tab create (focus) | `herdr workspace create --no-focus`, `herdr tab create --no-focus` | Verified: neither focuses by default once a workspace already exists in the session, matching pre-P3 (flagless) behavior; `--no-focus` is passed anyway for defense in depth, since the very first workspace ever created in a brand-new session focuses regardless of the flag. `--focus` was separately verified to reliably focus, confirming the flag has real effect. | -| Session targeting for DESTRUCTIVE calls | `herdr session stop --session --json`, then `herdr session delete --session --json`; never `herdr server stop` | Used only through `tests/herdr-test-safety.sh`, which re-queries `herdr session list --json` before every destructive call. See "Session targeting" below - `HERDR_SESSION` alone is not reliably honored once another herdr server is already running on the machine. | +| Recovery / list-live | `herdr tab list --workspace `, filter labels starting with `fm-` | Label-based, never trusts a stored id blindly - see "ID stability" below. `` is always THIS home's own workspace (`fm_backend_herdr_workspace_find`), so recovery never sees a sibling home's tabs. | +| Workspace create / tab create (focus) | `herdr workspace create --no-focus`, `herdr tab create --no-focus` | Verified: neither focuses by default once a workspace already exists in the session, matching pre-P3 (flagless) behavior; `--no-focus` is passed anyway for defense in depth, since the very first workspace ever created in a brand-new session focuses regardless of the flag. `--focus` was separately verified to reliably focus, confirming the flag has real effect. | +| Session targeting for DESTRUCTIVE calls | `herdr session stop --session --json`, then `herdr session delete --session --json`; never `herdr server stop` | Used only through `tests/herdr-test-safety.sh`, which re-queries `herdr session list --json` before every destructive call. See "Session targeting" below - `HERDR_SESSION` alone is not reliably honored once another herdr server is already running on the machine. | ## Verified bug: `pane read --lines N` returns empty for small N diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 55142998..8c4973a4 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -45,7 +45,7 @@ Before spawn mutates any repo/worktree state, firstmate runs `orca status --json An Orca task is one Orca-managed git worktree plus one Orca terminal. Unlike `tmux`, `herdr`, `zellij`, and `cmux`, Orca is not only a session provider; it also provides the task worktree, so `fm-spawn.sh` does not run `treehouse get` for Orca tasks. -The normal firstmate invariant still applies: a ship or scout task must run outside the project primary checkout, and teardown must refuse to discard unlanded ship work. +The normal firstmate invariant still applies: a ship, scout, or campaign task must run outside the project primary checkout, and teardown must refuse to discard unlanded ship or campaign work. ## Metadata diff --git a/docs/scripts.md b/docs/scripts.md index b8aa8aad..736be00e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -4,55 +4,55 @@ The first mate drives these; interactive entrypoints work by hand too, while `*- Each file also starts with a short header comment. If you have changed away from the firstmate home in an interactive shell, invoke these scripts by absolute path through the repo's `bin/` directory; the scripts self-locate internally after they start. -| Script | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `fm-session-start.sh` | The one command AGENTS.md sections 3 and 5 run at every session start: composes `fm-lock.sh`, `fm-bootstrap.sh` (its three mutating sweeps gated on holding the lock via `FM_BOOTSTRAP_DETECT_ONLY`), and `fm-wake-drain.sh`, then prints a full context digest (`data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, each `ABSENT`-marked when missing) and fleet-state digest (`data/backlog.md`, every `state/*.meta`, a bounded `state/*.status` tail, `state/.afk`, and a cheap per-task endpoint-liveness read); prints a loud read-only banner and skips every mutating step when the lock is held elsewhere; never arms the watcher itself | -| `fm-bootstrap.sh` | Detect required toolchain and version problems, dispatch profile JSON errors or active-rule blocks, default backlog-backend status, primary-checkout `TANGLE:` problems, and actionable clone refresh outcomes; refresh project clones best-effort; locally sync live secondmate homes and propagate declared inheritable config; set up opt-in X mode; install tools only after consent; `FM_BOOTSTRAP_DETECT_ONLY=1` skips the three mutating sweeps and prints advisory-only `TANGLE:` wording without a checkout command | -| `fm-fleet-sync.sh` | Fetch clones, fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone | -| `fm-update.sh` | Self-update the running firstmate repo and registered secondmate homes with fast-forward-only pulls from origin | -| `fm-backlog-handoff.sh` | Move already-judged in-scope queued backlog items from the main home into a seeded secondmate home | -| `fm-brief.sh` | Scaffold a ship brief with a worktree-isolation assertion, a report-only scout brief with `--scout`, or a secondmate charter with `--secondmate` | -| `fm-ensure-agents-md.sh` | Ensure project `AGENTS.md` is the real memory file and `CLAUDE.md` symlinks to it | -| `fm-guard.sh` | Warn when the primary checkout is tangled, when queued wakes are pending, or when a stale or missing watcher needs a prominent banner; `FM_GUARD_READ_ONLY=1` keeps the alarms but suppresses drain, arm, and checkout repair commands | -| `fm-turnend-guard.sh` | Claude Code Stop hook, primary-scoped only: blocks (exit 2, exact reason) a primary turn end when work is in flight without a live identity-matched watcher lock and fresh beacon, using Claude Code's own `stop_hook_active` field so it never blocks twice in one turn (docs/turnend-guard.md) | -| `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects, initialize gates, and maintain `data/secondmates.md` | -| `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and `--backend` axes; ship/scout spawns require an explicit resolved harness when dispatch profiles are active and an isolated worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, land herdr tabs in the target home's workspace, land home-scoped zellij tabs in the selected shared zellij session, land cmux workspaces in the shared cmux app, or create Orca worktrees/terminals before launch | -| `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, selector resolver, spawn-capability validation, operation dispatcher, and shell-portable backend-name membership for bash-sourced scripts or zsh-sourced diagnostics; defaults absent `backend=` meta to `tmux`; `fm_backend_target_exists` is a cheap read-only alive/dead endpoint check that never starts a server or session; `fm_backend_composer_state` exposes backend composer checks for guarded submit paths | -| `fm-backend-hometag-lib.sh` | Shared home-tag derivation for zellij tab titles and cmux workspace titles, using the active `FM_HOME` label plus a short hash of the resolved `FM_ROOT` path | -| `backends/tmux.sh` | Verified tmux session-provider adapter used by `fm-backend.sh`; owns create, send, capture, current-path, live-window, and kill primitives | -| `backends/herdr.sh` | Experimental herdr session-provider adapter used by `fm-backend.sh`; owns version/tool gating, per-home workspace/tab creation, created-vs-adopted default-tab prune safety, restored-layout husk respawn replacement, session-scoped CLI calls, send with structural composer-state verification, capture, native busy-state, current-path, label-based live discovery, and kill primitives | -| `backends/zellij.sh` | Experimental zellij session-provider adapter used by `fm-backend.sh`; owns version/tool gating, one-session/tab-per-task creation with home-scoped titles, session-scoped CLI calls, send, capture, active current-path probing, label-checked target safety, scoped live discovery, legacy-title fallback, and tab cleanup primitives | -| `backends/orca.sh` | Experimental Orca backend used by `fm-backend.sh`; owns repo registration, worktree creation/removal, terminal creation, capture, send text, Enter/Ctrl-C interrupt keys, and close; Escape is unsupported | -| `backends/cmux.sh` | Experimental cmux session-provider adapter used by `fm-backend.sh`; owns version/tool/socket-access gating, home-scoped workspace creation, current-path probing, title-based recovery, send/capture, structural composer-state verification, Enter/Escape/Ctrl-C keys, and workspace cleanup primitives | -| `fm-config-push.sh` | Config-only mid-session push of declared inheritable local config into live secondmate homes; reports each item as pushed, unchanged, skipped, or error without fast-forwarding tracked files or nudging agents | -| `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | -| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | -| `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base, warning and falling back to the local branch when an expected PR head cannot be resolved, with optional `--stat` output | -| `fm-marker-lib.sh` | Shared from-firstmate request marker and detector sourced by `fm-send.sh`, `fm-brief.sh`, and tests | -| `fm-watch-arm.sh` | Verified per-home watcher re-arm; reports `started`, `healthy`, or `FAILED`; `--restart` relaunches only this home's watcher | -| `fm-watch.sh` | Singleton-safe always-on watcher and default pull-based event source; uses backend-native busy state when available before the shared regex fallback, absorbs no-verb signal and stale wakes only when the crew is provably working, checks that evidence before trusting stale status-log terminality, queues and exits for actionable wakes, and reverts to daemon-owned one-shot behavior while `state/.afk` exists | -| `fm-supervise-daemon.sh` | Presence-gated sub-supervisor for walk-away (`/afk`) supervision: wraps `fm-watch.sh`, uses the shared wake classifier, backend-aware stale rechecks, and tmux/herdr supervisor injection, self-handles routine wakes in bash, and escalates only captain-relevant events as one verified, batched, single-line digest prefixed with a sentinel marker | -| `fm-crew-state.sh` | Print one stable current-state line for a crew by reconciling its matching no-mistakes run-step, including coarse cross-branch attribution from `no-mistakes runs`, even when the pane has closed, with backend-aware pane fallback that corroborates native idle/unknown verdicts before the status log | -| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification sourced by bootstrap and guard | -| `fm-supervision-lib.sh` | Shared grace-based "in-flight work exists but no watcher has a fresh beacon" status and predicate used by `fm-guard.sh`; `fm-turnend-guard.sh` uses it for banner fields and relies on `fm-wake-lib.sh` for live watcher lock health | -| `fm-ff-lib.sh` | Shared guarded fast-forward helper for `/updatefirstmate` origin pulls and no-fetch local secondmate syncs | -| `fm-config-inherit-lib.sh` | Shared primary->secondmate inheritable-config propagation (a declared, extensible item list - currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`) sourced by spawn, bootstrap, and config push | -| `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe sourced by bootstrap and teardown | -| `fm-wake-drain.sh` | Atomically drain queued watcher wakes before handling supervision work, then run the watcher-liveness guard | -| `fm-wake-lib.sh` | Shared durable wake queue, portable lock helpers, and watcher identity/health helpers sourced by the watcher, drain, arm, guard, turn-end guard, and daemon | -| `fm-classify-lib.sh` | Shared captain-relevant wake classifier sourced by the watcher and daemon, plus the watcher's provably-working predicate | -| `fm-send.sh` | Send one verified literal line or backend-supported `--key` through the target's recorded runtime backend; exits non-zero on confirmed swallowed Enter; bare `kind=secondmate` targets are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before backend-specific submit verification; text sends pause `FM_SEND_SETTLE` seconds after success | -| `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, dim-ghost-aware and border-aware composer detection, and verified submit retry | -| `fm-peek.sh` | Print a bounded tail of a crewmate endpoint through the target's recorded runtime backend | -| `fm-pr-check.sh` | Record `pr=` and GitHub's `pr_head=` when available for a PR-ready task, then arm the watcher's merge poll | -| `fm-pr-merge.sh` | Require a full GitHub PR URL, record `pr=` and available `pr_head=` via `fm-pr-check.sh`, parse it into `gh-axi pr merge --repo /`, default to `--squash` unless a merge method is forwarded, and reject malformed URLs or repo overrides | -| `fm-promote.sh` | Promote a scout task in place so it becomes a protected ship task | -| `fm-teardown.sh` | Return a clean, landed ship worktree or retire/release a secondmate home; requires scout reports, checks child work, removes firstmate-owned hook artifacts, closes recorded backend endpoints under their owning home context, releases Orca worktrees through `orca worktree rm`, and prints the backlog-backend reminder | -| `fm-harness.sh` | Detect the running harness; resolve the effective crewmate (`crew`) or secondmate-launch (`secondmate`) harness; expose optional `config/secondmate-harness` model and effort tokens with `secondmate-model` and `secondmate-effort` | -| `fm-lock.sh` | Per-home firstmate session lock | -| `fm-x-lib.sh` | Shared X-mode `.env`, alternate env-file, relay, dry-run config, reply-thread splitting, outbound image payloads, and task-to-X-request meta-link helpers | -| `fm-x-poll.sh` | Do one bounded X relay poll; without `FMX_PAIRING_TOKEN` it is silent, with a pending mention it stashes the full inbox JSON, including `in_reply_to`, and prints `x-mention ` | -| `fm-x-reply.sh` | Post or dry-run preview a composed public-safe X answer or `--followup`, auto-splitting long text into `{request_id,text,texts}` threads and optionally attaching `--image ` to the opener; reads text from an argument, stdin, or `--text-file` | -| `fm-x-dismiss.sh` | Dismiss or dry-run preview a skipped X mention without replying by sending `{request_id}` to the relay's `connector/dismiss` endpoint | -| `fm-x-link.sh` | Link a spawned task to its originating X mention by recording `x_request=`, `x_request_ts=`, and a follow-up counter `x_followups=` in `state/.meta`; paired `--carry-count --carry-ts ` preserves the original counter and timestamp when re-linking onto a successor task | -| `fm-x-followup.sh` | Detect, post, and manage up to three completion follow-ups (within a 7-day window) for an X-linked task, forwarding optional `--image `, incrementing the counter on a non-final success, clearing the link on `--final`/cap/window/relay-rejection, and retrying only on a generic post failure | +| Script | Description | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fm-session-start.sh` | The one command AGENTS.md sections 3 and 5 run at every session start: composes `fm-lock.sh`, `fm-bootstrap.sh` (its three mutating sweeps gated on holding the lock via `FM_BOOTSTRAP_DETECT_ONLY`), and `fm-wake-drain.sh`, then prints a full context digest (`data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, each `ABSENT`-marked when missing) and fleet-state digest (`data/backlog.md`, every `state/*.meta`, a bounded `state/*.status` tail, `state/.afk`, and a cheap per-task endpoint-liveness read); prints a loud read-only banner and skips every mutating step when the lock is held elsewhere; never arms the watcher itself | +| `fm-bootstrap.sh` | Detect required toolchain and version problems, dispatch profile JSON errors or active-rule blocks, default backlog-backend status, primary-checkout `TANGLE:` problems, and actionable clone refresh outcomes; refresh project clones best-effort; locally sync live secondmate homes and propagate declared inheritable config; set up opt-in X mode; install tools only after consent; `FM_BOOTSTRAP_DETECT_ONLY=1` skips the three mutating sweeps and prints advisory-only `TANGLE:` wording without a checkout command | +| `fm-fleet-sync.sh` | Fetch clones, fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone | +| `fm-update.sh` | Self-update the running firstmate repo and registered secondmate homes with fast-forward-only pulls from origin | +| `fm-backlog-handoff.sh` | Move already-judged in-scope queued backlog items from the main home into a seeded secondmate home | +| `fm-brief.sh` | Scaffold a ship brief with a worktree-isolation assertion, a report-only scout brief with `--scout`, a long-lived roadmap campaign brief with `--campaign`, or a secondmate charter with `--secondmate` | +| `fm-ensure-agents-md.sh` | Ensure project `AGENTS.md` is the real memory file and `CLAUDE.md` symlinks to it | +| `fm-guard.sh` | Warn when the primary checkout is tangled, when queued wakes are pending, or when a stale or missing watcher needs a prominent banner; `FM_GUARD_READ_ONLY=1` keeps the alarms but suppresses drain, arm, and checkout repair commands | +| `fm-turnend-guard.sh` | Claude Code Stop hook, primary-scoped only: blocks (exit 2, exact reason) a primary turn end when work is in flight without a live identity-matched watcher lock and fresh beacon, using Claude Code's own `stop_hook_active` field so it never blocks twice in one turn (docs/turnend-guard.md) | +| `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects, initialize gates, and maintain `data/secondmates.md` | +| `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and `--backend` axes; ship/scout/campaign spawns require an explicit resolved harness when dispatch profiles are active and an isolated worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, land herdr tabs in the target home's workspace, land home-scoped zellij tabs in the selected shared zellij session, land cmux workspaces in the shared cmux app, or create Orca worktrees/terminals before launch | +| `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, selector resolver, spawn-capability validation, operation dispatcher, and shell-portable backend-name membership for bash-sourced scripts or zsh-sourced diagnostics; defaults absent `backend=` meta to `tmux`; `fm_backend_target_exists` is a cheap read-only alive/dead endpoint check that never starts a server or session; `fm_backend_composer_state` exposes backend composer checks for guarded submit paths | +| `fm-backend-hometag-lib.sh` | Shared home-tag derivation for zellij tab titles and cmux workspace titles, using the active `FM_HOME` label plus a short hash of the resolved `FM_ROOT` path | +| `backends/tmux.sh` | Verified tmux session-provider adapter used by `fm-backend.sh`; owns create, send, capture, current-path, live-window, and kill primitives | +| `backends/herdr.sh` | Experimental herdr session-provider adapter used by `fm-backend.sh`; owns version/tool gating, per-home workspace/tab creation, created-vs-adopted default-tab prune safety, restored-layout husk respawn replacement, session-scoped CLI calls, send with structural composer-state verification, capture, native busy-state, current-path, label-based live discovery, and kill primitives | +| `backends/zellij.sh` | Experimental zellij session-provider adapter used by `fm-backend.sh`; owns version/tool gating, one-session/tab-per-task creation with home-scoped titles, session-scoped CLI calls, send, capture, active current-path probing, label-checked target safety, scoped live discovery, legacy-title fallback, and tab cleanup primitives | +| `backends/orca.sh` | Experimental Orca backend used by `fm-backend.sh`; owns repo registration, worktree creation/removal, terminal creation, capture, send text, Enter/Ctrl-C interrupt keys, and close; Escape is unsupported | +| `backends/cmux.sh` | Experimental cmux session-provider adapter used by `fm-backend.sh`; owns version/tool/socket-access gating, home-scoped workspace creation, current-path probing, title-based recovery, send/capture, structural composer-state verification, Enter/Escape/Ctrl-C keys, and workspace cleanup primitives | +| `fm-config-push.sh` | Config-only mid-session push of declared inheritable local config into live secondmate homes; reports each item as pushed, unchanged, skipped, or error without fast-forwarding tracked files or nudging agents | +| `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | +| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | +| `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base, warning and falling back to the local branch when an expected PR head cannot be resolved, with optional `--stat` output | +| `fm-marker-lib.sh` | Shared from-firstmate request marker and detector sourced by `fm-send.sh`, `fm-brief.sh`, and tests | +| `fm-watch-arm.sh` | Verified per-home watcher re-arm; reports `started`, `healthy`, or `FAILED`; `--restart` relaunches only this home's watcher | +| `fm-watch.sh` | Singleton-safe always-on watcher and default pull-based event source; uses backend-native busy state when available before the shared regex fallback, absorbs no-verb signal and stale wakes only when the crew is provably working, checks that evidence before trusting stale status-log terminality, queues and exits for actionable wakes, and reverts to daemon-owned one-shot behavior while `state/.afk` exists | +| `fm-supervise-daemon.sh` | Presence-gated sub-supervisor for walk-away (`/afk`) supervision: wraps `fm-watch.sh`, uses the shared wake classifier, backend-aware stale rechecks, and tmux/herdr supervisor injection, self-handles routine wakes in bash, and escalates only captain-relevant events as one verified, batched, single-line digest prefixed with a sentinel marker | +| `fm-crew-state.sh` | Print one stable current-state line for a crew by reconciling its matching no-mistakes run-step, including coarse cross-branch attribution from `no-mistakes runs`, even when the pane has closed, with backend-aware pane fallback that corroborates native idle/unknown verdicts before the status log | +| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification sourced by bootstrap and guard | +| `fm-supervision-lib.sh` | Shared grace-based "in-flight work exists but no watcher has a fresh beacon" status and predicate used by `fm-guard.sh`; `fm-turnend-guard.sh` uses it for banner fields and relies on `fm-wake-lib.sh` for live watcher lock health | +| `fm-ff-lib.sh` | Shared guarded fast-forward helper for `/updatefirstmate` origin pulls and no-fetch local secondmate syncs | +| `fm-config-inherit-lib.sh` | Shared primary->secondmate inheritable-config propagation (a declared, extensible item list - currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`) sourced by spawn, bootstrap, and config push | +| `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe sourced by bootstrap and teardown | +| `fm-wake-drain.sh` | Atomically drain queued watcher wakes before handling supervision work, then run the watcher-liveness guard | +| `fm-wake-lib.sh` | Shared durable wake queue, portable lock helpers, and watcher identity/health helpers sourced by the watcher, drain, arm, guard, turn-end guard, and daemon | +| `fm-classify-lib.sh` | Shared captain-relevant wake classifier sourced by the watcher and daemon, plus the watcher's provably-working predicate | +| `fm-send.sh` | Send one verified literal line or backend-supported `--key` through the target's recorded runtime backend; exits non-zero on confirmed swallowed Enter; bare `kind=secondmate` targets are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before backend-specific submit verification; text sends pause `FM_SEND_SETTLE` seconds after success | +| `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, dim-ghost-aware and border-aware composer detection, and verified submit retry | +| `fm-peek.sh` | Print a bounded tail of a crewmate endpoint through the target's recorded runtime backend | +| `fm-pr-check.sh` | Record `pr=` and GitHub's `pr_head=` when available for a PR-ready task, then arm the watcher's merge poll | +| `fm-pr-merge.sh` | Require a full GitHub PR URL, record `pr=` and available `pr_head=` via `fm-pr-check.sh`, parse it into `gh-axi pr merge --repo /`, default to `--squash` unless a merge method is forwarded, and reject malformed URLs or repo overrides | +| `fm-promote.sh` | Promote a scout task in place so it becomes a protected ship task | +| `fm-teardown.sh` | Return a clean, landed ship or campaign worktree or retire/release a secondmate home; requires scout reports, verifies and prunes every `fm/`-prefixed branch (per-branch merged-PR lookup) for campaigns, checks child work, removes firstmate-owned hook artifacts, closes recorded backend endpoints under their owning home context, releases Orca worktrees through `orca worktree rm`, and prints the backlog-backend reminder | +| `fm-harness.sh` | Detect the running harness; resolve the effective crewmate (`crew`) or secondmate-launch (`secondmate`) harness; expose optional `config/secondmate-harness` model and effort tokens with `secondmate-model` and `secondmate-effort` | +| `fm-lock.sh` | Per-home firstmate session lock | +| `fm-x-lib.sh` | Shared X-mode `.env`, alternate env-file, relay, dry-run config, reply-thread splitting, outbound image payloads, and task-to-X-request meta-link helpers | +| `fm-x-poll.sh` | Do one bounded X relay poll; without `FMX_PAIRING_TOKEN` it is silent, with a pending mention it stashes the full inbox JSON, including `in_reply_to`, and prints `x-mention ` | +| `fm-x-reply.sh` | Post or dry-run preview a composed public-safe X answer or `--followup`, auto-splitting long text into `{request_id,text,texts}` threads and optionally attaching `--image ` to the opener; reads text from an argument, stdin, or `--text-file` | +| `fm-x-dismiss.sh` | Dismiss or dry-run preview a skipped X mention without replying by sending `{request_id}` to the relay's `connector/dismiss` endpoint | +| `fm-x-link.sh` | Link a spawned task to its originating X mention by recording `x_request=`, `x_request_ts=`, and a follow-up counter `x_followups=` in `state/.meta`; paired `--carry-count --carry-ts ` preserves the original counter and timestamp when re-linking onto a successor task | +| `fm-x-followup.sh` | Detect, post, and manage up to three completion follow-ups (within a 7-day window) for an X-linked task, forwarding optional `--image `, incrementing the counter on a non-final success, clearing the link on `--final`/cap/window/relay-rejection, and retrying only on a generic post failure | diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 184ca38f..1ca4a62a 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -61,16 +61,16 @@ This means a just-exited watcher with a fresh leftover beacon still blocks the S ## Scoping to the PRIMARY only -`.claude/settings.json` is a TRACKED file at the repo root, so it is checked out into every worktree of this repo: the primary checkout, any crewmate/scout task worktree spawned to work on firstmate itself (the recursive "firstmate improving itself" case, which is how this very feature was built), and every secondmate home (whether acquired via a treehouse lease or a `git clone`, per `bin/fm-home-seed.sh`). +`.claude/settings.json` is a TRACKED file at the repo root, so it is checked out into every worktree of this repo: the primary checkout, any crewmate/scout/campaign task worktree spawned to work on firstmate itself (the recursive "firstmate improving itself" case, which is how this very feature was built), and every secondmate home (whether acquired via a treehouse lease or a `git clone`, per `bin/fm-home-seed.sh`). `bin/fm-turnend-guard.sh` must therefore be inert everywhere except the actual primary, and does so at runtime with three checks, all fast (well under a second): 1. **Not a secondmate home.** `bin/fm-home-seed.sh` writes a `.fm-secondmate-home` marker into every secondmate home's root regardless of how it was acquired. Its presence is checked first and is sufficient by itself to exclude secondmate homes. -2. **Not a linked worktree.** `bin/fm-spawn.sh` only ever hands crewmate/scout tasks a genuine linked `git worktree` (it aborts the spawn otherwise - see the worktree-tangle guard in `bin/fm-tangle-lib.sh` and its tests). A linked worktree's `git rev-parse --git-dir` differs from `--git-common-dir` (the former lives under the main repo's `.git/worktrees/`); only a plain, non-worktree checkout has the two equal. This is a structural fact about how `fm-spawn.sh` provisions task worktrees, not a general property of "not being the primary": `bin/fm-brief.sh`'s own generated isolation assertion deliberately does not treat this comparison as authoritative proof for a crewmate verifying itself, precisely because a *secondmate* home acquired via `git clone` (the `ensure_home` path in `bin/fm-home-seed.sh` for an explicit, not-yet-existing home path) also has the two dirs equal. That is exactly why check 1 above is evaluated first and independently - it is what actually rules out that case here. +2. **Not a linked worktree.** `bin/fm-spawn.sh` only ever hands crewmate/scout/campaign tasks a genuine linked `git worktree` (it aborts the spawn otherwise - see the worktree-tangle guard in `bin/fm-tangle-lib.sh` and its tests). A linked worktree's `git rev-parse --git-dir` differs from `--git-common-dir` (the former lives under the main repo's `.git/worktrees/`); only a plain, non-worktree checkout has the two equal. This is a structural fact about how `fm-spawn.sh` provisions task worktrees, not a general property of "not being the primary": `bin/fm-brief.sh`'s own generated isolation assertion deliberately does not treat this comparison as authoritative proof for a crewmate verifying itself, precisely because a _secondmate_ home acquired via `git clone` (the `ensure_home` path in `bin/fm-home-seed.sh` for an explicit, not-yet-existing home path) also has the two dirs equal. That is exactly why check 1 above is evaluated first and independently - it is what actually rules out that case here. 3. **Looks like a firstmate session.** `AGENTS.md` and `bin/` exist at the resolved root, and the effective state dir exists after the same `FM_STATE_OVERRIDE` / `FM_HOME` / repo-root fallback used by the other firstmate scripts - cheap defense in depth against the settings file somehow loading somewhere unrelated. Both `.claude/settings.json` (tracked, this hook) and a task's own `.claude/settings.local.json` (untracked, the per-task `touch` turn-end signal `bin/fm-spawn.sh` installs) can be present simultaneously in the same crewmate task worktree; Claude Code merges the `Stop` hook arrays from both. That is fine: this hook's scoping check makes it a no-op there regardless. -This design is deliberately different from the per-task `.claude/settings.local.json` crewmate hook, and does not worsen the class of problem in GitHub issue #234 (stale Stop-hook entries accumulating in a pooled worktree's `.claude/settings.local.json` across task reuse): that issue is about a dynamically-*written*, untracked, per-task file that can accumulate stale entries across pooled-worktree reuse. `.claude/settings.json` here is a single static TRACKED file - every `git checkout` resets it to the same committed content, so there is nothing to accumulate. +This design is deliberately different from the per-task `.claude/settings.local.json` crewmate hook, and does not worsen the class of problem in GitHub issue #234 (stale Stop-hook entries accumulating in a pooled worktree's `.claude/settings.local.json` across task reuse): that issue is about a dynamically-_written_, untracked, per-task file that can accumulate stale entries across pooled-worktree reuse. `.claude/settings.json` here is a single static TRACKED file - every `git checkout` resets it to the same committed content, so there is nothing to accumulate. ## Installation path diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index 551464a9..f0625c22 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -19,7 +19,7 @@ Prerequisites: Select zellij by putting `zellij` in a local `config/backend` file - the durable way to pick it - or by exporting `FM_BACKEND=zellij` when you launch your harness for a one-off session; telling the first mate in chat to use zellij also works. Unlike tmux and herdr, zellij is **never** auto-detected - it always requires an explicit choice. -A zellij spawn refuses loudly before creating a session container or acquiring a ship/scout worktree if `zellij` or `jq` is missing or the installed zellij is older than 0.44. +A zellij spawn refuses loudly before creating a session container or acquiring a ship/scout/campaign worktree if `zellij` or `jq` is missing or the installed zellij is older than 0.44. For `--secondmate` launches, secondmate home sync and inherited-config propagation happen before this spawn-time backend gate. No first-run provisioning is needed beyond having `zellij` and `jq` on `PATH`; firstmate creates the session and tab it needs on first spawn. @@ -88,23 +88,23 @@ Zellij tasks additionally record: ## Verified CLI facts -| Operation | Verified zellij call | What was verified | -|---|---|---| -| Version gate | `zellij --version` -> `"zellij 0.44.0"` | Session-independent; no server needs to be running. | -| Headless session start | `zellij attach -b ` with stdin redirected from `/dev/null` and no controlling TTY | Creates the session and returns promptly (cannot actually attach without a TTY, so it exits after creating). The session persists with zero attached clients - `dump-screen`, `list-panes`, etc. all work against it. Running it again against an EXISTING session prints `"Session already exists"` and exits 1 - harmless, since existence is checked first via `list-sessions` and the launch call's own exit status is never inspected. | -| Session existence check | `zellij list-sessions --short --no-formatting` | Plain one-name-per-line output, safe to `grep -qxF`. Passive - never starts a session (unlike herdr's `target_ready`, which DOES auto-start: a herdr server restart is non-destructive and recovers persisted state, but zellij's `kill-session` is destructive, so auto-recreating under an unexpected name would silently orphan whatever the caller meant to reach). | -| Duplicate task check | `zellij action list-tabs --json`, match by home-scoped `.name` | Zellij does NOT enforce tab-name uniqueness itself (verified: two tabs can share a name, same as herdr's tabs). The adapter's own duplicate check is required, and it checks the home-scoped title such as `fm-firstmate-a1b2c3d4-` (see "Home-scoped tab titles" above), never the bare `fm-` label. | -| Create task tab | `zellij action new-tab --cwd --name ` | Returns the created tab's bare integer id on stdout, exactly as documented (resolves report gap #3). No `--no-focus`-equivalent flag exists at all - see "Focus-steal on new-tab" below. The caller passes `fm-`, but the adapter creates `fm--`. | -| Pane discovery | `zellij action list-panes --json`, filter `.tab_id == and .is_plugin == false` | `tab_id`, `id` (the pane's own bare integer id), `is_plugin`, and `pane_cwd` are ALL present in the default `--json` output with no extra flags (`--tab`/`--geometry`/`--state`/`--command` add more fields but are not needed here). Terminal (non-plugin) pane ids are globally unique across a session's whole tab set - a SEPARATE incrementing namespace from plugin panes, which is why a plugin pane and a terminal pane can share the same bare `id` (the CLI's own `--pane-id` contract, `"3 (equivalent to terminal_3)"`, already documents this split). | -| Worktree-path discovery | marked active cwd probe + capture-scrape (`fm_backend_zellij_current_path`), NOT `.pane_cwd` | `.pane_cwd` reflects a `cd` run directly in the pane's own top-level shell, but does NOT follow a NESTED SUBSHELL's own `cd` (exactly what `treehouse get` does) - see "Worktree-path discovery: pane_cwd does not track a subshell" below. This directly contradicts the design report's assumption that passive `pane_cwd` polling would be "acceptable for tmux and zellij" (report gap #4 is NOT cleanly resolved as originally framed; the adapter works around it instead). | -| Send literal (unsubmitted) | `zellij action paste --pane-id -- ` | Uses bracketed paste mode, does NOT auto-submit. Verified directly: a marker sent this way sits unexecuted at the prompt until a separate Enter. Behaves like tmux's `send-keys -l` / herdr's `pane send-text`. Chosen over `write-chars` per the design report's recommendation for popup-safety parity with the other backends. The `--` separator keeps option-shaped text such as `--help` literal. | -| Send key | `zellij action send-keys --pane-id ` | Verified names: `"Enter"` (also `"enter"`) works; `"Esc"`/`"esc"` work but `"Escape"`/`"escape"` are REJECTED with "Invalid key"; Ctrl-C must be the SINGLE shell argument `"Ctrl c"` (a two-word key expression as ONE argv entry) - `"C-c"`, `"Ctrl+c"`, and passing `Ctrl`/`c` as two SEPARATE argv words all fail. Resolves report gap #2. | -| Send + submit, composed | `paste` then `send-keys --pane-id Enter` | Zellij has no single-call atomic "type and submit" primitive (unlike tmux's `send-keys ... Enter` or herdr's `pane run`); `fm_backend_zellij_send_text_line` composes the two calls, which is the only form this adapter has for that operation. | -| Bounded capture | `zellij action dump-screen --pane-id ` for 40 lines or fewer; `zellij action dump-screen --pane-id --full` above that threshold | Works for a background session with NO attached client (resolves report gap #1). No `--lines`-style bound flag exists at all (unlike herdr's buggy small-N `--lines`, there is simply no flag). Routine watcher-sized reads use zellij's viewport-only dump to avoid unbounded scrollback reads; larger explicit peeks request `--full` and trim to the caller's requested line count locally with `tail`. The tradeoff: on a very short terminal viewport, a 40-line routine read can see fewer than 40 lines and miss content above the visible screen. | -| Busy state | *(no native primitive)* | D5 (`herdr-addendum.md`): zellij has no agent-state API. `fm_backend_busy_state`'s dispatcher (`bin/fm-backend.sh`) falls through to `unknown` for zellij via its wildcard case, exactly like tmux - the watcher's existing pane-hash + regex path is the only busy-state source for this backend. | -| Kill | `zellij action close-tab-by-id ` (tab id resolved fresh from the pane id when possible; teardown can pass recorded `zellij_tab_id` plus the expected caller-facing `fm-` label when the pane is already gone) | Unlike herdr (where closing a tab's only pane also closes the tab), closing a zellij pane with `close-pane` does NOT close the now-empty tab - it survives as an empty "ghost" entry in `list-tabs`. `close-tab-by-id` on a LIVE tab (with its pane still running) verified to cleanly remove both pane and tab in one call. Kill resolves the owning tab and closes by tab id; if teardown supplies an expected label, the tab id must still match it through the home-scoped-title or unambiguous legacy-title check before it is closed, including the recorded `zellij_tab_id` ghost-tab fallback. Best-effort (`\|\| true`), matching tmux's `kill-window` and herdr's `pane close` contract. | -| Recovery / list-live | `zellij action list-tabs --json`, filter names starting with this home's own `fm--` prefix | Name-based, never trusts a stored pane id blindly - the same posture herdr's `list_live` takes. Scoped to this installation's own home-scoped prefix (see "Home-scoped tab titles" above), so it never lists another firstmate home's tabs; the adapter strips the tag back off and reports the plain `fm-` label. Does not attempt the legacy untagged-title fallback (that fallback is for a single already-known tab, not a bulk sweep). | -| Session cleanup (test-only) | `zellij delete-session --force` | The single-call kill-and-delete form, gated behind `tests/zellij-test-safety.sh`'s guard (refuses an empty name, the literal `"firstmate"` default name, or a name not currently listed). Never `kill-all-sessions`/`delete-all-sessions` - see "Session safety" below. | +| Operation | Verified zellij call | What was verified | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Version gate | `zellij --version` -> `"zellij 0.44.0"` | Session-independent; no server needs to be running. | +| Headless session start | `zellij attach -b ` with stdin redirected from `/dev/null` and no controlling TTY | Creates the session and returns promptly (cannot actually attach without a TTY, so it exits after creating). The session persists with zero attached clients - `dump-screen`, `list-panes`, etc. all work against it. Running it again against an EXISTING session prints `"Session already exists"` and exits 1 - harmless, since existence is checked first via `list-sessions` and the launch call's own exit status is never inspected. | +| Session existence check | `zellij list-sessions --short --no-formatting` | Plain one-name-per-line output, safe to `grep -qxF`. Passive - never starts a session (unlike herdr's `target_ready`, which DOES auto-start: a herdr server restart is non-destructive and recovers persisted state, but zellij's `kill-session` is destructive, so auto-recreating under an unexpected name would silently orphan whatever the caller meant to reach). | +| Duplicate task check | `zellij action list-tabs --json`, match by home-scoped `.name` | Zellij does NOT enforce tab-name uniqueness itself (verified: two tabs can share a name, same as herdr's tabs). The adapter's own duplicate check is required, and it checks the home-scoped title such as `fm-firstmate-a1b2c3d4-` (see "Home-scoped tab titles" above), never the bare `fm-` label. | +| Create task tab | `zellij action new-tab --cwd --name ` | Returns the created tab's bare integer id on stdout, exactly as documented (resolves report gap #3). No `--no-focus`-equivalent flag exists at all - see "Focus-steal on new-tab" below. The caller passes `fm-`, but the adapter creates `fm--`. | +| Pane discovery | `zellij action list-panes --json`, filter `.tab_id == and .is_plugin == false` | `tab_id`, `id` (the pane's own bare integer id), `is_plugin`, and `pane_cwd` are ALL present in the default `--json` output with no extra flags (`--tab`/`--geometry`/`--state`/`--command` add more fields but are not needed here). Terminal (non-plugin) pane ids are globally unique across a session's whole tab set - a SEPARATE incrementing namespace from plugin panes, which is why a plugin pane and a terminal pane can share the same bare `id` (the CLI's own `--pane-id` contract, `"3 (equivalent to terminal_3)"`, already documents this split). | +| Worktree-path discovery | marked active cwd probe + capture-scrape (`fm_backend_zellij_current_path`), NOT `.pane_cwd` | `.pane_cwd` reflects a `cd` run directly in the pane's own top-level shell, but does NOT follow a NESTED SUBSHELL's own `cd` (exactly what `treehouse get` does) - see "Worktree-path discovery: pane_cwd does not track a subshell" below. This directly contradicts the design report's assumption that passive `pane_cwd` polling would be "acceptable for tmux and zellij" (report gap #4 is NOT cleanly resolved as originally framed; the adapter works around it instead). | +| Send literal (unsubmitted) | `zellij action paste --pane-id -- ` | Uses bracketed paste mode, does NOT auto-submit. Verified directly: a marker sent this way sits unexecuted at the prompt until a separate Enter. Behaves like tmux's `send-keys -l` / herdr's `pane send-text`. Chosen over `write-chars` per the design report's recommendation for popup-safety parity with the other backends. The `--` separator keeps option-shaped text such as `--help` literal. | +| Send key | `zellij action send-keys --pane-id ` | Verified names: `"Enter"` (also `"enter"`) works; `"Esc"`/`"esc"` work but `"Escape"`/`"escape"` are REJECTED with "Invalid key"; Ctrl-C must be the SINGLE shell argument `"Ctrl c"` (a two-word key expression as ONE argv entry) - `"C-c"`, `"Ctrl+c"`, and passing `Ctrl`/`c` as two SEPARATE argv words all fail. Resolves report gap #2. | +| Send + submit, composed | `paste` then `send-keys --pane-id Enter` | Zellij has no single-call atomic "type and submit" primitive (unlike tmux's `send-keys ... Enter` or herdr's `pane run`); `fm_backend_zellij_send_text_line` composes the two calls, which is the only form this adapter has for that operation. | +| Bounded capture | `zellij action dump-screen --pane-id ` for 40 lines or fewer; `zellij action dump-screen --pane-id --full` above that threshold | Works for a background session with NO attached client (resolves report gap #1). No `--lines`-style bound flag exists at all (unlike herdr's buggy small-N `--lines`, there is simply no flag). Routine watcher-sized reads use zellij's viewport-only dump to avoid unbounded scrollback reads; larger explicit peeks request `--full` and trim to the caller's requested line count locally with `tail`. The tradeoff: on a very short terminal viewport, a 40-line routine read can see fewer than 40 lines and miss content above the visible screen. | +| Busy state | _(no native primitive)_ | D5 (`herdr-addendum.md`): zellij has no agent-state API. `fm_backend_busy_state`'s dispatcher (`bin/fm-backend.sh`) falls through to `unknown` for zellij via its wildcard case, exactly like tmux - the watcher's existing pane-hash + regex path is the only busy-state source for this backend. | +| Kill | `zellij action close-tab-by-id ` (tab id resolved fresh from the pane id when possible; teardown can pass recorded `zellij_tab_id` plus the expected caller-facing `fm-` label when the pane is already gone) | Unlike herdr (where closing a tab's only pane also closes the tab), closing a zellij pane with `close-pane` does NOT close the now-empty tab - it survives as an empty "ghost" entry in `list-tabs`. `close-tab-by-id` on a LIVE tab (with its pane still running) verified to cleanly remove both pane and tab in one call. Kill resolves the owning tab and closes by tab id; if teardown supplies an expected label, the tab id must still match it through the home-scoped-title or unambiguous legacy-title check before it is closed, including the recorded `zellij_tab_id` ghost-tab fallback. Best-effort (`\|\| true`), matching tmux's `kill-window` and herdr's `pane close` contract. | +| Recovery / list-live | `zellij action list-tabs --json`, filter names starting with this home's own `fm--` prefix | Name-based, never trusts a stored pane id blindly - the same posture herdr's `list_live` takes. Scoped to this installation's own home-scoped prefix (see "Home-scoped tab titles" above), so it never lists another firstmate home's tabs; the adapter strips the tag back off and reports the plain `fm-` label. Does not attempt the legacy untagged-title fallback (that fallback is for a single already-known tab, not a bulk sweep). | +| Session cleanup (test-only) | `zellij delete-session --force` | The single-call kill-and-delete form, gated behind `tests/zellij-test-safety.sh`'s guard (refuses an empty name, the literal `"firstmate"` default name, or a name not currently listed). Never `kill-all-sessions`/`delete-all-sessions` - see "Session safety" below. | ## Worktree-path discovery: `pane_cwd` does not track a subshell (report gap #4, contradicted) @@ -133,7 +133,7 @@ This op is scoped to `fm-spawn.sh`'s own worktree-discovery poll loop, the only Verified against the real binary with a genuinely attached pty client (`script -q /dev/null zellij attach `): `zellij action new-tab` unconditionally focuses the newly created tab for every attached client, and **there is no flag to suppress this** - `new-tab --help` lists no `--no-focus` equivalent at all (unlike herdr's `--no-focus`, verified in `docs/herdr-backend.md`, or tmux's `new-window -d`). Before the client attached, the freshly created tab showed `"active": false` in `list-tabs --json`; after attaching a real pty client and creating another tab, that new tab immediately showed `"active": true` and the client's live view moved to it. -**Mitigation**, implemented in `fm_backend_zellij_create_task`: capture the session's previously-active tab id (`list-tabs --json`, `.active == true`) *before* calling `new-tab`, then call `go-to-tab-by-id ` afterward to restore it. +**Mitigation**, implemented in `fm_backend_zellij_create_task`: capture the session's previously-active tab id (`list-tabs --json`, `.active == true`) _before_ calling `new-tab`, then call `go-to-tab-by-id ` afterward to restore it. Verified empirically: this correctly moves an attached client's view back to where it was, and is a safe, silent no-op (`go-to-tab-by-id` against a session with zero attached clients returns exit 0 doing nothing observable) for the common unattended-spawn case where no client is attached at all. This is the one place this adapter deviates from a flag-based solution the other backends have, because zellij genuinely does not expose one; the mitigation is a best-effort second call, not a suppression flag, so there is a narrow window between tab creation and the restore call during which an attached client's view is briefly on the new tab.