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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ state/ volatile runtime signals; gitignored
<id>.grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown
<id>.kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown
<id>.parlay-listen-pid background `parlay listen` pid from a best-effort Parlay chat-panel enrollment at spawn (bin/fm-spawn.sh header); killed and removed by teardown
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); optional label= is recorded only when --label was passed at spawn; fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); optional label= is recorded only when --label was passed at spawn; optional beads_id= is recorded only when --beads was passed at spawn, driving the claim-first/close-last bead lifecycle (bin/fm-brief-hooks.d/beads.sh, bin/fm-teardown.sh header, bin/fm-ledger.sh) under section 7's bead-linked dispatch; fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
<id>.herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces"
<id>.check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution
<id>.check-trust private content binding created by fm-check-register.sh for an intentional custom check
Expand Down Expand Up @@ -268,6 +268,8 @@ Spawn only through `bin/fm-spawn.sh` after the profile and backend checks in sec
The spawn must resolve a genuine isolated task worktree distinct from the primary checkout; a failed isolation assertion stops the task.
After spawning, confirm the worker is processing the brief, handle any trust dialog through `harness-adapters`, and record ship or scout work as under way.
When spawning with `--beads <id>`, the task is linked to an external bead item for progress tracking on `mg` or similar tools; `fm-bead-stamp.sh` stamps the bead's `dispatch=sent` and `lifecycle=sent` state dimensions, and the generated brief includes instructions for the worker to confirm `dispatch=claimed` and `lifecycle=claimed` after reading, and to close the bead on completion.
`fm-teardown.sh` also closes the linked bead itself once a non-force teardown confirms the task's work landed, so a worker that cannot reach the closing step is still covered; `--force` and a refused teardown never close it.
`bin/fm-ledger.sh` is the fleet-wide safety net for a bead that falls outside both paths - claimed, still open, and gone quiet past its staleness window - and can list or close those likely-dropped beads.
A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item.

