Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,8 @@ An interrupted turn fires `stop` twice for one generation (`aborted` then `error
Launch-template rule: the launch command MUST let the shell exec `cursor-agent` as its sole/last command with no trailing `;` command.
The launcher execs node under `exec -a "$0"`, so with a clean exec the pane's `#{pane_current_command}` reports `cursor-agent` (which `bin/backends/tmux.sh`'s alive-set matches); any trailing shell command leaves the pane leader as `bash` and breaks liveness.

Attribution (hard gate): cursor defaults `attribution.attributeCommitsToAgent` and `attributePRsToAgent` to `true` in the global `~/.cursor/cli-config.json`, producing `Co-authored-by: Cursor <cursoragent@cursor.com>`, which violates `AGENTS.md`.
`fm-spawn` neutralizes this per worktree with a gitignored `.cursor/cli.json` project config (`{"attribution":{"attributeCommitsToAgent":false,"attributePRsToAgent":false}}`) that cursor reads by default and merges over the global (its `--disable-project-configs` flag documents that default read), so no destructive or racy edit of the shared global config is needed and the override is removed with the worktree.
Attribution (hard gate): cursor's server-driven attribution runs `git commit --trailer "Co-authored-by: Cursor <cursoragent@cursor.com>"`, which violates `AGENTS.md`.
The only config that suppresses it is `attribution.attributeCommitsToAgent`/`attributePRsToAgent` in the GLOBAL `~/.cursor/cli-config.json` (the project `.cursor/cli.json` schema accepts only `permissions`, so an `attribution` block there is rejected with `Unrecognized key(s)` and crashes the launch); cursor also rewrites that global file at runtime and shares it across concurrent workers, so no cursor config can neutralize attribution safely.
Instead `fm-spawn` installs a per-task git `commit-msg` hook under `state/<id>.cursor-git-hooks/` that strips any `Co-authored-by:` trailer in cursor's agent namespace (`@cursor.com`/`@cursor.sh`) and reaches it only for the cursor worker through an env-injected `core.hooksPath` (git resolves a linked worktree's hooks from the shared common dir, so a per-worktree hooks dir would not fire); the hook touches no cursor config, never races another worker, leaves human co-authors intact, and is removed at teardown.
The `→` composer glyph is handled by `bin/fm-composer-lib.sh` (a `→` agent-glyph arm) and `bin/fm-tmux-lib.sh` (the empty-box geometry proof), so a dim, idle cursor composer classifies empty rather than as pending input.
`docs/verification/runtime-backends.md` owns the dated live-verification evidence, and `data/cursor-verify/report.md` holds the raw probe transcript.
4 changes: 2 additions & 2 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -888,15 +888,15 @@ 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","cursor"] | index($h);
def effort_ok($h; $e):
if $e == null then true
elif ($e | type) != "string" then false
elif $h == "claude" then (["low","medium","high","xhigh","max"] | index($e))
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 == "cursor" then false
else true
end;
def profiles($value):
Expand Down
72 changes: 57 additions & 15 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,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|cursor)
# 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
Expand Down Expand Up @@ -135,8 +135,9 @@
# plus a gitignored .fm-grok-turnend worktree pointer and a state token.
# cursor uses firstmate-owned entries in $HOME/.cursor/hooks.json plus a global
# registry, a gitignored .fm-cursor-turnend worktree pointer and a state token,
# and a gitignored .cursor/cli.json that neutralizes commit/PR agent attribution
# for that worktree only.
# and a per-task commit-msg hook (reached via env-injected core.hooksPath) that
# strips cursor-agent's Co-authored-by attribution trailer without touching any
# cursor config; the hook dir lives under state/ and is removed at teardown.
# On success prints: spawned <id> harness=<name> kind=<ship|scout|secondmate> [mode=<mode> yolo=<on|off>] window=<backend-target> worktree=<path>
# A ship task records the explicit mode/yolo it was passed; a secondmate spawn records
# mode=secondmate, yolo=off, home=, and projects=; a scout records neither, and both the
Expand Down Expand Up @@ -2047,18 +2048,46 @@ EOF
printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-cursor-turnend"
exclude_path '.fm-cursor-turnend'
# Attribution neutralization (AGENTS.md: never add an agent name as a commit
# co-author). cursor defaults attribution.attributeCommitsToAgent and
# attributePRsToAgent to true in the GLOBAL ~/.cursor/cli-config.json, which
# cursor rewrites at runtime and which concurrent workers share. Instead of a
# destructive or racy global edit, drop a per-worktree project config
# (.cursor/cli.json) that cursor reads by default and merges over the global
# (proven by cursor's own --disable-project-configs flag: "Ignore
# .cursor/cli.json files"), setting both flags false for this worktree only.
# It is gitignored and removed with the worktree, so no restore is needed and
# concurrent cursor tasks never race.
mkdir -p "$WT/.cursor"
printf '%s\n' '{"version":1,"attribution":{"attributeCommitsToAgent":false,"attributePRsToAgent":false}}' > "$WT/.cursor/cli.json"
exclude_path '.cursor/cli.json'
# co-author). cursor's server-driven attribution runs `git commit --trailer
# "Co-authored-by: Cursor <cursoragent@cursor.com>"`; the only config that
# suppresses it lives in the GLOBAL ~/.cursor/cli-config.json (the project
# .cursor/cli.json schema accepts only `permissions`, so an attribution block
# there crashes the launch), and cursor rewrites that global file at runtime
# and shares it across concurrent workers. So instead of any cursor config we
# install a per-task git commit-msg hook that strips the Cursor trailer from
# every commit this worker makes, and reach it through env-injected
# core.hooksPath below (git resolves worktree hooks from the shared common
# dir, so a per-worktree hooks dir would not fire). The hook lives under
# state/, outside the worktree, so it never shows in git status, never races
# another worker, and is removed by teardown.
CURSOR_GIT_HOOKS_DIR="$STATE_REAL/$ID.cursor-git-hooks"
mkdir -p "$CURSOR_GIT_HOOKS_DIR"
cat > "$CURSOR_GIT_HOOKS_DIR/commit-msg" <<'CURSOR_COMMIT_MSG_HOOK'
#!/usr/bin/env bash
# firstmate-owned per-task commit-msg hook (bin/fm-spawn.sh, cursor adapter).
# Strips cursor-agent's Co-authored-by attribution trailer so a firstmate-launched
# cursor crew never records an agent co-author (AGENTS.md: never add an agent name
# as a commit co-author). Reached only by the cursor worker via env-injected
# core.hooksPath; it edits the message in place, leaves every other line byte for
# byte, and never blocks a commit.
set -u
msg=${1:-}
[ -n "$msg" ] && [ -f "$msg" ] || exit 0
# A Co-authored-by trailer whose email is in cursor's agent namespace
# (cursoragent@cursor.com and the cursor.com/cursor.sh domains), case-insensitive.
# A human co-author never uses that address, so legitimate trailers are preserved.
re='^[[:space:]]*Co-authored-by:.*<[^>]*@cursor\.(com|sh)>[[:space:]]*$'
if grep -iqE "$re" "$msg" 2>/dev/null; then
tmp="$msg.fm-cursor.$$"
if grep -ivE "$re" "$msg" > "$tmp" 2>/dev/null; then
mv -f "$tmp" "$msg"
else
rm -f "$tmp" 2>/dev/null || true
fi
fi
exit 0
CURSOR_COMMIT_MSG_HOOK
chmod +x "$CURSOR_GIT_HOOKS_DIR/commit-msg"
;;
esac
fi
Expand Down Expand Up @@ -2211,6 +2240,19 @@ if [ -n "$SPAWN_TRACEPARENT" ]; then
fi
fi
fi
# Activate the cursor attribution-stripping commit-msg hook for the cursor worker
# by pointing git at its per-task hooks dir via the GIT_CONFIG_* environment (git
# ignores a per-worktree hooks dir but honors an env-injected core.hooksPath).
# Sent on the same GOTMPDIR channel so it lands before launch and is inherited by
# every git the cursor worker runs; the hook itself is a no-op for any commit
# without a Cursor trailer, so it never disturbs no-mistakes commits in the pane.
# Gated to non-secondmate exactly like the hook creation above, so the injected
# path always names a hook dir that was actually written.
if [ "$HARNESS" = cursor ] && [ "$KIND" != secondmate ]; then
spawn_send_text_line "$T" "export GIT_CONFIG_COUNT=1"
spawn_send_text_line "$T" "export GIT_CONFIG_KEY_0=core.hooksPath"
spawn_send_text_line "$T" "export GIT_CONFIG_VALUE_0=$(shell_quote "$STATE_REAL/$ID.cursor-git-hooks")"
fi
sleep 0.3
spawn_send_literal "$T" "$LAUNCH"
sleep 0.3
Expand Down
11 changes: 7 additions & 4 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,9 @@ remove_kimi_turnend_auth() {

remove_cursor_turnend_auth() {
local state_dir=$1 id=$2 token hooks_dir
# The per-task commit-msg hook dir (attribution neutralization) is keyed only on
# the task id, so remove it unconditionally, before the turn-end token gate below.
rm -rf "$state_dir/$id.cursor-git-hooks"
token=$(cat "$state_dir/$id.cursor-turnend-token" 2>/dev/null || true)
case "$token" in ''|*[!A-Za-z0-9._-]*) return 0 ;; esac
hooks_dir="$HOME/.cursor/fm-turn-end.d"
Expand Down Expand Up @@ -1628,15 +1631,15 @@ cleanup_firstmate_home_children() {
validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1
rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \
"$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" \
"$child_wt/.fm-cursor-turnend" "$child_wt/.cursor/cli.json"
"$child_wt/.fm-cursor-turnend"
fi
fm_backend_remove_worktree "$child_backend" "$child_orca_worktree_id" || return 1
elif [ -n "$child_wt" ] && [ -d "$child_wt" ]; then
validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1
rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \
"$child_wt/.opencode/plugins/fm-busy-state.js" \
"$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" \
"$child_wt/.fm-cursor-turnend" "$child_wt/.cursor/cli.json"
"$child_wt/.fm-cursor-turnend"
if [ -n "$child_proj" ] && [ -d "$child_proj" ] && command -v treehouse >/dev/null 2>&1; then
if teardown_treehouse_return "$child_wt" "$child_proj" "child worktree"; then
:
Expand Down Expand Up @@ -1806,7 +1809,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then
rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \
"$WT/.opencode/plugins/fm-busy-state.js" \
"$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" \
"$WT/.fm-cursor-turnend" "$WT/.cursor/cli.json"
"$WT/.fm-cursor-turnend"
fi
[ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true
fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID"
Expand All @@ -1820,7 +1823,7 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then
# Remove our hook file so a reused pool worktree cannot fire signals for a dead task.
rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \
"$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" \
"$WT/.fm-cursor-turnend" "$WT/.cursor/cli.json"
"$WT/.fm-cursor-turnend"
# Kills remaining processes in the worktree (including the agent), resets, returns
# to pool. treehouse resolves the pool from the working directory, so run it from
# the project. teardown_treehouse_return tolerates transient and stale git locks
Expand Down
11 changes: 8 additions & 3 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,20 +142,25 @@ The confirmed facts the adapter depends on:

- Liveness: a worker launched so the shell execs `cursor-agent` as its sole command reports `#{pane_current_command}` = `cursor-agent`, stable at idle and after a turn, so `bin/backends/tmux.sh`'s `*cursor*` alive-set arm matches; a trailing shell command instead leaves the pane leader as `bash`, which is why the launch template ends with `cursor-agent`.
- Busy/turn-end: the native `beforeSubmitPrompt` (busy) and `stop` (turn-end/idle) user hooks fire for the interactive worker, carry `workspace_roots[]`, `generation_id`, and `hook_event_name`, and an interrupted turn fires `stop` twice for one `generation_id` (`aborted` then `error`), so the installed hook dedupes `stop` by `generation_id`.
- Attribution: the global `~/.cursor/cli-config.json` defaults `attributeCommitsToAgent`/`attributePRsToAgent` to `true`; cursor reads a per-project `.cursor/cli.json` by default (its `--disable-project-configs` flag documents that default read) and merges it over the global, so a gitignored per-worktree `.cursor/cli.json` disabling both flags neutralizes attribution without a global edit.
- Attribution: cursor's server-driven attribution runs `git commit --trailer "Co-authored-by: Cursor <cursoragent@cursor.com>"`; the only suppressing config is `attributeCommitsToAgent`/`attributePRsToAgent` in the global `~/.cursor/cli-config.json`, but the project `.cursor/cli.json` schema accepts only `permissions` (an `attribution` block there is rejected with `Unrecognized key(s)` and crashes the launch) and cursor rewrites the shared global at runtime, so `fm-spawn` instead installs a per-task `commit-msg` hook (reached via env-injected `core.hooksPath`) that strips the Cursor trailer with no cursor-config edit. See the live proof below.
- Composer: the idle composer is a bordered box whose `→` (U+2192) glyph and `Add a follow-up` placeholder are both dim (SGR 2).

CI-enforced portable regressions:

```sh
tests/fm-cursor-harness.test.sh # launch template, hook guard + busy lifecycle + stop dedupe, teardown, attribution, detection, lock
tests/fm-cursor-harness.test.sh # launch template, hook guard + busy lifecycle + stop dedupe, teardown, commit-msg attribution hook + core.hooksPath wiring, detection, lock
tests/fm-busy-adapter-wiring.test.sh # cursor-hook is the only trusted cursor source
tests/fm-composer-ghost.test.sh # dim → glyph + Add a follow-up idle composer reads empty
tests/fm-composer-lib.test.sh # → agent-glyph classification
tests/fm-tmux-agent-liveness.test.sh # a cursor-agent foreground process classifies alive
```

The live trailer-free-commit proof (a cursor worker committing with no `Co-authored-by: Cursor` trailer) requires an interactive worker and is captured during the supervised cursor test-drive, since a crewmate cannot launch the cursor TUI in its own pane.
Live attribution proof (2026-08-05, cursor-agent 2026.07.23-e383d2b, git 2.53.0), run with an interactive worker in a separate tmux window because a crewmate cannot launch the cursor TUI in its own pane:

- Old mechanism crashes the launch: a throwaway repo carrying the merged adapter's project `.cursor/cli.json` (`{"version":1,"attribution":{...}}`) launched with `cursor-agent --force --trust --model composer-2.5 "..."` printed `Invalid project config ... Unrecognized key(s) in object: 'version', 'attribution'` and dropped the pane leader to `bash`, so cursor never started.
- New mechanism launches and strips the trailer: with no cursor config, the per-task `commit-msg` hook installed under `state/<id>.cursor-git-hooks/`, and `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=<hook dir>` exported before launch, the pane leader stayed `cursor-agent` (no crash) and the worker's own autonomous `git commit --trailer "Co-authored-by: Cursor <cursoragent@cursor.com>" -m ...` produced commit body `Add READY marker.` with no `Co-authored-by` trailer (`git log -1 --format=%B`), the hook being the sole reason the trailer is absent because cursor's default attribution stayed enabled.

Refresh this proof after a cursor-agent upgrade with the same two-repo drive (bad-config crash, then hooked trailer-free commit); a unit test cannot cover it because the trailer is server-driven and only a real worker emits the `--trailer` commit.

### Cleanup endpoint identity

Expand Down
2 changes: 2 additions & 0 deletions tests/fm-bootstrap.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,8 @@ pi-signed max effort is accepted^{"rules":[{"when":"signed coding","use":{"harne
unsupported opencode effort is flagged^{"rules":[{"when":"opencode work","use":{"harness":"opencode","model":"anthropic/claude-sonnet-4-5","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: opencode:high
kimi model profile is accepted^{"rules":[{"when":"kimi work","use":{"harness":"kimi","model":"kimi-code/k3"}}]}^empty^
unsupported kimi effort is flagged^{"rules":[{"when":"kimi work","use":{"harness":"kimi","model":"kimi-code/k3","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: kimi:high
cursor model profile is accepted^{"rules":[{"when":"cursor work","use":{"harness":"cursor","model":"composer-2.5"}}]}^empty^
unsupported cursor effort is flagged^{"rules":[{"when":"cursor work","use":{"harness":"cursor","model":"composer-2.5","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: cursor:high
array use with quota-balanced is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude","model":"claude-sonnet-5","effort":"high"},{"harness":"codex","model":"gpt-5.5","effort":"high"}],"select":"quota-balanced"}]}^empty^
array use without select is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude"},{"harness":"codex"}]}]}^empty^
one-element array use is accepted^{"rules":[{"when":"focused feature","use":[{"harness":"claude"}]}]}^empty^
Expand Down
Loading
Loading