From 711cb1586b93048fecdb6c6fa7a94e90a0acbd16 Mon Sep 17 00:00:00 2001 From: Tomas Meulenberg Date: Fri, 2 Oct 2026 15:08:50 +0200 Subject: [PATCH 1/4] fix(context-budget): measure against the real window with an explicit token budget --- bin/fm-context-budget.sh | 72 +++++++++++++++++++++++-------- docs/configuration.md | 19 ++++++--- tests/fm-context-budget.test.sh | 75 +++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 23 deletions(-) diff --git a/bin/fm-context-budget.sh b/bin/fm-context-budget.sh index cf7ef5bd2f3..9f2e80cf687 100755 --- a/bin/fm-context-budget.sh +++ b/bin/fm-context-budget.sh @@ -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, -# " ", where step is percent/20 and band is quiet, +# " ", 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 @@ -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= @@ -76,7 +84,7 @@ 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 ;; @@ -84,7 +92,34 @@ while [ $# -gt 0 ]; do *) 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 @@ -141,10 +176,10 @@ tokens_from_bytes() { printf '%s\n' $((bytes / 4)) } -verdict_for() { # - if [ "$1" -lt 40 ]; then +verdict_for() { # + 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' @@ -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" @@ -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 ;; @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index 3f330c6d5e8..f1c75c62b2f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 ` `, 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 ` `, 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) @@ -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 diff --git a/tests/fm-context-budget.test.sh b/tests/fm-context-budget.test.sh index 8803358fbd9..94c3244333a 100755 --- a/tests/fm-context-budget.test.sh +++ b/tests/fm-context-budget.test.sh @@ -130,6 +130,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() { # @@ -403,6 +475,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 From 211981f54b34198a2dc337f2e1900ab1dca9b880 Mon Sep 17 00:00:00 2001 From: Tomas Meulenberg Date: Fri, 2 Oct 2026 15:13:28 +0200 Subject: [PATCH 2/4] no-mistakes(document): Align turn-end guard docs with context budget --- docs/turnend-guard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index b3284a58c9b..e95407c9859 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -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. From e05c49967a1fb2d521ebe910c625c750f8429919 Mon Sep 17 00:00:00 2001 From: Tomas Meulenberg Date: Fri, 2 Oct 2026 16:41:24 +0200 Subject: [PATCH 3/4] no-mistakes(review): Isolate budget tests from caller environment --- tests/fm-context-budget.test.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/fm-context-budget.test.sh b/tests/fm-context-budget.test.sh index 94c3244333a..ca9fc887695 100755 --- a/tests/fm-context-budget.test.sh +++ b/tests/fm-context-budget.test.sh @@ -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" From 8a0037ff6299b441f00d10ceade175c14b975ea2 Mon Sep 17 00:00:00 2001 From: Tomas Meulenberg Date: Fri, 2 Oct 2026 16:42:51 +0200 Subject: [PATCH 4/4] no-mistakes(document): Align turn-end guard comment with context budget --- bin/fm-turnend-guard.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index d878a463c41..08bb2911fd5 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -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