Steer a worker with short single-line messages through fail-closed `fm-send`; put long instructions in a file.
Expand Down
1 change: 1 addition & 0 deletions bin/fm-brief-hooks.d/beads.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ task set-state $FM_HOOK_BEADS_ID lifecycle=claimed --reason 'brief read and acce
# Bead Closure
Before appending \`done:\` to the status file, close this bead: \`task close $FM_HOOK_BEADS_ID\`.
That closure is what a registered watcher check uses to trigger your cleanup - do this as the last step before reporting done.
If you cannot reach this step, do not worry about it further: firstmate closes this bead automatically once your work is confirmed landed and this task is torn down.
SECTION
137 changes: 137 additions & 0 deletions bin/fm-ledger.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# fm-ledger.sh - surface beads whose linked work looks landed but were never
# closed, so they can be reviewed and closed instead of leaking open forever.
#
# A crewmate claims its linked bead on receipt (dispatch=claimed / lifecycle=claimed,
# see bin/fm-brief-hooks.d/beads.sh) and fm-teardown.sh now closes it automatically
# once that task's landed work is torn down (see fm-teardown.sh's header). This
# command is the safety net for everything that falls outside that automatic close -
# a --force teardown, a task that never reached teardown, or a bead claimed outside
# firstmate's own dispatch. A bead is "likely_dropped" when it is claimed, still
# open, and has gone quiet longer than --stale-days: whoever claimed it is very
# unlikely to still be working it.
#
# fm-teardown.sh deletes a task's local meta as its very last step, so this command
# cannot rely on local firstmate state to find a leaked bead - it derives
# likely_dropped purely from the beads store itself (labels, status, timestamps).
#
# Usage:
# fm-ledger.sh [--json] [--stale-days <n>] list likely_dropped beads (default: 2 days)
# fm-ledger.sh --close <id> [<id>...] close specific bead ids
# fm-ledger.sh --close-all [--stale-days <n>] [--yes]
# close every currently-listed likely_dropped
# bead; without --yes, only lists what would close
#
# Env:
# FM_BEADS_BIN beads CLI to invoke (default: task)
set -u

BIN="${FM_BEADS_BIN:-task}"
STALE_DAYS=2
MODE=list
YES=0
CLOSE_IDS=()

usage() {
# The whole leading comment block, ending at the first line that is not a
# comment. Derived rather than a fixed line range, which would silently
# truncate this help mid-sentence every time the header above grew.
sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//'
}

while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--json) MODE=json; shift ;;
--stale-days)
[ $# -ge 2 ] || { echo "fm-ledger: --stale-days requires a value" >&2; exit 2; }
STALE_DAYS=$2; shift 2 ;;
--stale-days=*) STALE_DAYS=${1#--stale-days=}; shift ;;
--close)
MODE=close; shift
while [ $# -gt 0 ] && [ "${1#--}" = "$1" ]; do
CLOSE_IDS+=("$1"); shift
done
;;
--close-all) MODE=close-all; shift ;;
--yes) YES=1; shift ;;
*) echo "fm-ledger: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done

case "$STALE_DAYS" in
''|*[!0-9]*) echo "fm-ledger: --stale-days must be a non-negative integer, got: $STALE_DAYS" >&2; exit 2 ;;
esac

command -v "$BIN" >/dev/null 2>&1 || { echo "fm-ledger: $BIN CLI not found" >&2; exit 1; }

likely_dropped_query() {
printf 'label=lifecycle:claimed AND status!=closed AND updated<%sd' "$STALE_DAYS"
}

fetch_likely_dropped() {
"$BIN" query "$(likely_dropped_query)" --json --limit 0 2>/dev/null || echo '[]'
}

close_one() {
local id=$1
if "$BIN" close "$id" --reason "closed via fm-ledger: work landed, bead was never closed" >/dev/null 2>&1; then
echo "closed $id"
else
echo "warning: could not close $id" >&2
return 1
fi
}

case "$MODE" in
close)
[ "${#CLOSE_IDS[@]}" -gt 0 ] || { echo "fm-ledger: --close requires at least one bead id" >&2; exit 2; }
rc=0
for id in "${CLOSE_IDS[@]}"; do
close_one "$id" || rc=1
done
exit "$rc"
;;
close-all)
command -v jq >/dev/null 2>&1 || { echo "fm-ledger: jq not found" >&2; exit 1; }
beads=$(fetch_likely_dropped)
ids=$(printf '%s' "$beads" | jq -r '.[].id')
if [ -z "$ids" ]; then
echo "no likely-dropped beads to close"
exit 0
fi
if [ "$YES" != 1 ]; then
echo "would close:"
printf '%s\n' "$ids"
echo "re-run with --yes to close these beads"
exit 0
fi
rc=0
while IFS= read -r id; do
[ -n "$id" ] || continue
close_one "$id" || rc=1
done <<EOF
$ids
EOF
exit "$rc"
;;
json)
command -v jq >/dev/null 2>&1 || { echo "fm-ledger: jq not found" >&2; exit 1; }
beads=$(fetch_likely_dropped)
printf '%s' "$beads" | jq '[.[] | {id, title, status, updated_at, likely_dropped: true}]'
;;
list)
command -v jq >/dev/null 2>&1 || { echo "fm-ledger: jq not found" >&2; exit 1; }
beads=$(fetch_likely_dropped)
count=$(printf '%s' "$beads" | jq 'length')
if [ "$count" -eq 0 ]; then
echo "No likely-dropped beads (claimed, unclosed, idle > ${STALE_DAYS}d)."
exit 0
fi
echo "Likely-dropped beads (claimed, unclosed, idle > ${STALE_DAYS}d):"
echo
printf '%s' "$beads" | jq -r '.[] | " \(.id) \(.updated_at) \(.title)"'
echo
echo "close with: fm-ledger.sh --close <id> [<id>...], or fm-ledger.sh --close-all --yes"
;;
esac
29 changes: 22 additions & 7 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a
# secondmate in its isolated firstmate home.
# Usage: fm-spawn.sh <task-id> <project-dir> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--label <string>] [--scout]
# fm-spawn.sh <task-id> [<firstmate-home>] [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--label <string>] --secondmate
# Usage: fm-spawn.sh <task-id> <project-dir> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--label <string>] [--beads <id>] [--scout]
# fm-spawn.sh <task-id> [<firstmate-home>] [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--label <string>] [--beads <id>] --secondmate
# --harness <name> is the explicit per-spawn harness/profile adapter. The old
# positional harness arg still works for back-compat.
# --model <name> and --effort <low|medium|high|xhigh|max> are concrete profile
Expand Down Expand Up @@ -95,11 +95,12 @@
# --scout records kind=scout in the task's meta (report deliverable, scratch worktree;
# see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a
# provisioned firstmate home; the default is kind=ship.
# --beads <id> links this task to an external bead item for lifecycle tracking: the
# dispatch=sent and lifecycle=sent state dimensions are stamped via fm-bead-stamp.sh
# after spawn, and the brief includes Bead Receipt/Closure sections (when FM_HOOK_BEADS_ID
# is set) asking the worker to confirm dispatch=claimed/lifecycle=claimed and close the
# bead on completion.
# --beads <id> links this task to an external bead item for lifecycle tracking: the id is
# recorded as beads_id= in the task's meta (fm-teardown.sh reads it to close the bead once
# this task's work is confirmed landed), the dispatch=sent and lifecycle=sent state
# dimensions are stamped via fm-bead-stamp.sh after spawn, and the brief includes Bead
# Receipt/Closure sections (when FM_HOOK_BEADS_ID is set) asking the worker to confirm
# dispatch=claimed/lifecycle=claimed and close the bead on completion.
# Before a secondmate launch, the home is locally fast-forwarded to the primary
# default-branch commit when safe; skipped syncs warn and launch unchanged.
# Ship/scout spawns refuse to launch unless the resolved task path is a real
Expand Down Expand Up @@ -202,11 +203,13 @@ MODEL=
EFFORT=
BACKEND_ARG=
LABEL_ARG=
BEADS_ARG=
HARNESS_SET=0
MODEL_SET=0
EFFORT_SET=0
BACKEND_SET=0
LABEL_SET=0
BEADS_SET=0
POS=()
want_value=
for a in "$@"; do
Expand All @@ -220,6 +223,7 @@ for a in "$@"; do
effort) EFFORT=$a; EFFORT_SET=1 ;;
backend) BACKEND_ARG=$a; BACKEND_SET=1 ;;
label) LABEL_ARG=$a; LABEL_SET=1 ;;
beads) BEADS_ARG=$a; BEADS_SET=1 ;;
*) echo "error: internal parser state for --$want_value" >&2; exit 1 ;;
esac
want_value=
Expand All @@ -238,6 +242,8 @@ for a in "$@"; do
--backend=*) BACKEND_ARG=${a#--backend=}; BACKEND_SET=1 ;;
--label) want_value=label ;;
--label=*) LABEL_ARG=${a#--label=}; LABEL_SET=1 ;;
--beads) want_value=beads ;;
--beads=*) BEADS_ARG=${a#--beads=}; BEADS_SET=1 ;;
*) POS+=("$a") ;;
esac
done
Expand All @@ -247,6 +253,7 @@ done
[ "$EFFORT_SET" -eq 0 ] || [ -n "$EFFORT" ] || { echo "error: --effort requires a non-empty value" >&2; exit 1; }
[ "$BACKEND_SET" -eq 0 ] || [ -n "$BACKEND_ARG" ] || { echo "error: --backend requires a non-empty value" >&2; exit 1; }
[ "$LABEL_SET" -eq 0 ] || [ -n "$LABEL_ARG" ] || { echo "error: --label requires a non-empty value" >&2; exit 1; }
[ "$BEADS_SET" -eq 0 ] || [ -n "$BEADS_ARG" ] || { echo "error: --beads requires a non-empty value" >&2; exit 1; }
case "$EFFORT" in
''|low|medium|high|xhigh|max) ;;
*) echo "error: --effort must be one of low, medium, high, xhigh, max" >&2; exit 1 ;;
Expand Down Expand Up @@ -1644,6 +1651,7 @@ META_WINDOW=$T
echo "effort=${EFFORT:-default}"
[ -z "${BUSY_GEN:-}" ] || echo "busy_gen=$BUSY_GEN"
[ -z "$LABEL_ARG" ] || echo "label=$LABEL_ARG"
[ -z "$BEADS_ARG" ] || echo "beads_id=$BEADS_ARG"
# backend= is written only for a non-default (non-tmux) backend, so the
# default path's meta stays byte-identical (absent backend= means tmux;
# data/fm-backend-design-d7's P1 compatibility contract).
Expand Down Expand Up @@ -1760,4 +1768,11 @@ if command -v parlay >/dev/null 2>&1; then
|| echo "warning: could not record parlay listen pid for $ID (non-blocking)" >&2
fi

# Best-effort bead dispatch stamp. fm-bead-stamp.sh is fail-open by design (a
# missing task CLI or unreachable bead warns on stderr and exits 0), so this
# never blocks or fails an already-confirmed spawn.
if [ -n "$BEADS_ARG" ]; then
"$FM_ROOT/bin/fm-bead-stamp.sh" "$BEADS_ARG" "$ID" || true
fi

echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$META_WINDOW worktree=$WT"
25 changes: 25 additions & 0 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@
# panel (see bin/fm-spawn.sh's header for the enrollment contract): the recorded
# `parlay listen` background pid is always killed, and `parlay agent-down` is
# called only when `parlay` is on PATH. Neither ever blocks or fails teardown.
# A task linked to a bead (beads_id= in meta, set by fm-spawn.sh --beads) has that
# bead closed automatically once teardown reaches this point without --force, i.e.
# every landed-work gate above already passed. --force never closes the linked bead,
# since --force tears down without confirming the work landed. Closing is fail-open
# like fm-bead-stamp.sh: a missing task CLI or a close the CLI rejects warns on
# stderr and never blocks or fails an already-confirmed teardown. bin/fm-ledger.sh
# is the safety net for a bead that was claimed but never reaches this path.
# Usage: fm-teardown.sh <task-id> [--force]
# --force skips ordinary-task dirty and landed-work checks, skips scout report
# checks, and discards secondmate child work for kind=secondmate. Only use it
Expand Down Expand Up @@ -152,6 +159,7 @@ KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true)
[ -n "$KIND" ] || KIND=ship
MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true)
[ -n "$MODE" ] || MODE=no-mistakes
BEADS_ID=$(fm_meta_get "$META" beads_id)
PUBLIC_FOLLOWUP_HOME=$FM_HOME
PUBLIC_FOLLOWUP_STATE=$STATE
PUBLIC_FOLLOWUP_WORK_HOME=main
Expand Down Expand Up @@ -312,6 +320,22 @@ deregister_parlay_agent() {
fi
}

# Close the bead linked to this task (beads_id= in meta, set by fm-spawn.sh --beads)
# once its work is confirmed landed. Only called for a non-force teardown that
# reached this point, i.e. every REFUSED landed-work gate above already passed.
# Fail-open by design, matching fm-bead-stamp.sh: a missing task CLI or a close
# the CLI rejects (already closed, unreachable store) warns on stderr and never
# blocks or fails an already-confirmed teardown.
close_linked_bead() {
local beads_id=$1 id=$2
command -v task >/dev/null 2>&1 || {
echo "warning: task CLI not found on PATH, could not close bead $beads_id for $id" >&2
return 0
}
task close "$beads_id" --reason "landed: firstmate task $id teardown confirmed work landed" >/dev/null 2>&1 \
|| echo "warning: could not close bead $beads_id for $id (already closed or unreachable)" >&2
}

validate_pr_poll_cleanup() {
local state_dir=$1 id=$2 quarantine state_device artifact has_artifact=0
fm_task_id_path_safe "$id" || return 0
Expand Down Expand Up @@ -1379,6 +1403,7 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true
[ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP"
remove_pr_poll_artifacts "$STATE" "$ID" || exit 1
retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1
[ -z "$BEADS_ID" ] || [ "$FORCE" = "--force" ] || close_linked_bead "$BEADS_ID" "$ID"
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"
Expand Down
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `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; load hooks when FM_HOOK_BEADS_ID or other hook env vars are set |
| `fm-bead-stamp.sh` | Stamp a linked bead's dispatch and lifecycle state when a task is spawned with --beads |
| `fm-ledger.sh` | List or close likely-dropped beads: claimed, still open, and idle past a staleness window |
| `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session |
| `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks |
| `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees |
Expand Down
Loading
Loading