Skip to content
Open
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,8 @@ Preserve durable structured identifiers, dependencies, and completion artifact l

## 11. Crewmate briefs

`bin/fm-brief.sh` and its help own scaffold syntax, generated variants, status protocol, delivery-mode definitions of done, and exact safety mechanics.
`bin/fm-brief.sh` and its help own scaffold syntax, generated variants, stop conditions, status protocol, delivery-mode definitions of done, and exact safety mechanics.
At intake, pass `--stop-condition '<text>'` for ship or scout work whose unattended, long-running, or overnight shape needs an additional task-specific stop condition beyond the standard stop conditions; omit it for ordinary work.
Use its scaffold as the contract, then replace every `{TASK}` placeholder with a clear task description, acceptance criteria, constraints, and necessary context before dispatch or seeding.
Keep additions task-specific rather than repeating lifecycle instructions, and alter generated sections only when the task genuinely differs from the standard shape.

Expand Down
4 changes: 2 additions & 2 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'}
# Known bare (unbordered) prompt glyphs a composer row may start with: ❯
# (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still
# recognized after a bordered composer row has already been structurally found.
FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'}
FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'}
# Pi allows a multi-line composer between its horizontal separators. Bound the
# structural candidate so two unrelated transcript rules with an arbitrarily
# large region between them can never be promoted into a composer.
Expand Down Expand Up @@ -903,7 +903,7 @@ EOF
fi
# Delegate the empty/pending/unknown decision to the shared owner. The bare
# shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE
# is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown'
# is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown'
# via the no-composer-row path above, exactly as before.
fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE"
}
Expand Down
57 changes: 53 additions & 4 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# description, acceptance criteria, and context, and may adjust other sections
# when the task genuinely deviates (e.g. working an existing external PR instead
# of shipping a new one).
# Usage: fm-brief.sh <task-id> <repo-name> [--scout] [--herdr-lab]
# Usage: fm-brief.sh <task-id> <repo-name> [--scout] [--herdr-lab] [--stop-condition '<text>']
# fm-brief.sh <task-id> --secondmate {<project>...|--no-projects}
# --scout writes the scout contract instead: the deliverable is a report at
# data/<task-id>/report.md (no branch, no push, no PR) and the worktree is scratch.
Expand All @@ -26,6 +26,8 @@
# The flag must be explicit because {TASK} is filled after scaffolding and the
# caller-supplied repo string cannot reliably identify this repo. Briefs made
# without it carry a loud declaration so an omitted contract cannot be silent.
# --stop-condition '<text>' adds a task-specific advisory stop condition to a
# crewmate ship or scout brief's standard stop conditions.
# For ship tasks, the definition of done is shaped by the project's delivery mode
# (data/projects.md via fm-project-mode.sh; see the project-management skill
# and AGENTS.md task lifecycle):
Expand Down Expand Up @@ -57,6 +59,13 @@ usage() {
' "$0"
}

is_option_token() {
case "${1:-}" in
-h|--help|--scout|--secondmate|--herdr-lab|--no-projects|--stop-condition) return 0 ;;
*) return 1 ;;
esac
}

case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac
Expand All @@ -73,23 +82,44 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
KIND=ship
HERDR_LAB=0
NO_PROJECTS=0
STOP_CONDITION=""
POS=()
for a in "$@"; do
case "$a" in
while [ "$#" -gt 0 ]; do
case "$1" in
--scout) KIND=scout ;;
--secondmate) KIND=secondmate ;;
--herdr-lab) HERDR_LAB=1 ;;
--no-projects) NO_PROJECTS=1 ;;
*) POS+=("$a") ;;
--stop-condition)
if [ "$#" -lt 2 ] || [ -z "$2" ] || is_option_token "$2"; then
echo "error: --stop-condition requires non-empty text" >&2
exit 1
fi
STOP_CONDITION=$2
shift
;;
*) POS+=("$1") ;;
esac
shift
done

if [ "$KIND" != secondmate ] && [ "${#POS[@]}" -gt 2 ]; then
echo "error: ship and scout briefs accept only <task-id> and <repo-name> as positional arguments" >&2
exit 1
fi

ID=${POS[0]}

if [ "$KIND" = secondmate ] && [ "$HERDR_LAB" -eq 1 ]; then
echo "error: --herdr-lab applies only to crewmate ship or scout briefs" >&2
exit 1
fi

if [ "$KIND" = secondmate ] && [ -n "$STOP_CONDITION" ]; then
echo "error: --stop-condition applies only to crewmate ship or scout briefs" >&2
exit 1
fi

if [ "$NO_PROJECTS" -eq 1 ] && [ "$KIND" != secondmate ]; then
echo "error: --no-projects applies only to --secondmate charters" >&2
exit 1
Expand Down Expand Up @@ -189,6 +219,21 @@ fi

