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
51 changes: 51 additions & 0 deletions .agents/skills/work-ledger/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
name: work-ledger
description: >-
Agent-only procedure for the per-card work ledger's wakes.
Use on any `check: work-ledger:` wake: an over-budget card, capture dead in a
home, a lane digest, or a check error.
Also use before writing a card's rating or parent into its backlog title.
Owns what each wake asks firstmate to do, what must never be done with the
numbers, and the title fields the ledger reads at dispatch.
user-invocable: false
metadata:
internal: true
---

# work-ledger

Load this on any `check: work-ledger:` wake, and before writing a card's rating or parent into its backlog title.

The ledger exists to show, while the work is still happening, that a card is taking far longer than its difficulty warrants.
It is a problem finder, never a score: nothing here ranks a harness, a model, or an agent, and no number from it is ever shown to a worker.
`bin/fm-work-ledger-lib.sh` owns what is recorded and `bin/fm-work-ledger.sh` owns the store, the edges, and the measurement rules; read their headers rather than restating them.

## The wakes

The check reports an edge once and is silent otherwise, so a wake is never a repeat of one already handled.

- **`over-budget <card> in <lane>`** - look at that card's current state and its worker, then record exactly one of three as a keyed status note in that lane: continue, with the reason; re-scope, which goes to the captain as a decision; or re-rate, which is a blind re-rate by a session that has not seen the card's cost and never replaces the frozen rating.
The thresholds are post hoc and the line says so, so treat the wake as a prompt to look, not as proof of a problem.
Never interrupt, relaunch, or re-scope a card on the strength of the wake alone.
- **`capture dead in <lane>`** - capture stopped for a whole home, which is the one ledger failure that needs a person.
Find the cause in that home's `state/work-ledger/` - disk, permissions, or a writer regression - and check its `.errors` file.
A single card with a gap never wakes anyone; it is counted in the next digest.
- **`digest <lane>`** - relay the one line to the captain at the next natural reply, in plain language.
Name any factor that moved by 2x or more against the previous digest.
Take no other action: fewer than 20 cards cannot support a trend claim, so never present a digest as a trend alarm.
- **`check error`** - the evaluation itself failed; fix the named cause, because a broken check is otherwise silent.
`data/work-ledger/.last-run` shows when the check last ran.

## Reading the numbers honestly

An unmeasured card ran on a worker runtime that reports no turn boundaries; it is unmeasured, never zero minutes.
An incomplete or unrated card is left out of pace and counted in the digest rather than estimated.
These are turn-bracketed minutes, a different measurement from minutes rebuilt out of transcripts, so never compare the two series or join them, and never backfill the ledger from history.

## Rating and parent fields

