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/skills/firstmate-codexapp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ For a Firstmate-managed task, include an explicit status instruction:

```text
Append supervisor-visible status lines to <absolute-firstmate-home>/state/<task-id>.status.
Start every line with the current UTC time from `date -u +%Y-%m-%dT%H:%M:%SZ`, then the state prefix, so a supervisor can tell a fresh report from an old one.
Use only these prefixes for status changes: working:, needs-decision:, blocked:, paused:, done:, failed:.
Use paused: only for a deliberate known external wait that should be rechecked later, never for a blocker that needs firstmate to act.
Before doing substantive work, append "working: Codex Desktop thread started".
Before doing substantive work, append "<timestamp> working: Codex Desktop thread started".
```

Verify the return channel before treating the thread as supervised:
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole
<id>/report.md scout task deliverable, written by the crewmate; survives teardown
projects/ cloned repos; gitignored; READ-ONLY for you
state/ volatile runtime signals; gitignored
<id>.status appended by crewmates: "<state>: <note>" wake-event lines, not current-state truth
<id>.status appended by crewmates: "<utc-stamp> <state>: <note>" wake-event lines, not current-state truth
<id>.turn-ended touched by turn-end hooks
<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
Expand Down
21 changes: 18 additions & 3 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
# declared-external-wait verb (FM_CLASSIFY_PAUSED_VERB, default "paused") from
# "blocked:": pause for a known external wait expected to clear on its own,
# blocked when firstmate must act.
# Every generated status instruction is one copy-pasteable line that stamps the
# append with UTC time, so a supervisor can tell a fresh event from a stale one;
# fm-classify-lib.sh owns the stamp format (FM_STATUS_STAMP_CMD_DEFAULT).
# Ship tasks include a project-memory section so durable project-intrinsic
# learnings can be committed to AGENTS.md through the project's delivery path;
# it carries the AGENTS.md authoring bar (widely useful knowledge only, pointers
Expand Down Expand Up @@ -66,6 +69,13 @@ esac
# shellcheck source=bin/fm-classify-lib.sh
. "$SCRIPT_DIR/fm-classify-lib.sh"
PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}
# The exact command every generated status instruction embeds so each append
# carries its own UTC time. fm-classify-lib.sh is the sole owner of the format
# and is not overridable here: its strippers match a fixed stamp glob, so a
# writer-side override could only ever desync the writer from every parser. The
# scaffold inlines the command rather than a helper script so the instruction
# stays one self-contained line a worker can copy with nothing else on its PATH.
STATUS_STAMP_CMD=$FM_STATUS_STAMP_CMD_DEFAULT

