diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 85cd11c35c..6ff29a9b81 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -25,7 +25,8 @@ If `config/crew-harness` is unset or `default`, there is no concrete value to in Inheritance also copies the literal `config/crew-dispatch.json` file, so secondmates apply the same best-fit profile rules for their own crewmates. Each adapter splits into mechanics and knowledge. -The per-task mechanics, including launch command, autonomy flag, and any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. +The per-task mechanics, including any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. +`bin/fm-launch-lib.sh` is the single owner of every verified launch command and the autonomy, model, and effort flags it carries, for crewmate, scout, secondmate, and primary sessions alike; never hand-write one. The primary-session "no turn ends blind" guard contract and harness hook installation paths live in `docs/turnend-guard.md`. The primary-session watcher wake protocols are rendered from `docs/supervision-protocols/` by `bin/fm-supervision-instructions.sh`. The supervision knowledge lives here: busy signature, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. @@ -33,7 +34,7 @@ The supervision knowledge lives here: busy signature, exit command, interrupt, d Never dispatch a crewmate or secondmate on an unverified adapter. If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain under `AGENTS.md` section 9 that the requested worker runtime is not verified yet, use firstmate's own verified runtime for current work, and ask only whether to verify the requested runtime before future use. Do not pause current work for that future-verification choice, and never launch an unverified adapter. -If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. +If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the verified launch command in `bin/fm-launch-lib.sh` and the remaining per-task mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. ## Detection @@ -121,7 +122,7 @@ The supported launch-profile flags below are verified locally; each row records | 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.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | | pi | `--model ` | `--thinking ` | Verified 2026-07-13 on Pi 0.80.6. `pi --help` advertises `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; `pi --print --model openai-codex/gpt-5.6-sol --thinking max 'Reply with exactly OK.'` completed successfully. | -| 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. | +| opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches opencode's interactive TUI, which has no verified effort flag. | | kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | ### Model support discovery @@ -177,7 +178,7 @@ If such a dialog is showing, accept it from an active firstmate session using `F Claude renders a predicted-next-prompt suggestion as dim/faint text inside an otherwise-empty composer after a turn completes. A plain `tmux capture-pane` cannot tell that ghost text apart from typed text. -Firstmate launches every claude crewmate and secondmate with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`, scoped to firstmate-launched agents through `bin/fm-spawn.sh`, so it never touches the captain's global config. +Firstmate launches every claude crewmate and secondmate with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`, scoped to firstmate-launched agents through the launch templates in `bin/fm-launch-lib.sh`, so it never touches the captain's global config. The CLI's `--prompt-suggestions` flag is print/SDK-mode only and does not suppress the interactive composer ghost text, verified empirically on v2.1.186. As defense in depth for any pane that flag cannot reach, including the captain's own firstmate composer that away-mode reads, the shared `fm_composer_strip_ghost` extractor in `bin/fm-composer-lib.sh` removes dim/faint SGR 2 ghost runs before pending-input classification on both ANSI-capable readers (tmux and herdr). Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are documented in `docs/herdr-backend.md` "Composer and injection safety", with active captures in `docs/verification/runtime-backends.md`. @@ -270,7 +271,7 @@ The follow-up was verified in the interactive TUI; `opencode run` can exit befor Pi has no permission system, so crewmates are always autonomous. Keep the brief as one positional argument. -Multiple positional args become separate queued messages; `fm-spawn`'s template already does this correctly. +Multiple positional args become separate queued messages; the verified pi template in `bin/fm-launch-lib.sh` already does this correctly. Project trust dialog can appear on the first pi run in any not-yet-trusted directory, observed even on clean worktrees. Accept with Enter. @@ -291,7 +292,7 @@ When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with ## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99; exit paths re-verified 2026-07-19 on grok 0.2.103) Grok Build TUI (`grok`), a Claude-Code-compatible CLI from xAI. -Launch with a positional prompt: `grok --always-approve "$(cat )"`. +A positional prompt starts the supervised interactive session; the verified command itself lives in `bin/fm-launch-lib.sh`, never here. For Grok's supported reasoning-effort values and omission behavior, see the [launch-profile-axes table](#launch-profile-axes). | Fact | Value | diff --git a/AGENTS.md b/AGENTS.md index 382413e821..674516db01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ Load `harness-adapters` before every spawn or recovery and before trust handling The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `grok`, and `kimi`; never dispatch on an unverified adapter. If static `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. -`docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. +`docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-launch-lib.sh` owns the verified launch commands, launch flags, and the fail-closed unverified-adapter guard that `bin/fm-spawn.sh` sources and enforces at spawn. When dispatch profiles exist, consult them at every crewmate or scout intake and pass the resolved concrete profile required by `fm-spawn`. Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose the candidate with the most real headroom. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f90f00202..c46a51f5bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star Test scripts and helpers in `tests/` are plain bash too. `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, and pinned shellcheck version), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. It pins one exact shellcheck version and refuses to run under any other; print it with `bin/fm-lint.sh --required-version` and install that build locally. -- Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, busy signatures in `bin/fm-watch.sh` and `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. +- Changes to harness adapters (detection in `bin/fm-harness.sh`, verified launch commands in `bin/fm-launch-lib.sh`, remaining launch and hook mechanics in `bin/fm-spawn.sh`, busy signatures in `bin/fm-watch.sh` and `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. - Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md). - [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes. - In Markdown, put each full sentence on its own line. diff --git a/bin/fm-launch-lib.sh b/bin/fm-launch-lib.sh new file mode 100644 index 0000000000..b91ff4bd4c --- /dev/null +++ b/bin/fm-launch-lib.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +# fm-launch-lib.sh - the single owner of firstmate's verified launch commands. +# +# Every firstmate-launched agent session composes its command from exactly these +# three functions. There is no second copy anywhere, and a caller must never +# hand-write a launch string: the drift that causes is not hypothetical. A +# downstream registry once hand-copied claude's command as +# `claude --dangerously-skip-permissions`, dropping the ghost-text suppression +# variable documented in launch_template() below - the exact omission that makes +# firstmate read predicted-prompt text as real typed input when it captures a +# pane. One owner, or that happens again. +# +# Sourced by bin/fm-spawn.sh (crewmate, scout, and secondmate sessions). +# +# launch_template [] the verified launch command, with +# placeholders the caller substitutes +# model_flag_for_harness resolved --model flag, or empty +# effort_flag_for_harness resolved effort flag, or empty +# +# The knowledge half of each adapter (busy signature, exit command, dialogs, +# quirks) lives in the harness-adapters skill, not here. +# +# shell_quote lives here because both flag resolvers depend on it; sourcing this +# library is what makes it available to bin/fm-spawn.sh. + +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + +# The verified launch command per adapter, as a template. A non-zero return has +# two distinct causes, and a caller reporting the refusal must tell them apart: +# 1. the harness has no verified adapter at all - the unverified-adapter guard +# every caller relies on, whose remedy is a raw launch command; +# 2. the harness is verified but this kind is deliberately unsupported for it - +# today only kimi with kind=primary, whose remedy is a different harness, +# NOT the raw-launch escape hatch. +# Never add a permissive default arm to either case. bin/fm-spawn.sh:437 and :441 +# name only cause 1 because fm-spawn never passes kind=primary; a consumer that +# does pass it owes the user the cause-2 wording. +# +# kind selects the session shape: +# ship|scout a crewmate working one task in an isolated worktree +# secondmate a firstmate PRIMARY launched in a provisioned secondmate home +# primary a firstmate PRIMARY launched in this home by the fleet launcher +# +# ship, scout, and secondmate all receive a launch brief, so their templates end +# in the encoded brief argument. A primary has no task, no worktree, no brief, +# and no status file, so it launches bare and is greeted by the session-start +# adapters already installed in the home (for pi and opencode those are the +# project-local extensions the harness auto-discovers once trusted, which is why +# a primary needs no explicit extension flag). A primary template therefore +# carries only its flag placeholders plus whatever briefless-launch flag that +# adapter was verified to need, and an unset flag leaves one trailing space; that +# is cosmetic in a shell command, and consumers may trim it. +# +# Placeholders every caller substitutes before launch: +# __MODELFLAG__ model_flag_for_harness output, or empty (see below) +# __EFFORTFLAG__ effort_flag_for_harness output, or empty (see below) +# __KIMIBIN__ shell-quoted absolute path to the resolved kimi binary +# Placeholders only a task-scoped (ship|scout|secondmate) launch substitutes: +# __BRIEF__ absolute path to data//brief.md +# __TURNEND__ absolute path to state/.turn-ended (for harnesses whose +# turn-end signal rides the launch command, e.g. codex -c notify=[...]) +# __PIEXT__ absolute path to state/.pi-ext.ts (pi turn-end extension, +# written by fm-spawn.sh; outside the worktree to avoid pi's trust gate) +# __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home +# __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home +# __OPINPUT__ absolute path to the canonical operational-input encoder +# +# __KIMIBIN__ is resolved by bin/fm-spawn.sh alone, deliberately: the fleet +# launcher reaches Kimi through the pi harness rather than a native kimi binary, +# so there is no second caller to drift from. +# Revisit that only if a native kimi launch ever becomes a launcher entry. +# That same reasoning is why kimi has no primary arm: only bin/fm-spawn.sh can +# substitute __KIMIBIN__, and it only ever launches crewmates, so a primary kimi +# template could not be substituted by the caller that would ask for it. +# +# No primary template below is the crewmate command with its brief argument +# subtracted; each arm cites the specific in-repo evidence that fixes its flags, +# and tests/fm-launch-lib.test.sh pins each one against those same citations. +# The evidence is not uniform, and each arm says which kind it rests on: opencode +# and grok are pinned to an empirical briefless PRIMARY launch in a live e2e test, +# pi to the documented bare launch, and claude and codex to the secondmate +# precedent - this file's own `secondmate` kind is a firstmate PRIMARY (see the +# kind list above), and its shipped crewmate templates - the claude arm and the +# codex `kind = secondmate` arm in the second case block below - launch that +# interactive primary with exactly the autonomy flags those two arms carry. +# +# CONSUMER OBLIGATION (binding, not advisory). The rule, which governs whatever +# the templates below happen to say: EVERY primary template here starts a session +# that runs without permission prompts, and a consumer composing a primary launch +# MUST surface that to the captain at launch time. One short line at launch or in +# the menu row is enough. The captain is entitled to know the posture of the +# session their front door starts. There are no exempt adapters. The rule binds on +# the posture, not on the presence of a particular flag, so a consumer cannot +# satisfy the letter of this note while silently shipping a no-prompt session. +# +# All five reach that posture, four by an explicit bypass and one structurally: +# claude --dangerously-skip-permissions +# codex --dangerously-bypass-approvals-and-sandbox +# opencode OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}', which +# pre-allows every permission before the TUI starts +# grok --always-approve, which .agents/skills/harness-adapters/SKILL.md:304 +# records as auto-approving every tool execution, verified to run +# fully unattended and equivalent to --permission-mode bypassPermissions +# pi no flag, because none exists to pass: SKILL.md:272 records that pi has +# no permission system at all, so a pi session is autonomous by +# construction. Its bare template is complete, NOT missing an autonomy +# flag its siblings carry - do not add one. +# Pi's first-run project trust dialog (SKILL.md:276-278) is folder trust, not a +# permission prompt, and is a separate concern that neither satisfies nor softens +# this obligation. +launch_template() { + local harness=$1 kind=${2:-ship} + # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here + case "$kind" in + primary) + case "$harness" in + # The ghost-text suppression prefix is firstmate-required on every claude + # launch, primary included (see the crewmate arm below for why). + # --dangerously-skip-permissions is settled knowledge, decided and recorded + # rather than inherited by accident. The evidence is the secondmate + # precedent: the claude arm in the crewmate case block below serves every + # crewmate kind INCLUDING secondmate, and this `secondmate` kind is itself a + # firstmate PRIMARY launched in a provisioned home, so the repo already + # ships an interactive firstmate primary carrying this flag. The captain's + # own attended session already runs as `claude --dangerously-skip-permissions`, + # so keeping it preserves the status quo instead of creating new exposure, + # and dropping it would break the supervision contract: a firstmate stalled + # on a permission prompt cannot run bin/fm-wake-drain.sh to drain its wake + # queue or bin/fm-watch-arm.sh to arm its own watcher. Note that README.md:90 + # documents the primary launch as bare `claude` and the only in-repo claude + # launch carrying the flag directly is the headless print-mode session at + # tests/fm-claude-stop-autoarm-live-e2e.test.sh:115; the secondmate + # precedent, not those, is what fixes this arm. See the CONSUMER OBLIGATION + # in the header: a consumer must tell the captain this session has no + # permission prompts. + claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' ;; + # --dangerously-bypass-approvals-and-sandbox rests on the same secondmate + # precedent: the codex `kind = secondmate` arm in the crewmate case block + # below is the SECONDMATE template, and it launches an + # interactive firstmate primary with exactly this flag. The same status-quo + # and supervision-contract reasoning as the claude arm applies. It is worth + # recording what is NOT the evidence here: + # tests/fm-codex-continuity-live-e2e.test.sh:40 runs `codex exec`, headless, + # so it says nothing about the interactive primary TUI shape. The header's + # CONSUMER OBLIGATION covers this arm too. + codex) printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox' ;; + # --prompt carries the crewmate's brief, so it has no place in a briefless + # primary; --auto is the empirically verified briefless form (a primary + # opencode TUI is launched that way in + # tests/fm-opencode-primary-live-e2e.test.sh:256 and :310). The + # OPENCODE_CONFIG_CONTENT JSON pre-allows every permission, so the header's + # CONSUMER OBLIGATION covers this arm too. + opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--auto' ;; + # Bare `pi` is the documented primary launch (README.md:102); the project + # trust prompt approved once per clone is what makes the tracked + # .pi/extensions/*.ts auto-load (README.md:106), so a primary needs no + # explicit -e flag. tests/fm-pi-primary-live-e2e.test.sh:266 adds + # --approve --no-session --no-context-files --no-extensions with explicit + # -e paths, but those are that test's isolation scaffolding - it runs + # against a throwaway clone - not the verified primary form, so they are + # deliberately not copied here. This template carries no autonomy flag + # because pi has none to carry: SKILL.md:272 records that pi has no + # permission system, so the session is autonomous by construction. The + # header's CONSUMER OBLIGATION therefore covers this arm like every other - + # a pi primary runs without permission prompts too, it just gets there + # structurally rather than by a bypass flag. Nothing is missing here. + pi) printf '%s' 'pi __MODELFLAG____EFFORTFLAG__' ;; + # --trust is supervision-safety knowledge, not one-time setup trivia: + # without folder trust the primary turn-end guard FAILS OPEN + # (.agents/skills/harness-adapters/SKILL.md:345), and because trust is + # granted once per clone a fresh clone is exactly when its absence bites + # (README.md:105, docs/turnend-guard.md:63). The empirical primary launch + # is tests/fm-grok-continuity-live-e2e.test.sh:76, + # `grok --trust --always-approve --reasoning-effort low`, where + # --reasoning-effort is what __EFFORTFLAG__ resolves to; README.md:96 + # documents the same `grok --trust`. --always-approve auto-approves every + # tool execution (.agents/skills/harness-adapters/SKILL.md:304), so the + # header's CONSUMER OBLIGATION covers this arm: a consumer must tell the + # captain this session has no permission prompts. + grok) printf '%s' 'grok --trust --always-approve __MODELFLAG____EFFORTFLAG__' ;; + # kimi refuses rather than emitting an unsubstitutable command: README.md:61 + # lists only Claude Code, Grok, Pi, Codex, and OpenCode as verified primary + # harnesses (docs/configuration.md:177 defers that narrower set to README), + # and only bin/fm-spawn.sh can resolve __KIMIBIN__ (see the header above). + # A non-zero return is the same refusal an unverified adapter gets. + kimi) return 1 ;; + *) return 1 ;; + esac + return 0 + ;; + esac + # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here + case "$harness" in + # CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false disables claude's interactive + # predicted-next-prompt ghost text, which renders as dim/faint text inside an + # otherwise-empty composer and would otherwise read like real typed input when + # firstmate captures the pane (see the harness-adapters skill). It is a per-launch env + # prefix scoped to this firstmate-launched agent; it never touches the captain's + # global config. The CLI's --prompt-suggestions flag is print/SDK-mode only and + # does NOT suppress the interactive ghost text (verified empirically), so the env + # var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is + # the defense-in-depth backstop for any pane this flag cannot reach. + claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + codex) + if [ "$kind" = secondmate ]; then + printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + else + printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + fi + ;; + opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + pi) + if [ "$kind" = secondmate ]; then + printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + else + printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + fi + ;; + # grok (Grok Build TUI): a positional prompt starts the supervised interactive + # session. --always-approve auto-approves every tool execution (verified: the + # crewmate runs fully autonomously, no permission gate), which an unattended + # 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 by fm-spawn.sh (global hook + + # per-task pointer), so the template is identical for ship/scout/secondmate. + grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + # Kimi Code rejects a positional prompt, so it launches bare and receives + # only an absolute brief pointer after fm-spawn.sh's TUI readiness gate. + # Its turn-end signal is a globally configured Stop hook plus a guarded + # per-task worktree token, so no launch placeholder belongs here. + kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; + *) return 1 ;; + esac +} + +model_flag_for_harness() { + local harness=$1 model=$2 + [ -n "$model" ] && [ "$model" != default ] || return 0 + case "$harness" in + claude|codex|opencode|pi|grok|kimi) + printf -- '--model %s ' "$(shell_quote "$model")" + ;; + esac +} + +effort_flag_for_harness() { + local harness=$1 effort=$2 + [ -n "$effort" ] && [ "$effort" != default ] || return 0 + case "$harness" in + claude) + case "$effort" in + low|medium|high|xhigh|max) printf -- '--effort %s ' "$(shell_quote "$effort")" ;; + esac + ;; + codex) + # The installed codex config schema uses model_reasoning_effort, and the + # bundled model catalog advertises low|medium|high|xhigh. Omit max rather + # than passing an unsupported value. + case "$effort" in + low|medium|high|xhigh) printf -- '-c %s ' "$(shell_quote "model_reasoning_effort=\"$effort\"")" ;; + esac + ;; + grok) + # grok exposes both --effort and --reasoning-effort; firstmate's profile + # axis is the reasoning knob. As of grok 0.2.99, --reasoning-effort accepts + # only low|medium|high and rejects both xhigh and max, so omit those rather + # than passing a known-bad value. + case "$effort" in + low|medium|high) printf -- '--reasoning-effort %s ' "$(shell_quote "$effort")" ;; + esac + ;; + pi) + # Pi 0.80.6 accepts the full shared effort vocabulary, including max, through + # its --thinking flag. + case "$effort" in + low|medium|high|xhigh|max) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;; + esac + ;; + # opencode's interactive `opencode --prompt` launch has a verified --model + # flag but no verified effort flag. Its `opencode run --variant` flag belongs + # to a different, non-interactive launch mode, so fm-spawn does not pass it. + # kimi likewise has no reasoning-effort flag; the requested axis stays in + # task metadata but never reaches the launch command. + esac +} diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 00a2262ef4..90d13e5c90 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -92,15 +92,9 @@ # and scout 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: -# __BRIEF__ absolute path to data//brief.md -# __TURNEND__ absolute path to state/.turn-ended (for harnesses whose -# turn-end signal rides the launch command, e.g. codex -c notify=[...]) -# __PIEXT__ absolute path to state/.pi-ext.ts (pi turn-end extension, -# written by this script; outside the worktree to avoid pi's trust gate) -# __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home -# __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home -# __OPINPUT__ absolute path to the canonical operational-input encoder +# Launch templates live in launch_template() in bin/fm-launch-lib.sh, the single owner +# of every firstmate launch command; that library's header owns the placeholder +# contract this script substitutes before launch. # Verified per-harness turn-end hooks are installed automatically where enabled; some live outside the worktree. # Kimi uses one surgically installed Firstmate region in $HOME/.kimi-code/config.toml, # a firstmate-owned global hook and registry, and a gitignored per-task pointer. @@ -128,6 +122,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" SUB_HOME_MARKER=".fm-secondmate-home" +# shellcheck source=bin/fm-launch-lib.sh +. "$SCRIPT_DIR/fm-launch-lib.sh" # shellcheck source=bin/fm-ff-lib.sh . "$SCRIPT_DIR/fm-ff-lib.sh" # shellcheck source=bin/fm-wake-lib.sh @@ -410,54 +406,6 @@ else fi [ -z "$HARNESS_ARG" ] || ARG3=$HARNESS_ARG -# The verified launch command per adapter. The knowledge half of each adapter -# (busy signature, exit command, dialogs, quirks) lives in the harness-adapters skill. -launch_template() { - local harness=$1 kind=${2:-ship} - # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here - case "$harness" in - # CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false disables claude's interactive - # predicted-next-prompt ghost text, which renders as dim/faint text inside an - # otherwise-empty composer and would otherwise read like real typed input when - # firstmate captures the pane (see the harness-adapters skill). It is a per-launch env - # prefix scoped to this firstmate-launched agent; it never touches the captain's - # global config. The CLI's --prompt-suggestions flag is print/SDK-mode only and - # does NOT suppress the interactive ghost text (verified empirically), so the env - # var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is - # the defense-in-depth backstop for any pane this flag cannot reach. - claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; - codex) - if [ "$kind" = secondmate ]; then - printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' - else - printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' - fi - ;; - opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; - pi) - if [ "$kind" = secondmate ]; then - printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' - else - printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' - fi - ;; - # grok (Grok Build TUI): a positional prompt starts the supervised interactive - # session. --always-approve auto-approves every tool execution (verified: the - # crewmate runs fully autonomously, no permission gate), which an unattended - # 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. - grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; - # Kimi Code rejects a positional prompt, so it launches bare and receives - # only an absolute brief pointer after the TUI readiness gate below. - # Its turn-end signal is a globally configured Stop hook plus a guarded - # per-task worktree token, so no launch placeholder belongs here. - kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; - *) return 1 ;; - esac -} - case "$ARG3" in *' '*) # raw launch command (unverified-adapter escape hatch) LAUNCH=$ARG3 @@ -531,12 +479,6 @@ secondmate_registry_value() { printf '%s\n' "$value" } -shell_quote() { - printf "'" - printf '%s' "$1" | sed "s/'/'\\\\''/g" - printf "'" -} - resolve_kimi_binary() { local candidate dir fallback candidate=$(command -v kimi 2>/dev/null || true) @@ -561,57 +503,6 @@ resolve_kimi_binary() { return 1 } -model_flag_for_harness() { - local harness=$1 model=$2 - [ -n "$model" ] && [ "$model" != default ] || return 0 - case "$harness" in - claude|codex|opencode|pi|grok|kimi) - printf -- '--model %s ' "$(shell_quote "$model")" - ;; - esac -} - -effort_flag_for_harness() { - local harness=$1 effort=$2 - [ -n "$effort" ] && [ "$effort" != default ] || return 0 - case "$harness" in - claude) - case "$effort" in - low|medium|high|xhigh|max) printf -- '--effort %s ' "$(shell_quote "$effort")" ;; - esac - ;; - codex) - # The installed codex config schema uses model_reasoning_effort, and the - # bundled model catalog advertises low|medium|high|xhigh. Omit max rather - # than passing an unsupported value. - case "$effort" in - low|medium|high|xhigh) printf -- '-c %s ' "$(shell_quote "model_reasoning_effort=\"$effort\"")" ;; - esac - ;; - grok) - # grok exposes both --effort and --reasoning-effort; firstmate's profile - # axis is the reasoning knob. As of grok 0.2.99, --reasoning-effort accepts - # only low|medium|high and rejects both xhigh and max, so omit those rather - # than passing a known-bad value. - case "$effort" in - low|medium|high) printf -- '--reasoning-effort %s ' "$(shell_quote "$effort")" ;; - esac - ;; - pi) - # Pi 0.80.6 accepts the full shared effort vocabulary, including max, through - # its --thinking flag. - case "$effort" in - low|medium|high|xhigh|max) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;; - esac - ;; - # opencode's interactive `opencode --prompt` launch has a verified --model - # flag but no verified effort flag. Its `opencode run --variant` flag belongs - # to a different, non-interactive launch mode, so fm-spawn does not pass it. - # kimi likewise has no reasoning-effort flag; the requested axis stays in - # task metadata but never reaches the launch command. - esac -} - case "$LAUNCH" in *__KIMIBIN__*) KIMI_BIN=$(resolve_kimi_binary) || exit 1 diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index c90d759c0d..7350bcce74 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -122,7 +122,8 @@ family_for_basename() { fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ - fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-instruction-owners.test.sh|fm-lint.test.sh|\ + fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-instruction-owners.test.sh|\ + fm-launch-lib.test.sh|fm-lint.test.sh|\ fm-install-herdr.test.sh|fm-nm-test-contract.test.sh|fm-no-mistakes-ownership.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|fm-stow-contract.test.sh|\ @@ -668,7 +669,7 @@ families_for_changed_path() { bin/fm-x-*|bin/fm-check*) printf '%s\n' pr-forge ;; - bin/fm-spawn.sh|bin/fm-send.sh|bin/fm-harness.sh|\ + bin/fm-spawn.sh|bin/fm-launch-lib.sh|bin/fm-send.sh|bin/fm-harness.sh|\ bin/fm-peek.sh|bin/fm-composer*) printf '%s\n' backend-dispatch printf '%s\n' pure-contract-unit diff --git a/docs/configuration.md b/docs/configuration.md index 896b98ae31..ceb0a871da 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -177,7 +177,7 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa claude, codex, opencode, pi, grok, and kimi are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the narrower set supported for the primary session. 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). +The verified launch command templates have one owner, [`bin/fm-launch-lib.sh`](../bin/fm-launch-lib.sh); the remaining per-task launch mechanics live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh), which sources it. Enabled primary-session turn-end guard integrations are tracked as repo-level hook files and documented in [`docs/turnend-guard.md`](turnend-guard.md). Kimi remains outside the primary turn-end guard integrations; [`docs/turnend-guard.md`](turnend-guard.md#compatibility-limits) owns its separate captain-approved crew wake hook. Primary-session watcher wake protocols are rendered at session start by [`bin/fm-supervision-instructions.sh`](../bin/fm-supervision-instructions.sh) from [`docs/supervision-protocols/`](supervision-protocols/). diff --git a/docs/scripts.md b/docs/scripts.md index 6a10d1310a..9429c52f2b 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -39,6 +39,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block or the one-line repair instruction | | `fm-home-seed.sh` | Transactionally provision a secondmate home and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn crewmates, scouts, `id=repo` batches, and secondmates on the resolved harness and runtime backend | +| `fm-launch-lib.sh` | Single owner of every verified harness launch command for crewmate, scout, secondmate, and primary sessions | | `fm-backend.sh` | Runtime-backend selection, meta helpers, selector resolution, and operation dispatch | | `fm-backend-hometag-lib.sh` | Shared per-installation home-tag derivation for zellij tab and cmux workspace titles | | `fm-composer-lib.sh` | Single fleet-wide owner of composer-content classification for all backends | diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 74b469a619..b253c9610b 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -112,7 +112,7 @@ BASE_REF=$(resolve_base_ref) \ # tmux-only conformance run the tmux adapter's behavior is what is under test, # and that is unchanged by any later (e.g. non-tmux backend) addition to # fm-backend.sh's own dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-launch-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh" # A pull-request merge may add a new main-only dependency that the branch's older baseline does not have yet. OLD_BIN_OPTIONAL_SIBLINGS="fm-pending-reply-lib.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh" diff --git a/tests/fm-captain-translation-contract.test.sh b/tests/fm-captain-translation-contract.test.sh index cc7c75311e..88b2b66f6d 100755 --- a/tests/fm-captain-translation-contract.test.sh +++ b/tests/fm-captain-translation-contract.test.sh @@ -238,7 +238,7 @@ test_ahoy_scans_visible_history_for_open_decisions() { } test_ahoy_user_role_injections_share_one_marker() { - local daemon grok_guard opencode_guard opencode_watch pi_guard pi_watch owner sessionstart spawn + local daemon grok_guard opencode_guard opencode_watch pi_guard pi_watch owner sessionstart spawn launch_lib daemon=$(cat "$ROOT/bin/fm-supervise-daemon.sh") grok_guard=$(cat "$ROOT/bin/fm-turnend-guard-grok.sh") opencode_guard=$(cat "$ROOT/.opencode/plugins/fm-primary-turnend-guard.js") @@ -248,6 +248,8 @@ test_ahoy_user_role_injections_share_one_marker() { owner=$(cat "$ROOT/bin/fm-operational-input.sh") sessionstart=$(cat "$ROOT/bin/fm-sessionstart-nudge.sh") spawn=$(cat "$ROOT/bin/fm-spawn.sh") + # Launch templates live in their single owner, bin/fm-launch-lib.sh. + launch_lib=$(cat "$ROOT/bin/fm-launch-lib.sh") assert_contains "$owner" 'FM_OPERATIONAL_PREFIX="${FM_OPERATIONAL_MARK}FIRSTMATE_OP: "' \ "canonical owner lost the landed Ahoy prefix" @@ -269,9 +271,9 @@ test_ahoy_user_role_injections_share_one_marker() { "Pi guard does not retain its exact current kind" assert_contains "$pi_watch" '"watcher"' \ "Pi watcher does not retain its exact current kind" - assert_contains "$spawn" 'encode launch-brief' \ + assert_contains "$launch_lib" 'encode launch-brief' \ "cross-harness launches do not use the canonical launch-instruction kind" - for producer in "$daemon" "$grok_guard" "$opencode_guard" "$opencode_watch" "$pi_guard" "$pi_watch" "$sessionstart" "$spawn"; do + for producer in "$daemon" "$grok_guard" "$opencode_guard" "$opencode_watch" "$pi_guard" "$pi_watch" "$sessionstart" "$spawn" "$launch_lib"; do assert_not_contains "$producer" 'FIRSTMATE_OP: ' \ "a current producer copied the canonical marker grammar" done diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 9e0f450439..737a8ed8f5 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -14,9 +14,13 @@ PYTHON_BIN_DIR=$(dirname "$PYTHON_BIN") JQ_BIN=$(command -v jq) || fail "test needs jq" BASE_PATH=${FM_TEST_BASE_PATH:-$PYTHON_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin} +# Launch templates moved out of fm-spawn.sh into their single owner, +# bin/fm-launch-lib.sh; the byte pin follows the definitions. +LAUNCH_LIB="$ROOT/bin/fm-launch-lib.sh" + assert_source_line() { local line=$1 - grep -Fqx -- "$line" "$SPAWN" || fail "existing launch template changed: $line" + grep -Fqx -- "$line" "$LAUNCH_LIB" || fail "existing launch template changed: $line" } test_existing_launch_templates_are_byte_pinned() { @@ -27,7 +31,7 @@ test_existing_launch_templates_are_byte_pinned() { assert_source_line " printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" assert_source_line " printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" assert_source_line " grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__\"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - pass "fm-spawn: the five pre-existing adapters' launch templates stay byte-pinned" + pass "fm-launch-lib: the five pre-existing adapters' launch templates stay byte-pinned" } test_tracked_files_have_no_user_absolute_paths() { diff --git a/tests/fm-launch-lib.test.sh b/tests/fm-launch-lib.test.sh new file mode 100755 index 0000000000..7a262de93a --- /dev/null +++ b/tests/fm-launch-lib.test.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# tests/fm-launch-lib.test.sh - bin/fm-launch-lib.sh, the single owner of every +# firstmate launch command. +# +# Why this file is load-bearing: a hand-copied launch command has already +# drifted once, dropping claude's CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false +# prefix - the ghost-text suppression that keeps firstmate from reading +# predicted-prompt text as real typed input when it captures a pane. So this +# suite pins two things: +# +# 1. Every crewmate/scout/secondmate template composes exactly what it did +# before the extraction out of bin/fm-spawn.sh (behavior-preserving pin). +# 2. bin/fm-spawn.sh defines none of the three functions itself, so there is +# exactly one copy to keep verified. +# +# The `primary` kind (a firstmate PRIMARY session: no task, no worktree, no +# brief, no status file) is pinned here too, so the fleet launcher inherits the +# same verified commands instead of hand-writing them. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +LAUNCH_LIB="$ROOT/bin/fm-launch-lib.sh" +SPAWN="$ROOT/bin/fm-spawn.sh" + +# shellcheck source=/dev/null +. "$LAUNCH_LIB" + +HARNESSES=(claude codex opencode pi grok kimi) +# The harnesses README.md:61 lists as verified for a PRIMARY session. kimi is +# deliberately absent, so launch_template refuses it for kind=primary. +PRIMARY_HARNESSES=(claude codex opencode pi grok) + +# assert_template +assert_template() { + local kind=$1 harness=$2 expected=$3 got + got=$(launch_template "$harness" "$kind") \ + || fail "launch_template $harness $kind returned non-zero for a verified adapter" + [ "$got" = "$expected" ] \ + || fail "launch_template $harness $kind drifted: + expected: $expected + got: $got" +} + +# --- crewmate/scout templates: byte-identical to the pre-extraction commands -- + +test_ship_and_scout_templates_are_pinned() { + local kind + # shellcheck disable=SC2016 # single quotes are deliberate: these expand in the crewmate pane + for kind in ship scout; do + assert_template "$kind" claude 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" codex 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" opencode 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" pi 'pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" grok 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" kimi '__KIMIBIN__ __MODELFLAG__--auto' + done + pass "launch_template: ship and scout templates are unchanged for all six verified adapters" +} + +test_ship_is_the_default_kind() { + local h + for h in "${HARNESSES[@]}"; do + [ "$(launch_template "$h")" = "$(launch_template "$h" ship)" ] \ + || fail "launch_template $h with no kind must equal the ship template" + done + pass "launch_template: an omitted kind still means ship" +} + +test_secondmate_templates_are_pinned() { + # Only codex and pi differ from the ship shape: codex drops the per-task + # notify hook and pi points at the secondmate home's own primary extensions. + # shellcheck disable=SC2016 # single quotes are deliberate: these expand in the agent pane + assert_template secondmate codex 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + # shellcheck disable=SC2016 + assert_template secondmate pi 'pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + local h + for h in claude opencode grok kimi; do + [ "$(launch_template "$h" secondmate)" = "$(launch_template "$h" ship)" ] \ + || fail "launch_template $h secondmate must match its ship template" + done + pass "launch_template: secondmate templates are unchanged (codex and pi differ, the rest match ship)" +} + +# --- the primary kind ------------------------------------------------------- + +# Each primary shape gets its own test naming the evidence that fixes its flags, +# so an edit that drops a load-bearing flag fails here rather than reaching a +# reviewer. A primary template is NOT the crewmate command minus its brief; it is +# whatever this repo empirically verified for a briefless PRIMARY launch. + +test_primary_claude_template_is_pinned() { + # CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false is the ghost-text suppression a + # hand-copied command already dropped once. README.md:90 documents the primary + # launch as bare `claude`; the only in-repo launch carrying + # --dangerously-skip-permissions is the headless print-mode session at + # tests/fm-claude-stop-autoarm-live-e2e.test.sh:115, so this pin records the + # flag's presence rather than claiming an interactive primary verified it. + assert_template primary claude 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' + pass "launch_template: the claude primary template is pinned" +} + +test_primary_codex_template_is_pinned() { + # tests/fm-codex-continuity-live-e2e.test.sh:40 runs codex headlessly via + # `codex exec`, which is not evidence of the interactive primary TUI shape; + # --dangerously-bypass-approvals-and-sandbox carries over from the crewmate + # command and this pin holds it steady until a primary TUI launch verifies it. + assert_template primary codex 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox' + pass "launch_template: the codex primary template is pinned" +} + +test_primary_opencode_template_is_pinned() { + # tests/fm-opencode-primary-live-e2e.test.sh:256 and :310 both launch a primary + # opencode TUI as OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}' with + # `opencode --auto`. --prompt belongs to the crewmate, which has a brief to pass. + assert_template primary opencode 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--auto' + pass "launch_template: the opencode primary template is pinned" +} + +test_primary_pi_template_is_pinned() { + # README.md:102 documents the primary launch as bare `pi`, and README.md:106 + # records that the once-per-clone project trust prompt is what auto-loads the + # tracked .pi/extensions/*.ts. tests/fm-pi-primary-live-e2e.test.sh:266 adds + # --approve --no-session --no-context-files --no-extensions plus explicit -e + # paths, but that is the test's own isolation scaffolding against a throwaway + # clone, not the verified primary form, so it must not leak into this template. + assert_template primary pi 'pi __MODELFLAG____EFFORTFLAG__' + pass "launch_template: the pi primary template is pinned" +} + +test_primary_grok_template_is_pinned() { + # tests/fm-grok-continuity-live-e2e.test.sh:76 launches a primary grok as + # `grok --trust --always-approve --reasoning-effort low`, where + # --reasoning-effort is what __EFFORTFLAG__ resolves to; README.md:96 documents + # `grok --trust` too. --trust is load-bearing, not setup trivia: + # .agents/skills/harness-adapters/SKILL.md:345 records that without folder trust + # the primary turn-end guard fails open, README.md:105 and + # docs/turnend-guard.md:63 say the same, and trust being granted once per clone + # means a fresh clone is exactly when dropping it bites. + assert_template primary grok 'grok --trust --always-approve __MODELFLAG____EFFORTFLAG__' + pass "launch_template: the grok primary template is pinned, --trust included" +} + +test_primary_kimi_refuses() { + # README.md:61 lists only Claude Code, Grok, Pi, Codex, and OpenCode as verified + # primary harnesses (docs/configuration.md:177 defers that narrower set to + # README), and __KIMIBIN__ is resolvable by bin/fm-spawn.sh alone, which never + # launches a primary. Refusing beats handing back an unsubstitutable command. + launch_template kimi primary >/dev/null 2>&1 \ + && fail "launch_template kimi primary must refuse: kimi is not a verified primary harness and only fm-spawn.sh can resolve __KIMIBIN__" + [ -z "$(launch_template kimi primary 2>/dev/null)" ] \ + || fail "launch_template kimi primary must emit nothing when it refuses" + pass "launch_template: kimi has no primary template and refuses instead of emitting __KIMIBIN__" +} + +test_primary_kimi_refusal_leaves_the_crewmate_template_intact() { + # bin/fm-spawn.sh depends on the kimi crewmate command byte-for-byte. + local kind + for kind in ship scout secondmate; do + assert_template "$kind" kimi '__KIMIBIN__ __MODELFLAG__--auto' + done + pass "launch_template: the kimi crewmate template is unaffected by the primary refusal" +} + +test_primary_carries_no_task_scoped_placeholder() { + local h tpl + for h in "${PRIMARY_HARNESSES[@]}"; do + tpl=$(launch_template "$h" primary) + case "$tpl" in + *__BRIEF__*|*__OPINPUT__*|*__TURNEND__*|*__PIEXT__*|*__PITURNEND__*|*__PIWATCH__*) + fail "primary template for $h carries a task-scoped placeholder, but a primary has no task or brief: $tpl" + ;; + esac + done + pass "launch_template: no primary template references a brief, turn-end token, or task extension" +} + +test_primary_keeps_the_autonomy_and_ghost_text_knowledge() { + # The exact knowledge a hand-written launcher command has already lost once. + assert_contains "$(launch_template claude primary)" 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false' \ + "the claude primary template must keep the ghost-text suppression prefix" + assert_contains "$(launch_template claude primary)" '--dangerously-skip-permissions' \ + "the claude primary template must keep its autonomy flag" + assert_contains "$(launch_template codex primary)" '--dangerously-bypass-approvals-and-sandbox' \ + "the codex primary template must keep its autonomy flag" + assert_contains "$(launch_template grok primary)" '--always-approve' \ + "the grok primary template must keep its autonomy flag" + assert_contains "$(launch_template grok primary)" '--trust' \ + "the grok primary template must keep --trust, without which the primary turn-end guard fails open" + assert_contains "$(launch_template opencode primary)" '"permission":{"*":"allow"}' \ + "the opencode primary template must keep its permission config" + assert_contains "$(launch_template opencode primary)" '--auto' \ + "the opencode primary template must keep the verified briefless --auto form" + pass "launch_template: primary templates keep each adapter's verified autonomy and ghost-text knowledge" +} + +test_primary_and_ship_share_a_model_and_effort_surface() { + local h + for h in "${PRIMARY_HARNESSES[@]}"; do + assert_contains "$(launch_template "$h" primary)" '__MODELFLAG__' \ + "the $h primary template must accept the shared model flag" + case "$(launch_template "$h" ship)" in + *__EFFORTFLAG__*) + assert_contains "$(launch_template "$h" primary)" '__EFFORTFLAG__' \ + "the $h primary template must accept the effort flag its ship template accepts" + ;; + esac + done + pass "launch_template: primary templates expose the same model/effort placeholders as their ship templates" +} + +# --- the unverified-adapter guard ------------------------------------------- + +test_unknown_harness_returns_non_zero_for_every_kind() { + local kind + for kind in ship scout secondmate primary; do + launch_template not-a-harness "$kind" >/dev/null 2>&1 \ + && fail "launch_template must refuse an unverified adapter for kind=$kind" + done + pass "launch_template: an unverified adapter returns non-zero for every kind, including primary" +} + +test_unknown_kind_falls_back_to_the_crewmate_shape() { + # fm-spawn passes only ship|scout|secondmate; anything else must not silently + # produce a briefless primary command. + [ "$(launch_template claude bogus-kind)" = "$(launch_template claude ship)" ] \ + || fail "an unrecognized kind must keep the crewmate shape, never fall through to primary" + pass "launch_template: an unrecognized kind keeps the crewmate shape" +} + +# --- flag resolution -------------------------------------------------------- + +test_model_flag_covers_every_verified_adapter() { + local h + for h in "${HARNESSES[@]}"; do + [ "$(model_flag_for_harness "$h" opus)" = "--model 'opus' " ] \ + || fail "model_flag_for_harness $h opus drifted: $(model_flag_for_harness "$h" opus)" + done + [ -z "$(model_flag_for_harness not-a-harness opus)" ] \ + || fail "model_flag_for_harness must emit nothing for an unverified adapter" + pass "model_flag_for_harness: every verified adapter takes --model, quoted" +} + +test_model_flag_is_empty_when_unset_or_default() { + local h v + for h in "${HARNESSES[@]}"; do + for v in '' default; do + [ -z "$(model_flag_for_harness "$h" "$v")" ] \ + || fail "model_flag_for_harness $h '$v' must emit nothing" + done + done + pass "model_flag_for_harness: an unset or 'default' model emits no flag" +} + +test_effort_flag_per_harness_vocabulary() { + # Each adapter's verified effort flag and the exact vocabulary it accepts; + # values outside that vocabulary are omitted rather than passed through. + [ "$(effort_flag_for_harness claude xhigh)" = "--effort 'xhigh' " ] || fail "claude effort flag drifted" + [ "$(effort_flag_for_harness claude max)" = "--effort 'max' " ] || fail "claude must accept max" + [ "$(effort_flag_for_harness codex xhigh)" = "-c 'model_reasoning_effort=\"xhigh\"' " ] || fail "codex effort flag drifted" + [ -z "$(effort_flag_for_harness codex max)" ] || fail "codex must omit max, not pass an unsupported value" + [ "$(effort_flag_for_harness grok high)" = "--reasoning-effort 'high' " ] || fail "grok effort flag drifted" + [ -z "$(effort_flag_for_harness grok xhigh)" ] || fail "grok must omit xhigh" + [ -z "$(effort_flag_for_harness grok max)" ] || fail "grok must omit max" + [ "$(effort_flag_for_harness pi max)" = "--thinking 'max' " ] || fail "pi effort flag drifted" + [ -z "$(effort_flag_for_harness opencode high)" ] || fail "opencode has no verified effort flag" + [ -z "$(effort_flag_for_harness kimi high)" ] || fail "kimi has no verified effort flag" + pass "effort_flag_for_harness: each adapter's verified flag and vocabulary are unchanged" +} + +test_effort_flag_is_empty_when_unset_or_default() { + local h v + for h in "${HARNESSES[@]}"; do + for v in '' default; do + [ -z "$(effort_flag_for_harness "$h" "$v")" ] \ + || fail "effort_flag_for_harness $h '$v' must emit nothing" + done + done + pass "effort_flag_for_harness: an unset or 'default' effort emits no flag" +} + +test_flags_are_shell_quoted() { + # The composed command is evaluated by a shell in the target pane, so a value + # carrying a quote must not be able to break out of it. + [ "$(model_flag_for_harness claude "o'pus")" = "--model 'o'\\''pus' " ] \ + || fail "model_flag_for_harness must shell-quote a value containing a single quote" + pass "model_flag_for_harness: values are shell-quoted against injection" +} + +# --- one owner -------------------------------------------------------------- + +test_fm_spawn_defines_none_of_the_three_functions() { + local fn + for fn in launch_template model_flag_for_harness effort_flag_for_harness shell_quote; do + grep -qE "^$fn\(\) \{" "$SPAWN" \ + && fail "bin/fm-spawn.sh defines $fn again; bin/fm-launch-lib.sh is the single owner" + done + # shellcheck disable=SC2016 # matching fm-spawn.sh's literal source line, not expanding it + grep -Fq '. "$SCRIPT_DIR/fm-launch-lib.sh"' "$SPAWN" \ + || fail "bin/fm-spawn.sh must source bin/fm-launch-lib.sh" + pass "one owner: bin/fm-spawn.sh sources the library and redefines nothing" +} + +test_no_other_tracked_script_hand_writes_a_launch_command() { + # One marker per verified adapter's autonomy, permission, or ghost-text + # knowledge, so a hand-copied codex, opencode, grok, or kimi command is caught + # too and not just claude's. Each pattern must still match the library itself; + # a pattern that matches nothing would pass this guard while checking nothing. + # + # The two --auto markers are anchored to the binary they belong to. A bare + # --auto would also match unrelated legitimate flags - `gh pr merge --auto` in + # bin/fm-pr-*.sh is the obvious one - and fail with a misleading "a launch + # command is hand-written" message. Double-quoted so the single quote inside + # the character class stays literal; it stops the match at the template's own + # quoting so the pattern cannot run past the end of a launch string. + local markers=( + 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION' + '--dangerously-skip-permissions' + '--dangerously-bypass-approvals-and-sandbox' + 'OPENCODE_CONFIG_CONTENT=' + '--always-approve' + "opencode [^']*--auto([^-a-z]|$)" + "(kimi|__KIMIBIN__)[^']*--auto([^-a-z]|$)" + ) + local marker owners status matches + for marker in "${markers[@]}"; do + owners=$(git -C "$ROOT" grep -lE -e "$marker" -- bin) + status=$? + [ "$status" -le 1 ] \ + || fail "the one-owner guard could not run git grep for '$marker' (git grep exited $status)" + case $'\n'"$owners"$'\n' in + *$'\n'bin/fm-launch-lib.sh$'\n'*) ;; + *) fail "the one-owner guard's '$marker' pattern no longer matches bin/fm-launch-lib.sh, so it checks nothing" ;; + esac + matches=$(printf '%s\n' "$owners" | grep -v '^bin/fm-launch-lib.sh$' | grep -v '^$') + [ -z "$matches" ] \ + || fail "a launch command is hand-written outside bin/fm-launch-lib.sh ('$marker'): $matches" + done + pass "one owner: no other script under bin/ hand-writes any verified harness launch command" +} + +test_ship_and_scout_templates_are_pinned +test_ship_is_the_default_kind +test_secondmate_templates_are_pinned +test_primary_claude_template_is_pinned +test_primary_codex_template_is_pinned +test_primary_opencode_template_is_pinned +test_primary_pi_template_is_pinned +test_primary_grok_template_is_pinned +test_primary_kimi_refuses +test_primary_kimi_refusal_leaves_the_crewmate_template_intact +test_primary_carries_no_task_scoped_placeholder +test_primary_keeps_the_autonomy_and_ghost_text_knowledge +test_primary_and_ship_share_a_model_and_effort_surface +test_unknown_harness_returns_non_zero_for_every_kind +test_unknown_kind_falls_back_to_the_crewmate_shape +test_model_flag_covers_every_verified_adapter +test_model_flag_is_empty_when_unset_or_default +test_effort_flag_per_harness_vocabulary +test_effort_flag_is_empty_when_unset_or_default +test_flags_are_shell_quoted +test_fm_spawn_defines_none_of_the_three_functions +test_no_other_tracked_script_hand_writes_a_launch_command diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 7347a75ab7..e02f3a2b24 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -103,9 +103,12 @@ test_tracked_extension_present_and_self_hashing() { } test_spawn_template_mentions_pi_watch_placeholder() { - local text + local text template text=$(cat "$ROOT/bin/fm-spawn.sh") - assert_contains "$text" "-e __PITURNEND__ -e __PIWATCH__" "Pi secondmate launch template does not include both primary extensions" + # The template itself lives in bin/fm-launch-lib.sh, the single owner of every + # launch command; fm-spawn.sh only substitutes its placeholders. + template=$(cat "$ROOT/bin/fm-launch-lib.sh") + assert_contains "$template" "-e __PITURNEND__ -e __PIWATCH__" "Pi secondmate launch template does not include both primary extensions" assert_contains "$text" "\$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts" "fm-spawn does not point the Pi secondmate watch placeholder at the tracked extension" assert_not_contains "$text" "fm-pi-watch-extension.sh" "fm-spawn should no longer generate the Pi watch extension before launch" assert_contains "$text" "__PITURNEND__" "fm-spawn does not replace the Pi turn-end guard extension placeholder"