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
72 changes: 54 additions & 18 deletions bin/fm-context-budget.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,28 @@
# estimate is transcript bytes / 4 - deliberately coarse, and an overestimate on
# a session whose transcript is longer than its live context.
#
# Window and budget. The real window is the first of FM_CONTEXT_WINDOW (or
# --window), CLAUDE_CODE_AUTO_COMPACT_WINDOW when numeric, and 200000; the line
# names which source won. The budget, FM_CONTEXT_BUDGET (default 500000 tokens),
# is where the nudge is fully due, and is clamped to the window when larger.
# The bands are token thresholds: FM_CONTEXT_NUDGE_SUGGEST (default 40 percent
# of the budget) and FM_CONTEXT_NUDGE_NOW (default 60 percent of the budget).
#
# Modes.
# (default) print one line: the estimated percentage and the verdict.
# --percent print the integer percentage only.
# (default) print one line: tokens, percent of the window, percent of the
# budget, and the verdict.
# --percent print the integer percentage of the real window only.
# --nudge throttle mode for the turn-end guard: print the same one line and
# exit 0 only when this session has crossed into a NEW 20 percent
# step at or above 40 percent, or upgraded its verdict band;
# budget step outside the quiet band, or upgraded its verdict band;
# otherwise print nothing and exit 1.
#
# Verdicts follow the ruling's bands: under 40 percent is quiet, 40 to 60
# percent suggests /stow at the next quiet moment, and over 60 percent suggests
# /stow now.
# Verdicts: under the suggest threshold is quiet, from it up to the now
# threshold suggests /stow at the next quiet moment, and above the now
# threshold suggests /stow now.
#
# Throttle record. --nudge keeps state/.context-budget-nudged as one line,
# "<session-id> <step> <band>", where step is percent/20 and band is quiet,
# "<session-id> <step> <band>", where step is percent-of-budget/20 and band is quiet,
# next, or now. A step is announced at most once, except that an upward band
# change is announced once, a different session id resets the count, and a step
# BELOW the recorded one (the session was compacted, so its context shrank), or
Expand All @@ -55,8 +63,8 @@ NUDGE_RECORD="$STATE/.context-budget-nudged"
NUDGE_LOCK="$STATE/.context-budget-nudged.lock"
TAIL_LINES=${FM_CONTEXT_TAIL_LINES:-400}
case "$TAIL_LINES" in ''|*[!0-9]*|0) TAIL_LINES=400 ;; esac
WINDOW=${FM_CONTEXT_WINDOW:-200000}
case "$WINDOW" in ''|*[!0-9]*|0) WINDOW=200000 ;; esac
WINDOW=
WINDOW_SOURCE=

TRANSCRIPT=${FM_CONTEXT_TRANSCRIPT:-}
SESSION_ID=
Expand All @@ -76,15 +84,42 @@ USAGE
while [ $# -gt 0 ]; do
case "$1" in
--transcript) TRANSCRIPT=${2:-}; shift 2 || true ;;
--window) WINDOW=${2:-}; shift 2 || true ;;
--window) WINDOW=${2:-}; WINDOW_SOURCE=--window; shift 2 || true ;;
--session) SESSION_ID=${2:-}; shift 2 || true ;;
--percent) MODE=percent; shift ;;
--nudge) MODE=nudge; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
done
case "$WINDOW" in ''|*[!0-9]*|0) WINDOW=200000 ;; esac

ktok() { printf '%sk' $(( ($1 + 500) / 1000 )); }

is_count() { case "${1:-}" in ''|*[!0-9]*|0) return 1 ;; esac; }

# Real window, in precedence order: --window, FM_CONTEXT_WINDOW,
# CLAUDE_CODE_AUTO_COMPACT_WINDOW, then the 200000 default.
if is_count "$WINDOW"; then
:
elif is_count "${FM_CONTEXT_WINDOW:-}"; then
WINDOW=$FM_CONTEXT_WINDOW; WINDOW_SOURCE=FM_CONTEXT_WINDOW
elif is_count "${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-}"; then
WINDOW=$CLAUDE_CODE_AUTO_COMPACT_WINDOW; WINDOW_SOURCE=CLAUDE_CODE_AUTO_COMPACT_WINDOW
else
WINDOW=200000; WINDOW_SOURCE=default
fi