REPO=${POS[1]}

STOP_CONDITIONS=$(cat <<'EOF'
## Stop conditions
These stop conditions are advisory guidance evaluated by the crewmate, not deterministically enforced limits.
Keep working while the task makes meaningful progress.
Expected waiting on a validation run or long test suite does not count as a no-phase-change stretch.
If roughly two hours or 50 significant actions pass without a supervisor-actionable phase change, append `blocked: stop condition - {one-line progress + what remains}` and stop.
If the work is clearly larger than this brief, append `blocked: scope - {one-line progress + newly discovered work}` and stop before silently expanding scope.
EOF
)
if [ -n "$STOP_CONDITION" ]; then
STOP_CONDITIONS="${STOP_CONDITIONS}
Task-specific stop condition: $STOP_CONDITION
Treat this as advisory guidance alongside the standard stop conditions."
fi

if [ "$HERDR_LAB" -eq 1 ]; then
HERDR_LAB_HELPER=$(shell_quote "$FM_ROOT/bin/fm-herdr-lab.sh")
# shellcheck disable=SC2016 # single quotes are deliberate: these lines are literal brief text whose backtick-wrapped $(...) and "$HERDR_LAB_SESSION" snippets must reach the reading agent verbatim, not expand at scaffold time; only the '"$VAR"' break-outs interpolate.
Expand Down Expand Up @@ -258,6 +303,8 @@ The report is the only thing that survives, so anything worth keeping must be in
every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes
daemon error, append \`blocked: {the daemon error}\` and stop; only firstmate manages the daemon.

$STOP_CONDITIONS

# Definition of done
Write your findings to \`$DATA/$ID/report.md\`.
The report must stand alone: what you did, what you found, the evidence (commands run, output, file:line references), and what you recommend.
Expand Down Expand Up @@ -366,6 +413,8 @@ $RULE1
every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes
daemon error, append \`blocked: {the daemon error}\` and stop; only firstmate manages the daemon.

$STOP_CONDITIONS

# Project memory
If \`AGENTS.md\` or \`CLAUDE.md\` already exists, or if this task produced durable project-intrinsic knowledge, run \`$FM_ROOT/bin/fm-ensure-agents-md.sh .\` in the worktree.
Record only project knowledge useful to almost every future session.
Expand Down
14 changes: 12 additions & 2 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,18 @@ fm_composer_classify_content() { # <bordered> <content> [idle_re] [idle_case] [
fi
# Strip a leading prompt glyph, then re-judge the remainder.
case "$content" in
'❯ '*|'› '*|'> '*|'$ '*|'% '*|'# '*) content=${content#??} ;;
'❯'*|'›'*|'>'*|'$'*|'%'*|'#'*) content=${content#?} ;;
'❯ '*) content=${content#'❯ '} ;;
'› '*) content=${content#'› '} ;;
'> '*) content=${content#'> '} ;;
'$ '*) content=${content#'$ '} ;;
'% '*) content=${content#'% '} ;;
'# '*) content=${content#'# '} ;;
'❯'*) content=${content#'❯'} ;;
'›'*) content=${content#'›'} ;;
'>'*) content=${content#'>'} ;;
'$'*) content=${content#'$'} ;;
'%'*) content=${content#'%'} ;;
'#'*) content=${content#'#'} ;;
esac
content="${content#"${content%%[![:space:]]*}"}"
content="${content%"${content##*[![:space:]]}"}"
Expand Down
25 changes: 23 additions & 2 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -843,11 +843,32 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
# Compare against PROJ_ABS_REAL (physical), not PROJ_ABS: a symlinked project
# prefix would otherwise make the pane's OS-level cwd read differ from
# PROJ_ABS on the very first poll, before the pane has actually moved.
invalid_candidate=
invalid_candidate_polls=0
for _ in $(seq 1 60); do
p=$(spawn_current_path "$WT_TARGET" || true)
if [ -n "$p" ] && [ "$(real_path_or_raw "$p")" != "$PROJ_ABS_REAL" ]; then
WT="$p"
break
p_top=$(git -C "$p" rev-parse --show-toplevel 2>/dev/null || true)
if [ -n "$p_top" ] && [ "$(real_path_or_raw "$p")" = "$(real_path_or_raw "$p_top")" ]; then
WT="$p"
break
fi
# A backend can briefly report an intermediate non-worktree cwd while
# treehouse is settling. Ignore one sample, but hand a stable invalid
# path to the isolation validator instead of misreporting a timeout.
if [ "$(real_path_or_raw "$p")" = "$invalid_candidate" ]; then
invalid_candidate_polls=$((invalid_candidate_polls + 1))
else
invalid_candidate=$(real_path_or_raw "$p")
invalid_candidate_polls=1
fi
if [ "$invalid_candidate_polls" -ge 2 ]; then
WT="$p"
break
fi
else
invalid_candidate=
invalid_candidate_polls=0
fi
sleep 1
done
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ The helper's header owns the exact signal detection, relocated-home limitation,
## Two task shapes

Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks investigate, plan, reproduce bugs, or audit, then leave a report at `data/<id>/report.md` and never push.
Both task shapes receive the advisory stop conditions owned by `fm-brief.sh`, and an optional `--stop-condition '<text>'` adds a task-specific stop condition for unattended or long-running work.

## Dispatch profiles

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ FM_BACKEND= # optional runtime backend override for new spawns; tmux
HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md)
FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state
FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping
FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)")
FM_BACKEND_HERDR_BARE_PROMPT_RE='^(❯|›)' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; alternation keeps the glyph match locale-safe under byte-oriented locales, shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)")
FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Incident (2026-07-14)")
FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Native agent-state submit confirmation")
FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-update.sh` | Fast-forward-only self-update of firstmate and secondmate homes from origin |
| `fm-backlog-handoff.sh` | Validate and delegate queued backlog-item moves into a secondmate home |
| `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions |
| `fm-brief.sh` | Scaffold ship, scout, secondmate-charter, and Herdr-lab briefs |
| `fm-brief.sh` | Scaffold ship and scout briefs with standard and task-specific advisory stop conditions, plus secondmate-charter and Herdr-lab briefs |
| `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session |
| `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` symlink, and the canonical self-governance section |
| `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and stale watcher liveness |
Expand Down
23 changes: 23 additions & 0 deletions tests/fm-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,28 @@ test_spawn_symlinked_project_prefix_avoids_false_refusal() {
pass "fm-spawn.sh: a project reached through a symlinked prefix (e.g. macOS /tmp -> /private/tmp) does not trip the isolation guard's false refusal"
}

test_spawn_ignores_transient_non_worktree_cwd() {
local proj wt data id state config fb log out rc
proj="$TMP_ROOT/transient-cwd-project"; wt="$TMP_ROOT/transient-cwd-wt"; data="$TMP_ROOT/transient-cwd-data"
id="spawntransient1"
fm_git_worktree "$proj" "$wt" "fm/$id"
fb=$(make_spawn_symlink_fakebin "$TMP_ROOT/transient-cwd-fake" / "$wt")
mkdir -p "$data/$id"
printf 'test brief content\n' > "$data/$id/brief.md"
state="$TMP_ROOT/transient-cwd-state"; config="$TMP_ROOT/transient-cwd-config"
mkdir -p "$state" "$config"
log="$TMP_ROOT/transient-cwd-spawn.log"

out=$(run_spawn_case "$ROOT" "$fb" "$log" "$state" "$data" "$config" "$proj" -- "$id" "$proj" claude 2>&1)
rc=$?
expect_code 0 "$rc" "fm-spawn.sh should ignore a transient non-worktree cwd while treehouse get is settling"$'\n'"$out"
assert_contains "$out" "worktree=$wt" \
"fm-spawn.sh did not continue polling through a transient non-worktree cwd"

rm -rf "/tmp/fm-$id"
pass "fm-spawn.sh: transient non-worktree cwd is ignored until treehouse reports the leased worktree"
}

# --- old vs new: fm-teardown.sh ----------------------------------------------

make_teardown_fakebin() { # <dir> -> echoes fakebin dir; logs tmux+treehouse calls
Expand Down Expand Up @@ -1084,6 +1106,7 @@ test_backend_of_selector_matches_explicit_target_meta
test_send_conformance_old_vs_new
test_peek_conformance_old_vs_new
test_spawn_symlinked_project_prefix_avoids_false_refusal
test_spawn_ignores_transient_non_worktree_cwd
test_teardown_conformance_old_vs_new
test_spawn_refuses_unknown_backend_flag
test_spawn_refuses_codex_app_backend_flag
Expand Down
7 changes: 5 additions & 2 deletions tests/fm-backlog-handoff.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ set -u
. "$(dirname "${BASH_SOURCE[0]}")/secondmate-helpers.sh"

# The move is delegated to `tasks-axi mv`, so this suite exercises the real
# binary. Skip cleanly when it is absent (matching the backend smoke suites).
command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found (required by the delegated handoff path)"; exit 0; }
# binary. Skip cleanly when it is absent or lacks the atomic multi-ID contract
# required by the delegated handoff path (matching the backend smoke suites).
# shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091
. "$ROOT/bin/fm-tasks-axi-lib.sh"
fm_tasks_axi_compatible || { echo "skip: compatible tasks-axi not found (required by the delegated handoff path)"; exit 0; }

TMP_ROOT=$(fm_test_tmproot fm-backlog-handoff)

Expand Down
Loading