diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index aba6e3fb00..ecd7a18d04 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -84,7 +84,7 @@ The daemon constructs every current injection as the `away-supervisor` kind owne The bare `FM_INJECT_MARK` form remains accepted for legacy daemon escalations during rollout. U+2063 has no normal keyboard keystroke and survives terminal transport as UTF-8 text. This is how firstmate tells a daemon escalation apart from a real message in the same pane. -The operational prefix travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, pi-signed, grok, and kimi. +The operational prefix travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, pi-signed, grok, kimi, and devin. ## Busy-guard and composer guard diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index d8d50b07b4..5991004aec 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -13,7 +13,7 @@ It does not replace `AGENTS.md`, `docs/orca-backend.md`, or `harness-adapters`. Orca is a runtime backend, not an agent harness. The runtime backend owns the task endpoint and, for Orca, the task worktree. -The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, or `kimi`. +The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, or `devin`. Load `harness-adapters` for harness-specific launch, interrupt, resume, trust-dialog, and skill-invocation facts. Implementation details, metadata fields, teardown guarantees, and limitations live in `docs/orca-backend.md`. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 093b3f689c..f580b82934 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -1,6 +1,6 @@ --- name: harness-adapters -description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, and kimi. +description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, kimi, and devin. user-invocable: false metadata: internal: true @@ -53,8 +53,8 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. ## Primary turn-end guard -The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` have empirically validated hook paths for the "no turn ends blind" guard. -`claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. +The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `devin` have empirically validated hook paths for the "no turn ends blind" guard. +`claude`, `codex`, and `devin` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. `opencode`, `pi`, and `pi-signed` expose passive lifecycle callbacks and force one bounded follow-up when the shared predicate blocks. Grok selects native blocking or its pre-native bounded resume fallback from the exact running Stop payload; [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns that contract. Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals. @@ -64,8 +64,8 @@ When changing any primary turn-end hook, validate the real harness behavior in a ## Primary pre-arm (PreToolUse) seatbelt -The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` also have wired PreToolUse-equivalent hooks that deny a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. -`claude` and `codex` block directly through PreToolUse hooks; `grok` blocks the same way but requires every `$VAR` reference in its hook `command` string to carry an inline `:-default` or it fails to launch the hook entirely. +The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `devin` also have wired PreToolUse-equivalent hooks that deny a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. +`claude`, `codex`, and `devin` block directly through PreToolUse hooks; `grok` blocks the same way but requires every `$VAR` reference in its hook `command` string to carry an inline `:-default` or it fails to launch the hook entirely. `opencode`, `pi`, and `pi-signed` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. The exact hook files, commands, output-shaping quirks (Claude Code only honors the deny when stdout is empty), and validation transcripts are owned by `docs/arm-pretool-check.md`. When changing any watcher-arm PreToolUse hook, validate the real harness behavior in a scratch project before trusting it, then update that doc. @@ -93,6 +93,7 @@ Full mechanics, scoping, and fail-open behavior live in `docs/sessionstart-nudge - `opencode`: verified on 1.17.18; `session.created` plus `client.session.promptAsync` starts the nudge turn in the TUI, while `opencode run` remains fail-open headless. - `pi` and `pi-signed`: verified native `session_start`; the existing primary extension handles `startup`, `new`, and `resume` and uses `pi.sendMessage` to inject context without racing a positional launch prompt. - `grok`: the 0.2.103 project `SessionStart` event fires with `source=new`, but stdout does not reach model context; the tracked project hook remains fail-open, and a global token-guarded fallback requires a captain decision. +- `devin`: Devin CLI 3000.3.22 did not deliver a unique tracked `SessionStart` `add_context` probe to model context; keep the tracked hook configured, but treat native session-start delivery as unverified until a CLI version implements it. ## Primary watcher supervision @@ -127,6 +128,7 @@ The supported launch-profile flags below are verified locally; each row records | pi / pi-signed | `--model ` | `--thinking ` | Verified 2026-07-27 on Pi and pi-signed 0.82.0. Both expose the same accepted thinking levels and completed the same model-qualified max-thinking smoke. | | 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. | | kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | +| devin | `--model ` | none | Verified on Devin CLI 3000.1.27. The interactive CLI exposes no effort flag. | The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. `bin/fm-auth-preflight.sh` enforces that split deterministically, resolving a tuple's authentication surface from quota-axi's emitted auth sources rather than from a harness or model name; use it instead of reasoning about which credential store a tuple reads. @@ -162,6 +164,7 @@ Natural language is acceptable if uncertain. - pi and pi-signed: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. - grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) handles this through the structural composer reader; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. - kimi: `/`, for example `/no-mistakes`. +- devin: `/`. Devin CLI exposes skills as slash commands and discovers repository skills under `.devin/skills/`; use natural language if a specific installed skill is not listed. ## Submission acknowledgement hazards @@ -169,6 +172,37 @@ A send or key action reporting success is not proof that the intended action hap OpenCode can accept and queue an Enter while leaving text visible, Grok can consume Enter in its slash popup without submitting, and Kimi can silently drop a message sent before readiness even though the send returns success. The shared symptom is a healthy-looking pane with no work in progress, so each adapter must verify the observable postcondition that is specific to its TUI. +## devin (VERIFIED 2026-07-13, Devin CLI 3000.1.27) + +| Fact | Value | +|---|---| +| Busy-pane signature | `esc to interrupt` | +| Exit command | `/exit` | +| Interrupt | single Escape | +| Skill invocation | `/` | +| Autonomy | `--permission-mode dangerous` | +| Process name | `devin` | +| Resume | `devin --resume ` or `devin --continue` | + +Launch with `DEVIN_CLI=1 devin --permission-mode dangerous --respect-workspace-trust true --prompt-file `. +Firstmate-launched crewmates add `--config ` containing the native user configuration plus their task Stop hook, without overwriting the user's file. +The native user configuration accepts Devin's supported JSONC syntax, including line comments, block comments, trailing commas, and comment-like string content; Firstmate still requires the config root, `read_config_from`, `hooks`, and `hooks.Stop` values to have their documented object or array shapes before composing the state-owned copy. +That state-owned config disables Claude compatibility import so Devin has exactly one harness-owned Stop path. +Devin loads the repository configuration through its native project layer alongside that override, so project settings and hooks are not copied or applied twice. +Secondmates omit that override and load the tracked primary hooks from their Firstmate home. + +Devin automatically loads repository `.devin/config.json`. +Its shell tool is named `exec`, and the `PreToolUse` payload carries the exact command at `.tool_input.command`. +Its native `Stop` hook fires at every completed turn and accepts the Claude-compatible command-hook schema. +The tracked primary adapter uses direct blocking hooks for the watcher-arm seatbelt, cd guard, and turn-end guard. + +The primary supervision protocol uses bounded foreground checkpoints. +No Devin background-task completion auto-wake contract has been verified, so do not substitute Claude or Grok background-notify supervision. + +The first launch may print the logged-in account and organization before the TUI. +For a primary session launched normally, approve the workspace-trust prompt once per clone so repository hooks load. +Firstmate keeps workspace trust enabled and relies on Devin's persisted once-per-worktree approval instead of bypassing that gate. + ## claude (VERIFIED; busy-state hooks live-verified 2026-07-28 on Claude Code 2.1.220) | Fact | Value | diff --git a/.devin/config.json b/.devin/config.json new file mode 100644 index 0000000000..beac718898 --- /dev/null +++ b/.devin/config.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "read_config_from": { + "claude": false + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc 'root=${DEVIN_PROJECT_DIR:-}; if [ -z \"$root\" ]; then root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0; fi; [ -f \"$root/.devin/config.json\" ] && [ -x \"$root/bin/fm-sessionstart-nudge.sh\" ] || exit 0; nudge=$(\"$root/bin/fm-sessionstart-nudge.sh\") || exit 0; [ -n \"$nudge\" ] || exit 0; node -e \"process.stdout.write(JSON.stringify({add_context: process.argv[1]}))\" \"$nudge\"'" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "exec", + "hooks": [ + { + "type": "command", + "command": "bash -lc 'root=${DEVIN_PROJECT_DIR:-}; if [ -z \"$root\" ]; then root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0; fi; [ -f \"$root/.devin/config.json\" ] && [ -x \"$root/bin/fm-arm-pretool-check.sh\" ] || exit 0; exec \"$root/bin/fm-arm-pretool-check.sh\" --claude'" + }, + { + "type": "command", + "command": "bash -lc 'root=${DEVIN_PROJECT_DIR:-}; if [ -z \"$root\" ]; then root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0; fi; [ -f \"$root/.devin/config.json\" ] && [ -x \"$root/bin/fm-cd-pretool-check.sh\" ] || exit 0; exec \"$root/bin/fm-cd-pretool-check.sh\" --claude'" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc 'root=${DEVIN_PROJECT_DIR:-}; if [ -z \"$root\" ]; then root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0; fi; [ -f \"$root/.devin/config.json\" ] && [ -x \"$root/bin/fm-turnend-guard.sh\" ] || exit 0; exec \"$root/bin/fm-turnend-guard.sh\"'" + } + ] + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index e09502cb05..1193f437e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti ## 4. Harness and runtime dispatch Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. -The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `kimi`; never dispatch on an unverified adapter. +The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, and `devin`; 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. diff --git a/README.md b/README.md index ac54cf7025..44ec56ee28 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m ### Requirements -- A verified primary agent harness: Claude Code, Grok, Pi, `pi-signed`, Codex, or OpenCode. +- A verified primary agent harness: Claude Code, Grok, Pi, `pi-signed`, Codex, OpenCode, or Devin CLI. - Git and the GitHub CLI, authenticated through `gh auth login`. - The CLI and dependencies for your selected runtime backend; tmux is the reference default. @@ -72,7 +72,7 @@ Claude Code uses a tracked Stop hook for tokenless watcher re-arm and rewake, Gr All three have verified turn-end guard paths when launched with their documented setup. Pick whichever one matches your subscription and workflow. -Codex and OpenCode are also verified and supported as primary harnesses; Codex uses bounded foreground checkpoints, and OpenCode uses a TUI plugin, so both carry more harness-specific supervision tradeoffs than the three co-primaries. +Codex, OpenCode, and Devin CLI are also verified and supported as primary harnesses; Codex and Devin use bounded foreground checkpoints, and OpenCode uses a TUI plugin, so all three carry more harness-specific supervision tradeoffs than the three co-primaries. ### Install and launch @@ -111,6 +111,18 @@ The hidden operational inputs remain ordinary user-role messages with unchanged The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. [Calm's current behavior and supported limits](docs/calm.md) are separate from its [version-scoped maintainer evidence](docs/calm-mode-feasibility.md). +The other verified primary harnesses launch normally from the repository root: + +```sh +codex +opencode +devin +``` + +Install Devin CLI with Cognition's official installer when needed: `curl -fsSL https://cli.devin.ai/install.sh | bash`. +Approve Devin's workspace-trust prompt once per clone so it loads the tracked `.devin/config.json` safety hooks. +Devin uses bounded foreground supervision checkpoints. + ### Talk to it ```sh @@ -165,7 +177,7 @@ Full architecture - the supervision engine, worktree isolation, secondmates, dis ## Built-in skills 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`. +Claude, Grok, and Devin use the slash form shown here; Codex uses the same names with `$`, such as `$afk`. | Skill | What it does | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | @@ -210,7 +222,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for GitLab merge watching on arbitrary instances. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits. - [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations. -- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, and unknown harness fallback. +- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, Devin, OpenCode, Pi and `pi-signed`, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [docs/documentation-audiences.md](docs/documentation-audiences.md) - documentation audiences and the machine-checked placement boundary. - [`AGENTS.md`](AGENTS.md) - the distro's always-loaded operating contract and routing index for conditional procedures. diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index f8da21bf0d..24f07dabdc 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -192,11 +192,15 @@ fm_backend_tmux_agent_state() { # return 0 } comm=${comm#-} + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + # shellcheck source=bin/fm-harness-process.sh + . "$script_dir/fm-harness-process.sh" case "$comm" in - *claude*|*codex*|*opencode*|*grok*|*kimi*|pi|pi-signed|pi-launcher|Pi) printf 'alive' ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'dead' ;; '') printf 'unreadable' ;; - *) printf 'ambiguous' ;; + *) + if fm_harness_process_name "$comm" '' >/dev/null; then printf 'alive'; else printf 'ambiguous'; fi + ;; esac } diff --git a/bin/fm-arm-pretool-check.sh b/bin/fm-arm-pretool-check.sh index 6ac8941b95..89d9a9ca6e 100755 --- a/bin/fm-arm-pretool-check.sh +++ b/bin/fm-arm-pretool-check.sh @@ -15,7 +15,7 @@ # bin/fm-arm-pretool-check.sh --command '' [--background true|false] # # Stdin mode extracts .toolInput.command for Grok or .tool_input.command for -# Claude and Codex. +# Claude, Codex, and Devin. # CLI mode is used by OpenCode and Pi after their adapters extract the exact # command string. # --background remains accepted for compatibility, but harness-native tracked @@ -44,7 +44,7 @@ usage() { Usage: fm-arm-pretool-check.sh [--command ] [--background true|false] [--claude] With no --command, reads a PreToolUse-style JSON payload on stdin (Grok -toolInput.command, or Claude/Codex tool_input.command). +toolInput.command, or Claude/Codex/Devin tool_input.command). Exits 0 to allow and 2 to deny. The deny reason is written to stderr, with a Grok decision object on stdout unless --claude is supplied. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 0ac06b162f..2c7a61eb12 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -450,7 +450,7 @@ secondmate_liveness_sweep() { [ -n "$target" ] || target="$window" agent_state=$(fm_backend_agent_state "$backend" "$target" 2>/dev/null) || agent_state=unreadable case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi) ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|devin) ;; *) case "$agent_state" in dead|missing) agent_state=unverified-harness ;; esac ;; @@ -734,7 +734,7 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi"] | index($h); + def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi","devin"] | index($h); def effort_ok($h; $e): if $e == null then true elif ($e | type) != "string" then false @@ -742,7 +742,7 @@ crew_dispatch_validate() { elif $h == "codex" then (["low","medium","high","xhigh"] | index($e)) elif $h == "grok" then (["low","medium","high"] | index($e)) elif $h == "pi" or $h == "pi-signed" then (["low","medium","high","xhigh","max"] | index($e)) - elif $h == "opencode" or $h == "kimi" then false + elif $h == "opencode" or $h == "kimi" or $h == "devin" then false else true end; def profiles($value): diff --git a/bin/fm-cd-pretool-check.sh b/bin/fm-cd-pretool-check.sh index a57ba9d2ab..281a4a9b09 100755 --- a/bin/fm-cd-pretool-check.sh +++ b/bin/fm-cd-pretool-check.sh @@ -17,7 +17,7 @@ # bin/fm-cd-pretool-check.sh --command '' # # Stdin mode extracts .toolInput.command for Grok or .tool_input.command for -# Claude and Codex. CLI mode is used by OpenCode and Pi after their adapters +# Claude, Codex, and Devin. CLI mode is used by OpenCode and Pi after their adapters # extract the exact command string. # # Exit/output contract (identical shape to bin/fm-arm-pretool-check.sh): @@ -44,7 +44,7 @@ usage() { Usage: fm-cd-pretool-check.sh [--command ] [--claude] With no --command, reads a PreToolUse-style JSON payload on stdin (Grok -toolInput.command, or Claude/Codex tool_input.command). +toolInput.command, or Claude/Codex/Devin tool_input.command). Fires only in the real primary firstmate checkout; it is a silent no-op in a crewmate/scout task worktree or any non-firstmate repo. Exits 0 to allow and 2 to deny a persistent top-level cwd change. diff --git a/bin/fm-harness-process.sh b/bin/fm-harness-process.sh new file mode 100755 index 0000000000..ad5056eedb --- /dev/null +++ b/bin/fm-harness-process.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Shared process-shape classification for verified harness adapters. + +fm_harness_executable_name() { # + local name=${1##*/} + name=${name#-} + case "$name" in + claude|claude-code) printf 'claude\n' ;; + codex) printf 'codex\n' ;; + opencode) printf 'opencode\n' ;; + grok) printf 'grok\n' ;; + kimi) printf 'kimi\n' ;; + devin) printf 'devin\n' ;; + pi|pi-signed|pi-launcher|Pi) printf 'pi\n' ;; + *) return 1 ;; + esac +} + +fm_harness_script_name() { # + local path=$1 component stem + path=${path#\"} + path=${path%\"} + path=${path#\'} + path=${path%\'} + while [ -n "$path" ]; do + component=${path%%/*} + path=${path#*/} + [ "$component" = "$path" ] && path= + stem=${component%%.*} + case "$component:$stem" in + claude-code:*|claude:claude) printf 'claude\n'; return 0 ;; + codex:codex|*:codex) printf 'codex\n'; return 0 ;; + opencode:opencode|*:opencode) printf 'opencode\n'; return 0 ;; + grok:grok|*:grok) printf 'grok\n'; return 0 ;; + kimi:kimi|*:kimi) printf 'kimi\n'; return 0 ;; + devin:devin|*:devin) printf 'devin\n'; return 0 ;; + pi:pi|pi-signed:*|pi-launcher:*|Pi:*|*:pi) printf 'pi\n'; return 0 ;; + esac + done + return 1 +} + +fm_harness_process_name() { # + local comm=$1 args=${2:-} executable script + executable=${comm##*/} + fm_harness_executable_name "$executable" && return 0 + case "$executable" in + node|node[0-9]*|nodejs|python|python[0-9]*) + script=${args#* } + [ "$script" != "$args" ] || return 1 + case "$script" in + \"*) script=${script#\"}; script=${script%%\"*} ;; + \'*) script=${script#\'}; script=${script%%\'*} ;; + *) script=${script%% *} ;; + esac + fm_harness_script_name "$script" + ;; + *) return 1 ;; + esac +} diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 824b95804d..bd84cc09ab 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Detect the agent harness this process tree runs on. -# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|pi-signed|grok|kimi|unknown +# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|pi-signed|grok|kimi|devin|unknown # fm-harness.sh crew print the effective CREWMATE harness # (config/crew-harness; "default" resolves to own) # fm-harness.sh secondmate print the harness the PRIMARY uses to launch @@ -26,15 +26,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +# shellcheck source=bin/fm-harness-process.sh +. "$SCRIPT_DIR/fm-harness-process.sh" detect_own() { # Layer 1: environment markers for verified harnesses. # Keep marker detection before ancestry detection as an explicit precedence rule. - # Only claude, pi, and grok set verified markers of their own; codex, opencode, - # and kimi are markerless, so a foreign marker retained in a terminal + # Claude, pi, grok, and Devin set verified markers of their own; codex, + # opencode, and kimi are markerless, so a foreign marker retained in a terminal # multiplexer's stored environment can silently misidentify one of them before # ancestry is consulted. This is a precedence hazard, not evidence that # CLAUDECODE inheritance into a kimi child was observed; it was not observed. + [ "${DEVIN_CLI:-}" = "1" ] && { echo devin; return; } [ "${CLAUDECODE:-}" = "1" ] && { echo claude; return; } if [ "${PI_CODING_AGENT:-}" = "true" ]; then if [ "${FM_PI_HARNESS:-}" = pi-signed ]; then echo pi-signed; else echo pi; fi @@ -48,25 +51,8 @@ detect_own() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break - case "$(basename -- "$comm")" in - *claude*) echo claude; return ;; - *codex*) echo codex; return ;; - *opencode*) echo opencode; return ;; - *grok*) echo grok; return ;; - kimi) echo kimi; return ;; - pi-signed) echo pi; return ;; - pi) echo pi; return ;; - node*|python*) - # Bare interpreter: match the harness name in its script path. - args=$(ps -o args= -p "$pid" 2>/dev/null) - case "$args" in - *claude*) echo claude; return ;; - *codex*) echo codex; return ;; - *opencode*) echo opencode; return ;; - *grok*) echo grok; return ;; - *" pi "*|*/pi) echo pi; return ;; - esac ;; - esac + args=$(ps -o args= -p "$pid" 2>/dev/null) + fm_harness_process_name "$comm" "$args" && return pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') if [ -z "$pid" ] || [ "$pid" -le 1 ]; then break diff --git a/bin/fm-herdr-lab.sh b/bin/fm-herdr-lab.sh index f8ea014c6b..6ec65aa7dd 100755 --- a/bin/fm-herdr-lab.sh +++ b/bin/fm-herdr-lab.sh @@ -21,7 +21,7 @@ # delete is available only through teardown. # Both paths perform a fresh refuse-default check immediately before each # destructive call. -# Provision records the running default session as a fleet-state tripwire and +# Provision records the default-session baseline as a fleet-state tripwire and # teardown requires that record to be identical afterward. set -u @@ -65,14 +65,17 @@ fm_herdr_lab_fleet_state() { # return 1 } snapshot=$(printf '%s' "$sessions" | jq -c ' - [.sessions[]? | select(.default == true)] - | if length == 1 and .[0].name == "default" and .[0].running == true - then .[0] | {name, default, running, socket_path} - else empty - end + if (.sessions | type) != "array" then empty + else [.sessions[] | select(.default == true)] + | if length == 0 then {default_session: null} + elif length == 1 and .[0].name == "default" and .[0].running == true + then {default_session: (.[0] | {name, default, running, socket_path})} + else empty + end + end ' 2>/dev/null) [ -n "$snapshot" ] || { - fm_herdr_lab_error "fleet-state tripwire requires exactly one running default session" + fm_herdr_lab_error "fleet-state tripwire requires either no default session or exactly one running default session" return 1 } printf '%s\n' "$snapshot" diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 8343a8efd9..650a6086e4 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -8,8 +8,9 @@ # lock-owning primary session before it may arm or rewake. # This file is sourced by scripts and has no side effects on source. -# Known harness command names; extend when a new adapter is verified. -FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$|^pi-signed$' +FM_SESSION_LOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-harness-process.sh +. "$FM_SESSION_LOCK_LIB_DIR/fm-harness-process.sh" # Walk the current process ancestry (up to 16 hops) and print a harness pid. # For every harness except Claude, the first match wins (innermost pid), which @@ -27,29 +28,14 @@ FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$|^pi-signed$' # as long as the session, unlike the transient subshell pid of any one tool # call. fm_harness_ancestry_pid() { - local pid=$$ comm args best='' bc extending=0 hit=0 is_claude=0 + local pid=$$ comm args best='' harness extending=0 for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break args=$(ps -o args= -p "$pid" 2>/dev/null) - bc=$(basename -- "$comm") - hit=0; is_claude=0 - if printf '%s' "$bc" | grep -qE "$FM_HARNESS_RE"; then - hit=1 - case "$bc" in *claude*) is_claude=1 ;; esac - else - # Bare interpreter (e.g. node): match the harness name in its script path. - case "$comm" in - *node*|*python*) - if printf '%s' "$args" | grep -qE "$FM_HARNESS_RE"; then - hit=1 - case "$args" in *claude*) is_claude=1 ;; esac - fi - ;; - esac - fi - if [ "$hit" -eq 1 ]; then + harness=$(fm_harness_process_name "$comm" "$args" 2>/dev/null) || harness= + if [ -n "$harness" ]; then best="$pid" - if [ "$is_claude" -eq 1 ]; then + if [ "$harness" = claude ]; then extending=1 else break @@ -69,16 +55,8 @@ fm_harness_pid_alive() { local pid=$1 comm args kill -0 "$pid" 2>/dev/null || return 1 comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - if printf '%s' "$(basename -- "$comm")" | grep -qE "$FM_HARNESS_RE"; then - return 0 - fi - case "$comm" in - *node*|*python*) - args=$(ps -o args= -p "$pid" 2>/dev/null) - printf '%s' "$args" | grep -qE "$FM_HARNESS_RE" - ;; - *) return 1 ;; - esac + args=$(ps -o args= -p "$pid" 2>/dev/null) || return 1 + fm_harness_process_name "$comm" "$args" >/dev/null } # True when state dir $1 holds a session lock whose pid is the harness ancestor diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index ec485fd241..d9e5f61c43 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -68,7 +68,7 @@ # 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|pi-signed|grok|kimi) +# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|pi-signed|grok|kimi|devin) # overrides it for this spawn (either kind). A non-flag string containing # whitespace is treated as a RAW launch command - the escape hatch for verifying # new adapters. pi-signed launches that exact executable name from PATH and @@ -114,6 +114,8 @@ # 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. +# __DEVINCONFIG__ absolute path to state/.devin-config.json (user config plus task Stop hook) +# 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= @@ -423,7 +425,7 @@ FIRSTMATE_HOME= if [ "$KIND" = secondmate ]; then case "${POS[1]:-}" in - ''|claude|codex|opencode|pi|pi-signed|grok|kimi) + ''|claude|codex|opencode|pi|pi-signed|grok|kimi|devin) ARG3=${POS[1]:-} ;; *' '*) @@ -489,6 +491,17 @@ launch_template() { # 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' ;; + # Devin CLI: --prompt-file avoids shell interpolation of the brief, dangerous + # is the unattended equivalent of the other adapters' bypass modes, workspace + # trust remains enabled so each worktree must be approved once, and the per-task + # config carries the Stop turn-end hook without touching project config. + devin) + if [ "$kind" = secondmate ]; then + printf '%s' 'DEVIN_CLI=1 devin --permission-mode dangerous --respect-workspace-trust true __MODELFLAG__--prompt-file __BRIEF__' + else + printf '%s' 'DEVIN_CLI=1 devin --config __DEVINCONFIG__ --permission-mode dangerous --respect-workspace-trust true __MODELFLAG__--prompt-file __BRIEF__' + fi + ;; *) return 1 ;; esac } @@ -612,7 +625,7 @@ model_flag_for_harness() { local harness=$1 model=$2 [ -n "$model" ] && [ "$model" != default ] || return 0 case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi) + claude|codex|opencode|pi|pi-signed|grok|kimi|devin) printf -- '--model %s ' "$(shell_quote "$model")" ;; esac @@ -673,7 +686,7 @@ case "$LAUNCH" in esac json_escape() { - printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' + node -e 'process.stdout.write(JSON.stringify(process.argv[1]).slice(1, -1))' "$1" } resolved_existing_dir() { @@ -1520,6 +1533,12 @@ EOF # wiring is installed. The turn-end NOTIFICATION marker still rides # the launch command via -c notify=[...] and __TURNEND__. ;; + devin*) + # Crewmates use an isolated state-owned merge of user config and the task + # Stop hook. Devin continues to load repository config through its native + # project layer, so neither source file is overwritten or applied twice. + # Secondmates omit the override and load their tracked primary hooks natively. + ;; grok*) # grok fires a Stop hook at every turn boundary (verified, grok 0.2.73), the # clean equivalent of codex's notify= and pi's turn_end. But grok only loads @@ -1587,7 +1606,7 @@ EOF esac fi -# Per-project delivery mode + yolo flag (bin/fm-project-mode.sh; the project-management skill and AGENTS.md task lifecycle). +# 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. @@ -1648,12 +1667,126 @@ META_WINDOW=$T } > "$STATE/$ID.meta" [ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0 +DEVIN_CONFIG="$STATE/$ID.devin-config.json" +if [ "$HARNESS" = devin ]; then + if [ "$KIND" != secondmate ]; then + turnend_json=$(json_escape "touch $(shell_quote "$TURNEND")") + devin_config_home=${XDG_CONFIG_HOME:-${HOME:+$HOME/.config}} + user_devin_config="$devin_config_home/devin/config.json" + rm -f "$DEVIN_CONFIG" + if [ -n "$devin_config_home" ] && [ -f "$user_devin_config" ]; then + (umask 077; node - "$user_devin_config" "touch $(shell_quote "$TURNEND")" > "$DEVIN_CONFIG") <<'NODE' +const fs = require("node:fs"); +const path = process.argv[2]; +function parseJsonc(source) { + let withoutComments = ""; + let inString = false; + for (let i = 0; i < source.length; i += 1) { + const char = source[i]; + const next = source[i + 1]; + if (inString) { + withoutComments += char; + if (char === "\\") { + i += 1; + if (i < source.length) withoutComments += source[i]; + } else if (char === "\"") { + inString = false; + } + continue; + } + if (char === "\"") { + inString = true; + withoutComments += char; + continue; + } + if (char === "/" && next === "/") { + i += 2; + while (i < source.length && source[i] !== "\n" && source[i] !== "\r") i += 1; + if (i < source.length) withoutComments += source[i]; + continue; + } + if (char === "/" && next === "*") { + i += 2; + while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) { + if (source[i] === "\n" || source[i] === "\r") withoutComments += source[i]; + i += 1; + } + if (i >= source.length) throw new Error("unterminated block comment"); + i += 1; + continue; + } + withoutComments += char; + } + if (inString) return JSON.parse(withoutComments); + + let withoutTrailingCommas = ""; + inString = false; + for (let i = 0; i < withoutComments.length; i += 1) { + const char = withoutComments[i]; + if (inString) { + withoutTrailingCommas += char; + if (char === "\\") { + i += 1; + if (i < withoutComments.length) withoutTrailingCommas += withoutComments[i]; + } else if (char === "\"") { + inString = false; + } + continue; + } + if (char === "\"") { + inString = true; + withoutTrailingCommas += char; + continue; + } + if (char === ",") { + let lookahead = i + 1; + while (lookahead < withoutComments.length && /\s/.test(withoutComments[lookahead])) lookahead += 1; + if (withoutComments[lookahead] === "}" || withoutComments[lookahead] === "]") continue; + } + withoutTrailingCommas += char; + } + return JSON.parse(withoutTrailingCommas); +} +try { + const config = parseJsonc(fs.readFileSync(path, "utf8")); + if (config === null || Array.isArray(config) || typeof config !== "object") { + throw new Error("config root must be an object"); + } + if (config.read_config_from === undefined) config.read_config_from = {}; + if (config.read_config_from === null || Array.isArray(config.read_config_from) || typeof config.read_config_from !== "object") { + throw new Error("read_config_from must be an object"); + } + config.read_config_from.claude = false; + if (config.hooks === undefined) config.hooks = {}; + if (config.hooks === null || Array.isArray(config.hooks) || typeof config.hooks !== "object") { + throw new Error("hooks must be an object"); + } + if (config.hooks.Stop === undefined) config.hooks.Stop = []; + if (!Array.isArray(config.hooks.Stop)) throw new Error("hooks.Stop must be an array"); + const command = process.argv[3]; + const present = config.hooks.Stop.some(group => + group && Array.isArray(group.hooks) && group.hooks.some(hook => + hook && hook.type === "command" && hook.command === command)); + if (!present) config.hooks.Stop.push({ hooks: [{ type: "command", command }] }); + process.stdout.write(`${JSON.stringify(config)}\n`); +} catch (error) { + process.stderr.write(`error: invalid Devin config at ${path}: ${error.message}\n`); + process.exit(1); +} +NODE + else + (umask 077; printf '{"version":1,"read_config_from":{"claude":false},"hooks":{"Stop":[{"hooks":[{"type":"command","command":"%s"}]}]}}\n' "$turnend_json" > "$DEVIN_CONFIG") + fi + fi +fi + sq_brief=$(shell_quote "$BRIEF") sq_turnend=$(shell_quote "$TURNEND") sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh") +sq_devinconfig=$(shell_quote "$DEVIN_CONFIG") MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT") LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} @@ -1664,6 +1797,7 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} +LAUNCH=${LAUNCH//__DEVINCONFIG__/$sq_devinconfig} # Crewmate panes are created by a long-lived tmux/herdr daemon that does not # inherit firstmate's current environment, so a bare `claude` in the pane falls # back to the default ~/.claude store even when firstmate itself runs under a diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 6cd87699b0..e8be5a6ff4 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -81,8 +81,8 @@ if [ -z "$HARNESS" ]; then fi case "$HARNESS" in - claude|codex|opencode|pi|grok) SNIPPET="$DOC_DIR/$HARNESS.md" ;; pi-signed) SNIPPET="$DOC_DIR/pi.md" ;; + claude|codex|opencode|pi|grok|devin) SNIPPET="$DOC_DIR/$HARNESS.md" ;; *) HARNESS=unknown; SNIPPET="$DOC_DIR/unknown.md" ;; esac [ -f "$SNIPPET" ] || SNIPPET="$DOC_DIR/unknown.md" @@ -109,6 +109,7 @@ render_snippet() { while IFS= read -r line || [ -n "$line" ]; do line=${line//__FM_PI_EXT__/$pi_ext} line=${line//__FM_PI_TURNEND_EXT__/$pi_turnend_ext} + line=${line//__FM_CODEX_WATCH_CHECKPOINT__/$checkpoint_seconds} line=${line//__FM_X_MODE_ENV_SH__/$x_mode_env_sh} line=${line//__FM_X_MODE_ENV__/$x_mode_env} printf '%s\n' "$line" @@ -143,6 +144,9 @@ repair_line() { pi|pi-signed) printf '%s%s%s%s%s%s\n' "$prefix" 'repair a missing or failed watcher cycle with the Pi tool fm_watch_arm_pi, or restart Pi with -e ' "$pi_turnend_ext" ' -e ' "$pi_ext" ' if the extensions are not loaded.' ;; + devin) + printf '%s%s%s%s\n' "$prefix" 'resume supervision with a foreground checkpoint: bin/fm-watch-checkpoint.sh --seconds ' "$checkpoint_seconds" '.' + ;; opencode) printf '%s%s\n' "$prefix" 'repair missing watcher supervision by letting the OpenCode TUI plugin arm after idle; use bin/fm-watch-arm.sh only as a manual recovery probe if the plugin reports failure.' ;; diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 6440c510e8..9508a4077d 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1133,6 +1133,7 @@ cleanup_firstmate_home_children() { rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" \ "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" \ "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.kimi-turnend-token" + rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" "$sub_state/$child_id.check.sh" "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" "$sub_state/$child_id.devin-config.json" "$sub_state/$child_id.grok-turnend-token" done } @@ -1348,6 +1349,7 @@ retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1 rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ "$STATE/$ID.kimi-turnend-token" +rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.check.sh" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.devin-config.json" "$STATE/$ID.grok-turnend-token" if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index e8284ba1e0..c70843c4ad 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -66,7 +66,7 @@ # shellcheck source=bin/fm-composer-lib.sh . "$(dirname -- "${BASH_SOURCE[0]}")/fm-composer-lib.sh" -# Delivery-only rendered busy footers per harness. claude/codex: "esc to +# Delivery-only rendered busy footers per harness. claude/codex/devin: "esc to # interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel". # Claude's current spinner has a rotating glyph and word, but every active-turn # line has an ellipsis followed by a parenthesized elapsed duration. Keep this @@ -99,6 +99,7 @@ fm_busy_lines_match() { # [harness] case "$harness" in claude) regex=$FM_TMUX_CLAUDE_BUSY_REGEX_DEFAULT ;; codex) regex=$FM_TMUX_CODEX_BUSY_REGEX_DEFAULT ;; + devin) regex=$FM_TMUX_CODEX_BUSY_REGEX_DEFAULT ;; opencode) regex=$FM_TMUX_OPENCODE_BUSY_REGEX_DEFAULT ;; pi|pi-signed) regex=$FM_TMUX_PI_BUSY_REGEX_DEFAULT ;; grok) regex=$FM_TMUX_GROK_BUSY_REGEX_DEFAULT ;; diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 9f17cbc357..4b8e2f5360 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -685,7 +685,9 @@ watcher_cleanup() { fm_lock_release "$WATCH_LOCK" } trap watcher_cleanup EXIT -trap 'exit 1' HUP INT TERM +trap 'exit 129' HUP +trap 'exit 143' TERM +trap 'exit 130' INT # This watcher's own pid, as recorded in the lock by fm_lock_claim (which writes # ${BASHPID:-$$} from this same main shell). Read directly, never via a command # substitution, so it matches the stored holder pid for the self-eviction check. diff --git a/docs/architecture.md b/docs/architecture.md index 8682cfe95c..b5af767e49 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,14 +55,14 @@ The default path remains local-only; live GitHub enrichment exists only behind t Optional X mode integrates with the watcher only after explicit opt-in; [configuration.md](configuration.md#x-mode-env) owns its generated-artifact and dispatch mechanics. At session start, `bin/fm-session-start.sh` emits exactly one primary-harness supervision block rendered by `bin/fm-supervision-instructions.sh` from `docs/supervision-protocols/`. -That block owns the live wait shape for the running primary harness: Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Grok uses background-notify cycles, Codex uses bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. +That block owns the live wait shape for the running primary harness: Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Grok uses background-notify cycles, Codex and Devin use bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. `bin/fm-watch-arm.sh` remains the verified arm wrapper for protocols that call it; it forks the watcher as a tracked child, verifies it is genuinely alive with a fresh liveness beacon, and prints an honest `started`, `attached`, or nonzero `FAILED` status. On `attached` it stays live across identity-matched successors, and an unexplained clean child close either attaches to a verified healthy successor or becomes the typed nonzero `watcher: FAILED - cycle ended without an actionable reason` result. The arm layer records one bounded lifecycle row per observed cycle in `state/.watch-cycle-exits.log`; `state/.watch-triage.log` remains exclusively the absorbed-wake debug log. Pi and OpenCode verify session-lock ownership and launch one singleton successor from their child-close handlers before delivering an actionable wake prompt, with bounded exponential retry for failed restoration. Claude's `bin/fm-claude-stop-autoarm.sh` hook fires on every Stop and, when the home is eligible and still needs supervision, claims one home-scoped cycle, foregrounds the arm wrapper, and translates an actionable close or typed failure into one exit-2 rewake. [`watcher-continuity.md`](watcher-continuity.md) owns Claude's residual active-turn coverage and watcher-status command-gating boundary. -The existing turn-end guard remains the final backstop for all five harness-engine protocols, with pi-signed sharing Pi's protocol and the `--claude` mode cooperating with the auto-arm claim. +The existing turn-end guard remains the final backstop for all six harness-engine protocols, with pi-signed sharing Pi's protocol and the `--claude` mode cooperating with the auto-arm claim. Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers. A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, or if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained. 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. @@ -167,7 +167,7 @@ The session-start bootstrap step keeps valid dispatch configuration silent unles 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. 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, opencode, and kimi while preserving the requested profile for later audit. +That keeps spawn launch compatible across claude, codex, grok, pi, pi-signed, opencode, kimi, and devin while preserving the requested profile for later audit. ## Optional secondmates diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index f4747e0abd..1c6140ef5b 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -22,11 +22,12 @@ It tokenizes the bytes and classifies lexical execution positions only. `bin/fm-arm-pretool-check.sh` supports these entry forms: -- Stdin JSON at `.tool_input.command` for Claude and Codex. +- Stdin JSON at `.tool_input.command` for Claude, Codex, and Devin. - Stdin JSON at `.toolInput.command` for Grok. - `--command ` for OpenCode, Pi, and pi-signed. - `--background` as a compatibility-only field that never changes the decision. - `--claude` to preserve Claude's stderr-only deny requirement. + Devin uses the same stderr-only command-hook response shape. The wrapper discovers the code root from its own location. The active firstmate home is `${FM_HOME:-}`. @@ -149,6 +150,7 @@ Prose may improve without changing adapter behavior. - Deny returns exit 2 and writes `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"[code] reason"}` to stderr. - Default deny mode also writes `{"decision":"deny","reason":"[code] reason"}` to stdout for Grok. - `--claude` suppresses stdout completely because Claude ignores a PreToolUse deny when stdout is nonempty. + Devin uses the same stderr-only response shape. - Codex blocks on exit 2 and displays stderr. - OpenCode throws only when the checker exits 2. - Pi and pi-signed return `{block: true}` only when the checker exits 2. @@ -162,6 +164,7 @@ Prose may improve without changing adapter behavior. | Grok | `.toolInput.command` | `.grok/hooks/fm-primary-pretool-check.json` forwards stdin and Grok consumes the stdout `decision=deny` object. | | OpenCode | `output.args.command` | `.opencode/plugins/fm-primary-pretool-check.js` passes one `--command` argument and throws only for exit 2. | | Pi / pi-signed | `event.input.command` | `.pi/extensions/fm-primary-turnend-guard.ts` passes one `--command` argument and returns `{block: true}` only for exit 2. | +| Devin | `.tool_input.command` | `.devin/config.json` matcher-scopes to `exec`, forwards stdin through `--claude`, and blocks on exit 2. | Grok project hooks require folder trust. Every shell variable reference in a Grok hook command must carry an inline default such as `${GROK_WORKSPACE_ROOT:-}` because Grok expands the raw hook command before `bash -lc` runs it. @@ -229,7 +232,7 @@ Every native-path automatic marker was present and every deny sentinel remained ## Automated validation `tests/fm-arm-pretool-check.test.sh` owns the adversarial acceptance matrix. -Every row runs through Codex-shaped stdin, Claude-shaped stdin, Grok-shaped stdin, OpenCode-shaped CLI, and Pi-shaped CLI entry forms. +Every row runs through Codex-shaped stdin, Claude-shaped stdin, Devin-shaped stdin, Grok-shaped stdin, OpenCode-shaped CLI, and Pi-shaped CLI entry forms. The suite also verifies real newline bytes, direct classifier reason codes, comments, heredoc data, malformed and unsupported protected syntax, constructed dynamic payloads, malformed transport fail-open behavior, missing runtime fail-open behavior, output shapes, and exact adapter field forwarding plus exit-2 mapping. Run: diff --git a/docs/cd-guard.md b/docs/cd-guard.md index 998a9b540c..17838b8f97 100644 --- a/docs/cd-guard.md +++ b/docs/cd-guard.md @@ -74,13 +74,14 @@ It does not permit `cd /home/project`, because an absolute-path `cd` remains a p ## Transport and fail-open behavior -`bin/fm-cd-pretool-check.sh` supports all five harness-engine entry shapes used by the tracked adapters, with pi-signed sharing Pi's shape: +`bin/fm-cd-pretool-check.sh` supports all six harness entry shapes used by the tracked adapters: - Claude sends stdin JSON at `.tool_input.command` and adds `--claude` to preserve Claude's stderr-only deny requirement. - Codex sends stdin JSON at `.tool_input.command` without `--claude`. - Grok sends stdin JSON at `.toolInput.command`. - OpenCode sends the exact command string through `--command `. - Pi and pi-signed send the exact command string through `--command `. +- Devin sends stdin JSON at `.tool_input.command` and adds `--claude` for its stderr-only deny response. Processing order is cheapest-first: a strict-superset prefilter, then the primary-checkout scope, then the Node policy owner. The prefilter removes ordinary single quotes, double quotes, backslashes, carriage returns, and newlines before fast-allowing any command that carries no `cd`, `pushd`, or `popd` substring and no quoting-decoder marker (`$'` ANSI-C or `$"` locale), so quoted or escaped command-word fragments delegate to the policy while most commands never pay for the git scoping calls or the Node process. @@ -97,6 +98,7 @@ Identical in shape to `docs/arm-pretool-check.md`: - Deny returns exit 2 and writes `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"[persistent-cd] reason"}` to stderr. - Default deny mode also writes `{"decision":"deny","reason":"[persistent-cd] reason"}` to stdout for Grok. - `--claude` suppresses stdout completely because Claude ignores a PreToolUse deny when stdout is nonempty. + Devin uses the same stderr-only response shape. - Codex blocks on exit 2 and displays stderr. - OpenCode throws only when the checker exits 2. - Pi and pi-signed return `{block: true}` only when the checker exits 2. @@ -117,6 +119,7 @@ The cd-guard never duplicates shell lexing; it adds only the cd-specific decisio | Grok | `.grok/hooks/fm-primary-cd-check.json` PreToolUse hook anchored on `${GROK_WORKSPACE_ROOT:-}` | Consumes the stdout `decision=deny` object. | | OpenCode | `.opencode/plugins/fm-primary-cd-check.js` `tool.execute.before` | Throws, which surfaces as the failed tool result. | | Pi | `.pi/extensions/fm-primary-turnend-guard.ts` `tool_call` handler | Returns `{block: true}`; piggybacks on the already-loaded primary extension so no extra `-e` flag is needed. | +| Devin | `.devin/config.json` PreToolUse hook matcher-scoped to `exec` and forwarding stdin through `--claude` | Blocks the tool call; stderr deny object, stdout empty. | Each harness runs the cd-guard alongside the watcher-arm seatbelt; the two are independent checks, and either deny blocks the command. Every shell variable reference in the Grok hook command carries an inline default (`${GROK_WORKSPACE_ROOT:-}`) because Grok expands the raw hook command before `bash -lc` runs it, the same requirement documented in `docs/arm-pretool-check.md`. @@ -124,7 +127,7 @@ Every shell variable reference in the Grok hook command carries an inline defaul ## Automated validation `tests/fm-cd-pretool-check.test.sh` owns the acceptance matrix. -Every block and allow case runs through Codex-shaped stdin, Claude-shaped stdin, Grok-shaped stdin, OpenCode-shaped CLI, and Pi-shaped CLI entry forms. +Every block and allow case runs through Codex-shaped stdin, Claude-shaped stdin, Devin-shaped stdin, Grok-shaped stdin, OpenCode-shaped CLI, and Pi-shaped CLI entry forms. The suite also proves the end-to-end cwd-leak regression (a firstmate-owned backlog write leaking into a project clone, then denied at the exact command), the checkout scoping (fires in a git-cloned secondmate fixture, inert in a crewmate/scout linked worktree, inert outside a firstmate checkout, inert outside a git repo), the fail-open transport behavior, the prefilter fast path, the policy CLI output contract, and the per-harness wiring. Run: diff --git a/docs/configuration.md b/docs/configuration.md index 3b91fc08a2..2d64c964b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -193,14 +193,14 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa ## Harness support -claude, codex, opencode, pi, pi-signed, grok, and kimi are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the set supported for the primary session. +claude, codex, opencode, pi, pi-signed, grok, kimi, and devin are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the set supported for the primary session. New harnesses get verified through a supervised trial task before joining the set. The verified adapter knowledge - each harness's busy-state source, 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). 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/). -Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Grok uses background-notify cycles, Codex uses bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. +Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Grok uses background-notify cycles, Codex and Devin use bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. `config/crew-harness` is a local, gitignored file containing one adapter name for crewmate and scout launches. When pi-signed is selected, Firstmate launches the executable named `pi-signed` from `PATH` with `FM_PI_HARNESS=pi-signed` and refuses the launch if it is unavailable rather than falling back to pi. Plain Pi launches set `FM_PI_HARNESS=pi`, so a signed primary's environment cannot relabel a plain Pi worker. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index f6075ab085..4024dc469d 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -279,6 +279,10 @@ "path": "docs/supervision-protocols/codex.md", "audience": "agent-runtime" }, + { + "path": "docs/supervision-protocols/devin.md", + "audience": "agent-runtime" + }, { "path": "docs/supervision-protocols/grok.md", "audience": "agent-runtime" diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 42b9815cec..48d58b8a40 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -1,8 +1,8 @@ # Orca runtime backend -Orca is an experimental macOS backend in which the Orca app owns both the task worktree and terminal endpoint. -The crewmate harness remains the agent process launched inside that endpoint. -Firstmate agents load [`firstmate-orca`](../.agents/skills/firstmate-orca/SKILL.md) before operating or recovering this backend. +Orca is an experimental runtime backend for firstmate. +It is distinct from the crewmate harness: the harness is the agent process firstmate launches (`claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, or `devin`), while the Orca app owns the task worktree and terminal endpoint underneath that process. +Firstmate agents operating this backend should load the agent-only [`firstmate-orca`](../.agents/skills/firstmate-orca/SKILL.md) checklist before switching to Orca, spawning or supervising Orca-backed work, smoke-testing, debugging task state, or reconciling Orca metadata. ## Setup diff --git a/docs/scripts.md b/docs/scripts.md index 17e0e532cb..21e5797db9 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -90,6 +90,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-promote.sh` | Promote a scout task in place to a protected ship task | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | +| `fm-harness-process.sh` | Classify verified harness executable and interpreter-script process shapes | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers | | `fm-x-poll.sh` | One bounded X relay poll: stash newly offered mentions and emit their once-only wake | diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index c39c814925..10a5e6749d 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -25,6 +25,7 @@ Every path exits 0, including malformed state and adapter errors, because a Clau | OpenCode | `.opencode/plugins/fm-primary-sessionstart-nudge.js` listens for `session.created`, runs once per session id, and calls `client.session.promptAsync` only when the wrapper prints a nudge. | Interactive TUI delivery is supported; headless `opencode run` is intentionally fail-open because the process can exit before the queued turn. | | Pi / pi-signed | `.pi/extensions/fm-primary-turnend-guard.ts` handles `session_start` reasons `startup`, `new`, and `resume`, then injects the wrapper output with `pi.sendMessage`. | The custom message reaches model context without racing an initial positional prompt. | | Grok | `.grok/hooks/fm-primary-sessionstart-nudge.json` registers a project `SessionStart` hook and invokes the wrapper through inline-defaulted `${GROK_WORKSPACE_ROOT:-}`. | The project hook runs when the checkout is trusted, but Grok currently discards hook stdout from model context, so this path is intentionally fail-open. | +| Devin | `.devin/config.json` registers a project `SessionStart` hook and resolves the trusted project root through `DEVIN_PROJECT_DIR` with a Git-root fallback. | The tracked hook is configured, but Devin CLI 3000.3.22 did not deliver its `add_context` output, so native context injection remains unverified. | The OpenCode nudge runs only on `session.created`. The watcher-arm and turn-end plugins run later on `session.idle`, and the guard lets the watcher coordinator act first, so the plugins do not race for one lifecycle event. diff --git a/docs/subagent-guard.md b/docs/subagent-guard.md index 47aaf10e0f..8134f41e2c 100644 --- a/docs/subagent-guard.md +++ b/docs/subagent-guard.md @@ -175,7 +175,7 @@ When that script is absent the message still defers to intake classification and ## Harness wiring -Every supported primary harness was reviewed. +The harnesses listed below were reviewed, except where a row explicitly records an open applicability check. Applicability turns on one question: does the harness expose built-in delegation tools that a primary session could use instead of `bin/fm-spawn.sh`? | Harness | Delegation surface | Status | @@ -185,6 +185,7 @@ Applicability turns on one question: does the harness expose built-in delegation | Grok | present, exact tokens unconfirmed | Not wired pending live verification. See below. | | OpenCode | present, exact tokens unconfirmed | Not wired pending live verification. See below. | | Pi | none reported | Not wired pending live verification. See below. | +| Devin | not established | Not classified in this record; enumerate its primary tool surface before claiming that the delegation guard is applicable or unnecessary. | ### Codex, verified not applicable @@ -241,6 +242,11 @@ The bounded follow-up for each is identical to the Codex procedure above. On a host with the binary installed, ask the harness to enumerate its tools, then wire the matcher and re-run the live matrix below. `bin/fm-subagent-pretool-check.sh` needs no change for any of them: it already accepts Grok's stdin shape and the `--tool` CLI form OpenCode and Pi use, and it already emits the Grok stdout decision object by default. +### Devin applicability + +This record does not contain an empirical enumeration of Devin's primary-session tools. +Until that check is performed, no Devin delegation tool is classified as safe or blocked by this guard. + ## Live validation record, 2026-07-22 Harness version: diff --git a/docs/supervision-protocols/devin.md b/docs/supervision-protocols/devin.md new file mode 100644 index 0000000000..43a33d9862 --- /dev/null +++ b/docs/supervision-protocols/devin.md @@ -0,0 +1,17 @@ +Mode: Devin foreground checkpoint. + +When this session owns supervision and away mode is not active: +1. Drain first with `bin/fm-wake-drain.sh`. +2. Source `__FM_X_MODE_ENV__` first when X mode is active. +3. Run `bin/fm-watch-checkpoint.sh --seconds __FM_CODEX_WATCH_CHECKPOINT__` as one foreground tool call. +4. Never bundle the checkpoint with unrelated commands. +5. Never use shell `&` for watcher supervision. + A shell `&`, a truncating pipe, or bundling is denied automatically by the PreToolUse seatbelt registered in `.devin/config.json`. +6. When the checkpoint returns with `signal:`, `stale:`, `check:`, or `heartbeat`, drain queued wakes, handle them, then run one fresh checkpoint. +7. A timeout is only a bounded control return. + If work remains in flight, run another checkpoint after handling user input and queued wakes. +8. Treat any failed checkpoint as an alarm and repair it before ending the turn. +9. Do not send idle progress while a checkpoint is running. + +Devin has no verified background-task completion wake contract in Firstmate. +The bounded foreground checkpoint is the conservative supported mechanism, matching Codex's supervision shape while retaining Devin's native blocking Stop-hook backstop. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 936e0e7b47..3993783268 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -46,7 +46,7 @@ Verify setup by spawning a small task and confirming its `fm-` window appear A target-existence check proves only that the pane exists. The deeper tmux agent-liveness probe first verifies exact window membership, then reads `#{pane_current_command}` to distinguish a running harness process from a bare idle shell. -It classifies recognized Claude, Codex, OpenCode, Pi, pi-signed, Grok, and Kimi process names as `alive`, common shells as `dead`, an authoritatively absent window as `missing`, unreadable state as `unreadable`, and every other process as `ambiguous`. +It classifies recognized Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, and Devin process names as `alive`, common shells as `dead`, an authoritatively absent window as `missing`, unreadable state as `unreadable`, and every other process as `ambiguous`. Only `dead` and `missing` authorize recovery because a false dead result could launch a duplicate agent. The verified Pi Launcher path reports the exact foreground command `pi-launcher` for both pi and pi-signed, while direct executable identities `pi`, `pi-signed`, and `Pi` remain accepted exactly. @@ -76,6 +76,7 @@ While OpenCode is mid-turn, Enter queues the message but leaves its text visible After the normal retry budget, only structurally proven pending text in a provably busy pane is accepted as queued, while an idle pane remains `pending` as a genuine swallowed Enter. Ambiguous pending text never receives the busy-queue conversion. `tests/fm-tmux-submit-busy.test.sh` covers busy and idle panes with proven, ambiguous, and cleared composers. +The shared process classifier accepts exact verified harness executables and harness-bearing interpreter scripts while rejecting unrelated names that merely contain a harness substring. ## Limits and regression entry points diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 8ee750de39..081f579d63 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -44,10 +44,11 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s - Pi listens for `agent_settled` in `.pi/extensions/fm-primary-turnend-guard.ts`, runs once per logical agent run, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` once when the guard returns 2. - Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and delegates capability selection to `bin/fm-turnend-guard-grok.sh`. The tracked Claude Stop entries are inert when `GROK_AGENT` is present, so Grok's Claude-compatible settings loading cannot create a second continuation path. +- Devin registers a direct blocking `Stop` hook in `.devin/config.json`, anchors through `DEVIN_PROJECT_DIR` with a quoted Git-root fallback for nested working directories, and invokes `bin/fm-turnend-guard.sh`. -Claude and Codex can block a Stop directly with exit status 2 and stderr. -Both payloads carry `stop_hook_active`. -In the default Codex mode, a true value lets the second stop finish after one forced continuation. +Claude, Codex, and Devin can block a Stop directly with exit status 2 and stderr. +Their payloads carry `stop_hook_active`. +In the default Codex and Devin mode, a true value lets the second stop finish after one forced continuation. Claude runs the guard with `--claude`, which ignores `stop_hook_active` and cooperates with the Stop-owned auto-arm. Claude Code sets `stop_hook_active=true` on every stop after any stop-hook continuation, including `asyncRewake` rewakes, which re-opened the 2026-07-21 blind window under the default one-shot behavior. @@ -90,8 +91,8 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa ## Regression coverage -`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, Grok native and legacy selection, typed field precedence, malformed input, and exactly-one-path safety. +`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all primary registrations including Devin, Grok native and legacy selection, typed field precedence, malformed input, resume permission and recursion safety, and exactly-one-path safety. `tests/fm-kimi-harness.test.sh` covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup. `tests/fm-supervision-instructions.test.sh` covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. -[`verification/supervision.md`](verification/supervision.md#turn-end-guard) records the active cross-harness empirical evidence, including the 2026-07-24 Claude `asyncRewake` revalidation. +[`verification/supervision.md`](verification/supervision.md#turn-end-guard) records the active cross-harness empirical evidence, including the 2026-07-24 Claude `asyncRewake` revalidation and Devin CLI validation. diff --git a/docs/verification/public-followup.md b/docs/verification/public-followup.md index 48f9f6d39e..2be77c564c 100644 --- a/docs/verification/public-followup.md +++ b/docs/verification/public-followup.md @@ -93,7 +93,7 @@ Roughly 0.07 ms per session start, from a single `[ -f "$FM_HOME/.env" ]` test t ## Compatibility axes reviewed -Primary harnesses (`claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`): not applicable after inspection. +Primary harnesses (`claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, `devin`): not applicable after inspection. Nothing here reads or renders harness-specific state. The only supervision surfaces touched are the session-start digest, which `bin/fm-supervision-instructions.sh` already renders per harness without knowing this section exists, and the wake payload produced by the existing relay poll, which every harness protocol consumes identically. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 40b8aac86a..f5544c0ad7 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -29,6 +29,7 @@ zsh A persistent parent shell waiting for a child remained reported as the parent process, while a shell that directly execed a simple command changed identity with the process itself. Claude, Codex, OpenCode, and Grok were observed under their own process names. +Devin CLI 3000.1.27 was observed under the literal `devin` process name throughout an interactive turn on 2026-07-13. Kimi Code CLI 0.29.1 was observed under `kimi` on 2026-07-25. Pi and pi-signed 0.82.0 were reverified on 2026-07-27 through real isolated `fm-spawn.sh` launches. @@ -115,7 +116,7 @@ ok - fm-teardown: dedicated-socket invalid cleanup preserves target/control and The dedicated tmux cell removed ambient tmux variables, required a socket-bound wrapper, kept one target and one independent control window, and proved the wrapper was not called for invalid metadata or a direct empty target. Valid cleanup removed only the exact task-bound target and left the control window live. The metadata-only validation covers tmux, Herdr, Zellij, Orca, and cmux before backend dispatch. -Claude, Codex, OpenCode, Pi, pi-signed, Grok, and Kimi share that backend cleanup boundary; their harness-specific hook files and token cleanup run only after it, so no harness needs a separate endpoint parser. +Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, and Devin share that backend cleanup boundary; their harness-specific hook files and token cleanup run only after it, so no harness needs a separate endpoint parser. ## Herdr diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index d2fdd5a09c..92dfa99939 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -60,6 +60,22 @@ The Ahoy first-message boundary was reverified on 2026-07-22 with Pi 0.81.1 and Marked current operational input and the two exact legacy compatibility shapes selected Bearings, while genuine near-miss captain messages remained real boundaries. The detailed reconciliation and task chronology stay in the private audit report and PR evidence. +Devin command shape, using an isolated temporary Devin data directory, a plain temporary checkout containing the tracked `.devin`, `bin`, and `AGENTS.md` files, and the tracked project configuration: + +```sh +env HOME="$PROBE_ROOT/home" XDG_CONFIG_HOME="$PROBE_ROOT/home/.config" XDG_DATA_HOME="$PROBE_ROOT/data" \ + DEVIN_PROJECT_DIR="$PLAIN_ROOT" FM_GATE_REFUSE_BYPASS=1 FM_STATE_OVERRIDE="$PLAIN_ROOT/state" DEVIN_CLI=1 \ + devin --config "$PROBE_ROOT/config.json" --permission-mode dangerous \ + --respect-workspace-trust true --export "$PLAIN_ROOT/interactive-export.json" -- \ + 'Do not use tools. Reply exactly DEVIN_FIRSTMATE_HOOK_PAYLOAD_SEEN if the exact Firstmate SessionStart context is present; otherwise reply exactly DEVIN_FIRSTMATE_HOOK_PAYLOAD_MISSING.' +``` + +The probe ran on 2026-07-31 with Devin CLI 3000.3.22 (d5152ff5), after accepting the trust prompt in the isolated Devin data directory while leaving `--respect-workspace-trust true` enabled. +The effective user override was a copy of the local Devin config with no hooks, and the effective project configuration was the tracked `.devin/config.json` with `read_config_from.claude=false` and its `SessionStart` hook. +The tracked project configuration contains no organization, shell-setup, or theme values; those remain user-level Devin state. +A unique probe-only `SessionStart` command, `node -e 'process.stdout.write(JSON.stringify({add_context:"DEVIN_PROBE_CONTEXT_7f3a91c2"}))'`, returned that JSON, but the model replied `DEVIN_PROBE_CONTEXT_MISSING`; a separate `UserPromptSubmit` probe likewise replied `DEVIN_USERPROMPT_CONTEXT_MISSING`. +The installed Devin hook documentation labels Claude-format hooks as not yet available, so this version-scoped live check does not establish native context delivery and Devin session-start support remains unverified. + ## Semantic busy state The per-adapter semantic sources behind [`bin/fm-busy-lib.sh`](../../bin/fm-busy-lib.sh) were live-verified on 2026-07-28 against firstmate-launched workers wired exactly as `fm-spawn` writes them. @@ -96,7 +112,7 @@ tests/fm-crew-state.test.sh ## Turn-end guard -The direct and passive mechanisms were validated across all five harnesses on 2026-07-08 through 2026-07-12, with Claude's replacement Stop-owned path revalidated on 2026-07-24. +The direct and passive mechanisms were validated across six harnesses on 2026-07-08 through 2026-07-13, with Claude's replacement Stop-owned path revalidated on 2026-07-24. | Harness | Version verified | Mechanism | Observed result | | --- | --- | --- | --- | @@ -105,6 +121,7 @@ The direct and passive mechanisms were validated across all five harnesses on 20 | OpenCode | 1.17.6 | Passive `session.idle` callback | Throwing could not block, while `promptAsync` scheduled one TUI follow-up; headless remained fail-open. | | Pi | 0.80.5 | Passive `agent_settled` callback | Exactly one guard follow-up ran for an unhealthy cycle, with no recursion across tool turns. | | Grok | 0.2.112 native and 0.2.73 pre-native | Running-payload adaptive `Stop` | Native false-to-true continuation stayed in one process with two model turns and zero resume launches; the field-absent pre-native process launched exactly one guarded resume. | +| Devin | 3000.1.27 | Blocking `Stop` hook | Hook process root stayed anchored to the trusted checkout, exit status 2 and stderr blocked the stop, and `stop_hook_active` bounded the continuation. | The Grok adaptive matrix ran on 2026-07-28 with separate scratch repositories and homes, dedicated tmux sockets, one target plus one control window, ambient tmux variables removed, and a socket-bound wrapper first in `PATH`. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 52b3a9eec3..58722c0a10 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -65,6 +65,6 @@ The same suite covers ordinary same-process session replacement for `/new`, `/re The goal is continuity without a Pi or OpenCode model-memory re-arm step. No zero-latency guarantee is claimed because lock verification, watcher startup, and bounded retry delays remain deliberate safety work. OpenCode support targets persistent TUI sessions rather than headless `opencode run`. -Claude depends on the Stop `asyncRewake` rewake, Grok retains native background-completion notifications, and Codex retains bounded foreground checkpoints. +Claude depends on the Stop `asyncRewake` rewake, Grok retains native background-completion notifications, and Codex and Devin retain bounded foreground checkpoints. -[`verification/supervision.md`](verification/supervision.md#watcher-continuity) records the current five-harness live evidence, the 2026-07-24 Stop-owned Claude auto-arm results, and exact opt-in commands. +[`verification/supervision.md`](verification/supervision.md#watcher-continuity) records the current cross-harness live evidence, the 2026-07-24 Stop-owned Claude auto-arm results, and exact opt-in commands. diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 5ba750aea0..15a72f53af 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -3,7 +3,7 @@ # Behavior tests for the watcher-arm PreToolUse seatbelt (docs/arm-pretool-check.md). # # bin/fm-arm-command-policy.mjs is the single owner of command classification. -# This suite drives the stable shell transport through all five harness entry +# This suite drives the stable shell transport through all six harness entry # forms and asserts the per-harness wiring contract without spawning a harness. # Empirical harness evidence lives in docs/arm-pretool-check.md. set -u @@ -156,7 +156,7 @@ run_matrix_entry() { printf '%s' "$payload" | "$CHECK" >"$out_file" 2>"$err_file" rc=$? ;; - claude) + claude|devin) payload=$(jq -cn --arg command "$cmd" '{tool_name:"Bash",tool_input:{command:$command}}') printf '%s' "$payload" | "$CHECK" --claude >"$out_file" 2>"$err_file" rc=$? @@ -185,8 +185,8 @@ run_matrix_entry() { [ "$rc" -eq 2 ] || fail "$id via $entry must deny, got exit $rc" jq -e '.hookSpecificOutput.permissionDecision == "deny" and (.systemMessage | test("\\[(watcher-(background|pipeline|redirection|bundled|nested|direct)|broad-watcher-kill|unclassifiable-protected-command)\\]"))' "$err_file" >/dev/null 2>&1 \ || fail "$id via $entry deny must carry a stable reason code on stderr: $(cat "$err_file")" - if [ "$entry" = claude ]; then - [ ! -s "$out_file" ] || fail "$id via claude deny must leave stdout empty: $(cat "$out_file")" + if [ "$entry" = claude ] || [ "$entry" = devin ]; then + [ ! -s "$out_file" ] || fail "$id via $entry deny must leave stdout empty: $(cat "$out_file")" elif [ "$entry" = grok ]; then jq -e '.decision == "deny"' "$out_file" >/dev/null 2>&1 \ || fail "$id via grok deny must carry decision=deny on stdout: $(cat "$out_file")" @@ -196,10 +196,10 @@ run_matrix_entry() { test_full_acceptance_matrix() { local i entry for ((i = 0; i < ${#MATRIX_IDS[@]}; i++)); do - for entry in codex claude grok opencode pi; do + for entry in codex claude devin grok opencode pi; do run_matrix_entry "${MATRIX_IDS[$i]}" "${MATRIX_EXPECTED[$i]}" "$entry" "${MATRIX_COMMANDS[$i]}" done - pass "matrix ${MATRIX_IDS[$i]}: ${MATRIX_EXPECTED[$i]} through all five entry forms" + pass "matrix ${MATRIX_IDS[$i]}: ${MATRIX_EXPECTED[$i]} through all six entry forms" done } diff --git a/tests/fm-backend-autodetect-smoke.test.sh b/tests/fm-backend-autodetect-smoke.test.sh index 17fe88f617..0ed2e87ced 100755 --- a/tests/fm-backend-autodetect-smoke.test.sh +++ b/tests/fm-backend-autodetect-smoke.test.sh @@ -50,7 +50,6 @@ export FM_GATE_REFUSE_BYPASS=1 # it was launched in must not follow spawn into that session as a cross-session # parent identity; the spawn below sets HERDR_ENV explicitly. herdr_forget_inherited_pane - # TMP_ROOT is physically resolved (mktemp -d "$(pwd -P)"-relative) to keep this # real-herdr smoke fixture free of unrelated OS symlink noise. # The old fm-spawn bug that originally motivated this fixture shape was fixed in diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 6c72c0bbc4..b83d760735 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -130,9 +130,9 @@ make_fake_fleet_sync_root() { mkdir -p "$fake_root/bin" cat > "$fake_root/bin/fm-fleet-sync.sh" <<'SH' #!/usr/bin/env bash -[ -z "${FM_FAKE_FLEET_SYNC_STARTED_MARKER:-}" ] || : > "$FM_FAKE_FLEET_SYNC_STARTED_MARKER" printf '%s\n' 'alpha: synced' printf '%s\n' 'beta: skipped: no origin remote' +[ -z "${FM_FAKE_FLEET_SYNC_STARTED_MARKER:-}" ] || : > "$FM_FAKE_FLEET_SYNC_STARTED_MARKER" exec perl -e 'sleep 300' SH chmod +x "$fake_root/bin/fm-fleet-sync.sh" @@ -165,14 +165,22 @@ add_no_origin_projects() { run_bootstrap_timeout_case() { local home=$1 fake_root=$2 fakebin=$3 override started_marker git_record wait_for_marker override=__unset__ - started_marker=${5:-} + started_marker=${5:-"$home/fleet-sync-started"} git_record=${6:-} wait_for_marker=${7:-0} [ "$#" -lt 4 ] || override=$4 ( # shellcheck disable=SC2317,SC2329 # Exported and invoked by the bootstrap subprocess. sleep() { - local inc=${1:-1} + local inc tries + inc=${1:-1} + if [ -n "${FM_FAKE_FLEET_SYNC_STARTED_MARKER:-}" ]; then + tries=0 + while [ "$tries" -lt 100 ] && [ ! -e "$FM_FAKE_FLEET_SYNC_STARTED_MARKER" ]; do + command sleep 0.01 + tries=$((tries + 1)) + done + fi SECONDS=$((SECONDS + inc)) # Advance fake time quickly, but yield on every tick so the background # fleet-sync process can deterministically write its partial output before @@ -819,6 +827,8 @@ array use without select is accepted^{"rules":[{"when":"big feature","use":[{"ha one-element array use is accepted^{"rules":[{"when":"focused feature","use":[{"harness":"claude"}]}]}^empty^ default array is accepted^{"default":[{"harness":"pi","model":"anthropic/claude-sonnet-5"},{"harness":"grok"}]}^empty^ one-element default array is accepted^{"default":[{"harness":"codex"}]}^empty^ +devin profile is accepted^{"rules":[{"when":"devin work","use":{"harness":"devin","model":"swe-1.6"}}]}^empty^ +unsupported devin effort is flagged^{"rules":[{"when":"devin work","use":{"harness":"devin","model":"swe-1.6","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: devin:high empty array use is flagged^{"rules":[{"when":"big feature","use":[]}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - each rule needs at least one use profile array profile without harness is flagged^{"rules":[{"when":"big feature","use":[{"model":"gpt-5.5"}]}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - each use profile needs harness array profile with malformed model is flagged^{"rules":[{"when":"big feature","use":[{"harness":"codex","model":5}]}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - use profile model and effort must be non-empty strings when present diff --git a/tests/fm-cd-pretool-check.test.sh b/tests/fm-cd-pretool-check.test.sh index 80f8c03fc9..6dc9fabe9f 100755 --- a/tests/fm-cd-pretool-check.test.sh +++ b/tests/fm-cd-pretool-check.test.sh @@ -5,7 +5,7 @@ # bin/fm-cd-command-policy.mjs is the single owner of the block/allow decision; # it reuses the shell classifier owned by bin/fm-arm-command-policy.mjs. # bin/fm-cd-pretool-check.sh is the stable transport: it scopes the guard to the -# real primary checkout, then drives all five harness entry forms. This suite +# real primary checkout, then drives all six harness entry forms. This suite # proves the decision matrix, the harness-output shaping, the primary-checkout # scoping (including the deliberate secondmate-home difference from the turn-end # guard), the fail-open transport behavior, the prefilter fast path, the @@ -156,7 +156,7 @@ run_matrix_entry() { printf '%s' "$payload" | "$CHECK" >"$out_file" 2>"$err_file" rc=$? ;; - claude) + claude|devin) payload=$(jq -cn --arg command "$cmd" '{tool_name:"Bash",tool_input:{command:$command}}') printf '%s' "$payload" | "$CHECK" --claude >"$out_file" 2>"$err_file" rc=$? @@ -185,8 +185,8 @@ run_matrix_entry() { [ "$rc" -eq 2 ] || fail "$id via $entry must deny, got exit $rc" jq -e '.hookSpecificOutput.permissionDecision == "deny" and (.systemMessage | test("\\[persistent-cd\\]"))' "$err_file" >/dev/null 2>&1 \ || fail "$id via $entry deny must carry the persistent-cd reason code on stderr: $(cat "$err_file")" - if [ "$entry" = claude ]; then - [ ! -s "$out_file" ] || fail "$id via claude deny must leave stdout empty: $(cat "$out_file")" + if [ "$entry" = claude ] || [ "$entry" = devin ]; then + [ ! -s "$out_file" ] || fail "$id via $entry deny must leave stdout empty: $(cat "$out_file")" elif [ "$entry" = grok ]; then jq -e '.decision == "deny"' "$out_file" >/dev/null 2>&1 \ || fail "$id via grok deny must carry decision=deny on stdout: $(cat "$out_file")" @@ -196,11 +196,11 @@ run_matrix_entry() { test_full_acceptance_matrix() { local i entry for ((i = 0; i < ${#MATRIX_IDS[@]}; i++)); do - for entry in codex claude grok opencode pi; do + for entry in codex claude devin grok opencode pi; do run_matrix_entry "${MATRIX_IDS[$i]}" "${MATRIX_EXPECTED[$i]}" "$entry" "${MATRIX_COMMANDS[$i]}" done done - pass "cd-guard acceptance matrix: ${#MATRIX_IDS[@]} cases x 5 harness entry forms, block/allow all correct" + pass "cd-guard acceptance matrix: ${#MATRIX_IDS[@]} cases x 6 harness entry forms, block/allow all correct" } # --- primary-checkout scoping ---------------------------------------------- @@ -373,7 +373,10 @@ test_policy_cli_direct() { # --- per-harness wiring ----------------------------------------------------- test_scripts_are_shellcheck_clean() { - command -v shellcheck >/dev/null 2>&1 || { pass "shellcheck not installed, skipping"; return; } + if ! command -v shellcheck >/dev/null 2>&1; then + pass "shellcheck not installed, skipping" + return 0 + fi shellcheck "$ROOT/bin/fm-cd-pretool-check.sh" >/dev/null 2>&1 \ || fail "bin/fm-cd-pretool-check.sh is not shellcheck-clean" pass "bin/fm-cd-pretool-check.sh is shellcheck-clean" diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index 6be8bc1533..03363d1ba8 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -31,6 +31,7 @@ install_autoarm_scripts() { cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" cp "$ROOT/bin/fm-session-lock-lib.sh" "$dir/bin/fm-session-lock-lib.sh" + cp "$ROOT/bin/fm-harness-process.sh" "$dir/bin/fm-harness-process.sh" cp "$ROOT/bin/fm-lock.sh" "$dir/bin/fm-lock.sh" chmod +x "$dir/bin/fm-claude-stop-autoarm.sh" "$dir/bin/fm-lock.sh" } diff --git a/tests/fm-devin-harness.test.sh b/tests/fm-devin-harness.test.sh new file mode 100755 index 0000000000..d231bba28a --- /dev/null +++ b/tests/fm-devin-harness.test.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# Tests for Devin harness detection, launch wiring, hooks, and lock liveness. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-devin-harness.XXXXXX") +trap 'rm -rf "$TMP_ROOT"' EXIT +SPAWN="$ROOT/bin/fm-spawn.sh" + +test_detection_marker() { + local out + out=$(DEVIN_CLI=1 "$ROOT/bin/fm-harness.sh") + [ "$out" = devin ] || fail "DEVIN_CLI marker resolved '$out', expected devin" + out=$(DEVIN_CLI=1 CLAUDECODE=1 PI_CODING_AGENT=true "$ROOT/bin/fm-harness.sh") + [ "$out" = devin ] || fail "DEVIN_CLI marker lost precedence to inherited harness markers: $out" + pass "fm-harness detects the verified Devin launch marker" +} + +test_primary_hook_wiring() { + local config session pre stop matcher + config="$ROOT/.devin/config.json" + [ -f "$config" ] || fail "tracked .devin/config.json is missing" + jq -e '.read_config_from.claude == false' "$config" >/dev/null \ + || fail "tracked Devin config does not disable Claude compatibility import" + jq -e '(.devin // null) == null and (.shell // null) == null and (.theme_mode // null) == null' "$config" >/dev/null \ + || fail "tracked Devin config contains user-specific organization or setup state" + session=$(jq -r '.hooks.SessionStart[0].hooks[0].command // empty' "$config") + assert_contains "$session" 'fm-sessionstart-nudge.sh' "Devin SessionStart hook omitted session-start nudge" + assert_contains "$session" 'DEVIN_PROJECT_DIR' "Devin SessionStart hook omitted the native project root" + assert_contains "$session" 'git rev-parse --show-toplevel' "Devin SessionStart hook omitted the Git-root fallback" + assert_contains "$session" 'add_context' "Devin SessionStart hook does not return Devin context JSON" + [ "$(jq '[.hooks.SessionStart[].hooks[] | select(.command | contains("fm-sessionstart-nudge.sh"))] | length' "$config")" -eq 1 ] \ + || fail "tracked Devin config does not have exactly one SessionStart owner" + matcher=$(jq -r '.hooks.PreToolUse[0].matcher // empty' "$config") + [ "$matcher" = exec ] || fail "Devin PreToolUse matcher is '$matcher', expected exec" + pre=$(jq -r '.hooks.PreToolUse[0].hooks[].command' "$config") + assert_contains "$pre" 'fm-arm-pretool-check.sh' "Devin hook omitted watcher-arm checker" + assert_contains "$pre" 'fm-cd-pretool-check.sh' "Devin hook omitted cd checker" + assert_contains "$pre" '--claude' "Devin hook did not select stderr-only deny output" + stop=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$config") + assert_contains "$stop" 'DEVIN_PROJECT_DIR' "Devin Stop hook omitted the native project root" + assert_contains "$stop" 'git rev-parse --show-toplevel' "Devin Stop hook omitted the Git-root fallback" + assert_contains "$stop" 'fm-turnend-guard.sh' "Devin Stop hook omitted shared guard" + [ "$(jq '[.hooks.Stop[].hooks[] | select(.command | contains("fm-turnend-guard.sh"))] | length' "$config")" -eq 1 ] \ + || fail "tracked Devin config does not have exactly one Devin Stop owner" + pass "tracked Devin config owns exactly one Stop hook and disables Claude import" +} + +test_primary_pretool_hook_blocks() { + local config command payload out err rc + config="$ROOT/.devin/config.json" + command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$config") + payload="$TMP_ROOT/devin-pretool.json" + out="$TMP_ROOT/devin-pretool.out" + err="$TMP_ROOT/devin-pretool.err" + printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"exec","tool_input":{"command":"bin/fm-watch-arm.sh &"}}' > "$payload" + (trap - EXIT; cd "$ROOT" && bash -c "$command") < "$payload" >"$out" 2>"$err" + rc=$? + expect_code 2 "$rc" "Devin tracked PreToolUse adapter must preserve checker denial" + [ ! -s "$out" ] || fail "Devin deny must keep stdout empty: $(cat "$out")" + jq -e '.hookSpecificOutput.permissionDecision == "deny" and (.systemMessage | contains("[watcher-background]"))' "$err" >/dev/null \ + || fail "Devin deny did not preserve the stable checker response: $(cat "$err")" + pass "tracked Devin PreToolUse adapter blocks unsafe watcher commands with stderr-only output" +} + +test_launch_templates_preserve_workspace_trust() { + local source + source=$(cat "$SPAWN") + [ "$(printf '%s' "$source" | grep -o -- '--respect-workspace-trust true' | wc -l | tr -d ' ')" -eq 2 ] \ + || fail "Devin ordinary and secondmate launch templates do not both preserve workspace trust" + assert_not_contains "$source" '--respect-workspace-trust false' "a Devin launch template still bypasses workspace trust" + pass "ordinary and secondmate Devin launches preserve once-per-worktree trust" +} + +test_primary_hooks_anchor_from_nested_cwd() { + local fixture nested command script marker mode + fixture="$TMP_ROOT/root with spaces" + nested="$fixture/nested/cwd" + marker="$fixture/hook-invocations" + mkdir -p "$fixture/.devin" "$fixture/bin" "$nested" + cp "$ROOT/.devin/config.json" "$fixture/.devin/config.json" + git -C "$fixture" init -q + for script in fm-arm-pretool-check.sh fm-cd-pretool-check.sh fm-turnend-guard.sh fm-sessionstart-nudge.sh; do + # shellcheck disable=SC2016 # fixture script expands these values when its hook runs + printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "$(basename "$0")" >> "$FM_DEVIN_HOOK_MARKER"' '[ "$(basename "$0")" = fm-sessionstart-nudge.sh ] && printf "%s\n" "FIRSTMATE_OP: v1 session-start: probe"' > "$fixture/bin/$script" + chmod +x "$fixture/bin/$script" + done + for mode in env fallback; do + : > "$marker" + while IFS= read -r command; do + if [ "$mode" = env ]; then + out=$(trap - EXIT; cd "$nested" && DEVIN_PROJECT_DIR="$fixture" FM_DEVIN_HOOK_MARKER="$marker" bash -c "$command") + else + out=$(trap - EXIT; cd "$nested" && DEVIN_PROJECT_DIR='' FM_DEVIN_HOOK_MARKER="$marker" bash -c "$command") + fi + case "$command" in + *fm-sessionstart-nudge.sh*) + jq -e '.add_context == "FIRSTMATE_OP: v1 session-start: probe"' <<<"$out" >/dev/null \ + || fail "Devin $mode SessionStart hook did not return add_context JSON: $out" ;; + esac + done < <(jq -r '.hooks.SessionStart[].hooks[].command, .hooks.PreToolUse[].hooks[].command, .hooks.Stop[].hooks[].command' "$fixture/.devin/config.json") + [ "$(wc -l < "$marker" | tr -d ' ')" -eq 4 ] \ + || fail "Devin $mode root resolution did not invoke all four hooks from a nested cwd: $(cat "$marker")" + done + pass "all Devin hooks resolve quoted project roots from nested working directories" +} + +test_spawn_control_character_state_path() { + local d home proj wt state fakebin id log out config + d="$TMP_ROOT/control-path" + home="$d/home" + proj="$d/project" + wt="$d/wt" + state="$d/state"$'\n'"control" + id=devin-control-x1 + log="$d/tmux.log" + fakebin=$(fm_fakebin "$d/fake") + mkdir -p "$home/data/$id" "$home/projects" "$home/config" "$state" + state=$(cd "$state" && pwd -P) + printf 'brief\n' > "$home/data/$id/brief.md" + fm_git_worktree "$proj" "$wt" "fm/$id" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "$FM_FAKE_PANE_PATH"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows|has-session|new-session|new-window|send-keys|set-buffer|paste-buffer|delete-buffer|kill-window) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse gh-axi gh + out=$(XDG_CONFIG_HOME="$d/xdg" FM_HOME="$home" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" FM_FAKE_TMUX_LOG="$log" \ + TMUX='fake,1,0' PATH="$fakebin:$PATH" "$SPAWN" "$id" "$proj" \ + --harness devin 2>&1) + assert_contains "$out" "spawned $id harness=devin" "Devin spawn with a control-character state path did not succeed" + config="$state/$id.devin-config.json" + jq -e --arg state "$state" \ + '[.hooks.Stop[].hooks[] | select(.command | contains($state))] | length == 1' "$config" >/dev/null \ + || fail "Devin config did not preserve a control-character state path as valid JSON: $(cat "$config")" + pass "Devin config JSON-escapes control characters in state paths" +} + +test_spawn_launch_and_turnend_config() { + local d home proj wt fakebin id out log config project_config user_config user_config_before task_command config_mode + d="$TMP_ROOT/spawn" + home="$d/home" + proj="$d/project" + wt="$d/wt" + id=devin-spawn-x1 + log="$d/tmux.log" + fakebin=$(fm_fakebin "$d/fake") + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" + home=$(cd "$home" && pwd -P) + printf 'brief\n' > "$home/data/$id/brief.md" + fm_git_worktree "$proj" "$wt" "fm/$id" + project_config="$wt/.devin/config.json" + mkdir -p "$(dirname "$project_config")" + printf '%s\n' '{"version":1,"model":"repo-model","hooks":{"PreToolUse":[{"matcher":"exec","hooks":[{"type":"command","command":"repo-safety-hook"}]}],"Stop":[{"hooks":[{"type":"command","command":"repo-stop-hook"}]}]}}' > "$project_config" + user_config="$d/xdg/devin/config.json" + user_config_before="$d/user-config.before.jsonc" + mkdir -p "$(dirname "$user_config")" + task_command="touch '$home/state/$id.turn-ended'" + printf '%s\n' \ + '{' \ + ' // Devin documents line comments in user configuration.' \ + ' "version": 1,' \ + ' "theme_mode": "dark",' \ + ' "literal": "keep // and /* comment-like */ text",' \ + ' "read_config_from": {"claude": true},' \ + ' /* Block comments and trailing commas are accepted JSONC syntax. */' \ + ' "hooks": {' \ + ' "PreToolUse": [{"matcher":"exec","hooks":[{"type":"command","command":"user-safety-hook"},],},],' \ + " \"Stop\": [{\"hooks\":[{\"type\":\"command\",\"command\":\"user-stop-hook\"},],},{\"hooks\":[{\"type\":\"command\",\"command\":\"$task_command\"},],},]," \ + ' },' \ + '}' > "$user_config" + cp "$user_config" "$user_config_before" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "$FM_FAKE_PANE_PATH"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows|has-session|new-session|new-window|send-keys|set-buffer|paste-buffer|delete-buffer|kill-window) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + cat > "$fakebin/jq" <<'SH' +#!/usr/bin/env bash +exit 97 +SH + chmod +x "$fakebin/jq" + fm_fake_exit0 "$fakebin" treehouse gh-axi gh + out=$(XDG_CONFIG_HOME="$d/xdg" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" FM_FAKE_TMUX_LOG="$log" \ + TMUX='fake,1,0' PATH="$fakebin:$PATH" "$SPAWN" "$id" "$proj" \ + --harness devin --model swe-1.6 2>&1) + assert_contains "$out" "spawned $id harness=devin" "Devin spawn did not succeed" + assert_grep 'DEVIN_CLI=1 devin' "$log" "Devin launch marker/command missing" + assert_grep '--permission-mode dangerous' "$log" "Devin launch is not autonomous" + assert_grep '--respect-workspace-trust true' "$log" "Devin launch bypassed workspace trust" + assert_not_contains "$(cat "$log")" '--respect-workspace-trust false' "Devin launch explicitly disabled workspace trust" + assert_grep "--model 'swe-1.6'" "$log" "Devin model flag missing" + assert_grep '--prompt-file' "$log" "Devin prompt-file launch missing" + config="$home/state/$id.devin-config.json" + [ -f "$config" ] || fail "Devin per-task config was not created" + if [ "$(uname)" = Darwin ]; then config_mode=$(stat -f '%Lp' "$config"); else config_mode=$(stat -c '%a' "$config"); fi + [ "$config_mode" = 600 ] || fail "Devin per-task config permissions are not 0600" + jq -e '.theme_mode == "dark" + and .literal == "keep // and /* comment-like */ text" + and .read_config_from.claude == false + and .hooks.PreToolUse[0].matcher == "exec" + and .hooks.PreToolUse[0].hooks[0].command == "user-safety-hook" + and .hooks.Stop[0].hooks[0].command == "user-stop-hook" + and ([.hooks.Stop[].hooks[] | select(.command | contains(".turn-ended"))] | length == 1) + and (has("model") | not)' "$config" >/dev/null \ + || fail "Devin task config did not preserve only user settings and the task hook: $(cat "$config")" + jq -e '.model == "repo-model" and (.hooks.Stop | length == 1)' "$project_config" >/dev/null \ + || fail "spawn modified the project-local Devin config: $(cat "$project_config")" + cmp -s "$user_config_before" "$user_config" \ + || fail "spawn modified the JSONC user-level Devin config" + pass "fm-spawn composes JSONC user config with one task hook and preserves native project config" +} + +test_invalid_user_config_remains_recoverable() { + local name contents expected d home proj wt fakebin id out rc log config + for name in malformed unterminated-comment root-null root-array import-null import-array hooks-null hooks-array stop-null stop-shape; do + case "$name" in + malformed) contents='{"hooks":' ; expected='Unexpected end of JSON input' ;; + unterminated-comment) contents='{"version":1, /* unfinished' ; expected='unterminated block comment' ;; + root-null) contents='null' ; expected='config root must be an object' ;; + root-array) contents='[]' ; expected='config root must be an object' ;; + import-null) contents='{"read_config_from":null}' ; expected='read_config_from must be an object' ;; + import-array) contents='{"read_config_from":[]}' ; expected='read_config_from must be an object' ;; + hooks-null) contents='{"hooks":null}' ; expected='hooks must be an object' ;; + hooks-array) contents='{"hooks":[]}' ; expected='hooks must be an object' ;; + stop-null) contents='{"hooks":{"Stop":null}}' ; expected='hooks.Stop must be an array' ;; + stop-shape) contents='{"hooks":{"Stop":{}}}' ; expected='hooks.Stop must be an array' ;; + esac + d="$TMP_ROOT/$name" + home="$d/home" + proj="$d/project" + wt="$d/wt" + id="devin-$name-x1" + log="$d/tmux.log" + fakebin=$(fm_fakebin "$d/fake") + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" + printf 'brief\n' > "$home/data/$id/brief.md" + fm_git_worktree "$proj" "$wt" "fm/$id" + config="$d/xdg/devin/config.json" + mkdir -p "$(dirname "$config")" + printf '%s\n' "$contents" > "$config" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "$FM_FAKE_PANE_PATH"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows|has-session|new-session|new-window|send-keys|set-buffer|paste-buffer|delete-buffer|kill-window) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse gh-axi gh + out=$(XDG_CONFIG_HOME="$d/xdg" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" FM_FAKE_TMUX_LOG="$log" \ + TMUX='fake,1,0' PATH="$fakebin:$PATH" "$SPAWN" "$id" "$proj" \ + --harness devin 2>&1) + rc=$? + [ "$rc" -ne 0 ] || fail "$name Devin config unexpectedly spawned" + assert_contains "$out" "error: invalid Devin config at $config: $expected" "$name diagnostic was not actionable" + [ -f "$home/state/$id.meta" ] || fail "$name failure did not persist recoverable task metadata" + grep '^worktree=/' "$home/state/$id.meta" >/dev/null || fail "$name metadata omitted the isolated worktree" + done + pass "invalid Devin user configs leave endpoints and worktrees recoverable" +} + +test_lock_recognizes_devin_holder() { + local home fakebin out + home="$TMP_ROOT/lock-home" + fakebin=$(fm_fakebin "$TMP_ROOT/lock-fake") + mkdir -p "$home/state" + printf '%s\n' "$$" > "$home/state/.lock" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +case "$*" in + *"comm="*) printf '%s\n' '/opt/devin/bin/devin'; exit 0 ;; + *"args="*) printf '%s\n' 'devin'; exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/ps" + out=$(FM_HOME="$home" PATH="$fakebin:$PATH" "$ROOT/bin/fm-lock.sh" status) + assert_contains "$out" "lock: held by live harness pid" "fm-lock did not recognize Devin" + pass "fm-lock recognizes Devin harness processes" +} + +test_lock_rejects_unrelated_devin_argv() { + local home fakebin out + home="$TMP_ROOT/unrelated-lock-home" + fakebin=$(fm_fakebin "$TMP_ROOT/unrelated-lock-fake") + mkdir -p "$home/state" + printf '%s\n' "$$" > "$home/state/.lock" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +case "$*" in + *"comm="*) printf '%s\n' '/usr/local/bin/node'; exit 0 ;; + *"args="*) printf '%s\n' 'node /srv/worker.js --cache /tmp/devin-state'; exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/ps" + out=$(FM_HOME="$home" PATH="$fakebin:$PATH" "$ROOT/bin/fm-lock.sh" status) + assert_contains "$out" "lock: stale" "fm-lock treated an unrelated Devin argv substring as a harness" + pass "fm-lock rejects unrelated argv containing Devin" +} + +test_lock_recognizes_devin_interpreter_script() { + local home fakebin out + home="$TMP_ROOT/interpreter-lock-home" + fakebin=$(fm_fakebin "$TMP_ROOT/interpreter-lock-fake") + mkdir -p "$home/state" + printf '%s\n' "$$" > "$home/state/.lock" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +case "$*" in + *"comm="*) printf '%s\n' '/usr/local/bin/node'; exit 0 ;; + *"args="*) printf '%s\n' 'node /opt/devin/lib/devin'; exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/ps" + out=$(FM_HOME="$home" PATH="$fakebin:$PATH" "$ROOT/bin/fm-lock.sh" status) + assert_contains "$out" "lock: held by live harness pid" "fm-lock did not recognize an interpreter-launched Devin script" + pass "fm-lock recognizes Devin interpreter scripts" +} + +test_busy_signature_is_scoped_to_devin() { + source "$ROOT/bin/fm-tmux-lib.sh" + printf '%s\n' 'Working (3s - esc to interrupt)' | fm_busy_lines_match devin \ + || fail "Devin busy signature was not recognized" + if printf '%s\n' 'Working...' | fm_busy_lines_match devin; then + fail "Devin borrowed Pi's busy signature" + fi + pass "Devin busy detection uses its verified signature" +} + +test_detection_marker +test_primary_hook_wiring +test_primary_pretool_hook_blocks +test_launch_templates_preserve_workspace_trust +test_primary_hooks_anchor_from_nested_cwd +test_spawn_launch_and_turnend_config +test_spawn_control_character_state_path +test_invalid_user_config_remains_recoverable +test_lock_recognizes_devin_holder +test_lock_rejects_unrelated_devin_argv +test_lock_recognizes_devin_interpreter_script +test_busy_signature_is_scoped_to_devin diff --git a/tests/fm-harness-process.test.sh b/tests/fm-harness-process.test.sh new file mode 100755 index 0000000000..3bbe046451 --- /dev/null +++ b/tests/fm-harness-process.test.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Tests for shared harness process-shape classification. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=bin/fm-harness-process.sh +. "$ROOT/bin/fm-harness-process.sh" + +expect_process() { + local expected=$1 comm=$2 args=$3 out rc=0 + out=$(fm_harness_process_name "$comm" "$args") || rc=$? + [ "$rc" -eq 0 ] || fail "$comm / $args was not recognized" + [ "$out" = "$expected" ] || fail "$comm / $args resolved '$out', expected '$expected'" +} + +reject_process() { + local comm=$1 args=$2 + if fm_harness_process_name "$comm" "$args" >/dev/null; then + fail "$comm / $args was incorrectly recognized" + fi +} + +test_accepted_process_shapes() { + expect_process claude /opt/bin/claude claude + expect_process claude /opt/bin/claude-code claude-code + expect_process claude /usr/bin/node 'node /opt/claude-code/cli.js' + expect_process codex /usr/bin/node 'node /opt/tools/codex.js' + expect_process devin /usr/bin/python3 'python3 /opt/devin/cli.py' + expect_process devin /opt/bin/devin devin + pass "shared matcher accepts verified executables and harness-bearing scripts" +} + +test_rejected_process_shapes() { + reject_process /opt/bin/devinventory devinventory + reject_process /usr/bin/node 'node /opt/worker.js --state /tmp/devin-state' + reject_process /usr/bin/node 'node /tmp/devin-state/worker.js' + reject_process /usr/bin/python3 'python3 /tmp/code-indexer.py --label codex' + pass "shared matcher rejects unrelated harness substrings" +} + +test_harness_ancestry_uses_shared_shapes() { + local fakebin out + fakebin=$(fm_fakebin "$(fm_test_tmproot fm-harness-process)") + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +case "$*" in + *"comm="*) printf '%s\n' '/usr/bin/node' ;; + *"args="*) printf '%s\n' "${FM_TEST_ARGS:?}" ;; + *"ppid="*) printf '%s\n' 1 ;; +esac +SH + chmod +x "$fakebin/ps" + out=$(env -u DEVIN_CLI -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + FM_TEST_ARGS='node /opt/claude-code/cli.js' PATH="$fakebin:$PATH" "$ROOT/bin/fm-harness.sh") + [ "$out" = claude ] || fail "ancestry did not recognize Claude interpreter shape: $out" + out=$(env -u DEVIN_CLI -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + FM_TEST_ARGS='node /tmp/worker.js --state /tmp/devin-state' PATH="$fakebin:$PATH" "$ROOT/bin/fm-harness.sh") + [ "$out" = unknown ] || fail "ancestry accepted unrelated Devin argv: $out" + pass "fm-harness ancestry delegates to the shared matcher" +} + +test_accepted_process_shapes +test_rejected_process_shapes +test_harness_ancestry_uses_shared_shapes diff --git a/tests/fm-herdr-lab.test.sh b/tests/fm-herdr-lab.test.sh index 14ab7497a0..f60fd5e773 100755 --- a/tests/fm-herdr-lab.test.sh +++ b/tests/fm-herdr-lab.test.sh @@ -33,13 +33,21 @@ lab_state=absent case "$1 ${2:-}" in "session list") + default_state=${FM_FAKE_HERDR_DEFAULT_STATE:-running} + default_sessions='[]' + if [ "$default_state" != absent ]; then + default_running=false + [ "$default_state" = running ] && default_running=true + default_sessions=$(jq -nc --arg socket "$default_socket" --argjson running "$default_running" \ + '[{default:true,name:"default",running:$running,socket_path:$socket}]') + fi if [ "$lab_state" = absent ] || [ "$lab_state" = deleted ]; then - jq -nc --arg socket "$default_socket" '{sessions:[{default:true,name:"default",running:true,socket_path:$socket}]}' + jq -nc --argjson defaults "$default_sessions" '{sessions:$defaults}' else running=false [ "$lab_state" = running ] && running=true - jq -nc --arg socket "$default_socket" --arg name "$session" --argjson running "$running" \ - '{sessions:[{default:true,name:"default",running:true,socket_path:$socket},{default:false,name:$name,running:$running,socket_path:("/tmp/" + $name + ".sock")}]}' + jq -nc --argjson defaults "$default_sessions" --arg name "$session" --argjson running "$running" \ + '{sessions:($defaults + [{default:false,name:$name,running:$running,socket_path:("/tmp/" + $name + ".sock")}])}' fi ;; "server --session") @@ -82,6 +90,7 @@ run_with_fake() { FM_FAKE_HERDR_SERVER_DELAY="${FM_FAKE_HERDR_SERVER_DELAY:-0}" \ FM_FAKE_HERDR_FAST_POLL="${FM_FAKE_HERDR_FAST_POLL:-}" \ FM_FAKE_HERDR_DELETE_FAIL="${FM_FAKE_HERDR_DELETE_FAIL:-}" \ + FM_FAKE_HERDR_DEFAULT_STATE="${FM_FAKE_HERDR_DEFAULT_STATE:-running}" \ FM_HERDR_LAB_STATE_DIR="$TRIPWIRES" \ "$@" } @@ -100,6 +109,26 @@ test_refuses_unsafe_names() { pass "fm-herdr-lab: names fail closed and require the lab prefix" } +test_absent_default_baseline_is_guarded() { + local name="fm-lab-no-default-$$" + FM_FAKE_HERDR_DEFAULT_STATE=absent run_with_fake fm_herdr_lab_provision "$name" \ + || fail "absent-default baseline could not provision" + [ "$(cat "$TRIPWIRES/$name.fleet-state.json")" = '{"default_session":null}' ] \ + || fail "absent-default baseline was not recorded" + FM_FAKE_HERDR_DEFAULT_STATE=absent run_with_fake fm_herdr_lab_teardown "$name" \ + || fail "absent-default baseline could not teardown" + assert_absent "$TRIPWIRES/$name.fleet-state.json" "absent-default teardown left its tripwire" + pass "fm-herdr-lab: absent default is recorded and guarded through teardown" +} + +test_stopped_default_is_rejected() { + local name="fm-lab-stopped-default-$$" status=0 + FM_FAKE_HERDR_DEFAULT_STATE=stopped run_with_fake fm_herdr_lab_prepare "$name" >/dev/null 2>&1 || status=$? + expect_code 1 "$status" "stopped default must not be accepted as an absent baseline" + assert_absent "$TRIPWIRES/$name.fleet-state.json" "failed prepare left an empty tripwire" + pass "fm-herdr-lab: stopped default remains a hard failure" +} + test_provision_run_and_guarded_teardown() { local name='' line_count status=0 stop_line delete_line name="fm-lab-behavior-$$" @@ -235,6 +264,8 @@ SH } test_refuses_unsafe_names +test_absent_default_baseline_is_guarded +test_stopped_default_is_rejected test_provision_run_and_guarded_teardown test_missing_tripwire_blocks_destruction test_changed_default_trips_after_teardown diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index ed35663896..1dffce11e1 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -96,6 +96,17 @@ SH test_tmux_agent_state_classifies() { local fb out + fb=$(make_probe_tmux "$TMP_ROOT/tmux-devin" devin) + [ "$(PATH="$fb:$BASE_PATH" bash -c '. "$0/bin/fm-backend.sh"; fm_backend_source tmux; fm_backend_tmux_agent_alive sess:win' "$ROOT")" = alive ] \ + || fail "a live devin foreground process should classify as alive" + + fb=$(make_probe_tmux "$TMP_ROOT/tmux-devinventory" devinventory) + [ "$(PATH="$fb:$BASE_PATH" bash -c '. "$0/bin/fm-backend.sh"; fm_backend_source tmux; fm_backend_tmux_agent_alive sess:win' "$ROOT")" = unknown ] \ + || fail "an unrelated devinventory foreground process should classify as unknown" + + fb=$(make_probe_tmux "$TMP_ROOT/tmux-zsh" zsh) + [ "$(PATH="$fb:$BASE_PATH" bash -c '. "$0/bin/fm-backend.sh"; fm_backend_source tmux; fm_backend_tmux_agent_alive sess:win' "$ROOT")" = dead ] \ + || fail "a bare zsh foreground process should classify as dead" for harness in claude codex opencode grok kimi pi pi-signed pi-launcher Pi; do fb=$(make_probe_tmux "$TMP_ROOT/tmux-$harness" "$harness") diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 9bcf04eddb..b4b33041b5 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -406,18 +406,19 @@ SH # Drop every harness env marker from bin/fm-harness.sh detect_own so the # surrounding interactive shell cannot leak past the suite's fake ps harness. # Markers today: CLAUDECODE (claude), PI_CODING_AGENT plus FM_PI_HARNESS -# (Pi family), GROK_AGENT (grok). +# (Pi family), GROK_AGENT (grok), DEVIN_CLI (devin). # codex and opencode have no env markers (ancestry only). Without this, a local -# claude/pi/grok session fails cases that pin a different fake harness while CI +# claude/pi/grok/devin session fails cases that pin a different fake harness while CI # (no ambient markers) still passes. run_session_start() { local home=$1 root=$2 path=$3 pi_harness=${4:-} if [ -n "$pi_harness" ]; then - env -u CLAUDECODE -u GROK_AGENT PI_CODING_AGENT=true FM_PI_HARNESS="$pi_harness" \ + env -u CLAUDECODE -u GROK_AGENT -u DEVIN_CLI \ + PI_CODING_AGENT=true FM_PI_HARNESS="$pi_harness" \ FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ "$SESSION_START" else - env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT -u DEVIN_CLI \ FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ "$SESSION_START" fi diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index e8e5f4f919..5705cd7fd7 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -155,6 +155,20 @@ test_grok_command_sources_effective_config() { pass "grok rendered command sources the effective x-mode config" } +test_devin_uses_foreground_checkpoint() { + local out + out=$(FM_CODEX_WATCH_CHECKPOINT=7 "$RENDER" --harness devin) + assert_contains "$out" "Mode: Devin foreground checkpoint." "devin snippet missing foreground mode" + assert_contains "$out" "bin/fm-watch-checkpoint.sh --seconds 7" "devin snippet did not use the checkpoint override" + assert_not_contains "$out" "__FM_CODEX_WATCH_CHECKPOINT__" "devin snippet leaked the checkpoint placeholder" + assert_contains "$out" ".devin/config.json" "devin snippet missing native hook seatbelt" + assert_not_contains "$out" "background: true" "devin must not claim an unverified background wake" + out=$(FM_CODEX_WATCH_CHECKPOINT=7 "$RENDER" --harness devin --repair-line) + assert_contains "$out" "foreground checkpoint" "devin repair line is not checkpoint-shaped" + assert_contains "$out" "bin/fm-watch-checkpoint.sh --seconds 7" "devin repair line did not use the checkpoint override" + pass "devin supervision uses bounded foreground checkpoints" +} + test_pi_snippet_uses_effective_extension_path() { local home out turnend watch home="$TMP_ROOT/pi-home" @@ -179,4 +193,5 @@ test_cross_harness_ordinary_continuation_and_repair_matrix test_pi_signed_preserves_identity_with_pi_supervision_protocol test_grok_is_background_notify test_grok_command_sources_effective_config +test_devin_uses_foreground_checkpoint test_pi_snippet_uses_effective_extension_path