BUDGET=${FM_CONTEXT_BUDGET:-500000}
is_count "$BUDGET" || BUDGET=500000
BUDGET_NOTE=
if [ "$BUDGET" -gt "$WINDOW" ]; then
BUDGET_NOTE=" (budget $(ktok "$BUDGET") clamped to the window)"
BUDGET=$WINDOW
fi
SUGGEST_AT=${FM_CONTEXT_NUDGE_SUGGEST:-}
is_count "$SUGGEST_AT" || SUGGEST_AT=$((BUDGET * 40 / 100))
NOW_AT=${FM_CONTEXT_NUDGE_NOW:-}
is_count "$NOW_AT" || NOW_AT=$((BUDGET * 60 / 100))

# Claude slugs the working directory into a projects subdirectory by replacing
# every "/" and "." with "-". Auto-detection is a convenience for a hand-run
Expand Down Expand Up @@ -141,10 +176,10 @@ tokens_from_bytes() {
printf '%s\n' $((bytes / 4))
}

verdict_for() { # <percent>
if [ "$1" -lt 40 ]; then
verdict_for() { # <tokens>
if [ "$1" -lt "$SUGGEST_AT" ]; then
printf '%s\n' 'quiet'
elif [ "$1" -le 60 ]; then
elif [ "$1" -le "$NOW_AT" ]; then
printf '%s\n' 'suggest /stow at the next quiet moment'
else
printf '%s\n' 'suggest /stow now'
Expand Down Expand Up @@ -213,7 +248,8 @@ if [ "$MODE" = percent ]; then
exit 0
fi

LINE="context $PERCENT% of $WINDOW tokens - $(verdict_for "$PERCENT")"
BUDGET_PERCENT=$((TOKENS * 100 / BUDGET))
LINE="context $(ktok "$TOKENS") tokens, $PERCENT% of the $(ktok "$WINDOW") window ($WINDOW_SOURCE), $BUDGET_PERCENT% of the $(ktok "$BUDGET") budget$BUDGET_NOTE - $(verdict_for "$TOKENS")"

if [ "$MODE" = line ]; then
printf '%s\n' "$LINE"
Expand All @@ -222,8 +258,8 @@ fi

# --nudge
[ -n "$SESSION_ID" ] || SESSION_ID=unknown
STEP=$((PERCENT / 20))
BAND=$(verdict_for "$PERCENT")
STEP=$((BUDGET_PERCENT / 20))
BAND=$(verdict_for "$TOKENS")
case "$BAND" in
quiet) BAND=quiet ;;
'suggest /stow at the next quiet moment') BAND=next ;;
Expand All @@ -243,7 +279,7 @@ if [ "$STEP" -lt "$LAST" ] || [ "$BAND_RANK" -lt "$LAST_RANK" ]; then
fm_lock_release "$NUDGE_LOCK"
exit 1
fi
if [ "$PERCENT" -lt 40 ] || { [ "$STEP" -le "$LAST" ] && [ "$BAND_RANK" -le "$LAST_RANK" ]; }; then
if [ "$BAND" = quiet ] || { [ "$STEP" -le "$LAST" ] && [ "$BAND_RANK" -le "$LAST_RANK" ]; }; then
fm_lock_release "$NUDGE_LOCK"
exit 1
fi
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-turnend-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,10 @@ budget_reset() {
# --- context-budget nudge ----------------------------------------------------
# Captain ruling 2026-09-09 (token-burn report R1): firstmate suggests /stow
# plus a fresh session, or compaction, at a low-disruption moment once the
# session passes about 40 percent of its context, rather than running until the
# session passes about 40 percent of its context budget, rather than running until the
# window is dropped whole. This guard is the ONE surface that prints that
# suggestion into a session; bin/fm-context-budget.sh owns the estimate and the
# once-per-20-percent-step throttle, and docs/configuration.md "Context budget
# once-per-20-percent-budget-step throttle, and docs/configuration.md "Context budget
# nudge" owns the operator-facing contract. Do not add a second printing owner.
# The nudge fires only from the idle allow path below, where supervision is not
# needed at all, so it can never pre-empt the Stop-owned auto-arm, shorten a
Expand Down
19 changes: 14 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -702,13 +702,19 @@ The helper's header owns exact parsing, publication, and report output mechanics

## Context budget nudge (state/.context-budget-nudged)

Firstmate suggests `/stow` plus a fresh session, or compaction, at a low-disruption moment once the session passes about 40 percent of its context, rather than running a session until the whole window is dropped.
Firstmate suggests `/stow` plus a fresh session, or compaction, at a low-disruption moment once the session passes about 40 percent of its context budget, rather than running a session until the whole window is dropped.
A low-disruption moment is a turn ending with no open decision, no wake in hand, and no worker mid-steer: the fleet has no work in flight and no leftover task record, the durable wake queue is empty, no captain note is still waiting, and away mode is off.
`bin/fm-context-budget.sh` owns the estimate and the throttle, and the Claude Stop turn-end guard is the one surface that prints the suggestion into a session ([`turnend-guard.md`](turnend-guard.md)); there is deliberately no second printing owner.
The estimate reads the newest usage record in the current Claude transcript, so it is the context that request actually carried, and falls back to transcript bytes divided by four only when no usage record is readable.
Verdicts follow three bands: under 40 percent stays quiet, 40 to 60 percent suggests `/stow` at the next quiet moment, and over 60 percent suggests `/stow` now.
`state/.context-budget-nudged` records `<session-id> <step> <band>`, where step is the percentage divided by 20 and band is `quiet`, `next`, or `now`, so each 20 percent step is announced at most once, an upward band change is announced once, a new session starts its own count, and a session whose context shrank through compaction rewrites the record down and stays silent until the next real crossing.
Run `bin/fm-context-budget.sh` by hand at any time for the same one-line reading; `FM_CONTEXT_WINDOW` sets the assumed window and the script's header owns the remaining mechanics.
The real window is the first of `FM_CONTEXT_WINDOW`, a numeric `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, and 200000, and the printed line names which source won.
`FM_CONTEXT_BUDGET` (default 500000 tokens) is the point at which the nudge is fully due; a budget above the real window is clamped to it and the line says so.
Verdicts follow two token thresholds: under `FM_CONTEXT_NUDGE_SUGGEST` (default 40 percent of the budget) stays quiet, up to `FM_CONTEXT_NUDGE_NOW` (default 60 percent of the budget) suggests `/stow` at the next quiet moment, and above it suggests `/stow` now.
The line reports tokens used, the percentage of the real window, the percentage of the budget, and the verdict.
`state/.context-budget-nudged` records `<session-id> <step> <band>`, where step is the budget percentage divided by 20 and band is `quiet`, `next`, or `now`, so each 20 percent step is announced at most once, an upward band change is announced once, a new session starts its own count, and a session whose context shrank through compaction rewrites the record down and stays silent until the next real crossing.
Run `bin/fm-context-budget.sh` by hand at any time for the same one-line reading; the script's header owns the remaining mechanics.

The budget sits below the window on purpose.
Under the captain's 2026-10-02 decision, Kun Chen's compact-adviser (hint mode, 500000-token budget) and this nudge fire first, while Claude Code's hard auto-compaction at `CLAUDE_CODE_AUTO_COMPACT_WINDOW` stays as the backstop.

## Stow pass horizon (config/stow-pass-horizon)

Expand Down Expand Up @@ -2476,7 +2482,10 @@ FM_BOOTSTRAP_NETWORK=all # internal session-start phase split: all, skip (loca
FM_STARTUP_NETWORK_TIMEOUT=120 # seconds bounding the deferred inactive-outcome scan plus network checks, including the lock waits the worker makes before them; hitting it prints an actionable NETWORK_CHECKS line, and a lock a live process still holds at the deadline ends the worker with a failed-rerun record (publication and delivery are bounded by FM_SESSION_START_TIMEOUT the same way)
FM_TASKS_AXI_COMPATIBLE= # internal one-hop handoff of an already-computed tasks-axi compatibility verdict (0 or 1); consumed when bin/fm-tasks-axi-lib.sh is sourced
FM_GUARD_READ_ONLY=0 # internal/read-only guard mode: keep alarms but suppress drain, supervision repair, and checkout repair commands
FM_CONTEXT_WINDOW=200000 # assumed context window for bin/fm-context-budget.sh; see "Context budget nudge"
FM_CONTEXT_WINDOW= # real context window for bin/fm-context-budget.sh; unset falls back to CLAUDE_CODE_AUTO_COMPACT_WINDOW, then 200000; see "Context budget nudge"
FM_CONTEXT_BUDGET=500000 # tokens at which bin/fm-context-budget.sh's nudge is fully due; clamped to the real window
FM_CONTEXT_NUDGE_SUGGEST= # token threshold for the suggest-at-next-quiet-moment band; default 40 percent of FM_CONTEXT_BUDGET
FM_CONTEXT_NUDGE_NOW= # token threshold above which the nudge suggests /stow now; default 60 percent of FM_CONTEXT_BUDGET
FM_CONTEXT_TRANSCRIPT= # explicit Claude transcript path for bin/fm-context-budget.sh, mainly for tests; the turn-end guard passes the hook payload's own path
FM_CONTEXT_TAIL_LINES=400 # transcript lines bin/fm-context-budget.sh scans back for the newest usage record
FM_GUARD_CONTINUE_LINE='This is a supervision warning only; the guarded operation WILL still run.' # banner continuation line; fm-send.sh overrides it to name the requested message specifically
Expand Down
4 changes: 2 additions & 2 deletions docs/turnend-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,8 +518,8 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa

## Context budget nudge

The Claude Stop path carries one advisory that is not about supervision: the context-budget suggestion to `/stow` and start fresh, or compact, once the session passes about 40 percent of its context.
[`configuration.md`](configuration.md) "Context budget nudge" is the operator-facing owner of the bands, the throttle record, and the low-disruption definition, and `bin/fm-context-budget.sh` owns the estimate and the once-per-20-percent-step throttle.
The Claude Stop path carries one advisory that is not about supervision: the context-budget suggestion to `/stow` and start fresh, or compact, once the session passes about 40 percent of its context budget.
[`configuration.md`](configuration.md) "Context budget nudge" is the operator-facing owner of the bands, the throttle record, and the low-disruption definition, and `bin/fm-context-budget.sh` owns the estimate and the once-per-20-percent-budget-step throttle.
This guard is the only surface that prints that line into a session, so no second printing owner may be added.
It fires exclusively from the idle allow path, where `fm_supervision_status` reports no supervision need at all, which is why it can never pre-empt the Stop-owned auto-arm, shorten a Cursor park, or land inside a turn that is handling a wake.
The remaining low-disruption conditions are checked in the guard itself: away mode off, an empty durable wake queue, no captain inbox note still waiting, no unacknowledged steering message, and no leftover task status record.
Expand Down
78 changes: 78 additions & 0 deletions tests/fm-context-budget.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
# All hermetic over temp dirs; no real agent session is invoked.
set -u

unset FM_CONTEXT_WINDOW FM_CONTEXT_BUDGET FM_CONTEXT_NUDGE_SUGGEST FM_CONTEXT_NUDGE_NOW \
CLAUDE_CODE_AUTO_COMPACT_WINDOW

# shellcheck source=tests/lib.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

Expand Down Expand Up @@ -130,6 +133,78 @@ test_missing_transcript_is_silent() {
pass "estimator: an unreadable transcript prints nothing and exits 1"
}

test_window_source_precedence() {
local t line
t="$TMP_ROOT/window-source/transcript.jsonl"
write_transcript "$t" 120000

line=$(env -u FM_CONTEXT_WINDOW -u CLAUDE_CODE_AUTO_COMPACT_WINDOW "$BUDGET" --transcript "$t")
assert_contains "$line" "60% of the 200k window (default)" "no setting must fall back to 200000"

line=$(env -u FM_CONTEXT_WINDOW CLAUDE_CODE_AUTO_COMPACT_WINDOW=600000 "$BUDGET" --transcript "$t")
assert_contains "$line" "20% of the 600k window (CLAUDE_CODE_AUTO_COMPACT_WINDOW)" \
"the compaction window must be read when FM_CONTEXT_WINDOW is unset"

line=$(env -u FM_CONTEXT_WINDOW CLAUDE_CODE_AUTO_COMPACT_WINDOW=lots "$BUDGET" --transcript "$t")
assert_contains "$line" "(default)" "a non-numeric compaction window must be ignored"

line=$(FM_CONTEXT_WINDOW=400000 CLAUDE_CODE_AUTO_COMPACT_WINDOW=600000 "$BUDGET" --transcript "$t")
assert_contains "$line" "30% of the 400k window (FM_CONTEXT_WINDOW)" \
"FM_CONTEXT_WINDOW must win over the compaction window"

line=$(FM_CONTEXT_WINDOW=400000 "$BUDGET" --transcript "$t" --window 300000)
assert_contains "$line" "40% of the 300k window (--window)" "--window must win over the environment"
pass "estimator: window comes from --window, FM_CONTEXT_WINDOW, the compaction window, then 200000"
}

test_budget_bands_and_clamp() {
local t line
t="$TMP_ROOT/budget/transcript.jsonl"

write_transcript "$t" 212000
line=$(env -u FM_CONTEXT_BUDGET "$BUDGET" --transcript "$t" --window 600000)
assert_contains "$line" "context 212k tokens, 35% of the 600k window (--window), 42% of the 500k budget - suggest /stow at the next quiet moment" \
"the line must report tokens, window percent, budget percent, and the band"

write_transcript "$t" 199999
line=$("$BUDGET" --transcript "$t" --window 600000)
assert_contains "$line" "- quiet" "one token under 40 percent of the budget must stay quiet"
write_transcript "$t" 200000
line=$("$BUDGET" --transcript "$t" --window 600000)
assert_contains "$line" "at the next quiet moment" "40 percent of the budget must suggest"
write_transcript "$t" 300000
line=$("$BUDGET" --transcript "$t" --window 600000)
assert_contains "$line" "at the next quiet moment" "60 percent of the budget is still the suggest band"
write_transcript "$t" 300001
line=$("$BUDGET" --transcript "$t" --window 600000)
assert_contains "$line" "suggest /stow now" "past 60 percent of the budget must suggest now"

write_transcript "$t" 150000
line=$(FM_CONTEXT_NUDGE_SUGGEST=100000 FM_CONTEXT_NUDGE_NOW=140000 "$BUDGET" --transcript "$t" --window 600000)
assert_contains "$line" "suggest /stow now" "token overrides must move the band thresholds"

line=$(FM_CONTEXT_BUDGET=900000 "$BUDGET" --transcript "$t" --window 600000)
assert_contains "$line" "25% of the 600k budget (budget 900k clamped to the window)" \
"a budget above the window must clamp and say so"
pass "estimator: budget bands sit at 40 and 60 percent of the budget, overridable, clamped to the window"
}

test_nudge_steps_follow_the_budget() {
local state t out status
state=$(make_state throttle-budget)
t="$TMP_ROOT/throttle-budget/transcript.jsonl"
write_transcript "$t" 212000
out=$(FM_STATE_OVERRIDE="$state" "$BUDGET" --nudge --transcript "$t" --session s1 --window 600000) \
|| fail "42 percent of the budget must announce"
assert_contains "$out" "suggest /stow at the next quiet moment" "budget nudge lost its verdict"
assert_exact_line "$state/.context-budget-nudged" "s1 2 next" "the step must be budget percent / 20"
status=0
out=$(FM_STATE_OVERRIDE="$state" "$BUDGET" --nudge --transcript "$t" --session s1 --window 600000) || status=$?
expect_code 1 "$status" "a repeated reading inside the same budget step"
[ -z "$out" ] || fail "the same budget step must announce once, got: $out"
pass "nudge: steps and the once-per-step throttle follow the budget"
}

# --- THROTTLE ----------------------------------------------------------------

nudge() { # <state> <transcript> <session>
Expand Down Expand Up @@ -403,6 +478,9 @@ test_auto_detection_empty_projects_is_silent
test_verdict_bands
test_byte_fallback_when_no_usage_record
test_missing_transcript_is_silent
test_window_source_precedence
test_budget_bands_and_clamp
test_nudge_steps_follow_the_budget
test_nudge_is_quiet_below_forty_percent
test_nudge_announces_band_upgrade_at_sixty_one
test_nudge_reannounces_after_downward_band_change
Expand Down
Loading