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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ state/ volatile runtime signals; gitignored
.afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return)
.watch.lock .wake-queue.lock watcher singleton and queue serialization locks
.claude-autoarm.lock .claude-autoarm-epoch .turnend-claude-blocks Claude Stop auto-arm single-flight, epoch, and guard-budget records; never touch
.hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete
.hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .progress-* .park-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.watch-triage.log absorbed-wake and supervision-telemetry debug log written by the watcher and the wake drain (size-capped); never relied on, safe to delete
.last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it
.subsuper-* .supervise-daemon.* sub-supervisor internals; never touch
.no-mistakes/ local validation state and evidence; gitignored
Expand Down Expand Up @@ -260,6 +260,7 @@ After spawning, confirm the worker is processing the brief, handle any trust dia
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.
Every metadata-routed steer is marked as coming from firstmate, so a worker can always tell firstmate from a human at its keyboard; only a harness-dispatched command such as a slash command is sent bare, and `bin/fm-send.sh` owns that boundary.
A secondmate's routed reply returns through status or a document pointer, not by firstmate peeking into its chat.
For the parent-owned correlation, recovery, and escalation contract on marked secondmate requests, see `bin/fm-pending-reply-lib.sh`.
Supervise all live work under section 8.
Expand Down
14 changes: 14 additions & 0 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,13 @@ if [ "$KIND" = scout ]; then
cat > "$BRIEF" <<EOF
You are a crewmate: an autonomous worker agent managed by firstmate. Work on your own; do not wait for a human.