resolve_directory_input() {
local name=$1 path=$2 resolved
Expand Down Expand Up @@ -185,8 +195,9 @@ A message with NO marker is the captain typing directly into your pane: treat it
# Escalation to main firstmate
Handle routine work yourself.
Report only true captain-relevant outcomes or a declared external wait by appending one line:
\`echo "{state}: {one short line}" >> $STATUS_FILE\`
\`echo "\$($STATUS_STAMP_CMD) {state}: {one short line}" >> $STATUS_FILE\`
States: working, needs-decision, blocked, $PAUSED_VERB, done, failed.
Keep the leading UTC stamp on every append: it is how the main firstmate tells a report you just wrote from one that has been sitting in the log for hours.
Use \`$PAUSED_VERB: {why}\` (distinct from \`blocked:\`) only when your domain is deliberately idling on a known external wait you expect to clear on its own; use \`blocked:\` when you are stuck and need firstmate to act.
Use this only for material phase changes, a captain decision, a real blocker, a failure, or work ready for review.
This is also how you return the answer to a marked from-firstmate request above.
Expand Down Expand Up @@ -267,8 +278,10 @@ The report is the only thing that survives, so anything worth keeping must be in
2. Stay inside this worktree; the only files you may write outside it are the report and the status file below.
3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations.
4. Report status by appending one line:
\`echo "{state}: {one short line}" >> $STATUS_FILE\`
\`echo "\$($STATUS_STAMP_CMD) {state}: {one short line}" >> $STATUS_FILE\`
States: working, needs-decision, blocked, $PAUSED_VERB, done, failed.
Keep the leading UTC stamp on every append: it is how firstmate tells a report you
just wrote from one that has been sitting in the log for hours.
Each append wakes firstmate, so report sparingly: only phase changes a supervisor
would act on and the needs-decision/blocked/paused/done/failed states. No step-by-step
FYI progress lines; firstmate reads your pane for that.
Expand Down Expand Up @@ -378,8 +391,10 @@ $RULE1
2. Stay inside this worktree; modify nothing outside it.
3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations.
4. Report status by appending one line:
\`echo "{state}: {one short line}" >> $STATUS_FILE\`
\`echo "\$($STATUS_STAMP_CMD) {state}: {one short line}" >> $STATUS_FILE\`
States: working, needs-decision, blocked, $PAUSED_VERB, done, failed.
Keep the leading UTC stamp on every append: it is how firstmate tells a report you
just wrote from one that has been sitting in the log for hours.
Each append wakes firstmate, so report sparingly: only phase changes a supervisor
would act on (setup done, bug reproduced, fix implemented, validation passed) and the
needs-decision/blocked/paused/done/failed states. No step-by-step FYI progress lines;
Expand Down
136 changes: 126 additions & 10 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
#
# Most functions are pure, side-effect-free reads of status files: each takes
# what it needs as arguments and touches no globals beyond the optional
# FM_CAPTAIN_RE override. Consumers layer their own dedup/marker state on top (the
# FM_CAPTAIN_RE override and the _FM_STATUS_BODY scratch used by the timestamp
# strippers. Consumers layer their own dedup/marker state on top (the
# daemon keeps its escalation-digest seen-markers; the watcher keeps its .seen-*
# signatures).
#
Expand Down Expand Up @@ -73,6 +74,110 @@ FM_PAUSE_RESURFACE_SECS_DEFAULT=3600
FM_CLASSIFY_RESOLVE_VERB_DEFAULT='resolved'
FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT='captain-held'

# --- status line timestamps -------------------------------------------------
#
# A status append is an EVENT, and an event with no time on it cannot be told
# apart from the same event three hours ago: a `done:` line read out of the log
# was reported as current work more than once before this stamp existed. Every
# scaffolded append therefore carries a leading ISO-8601 UTC stamp,
# 2026-07-29T09:44:07Z done: PR https://... checks green
# and this file is the ONE owner of that format: the format string below, the
# copy-pasteable command bin/fm-brief.sh embeds in every generated status
# instruction, the fm_status_stamp writers use, and the parsers that strip it.
#
# Legacy untimestamped lines stay first-class. Everything below treats a missing
# stamp as an unknown age, never as an error and never as a guess, so status
# files written before this contract keep classifying exactly as they did.
FM_STATUS_STAMP_FORMAT='+%Y-%m-%dT%H:%M:%SZ'
# The literal single command a crewmate copies into its status append. Kept as a
# string (not only a function) because the brief scaffold must hand the worker one
# self-contained line that needs no helper script on its PATH.
# shellcheck disable=SC2034 # Read by bin/fm-brief.sh, not this lib.
FM_STATUS_STAMP_CMD_DEFAULT="date -u $FM_STATUS_STAMP_FORMAT"
# Bash glob matching exactly one stamp. Only the Z (UTC) form this repo emits is
# recognized; anything else is left alone as ordinary line text.
_FM_STATUS_STAMP_GLOB='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'

# Current stamp, for firstmate-side writers that append to a status file.
fm_status_stamp() {
date -u "$FM_STATUS_STAMP_FORMAT"
}

# Strip a leading stamp, publishing the remainder in _FM_STATUS_BODY. The one
# deliberate scratch global in this file: the verb/note/key parsers below run
# once per line of a whole-file fold, so this stays a plain assignment instead of
# a command substitution per line. Callers outside this file use status_line_body.
_fm_status_strip_stamp() { # <status-line>
_FM_STATUS_BODY=$1
# shellcheck disable=SC2254 # deliberate glob match, not a literal comparison.
case "$_FM_STATUS_BODY" in
$_FM_STATUS_STAMP_GLOB\ *) _FM_STATUS_BODY=${_FM_STATUS_BODY#* } ;;
esac
}

# The status line with any leading stamp removed (unchanged when unstamped).
status_line_body() { # <status-line>
_fm_status_strip_stamp "$1"
printf '%s' "$_FM_STATUS_BODY"
}

# The leading stamp of a status line, empty when the line carries none.
status_line_stamp() { # <status-line>
# shellcheck disable=SC2254 # deliberate glob match, not a literal comparison.
case "$1" in
$_FM_STATUS_STAMP_GLOB\ *) printf '%s' "${1%% *}" ;;
$_FM_STATUS_STAMP_GLOB) printf '%s' "$1" ;;
esac
}

# Epoch seconds for a stamp; returns 1 without printing for an absent or
# unparseable one, so callers report an unknown age rather than a wrong one.
# Pure integer arithmetic (Howard Hinnant's days_from_civil) rather than date(1),
# because the GNU and BSD parse flags differ and a silently misparsed stamp would
# be worse than no stamp at all.
status_stamp_epoch() { # <stamp>
local s=$1 y m d hh mm ss era yoe doy doe days
# shellcheck disable=SC2254 # deliberate glob match, not a literal comparison.
case "$s" in
$_FM_STATUS_STAMP_GLOB) ;;
*) return 1 ;;
esac
y=$((10#${s:0:4})); m=$((10#${s:5:2})); d=$((10#${s:8:2}))
hh=$((10#${s:11:2})); mm=$((10#${s:14:2})); ss=$((10#${s:17:2}))
{ [ "$m" -ge 1 ] && [ "$m" -le 12 ] && [ "$d" -ge 1 ] && [ "$d" -le 31 ]; } || return 1
{ [ "$hh" -le 23 ] && [ "$mm" -le 59 ] && [ "$ss" -le 60 ]; } || return 1
if [ "$m" -le 2 ]; then y=$((y - 1)); fi
era=$(( y / 400 ))
yoe=$(( y - era * 400 ))
if [ "$m" -gt 2 ]; then doy=$(( (153 * (m - 3) + 2) / 5 + d - 1 ))
else doy=$(( (153 * (m + 9) + 2) / 5 + d - 1 )); fi
doe=$(( yoe * 365 + yoe / 4 - yoe / 100 + doy ))
days=$(( era * 146097 + doe - 719468 ))
printf '%s' "$(( days * 86400 + hh * 3600 + mm * 60 + ss ))"
}

# Age in seconds of a stamped status line; returns 1 without printing when the
# line has no parseable stamp. A stamp in the future (clock skew) reads as 0.
status_line_age_secs() { # <status-line> [now-epoch]
local line=$1 now=${2:-} stamp epoch
stamp=$(status_line_stamp "$line")
[ -n "$stamp" ] || return 1
epoch=$(status_stamp_epoch "$stamp") || return 1
[ -n "$now" ] || now=$(date -u +%s)
case "$now" in ''|*[!0-9]*) return 1 ;; esac
if [ "$now" -le "$epoch" ]; then printf '0'; else printf '%s' "$(( now - epoch ))"; fi
}

# Human-scaled age for a duration in seconds: 45s, 12m, 2h14m, 3d4h.
fm_format_age() { # <seconds>
local s=$1
case "$s" in ''|*[!0-9]*) printf 'unknown'; return ;; esac
if [ "$s" -lt 60 ]; then printf '%ss' "$s"
elif [ "$s" -lt 3600 ]; then printf '%sm' "$(( s / 60 ))"
elif [ "$s" -lt 86400 ]; then printf '%sh%sm' "$(( s / 3600 ))" "$(( s % 3600 / 60 ))"
else printf '%sd%sh' "$(( s / 86400 ))" "$(( s % 86400 / 3600 ))"; fi
}

# Return the last non-blank line of a status file (empty if missing/blank).
last_status_line() {
local f=$1
Expand Down Expand Up @@ -113,7 +218,9 @@ status_is_captain_relevant() {
done|needs-decision|blocked|failed) return 0 ;;
esac
fi
printf '%s' "$line" | grep -qiE "${FM_CAPTAIN_RE:-$FM_CLASSIFY_CAPTAIN_RE_DEFAULT}"
# Match the body, not the raw line, so a leading timestamp cannot sit between a
# custom FM_CAPTAIN_RE's anchor and the verb it anchors to.
status_line_body "$line" | grep -qiE "${FM_CAPTAIN_RE:-$FM_CLASSIFY_CAPTAIN_RE_DEFAULT}"
}

# 0 if a status line's leading verb is the pause verb (paused: <reason>). A pure
Expand Down Expand Up @@ -157,23 +264,30 @@ status_is_paused_or_captain_held() { # <status-line>
# resolved [key=api-shape]: <how it was decided>
# A line with no token uses the key "default", preserving the historical
# one-open-decision-per-task behavior (a bare "resolved:" closes "default").
# The three parsers are pure reads of a single line; the verb parser strips any
# key token before the colon so the leading word is recovered cleanly.
# The three parsers are pure reads of a single line; each drops any leading
# timestamp first (its colons would otherwise be read as the verb separator),
# and the verb parser strips any key token before the colon so the leading word
# is recovered cleanly.
status_line_verb() { # <status-line> -> leading verb word
local v=${1%%:*}
local v
_fm_status_strip_stamp "$1"
v=${_FM_STATUS_BODY%%:*}
v=${v%%\[key=*}
v=${v#"${v%%[![:space:]]*}"}
v=${v%"${v##*[![:space:]]}"}
printf '%s' "$v"
}
status_line_note() { # <status-line> -> text after the first colon, trimmed
case "$1" in
*:*) local n=${1#*:}; printf '%s' "${n#"${n%%[![:space:]]*}"}" ;;
*) printf '%s' "$1" ;;
_fm_status_strip_stamp "$1"
case "$_FM_STATUS_BODY" in
*:*) local n=${_FM_STATUS_BODY#*:}; printf '%s' "${n#"${n%%[![:space:]]*}"}" ;;
*) printf '%s' "$_FM_STATUS_BODY" ;;
esac
}
_fm_decision_key() { # <status-line> -> key slug, or "default" when no token
local prefix=${1%%:*} k
local prefix k
_fm_status_strip_stamp "$1"
prefix=${_FM_STATUS_BODY%%:*}
case "$prefix" in
*\[key=*\]*)
k=${prefix#*\[key=}
Expand Down Expand Up @@ -317,7 +431,9 @@ signal_reason_is_actionable() { # <file> ...

# Classify WHY an idle/stale crew MIGHT be safely absorbed instead of surfaced,
# from bin/fm-crew-state.sh's one authoritative current-state line
# ("state: <s> · source: <src> · <detail>"). Prints exactly one token:
# ("state: <s> · source: <src> · age: <age> · <detail>"; the source token comes
# from "${line#*source: }", so the trailing fields do not affect it).
# Prints exactly one token:
# working - an actively-running no-mistakes step (running/fixing/ci) or a busy
# pane; the crew is legitimately mid-work on a static-looking pane
# (e.g. waiting on CI);
Expand Down
28 changes: 26 additions & 2 deletions bin/fm-crew-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@
# fixed mapping logic, no heuristics and no LLM. Output is one stable, parseable,
# token-tight line firstmate can read every heartbeat:
#
# state: <working|parked|done|blocked|paused|failed|unknown> · source: <run-step|pane|status-log|none> · <detail>
# state: <working|parked|done|blocked|paused|failed|unknown> · source: <run-step|pane|status-log|none> · age: <age> · <detail>
#
# `age` is how old the EVIDENCE behind the reported state is, so a `done` from two
# hours ago never reads the same as one from ten seconds ago:
# live the run-step or pane was just observed, so the state is current
# <duration> the reported status-log line's own timestamp, e.g. 2h14m
# unknown no evidence age is available - an untimestamped legacy status line
# (fm-classify-lib.sh's stamp contract), or no source at all
#
# Logic, in order:
# 1. Resolve worktree + backend target + kind from state/<id>.meta.
Expand Down Expand Up @@ -78,9 +85,26 @@ FM_CREW_STATE_RUNS_LIMIT=${FM_CREW_STATE_RUNS_LIMIT:-200}
case "$FM_CREW_STATE_RUNS_LIMIT" in ''|*[!0-9]*) FM_CREW_STATE_RUNS_LIMIT=200 ;; esac
SEP=' · '

# Age of the evidence behind a state reported from <source>. A run-step or pane
# verdict was read from the live system moments ago; a status-log verdict is only
# as current as the line it came from, which is exactly the distinction that was
# invisible before status appends carried a timestamp.
evidence_age() { # <source>
local secs
case "$1" in
run-step|pane) printf 'live'; return ;;
status-log) ;;
*) printf 'unknown'; return ;;
esac
secs=$(status_line_age_secs "${LOG_LINE:-}") || { printf 'unknown'; return; }
fm_format_age "$secs"
}

# Emit the one canonical line and exit 0. Detail is optional.
emit() { # <state> <source> [detail]
local line="state: $1${SEP}source: $2"
local line age
age=$(evidence_age "$2")
line="state: $1${SEP}source: $2${SEP}age: $age"
[ -n "${3:-}" ] && line="$line${SEP}$3"
printf '%s\n' "$line"
exit 0
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-decision-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,8 @@ EOF
while IFS=$'\t' read -r key _verb _summary; do
[ -n "$key" ] || continue
list_has_key "$keys" "$key" || continue
printf 'captain-held [key=%s]: tracked by %s\n' "$key" "$(hold_id "$origin" "$key")" >> "$status_file"
printf '%s captain-held [key=%s]: tracked by %s\n' \
"$(fm_status_stamp)" "$key" "$(hold_id "$origin" "$key")" >> "$status_file"
key_seen=1
done <<EOF
$raw_open
Expand Down
23 changes: 19 additions & 4 deletions bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
# resolves only when its structured record is Done, and missing ids stay open.
# tasks[]: one row per state/<id>.meta, sorted by id.
# current_state is parsed from bin/fm-crew-state.sh <id> and preserves
# state, source, detail, and raw line separately.
# state, source, age, detail, and raw line separately. age is that helper's
# evidence age: "live" for a run-step or pane read, a duration for a
# timestamped status-log line, "unknown" for an untimestamped legacy one.
# paths.status_log.last_event is historical wake-event data only, never
# current state.
# hints.open_decisions is the keyed open-decision set returned by
Expand Down Expand Up @@ -195,7 +197,7 @@ last_nonempty_line() { # <file>
}

crew_state_json() { # <id>
local id=$1 raw rest state source detail sep
local id=$1 raw rest state source detail age sep
raw=$(
FM_ROOT_OVERRIDE="$FM_ROOT" \
FM_HOME="$FM_HOME" \
Expand All @@ -210,6 +212,7 @@ crew_state_json() { # <id>
state=unknown
source=none
detail=
age=unknown
case "$raw" in
state:\ *"$sep"source:\ *)
rest=${raw#state: }
Expand All @@ -219,10 +222,22 @@ crew_state_json() { # <id>
*"$sep"*) source=${rest%%"$sep"*}; detail=${rest#*"$sep"} ;;
*) source=$rest ;;
esac
# fm-crew-state.sh reports the age of the evidence behind the state as its
# own field ahead of the detail; keep it out of detail so a consumer reads
# "a done from 2h14m ago" without parsing prose.
case "$detail" in
age:\ *)
age=${detail%%"$sep"*}; age=${age#age: }
case "$detail" in
*"$sep"*) detail=${detail#*"$sep"} ;;
*) detail= ;;
esac
;;
esac
;;
esac
jq -n --arg raw "$raw" --arg state "$state" --arg source "$source" --arg detail "$detail" \
'{state:$state,source:$source,detail:$detail,raw:$raw}'
jq -n --arg raw "$raw" --arg state "$state" --arg source "$source" --arg detail "$detail" --arg age "$age" \
'{state:$state,source:$source,age:$age,detail:$detail,raw:$raw}'
}

status_event_json() { # <status-log>
Expand Down
Loading
Loading