Spawn reads two optional fields from the card's backlog title at dispatch: `(rating: <number> by=<rater> blind=<yes|no> at=<when>)` and `(parent: <card-id>)`.
Write them before the `(kind: ...)` field so the backlog tool keeps them as part of the title.
Only the rating present at the card's first dispatch is its frozen rating; a rating added later is recorded on later launches and is never the anchor.
A card dispatched without one is recorded as unrated, which is visible in the digest, so rate before dispatch rather than after.
Sub-cards of a split card name the original as `parent` so their time folds into it.
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole
captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update
captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning
learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store
work-ledger/ per-lane copy of every local home's work ledger, its edge cursor, and its last-run marker; written only by bin/fm-work-ledger.sh (docs/configuration.md "Work ledger")
projects.md thin fleet navigation registry recording each project's standing delivery posture; firstmate-private, parsed for mechanical sync and seeding by fm-project-mode.sh (section 6)
secondmates.md local and remote secondmate routing table; firstmate-private, maintained by the secondmate seed helpers (section 6)
<id>/brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate
Expand Down Expand Up @@ -126,6 +127,8 @@ state/ runtime records and signals; gitignored
tool-updates.check.sh generated watched-tool update poll shim and its .check-trust binding; present only after bin/fm-tool-update-check.sh arm; its report record .tool-updates is what keeps one pending update from being reported on every poll
mail.check.sh generated received-mail poll shim and its .check-trust binding; present only after bin/fm-mail-check.sh arm; report record .mail-check (mail schema: docs/configuration.md "Mail plane")
.mail-seen .mail-woken .mail-retry .mail-retry-pos .mail-turn .mail-seen.lock mail-plane poll cursor, emission journal, transient-fetch retry set, retry-scan position, contended-slot turn flag, and overlapping-poll lock; written only by bin/fm-mail.sh (mail schema: docs/configuration.md "Mail plane")
work-ledger/ append-only per-task turn, spawn, PR-ready, and merged rows that survive teardown and relaunch; bin/fm-work-ledger-lib.sh owns the format, and teardown must never remove it
work-ledger.check.sh generated work-ledger poll shim and its .check-trust binding; primary home only, present only after bin/fm-work-ledger.sh arm
pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh
procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13)
procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line
Expand Down Expand Up @@ -585,6 +588,7 @@ These skills are not captain-invocable; load them only at their precise triggers
- `captain-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a captain decision, when recording or routing the captain's answer, and on any `RECORD DIVERGENCE` line from the wake drain.
- `process-event-sources` - load before arming a long-polling source, before registering a deterministic condition->action watch (do X as soon as Y is true), on any `procevent <adapter> <source-id> <sequence>` check wake, and on any `process-event source stranded` or `process-event source failed to start` check wake.
Never run a registered source's blocking command yourself in a conversational turn.
- `work-ledger` - load on any `check: work-ledger:` wake, and before writing a card's rating or parent into its backlog title.
- `fmx-respond` - load on an `x-mention <request_id>` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the Relay configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for a Relay-linked task before posting its completion follow-up; relevant only when Relay is on.
- `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work.
- `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task.
Expand Down
53 changes: 39 additions & 14 deletions bin/fm-busy-event.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
# an old task from retiring a newly armed incarnation. A missing sidecar
# is already retired, so any orphan record is removed idempotently.
#
# Work ledger: inside the same lock, and only after the record write (or the
# retirement) succeeded, arm, apply, and retire each append one row to
# <state-dir>/work-ledger/<id>.events. bin/fm-work-ledger-lib.sh owns that row
# format and its fail-open rule: a failed append never changes this script's
# exit code or output, so capture can never block or fail a turn.
#
# Exit codes: 0 applied; 1 refused (stale gen, unarmed task, lock timeout,
# invalid input); 2 usage. Adapter hook command lines append `|| true` so a
# refusal never breaks the harness's own lifecycle.
Expand All @@ -55,6 +61,8 @@ EOF
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=bin/fm-busy-lib.sh
. "$SCRIPT_DIR/fm-busy-lib.sh"
# shellcheck source=bin/fm-work-ledger-lib.sh
. "$SCRIPT_DIR/fm-work-ledger-lib.sh"

CMD=${1:-}
case "$CMD" in
Expand Down Expand Up @@ -152,6 +160,30 @@ write_record() { # <gen> <seq>
mv -f "$tmp" "$REC"
}

# Called with the lock held and the mutation already durable.
ledger_row() { # <row-kind> <gen> <seq> <state>
fm_work_ledger_append "$STATE" "$ID" "$1" \
"gen=$2 seq=$3 state=$4 source=${SOURCE:--} event=${EVENT:--}"
}

# The seq of the record currently held for <gen>, or 0 when there is none.
record_seq() { # <gen>
local line field
[ -f "$REC" ] || { printf '0\n'; return 0; }
line=$(head -n 1 "$REC" 2>/dev/null || true)
case "$line" in
*" gen=$1 "*)
field=${line##* seq=}
field=${field%% *}
case "$field" in
''|*[!0-9]*) printf '0\n' ;;
*) printf '%s\n' "$field" ;;
esac
;;
*) printf '0\n' ;;
esac
}

old_umask=$(umask)
umask 077

Expand All @@ -162,6 +194,7 @@ if [ "$CMD" = arm ]; then
printf '%s\n' "$GEN" > "$GEN_FILE.tmp.$$" && mv -f "$GEN_FILE.tmp.$$" "$GEN_FILE" \
&& write_record "$GEN" 1 && rm -f "$STATE/$ID.progress"
} || { lock_release; umask "$old_umask"; echo "error: arm failed for $ID" >&2; exit 1; }
ledger_row arm "$GEN" 1 "$NEW_STATE"
lock_release
umask "$old_umask"
printf '%s\n' "$GEN"
Expand Down Expand Up @@ -208,12 +241,16 @@ if [ "$GEN" != "$CURRENT" ]; then
exit 1
fi
if [ "$CMD" = retire ]; then
RETIRE_SEQ=$(($(record_seq "$GEN") + 1))
rm -f "$GEN_FILE" "$REC" "$STATE/$ID.progress" || {
lock_release
umask "$old_umask"
echo "error: busy-state retirement failed for $ID" >&2
exit 1
}
SOURCE=fm-retire
EVENT=retire
ledger_row retire "$GEN" "$RETIRE_SEQ" retired
lock_release
umask "$old_umask"
exit 0
Expand All @@ -224,26 +261,14 @@ if [ "$CMD" = progress ]; then
umask "$old_umask"
exit 0
fi
OLD_SEQ=0
if [ -f "$REC" ]; then
old_line=$(head -n 1 "$REC" 2>/dev/null || true)
case "$old_line" in
*" gen=$GEN "*)
old_seq_field=${old_line##* seq=}
old_seq_field=${old_seq_field%% *}
case "$old_seq_field" in
''|*[!0-9]*) OLD_SEQ=0 ;;
*) OLD_SEQ=$old_seq_field ;;
esac
;;
esac
fi
OLD_SEQ=$(record_seq "$GEN")
write_record "$GEN" $((OLD_SEQ + 1)) || {
lock_release
umask "$old_umask"
echo "error: record write failed for $ID" >&2
exit 1
}
ledger_row turn "$GEN" $((OLD_SEQ + 1)) "$NEW_STATE"
lock_release
umask "$old_umask"
exit 0
7 changes: 7 additions & 0 deletions bin/fm-merge-outcome-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ _FM_MERGE_OUTCOME_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$_FM_MERGE_OUTCOME_LIB_DIR/fm-pr-lib.sh"
# shellcheck source=bin/fm-parent-channel-lib.sh
. "$_FM_MERGE_OUTCOME_LIB_DIR/fm-parent-channel-lib.sh"
# shellcheck source=bin/fm-work-ledger-lib.sh
. "$_FM_MERGE_OUTCOME_LIB_DIR/fm-work-ledger-lib.sh"

# shellcheck disable=SC2034 # Public result consumed by sourcing callers.
FM_MERGE_OUTCOME_ALREADY_RECORDED=false
Expand Down Expand Up @@ -96,6 +98,11 @@ fm_merge_outcome_report() { # <home> <state> <task-id> <pr-url> <origin> [autho
return 0
fi

# The work ledger's merged row shares this operation's deduplication, so it
# inherits the same at-least-once shape: a retried publication may repeat the
# row, and the ledger's reader takes the first one.
fm_work_ledger_append "$state" "$id" merged "pr=$(fm_work_ledger_token "$FM_PR_URL")"

if [ -n "$destination" ]; then
fm_parent_channel_append_once "$destination" "$line" || status=1
fi
Expand Down
6 changes: 6 additions & 0 deletions bin/fm-pr-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
. "$SCRIPT_DIR/fm-wake-lib.sh"
# shellcheck source=bin/fm-parent-channel-lib.sh
. "$SCRIPT_DIR/fm-parent-channel-lib.sh"
# shellcheck source=bin/fm-work-ledger-lib.sh
. "$SCRIPT_DIR/fm-work-ledger-lib.sh"

if [ "$#" -ne 2 ]; then
echo "error: invalid PR check request" >&2
Expand Down Expand Up @@ -135,6 +137,10 @@ fm_pr_metadata_identity_parse "$META" || exit 1
&& [ "$FM_PR_META_NUMBER" = "$NUMBER" ] || exit 1
fm_lock_release "$META_LOCK"
META_LOCK_HELD=0
# Stamp the PR-ready moment in the work ledger with this home's clock, since
# neither the meta line above nor the worker's status line carries a time
# (bin/fm-work-ledger-lib.sh; the append cannot fail this registration).
fm_work_ledger_append "$STATE" "$ID" pr-ready "pr=$(fm_work_ledger_token "$URL")"

PR_POLL_PUBLISH_LOCK="$STATE/.pr-poll-publish-$ID.lock"
fm_lock_acquire_wait "$PR_POLL_PUBLISH_LOCK"
Expand Down
21 changes: 21 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,8 @@ fm_backlog_directory_present "$STATE" "state directory" || {
. "$SCRIPT_DIR/fm-gate-refuse-lib.sh"
# shellcheck source=bin/fm-busy-lib.sh
. "$SCRIPT_DIR/fm-busy-lib.sh"
# shellcheck source=bin/fm-work-ledger-lib.sh
. "$SCRIPT_DIR/fm-work-ledger-lib.sh"
# shellcheck source=bin/fm-cursor-lib.sh
. "$SCRIPT_DIR/fm-cursor-lib.sh"
# shellcheck source=bin/fm-pr-lib.sh
Expand Down Expand Up @@ -4635,6 +4637,25 @@ fi
fm_lock_release "$SPAWN_META_LOCK"
SPAWN_META_LOCK_HELD=0

# One work-ledger spawn row per launch, relaunches included, so a harness or
# model switch is on record beside the turns it explains. It is written only
# here, after the commit point, so a refused spawn leaves no row. The card's
# frozen rating and parent come from its backlog title, and a harness spawn did
# not arm the busy-state contract for is recorded as unmeasured rather than
# left to look like zero minutes (bin/fm-work-ledger-lib.sh owns the row format
# and guarantees the append cannot fail this spawn).
if [ "$KIND" != secondmate ]; then
LEDGER_TITLE=
LEDGER_RATING_READ=failed
if LEDGER_SHOW=$(fm_backlog_row_show "$DATA" "$ID" --full 2>/dev/null); then
LEDGER_RATING_READ=ok
LEDGER_TITLE=$(printf '%s\n' "$LEDGER_SHOW" | sed -n 's/^ title: *//p' | head -1)
fi
LEDGER_PARENT=$(fm_work_ledger_title_field "$LEDGER_TITLE" parent || true)
fm_work_ledger_append "$STATE_REAL" "$ID" spawn \
"harness=$(fm_work_ledger_token "$HARNESS") model=$(fm_work_ledger_token "$MODEL") kind=$(fm_work_ledger_token "$KIND") parent=$(fm_work_ledger_token "$LEDGER_PARENT") capture=$(fm_work_ledger_harness_capture "$HARNESS" "${BUSY_GEN:-}") $(fm_work_ledger_rating_fields "$LEDGER_TITLE") rating_read=$LEDGER_RATING_READ"
fi

SPAWN_DELIVERY=
[ -z "$MODE" ] || SPAWN_DELIVERY=" mode=$MODE yolo=$YOLO"
echo "spawned $ID harness=$HARNESS kind=$KIND$SPAWN_DELIVERY window=$META_WINDOW worktree=$WT"
19 changes: 19 additions & 0 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2487,12 +2487,31 @@ EOF
printf '%s\n' "$abs_home_path"
}

# A retiring home takes its work ledger with it, and this is the one place a
# missed copy would lose that history for good, so the removal refuses until
# the ledger is in this home's store (bin/fm-work-ledger.sh owns the copy). A
# home that recorded nothing has nothing to lose and is not held up.
preserve_firstmate_home_work_ledger() {
local home=$1 label=$2 lane=${3:-} events
[ -n "$lane" ] || lane=$(cat "$home/$SUB_HOME_MARKER" 2>/dev/null || true)
for events in "$home/state/work-ledger"/*.events; do
[ -e "$events" ] || continue
if FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$DATA" "$SCRIPT_DIR/fm-work-ledger.sh" copy --home "$home" --lane "$lane" >/dev/null; then
return 0
fi
echo "REFUSED: $label $home still holds a work ledger that could not be copied into $DATA/work-ledger; removing it would lose that history" >&2
return 1
done
return 0
}

remove_firstmate_home() {
local home=$1 label=$2 expected_id=${3:-} abs_home_path process_event_backup
[ -n "$home" ] || return 0
[ -e "$home" ] || return 0
abs_home_path=$(validate_firstmate_home_for_removal "$home" "$label" "$expected_id") || return 1
[ -n "$abs_home_path" ] || return 0
preserve_firstmate_home_work_ledger "$abs_home_path" "$label" "$expected_id" || return 1
process_event_backup=$(snapshot_firstmate_home_process_events "$abs_home_path" "$label") || return 1
if ! cleanup_firstmate_home_process_events "$abs_home_path" "$label"; then
restore_firstmate_home_process_events "$abs_home_path" "$label" "$process_event_backup" || return $?
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ family_for_basename() {
fm-session-lock-ancestry.test.sh|fm-cursor-primary.test.sh|\
fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\
fm-wake-drain-unread-status.test.sh|\
fm-tool-update-check.test.sh|\
fm-tool-update-check.test.sh|fm-work-ledger.test.sh|\
fm-mail.test.sh|fm-mail-check.test.sh|\
fm-turnend-foreign-owner-arm-fix.test.sh|\
fm-wake-queue.test.sh|fm-watch-arm.test.sh|fm-watch-checkpoint.test.sh|fm-watch-recovery-loop.test.sh|\
Expand Down Expand Up @@ -839,6 +839,7 @@ tests/fm-watch-checkpoint.test.sh 6076
tests/fm-watch-recovery-loop.test.sh 58946
tests/fm-watch-triage.test.sh 697969
tests/fm-watcher-lock.test.sh 108940
tests/fm-work-ledger.test.sh 5180
EOF
}

Expand Down
Loading
Loading