# Who is speaking to you
Messages from firstmate carry a leading invisible system marker followed by \`FIRSTMATE_OP:\`; that marker is untypable, so a human never produces it.
An unmarked message is a human typing directly into your pane.
Either way you answer in this pane and never address the captain: firstmate is your only channel, and every captain-facing word is firstmate's to write.
A worker that could not tell the two apart has composed a message addressed to the captain into its own pane and then blocked waiting for a reply that could never arrive - if you are about to write "Captain", you are about to make that mistake.
Slash commands and skill invocations arrive unmarked by design, because a prefix would stop the harness dispatching them; treat one as an instruction to run, not as a person speaking.

# Task
{TASK}

Expand Down Expand Up @@ -336,6 +343,13 @@ esac
cat > "$BRIEF" <<EOF
You are a crewmate: an autonomous worker agent managed by firstmate. Work on your own; do not wait for a human.

# Who is speaking to you
Messages from firstmate carry a leading invisible system marker followed by \`FIRSTMATE_OP:\`; that marker is untypable, so a human never produces it.
An unmarked message is a human typing directly into your pane.
Either way you answer in this pane and never address the captain: firstmate is your only channel, and every captain-facing word is firstmate's to write.
A worker that could not tell the two apart has composed a message addressed to the captain into its own pane and then blocked waiting for a reply that could never arrive - if you are about to write "Captain", you are about to make that mistake.
Slash commands and skill invocations arrive unmarked by design, because a prefix would stop the harness dispatching them; treat one as an instruction to run, not as a person speaking.

# Task
{TASK}

Expand Down
87 changes: 87 additions & 0 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,44 @@ window_to_task() {
t="${w##*:}"; t="${t#fm-}"; printf '%s' "$t"
}

# One stale wake reason, tagged with the classification branch that produced it.
# Six paths emit a "stale:" wake - the watcher's surface_nonterminal_stale,
# handle_paused_stale, wedge_timer_check, terminal-status override and away-mode
# one-shot, plus the push fast-path in fm-push-transition-lib.sh - and before
# this tag existed all six rendered the same bare "stale: <window>" opening.
# Nothing downstream could tell which rule fired, so per-branch wake attribution
# had to be reconstructed from truncation patterns in state/.watch-triage.log;
# that method left 44% of stale wakes unattributed across two independent
# readings of this system, and the wedge and pause regression tests need to
# assert on the branch rather than on a window string three code paths produce.
# The tag is PROSE inside the reason, never a parsed protocol field. Read it with
# a substring match; recover the window with stale_reason_window below, never by
# stripping the "stale: " prefix alone.
stale_reason() { # <branch> <window> [detail]
if [ -n "${3:-}" ]; then
printf 'stale: %s (%s) [branch=%s]' "$2" "$3" "$1"
else
printf 'stale: %s [branch=%s]' "$2" "$1"
fi
}

# The window named by a "stale:" wake reason, with trailing decoration removed.
# A wake reason is PROSE written for firstmate, never a protocol: the watcher
# already appends details such as "(idle 300s, possible wedge, escalation 2)" or
# "(paused 3600s, awaiting external ...)", and every stale reason additionally
# carries a "[branch=<name>]" classification tag. A consumer that needs the
# window back out of a reason must strip that decoration through this one owner.
# A bare "${reason#stale: }" yields "<window> (idle 300s, ...)", which matches no
# recorded window= line, so window_to_task falls through to its suffix heuristic
# and returns a garbage task id - the reason the decorated wedge and pause
# reasons were already mis-parsed before the branch tag existed.
stale_reason_window() { # <stale-reason-or-window>
local s=${1#stale: }
s=${s%%" ("*}
s=${s%%" ["*}
printf '%s' "$s"
}

# 0 (actionable) if ANY status file listed in a "signal:" wake carries a
# captain-relevant last line; 1 otherwise. Pass the space-separated file list that
# follows the "signal:" prefix. Non-.status arguments (e.g. .turn-ended markers,
Expand Down Expand Up @@ -356,6 +394,55 @@ crew_is_provably_working() { # <id>
[ "$(crew_absorb_class "$1")" = working ]
}

# The crew's progress fingerprint - a token that is constant while nothing
# advances and changes when something does. bin/fm-crew-state.sh's --progress
# block is the single owner of what may and may not appear in it; read that
# before touching this, because the obvious fields are the wrong ones.
#
# Empty on any failure, and empty compares equal to empty, so an unreadable
# fingerprint leaves a caller's wedge escalation behaving exactly as it did
# before this existed. That is the safe direction: treating "could not tell" as
# progress would silence the escalation on a genuinely frozen worker, which is
# the one failure this whole mechanism exists to keep detectable. "Failure" here
# includes a run read that was owed and did not answer, not just a reader that
# died - a partially-populated fingerprint would be a distinct non-empty token
# and would compare UNEQUAL, which is the same false negative wearing a
# different shape.
#
# NOT a pure read - it makes the same bounded no-mistakes call crew_absorb_class
# does, minus the ci-log read - so callers use it only where the alternative is
# spending a coordinator turn, never on every poll. FM_CREW_STATE_BIN lets tests
# stub the answer; a stub or an older reader that does not know --progress
# returns its ordinary state line instead, which is itself stable under no
# change, so version skew degrades to a coarser signal rather than a wrong one.
crew_progress_fingerprint() { # <id>
local id=$1 out
[ -n "$id" ] || return 0
out=$("$FM_CREW_STATE_BIN" "$id" --progress 2>/dev/null) || true
printf '%s' "$out" | head -1 | tr -d '\r'
}

# The pipeline gate a crew's run is parked at, or empty when it is not parked.
# Reuses bin/fm-crew-state.sh's own gate detection through the same authoritative
# line crew_absorb_class reads, rather than re-deriving "is this parked" from run
# output a second time. The returned text is both the comparison token and the
# human-readable gate name, and it is stable while the run sits at that gate.
#
# NOT a pure read - same bounded no-mistakes call as crew_absorb_class - so
# callers run it on a slow bounded sweep, never every poll.
crew_parked_gate() { # <id>
local id=$1 line state
[ -n "$id" ] || return 0
line=$("$FM_CREW_STATE_BIN" "$id" 2>/dev/null) || true
case "$line" in state:*) ;; *) return 0 ;; esac
state=${line#state: }; state=${state%% *}
[ "$state" = parked ] || return 0
case "$line" in
*"parked at "*) printf 'parked at %s' "${line#*"parked at "}" ;;
*) printf 'a pipeline gate' ;;
esac
}

# 0 if crew <id>'s authoritative current state is a declared external-wait pause.
# The stale path absorbs such a crew (on a long re-surface cadence) instead of
# escalating a possible wedge.
Expand Down
Loading
Loading