Skip to content

fix(#4985): statusline meter reads 100% where Claude Code auto-compacts, and the full window when auto-compact is off - #5252

Merged
trek-e merged 27 commits into
open-gsd:nextfrom
0xdhx:fix/4985-statusline-meter-compaction-threshold
Oct 14, 2026
Merged

trek-e merged 27 commits into
open-gsd:nextfrom
0xdhx:fix/4985-statusline-meter-compaction-threshold

Conversation

@0xdhx

@0xdhx 0xdhx commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fix PR

Using the wrong template?
— Enhancement: use enhancement.md
— Feature: use feature.md


Linked Issue

Required. This PR will be auto-closed if no valid issue link is found.

Fixes #4985
Fixes #4844

The linked issue must have the confirmed-bug label. If it doesn't, ask a maintainer to confirm the bug before continuing.

Supersedes #4959, at a maintainer's request: trek-e, 2026-10-07 asked for one PR that supersedes #4959 and carries the statusline fix.

Credit. The first seven commits are @tanji-dg's #4959 (the #4844 fix), carried with their authorship and rebased onto next. Their content is unchanged; the first one's subject now takes the fix(#4844): form. Their changeset is kept and credits the contribution. The 17 commits after them are the #4985 fix built on top, which reuses #4959's isAutoCompactDisabled for the disabled case, and the changes from the 2026-10-09 review.

The review findings on #4959 are addressed in the carried commits. #4959's head d21d3d1 (pushed 2026-10-07 02:41Z) addressed all three findings from the 2026-10-07 01:23Z review:

  • DISABLE_AUTO_COMPACT accepted values. Read from the Claude Code 2.1.292 binary: the second operand of Boolean(Le(DISABLE_COMPACT) || a.DISABLE_AUTO_COMPACT) is the env registry's D.bool() field, which runs the same ["1","true","yes","on"] helper. So both keys share one truthy set. The source is stated beside the constants and in the test.
  • tests/perf-317-context-monitor-fs.test.cjs. It sent the invented context_window.total_tokens; it now sends context_window_size.
  • The PR-body count. Remaining 16.5 renders 84% (round(83.5)), not 83%.

Two of #4844's acceptance criteria are superseded here, on purpose. #4844's AC2 (enabled behaviour unchanged) and AC3 (the bridge stays raw) were written with the enabled-case math out of scope. #4985 is that scope, and its fix changes both: the enabled case now measures against the real threshold, and the bridge moves to the threshold scale (option a). #4844's AC1 holds. With auto-compact disabled the meter shows the used share of the model window, which is round(100 − remaining) with no buffer (raw 40 reads 40; at the old buffer boundary, raw 83.5 reads 84).

Maintainer confirmation needed: the bridge scale. Option (a) reverses #2451's raw-bridge contract and #4844's AC3. @tanji-dg agreed to the supersede; that is not a maintainer's agreement to the scale change, and this PR should not merge without one.


What was broken

Two halves of the same meter block in hooks/gsd-statusline.js:

What this fix does

The meter measures usage against the token count at which Claude Code auto-compacts, resolved the way Claude Code resolves it, so it reaches 100% there (rounded: within half a percent of it). When compaction is off, the denominator is the model window. The bridge counts down to the same point, so the context monitor warns before compaction.

  • Window: CLAUDE_CODE_AUTO_COMPACT_WINDOW (raised to 100k, capped at 1M; an unparseable or non-positive value is ignored), else the autoCompactWindow setting, else context_window_size. Always capped at the model window.
  • Threshold: window − min(maxOutput, 20000) − 13000, lowered to floor(effective × pct/100) by CLAUDE_AUTOCOMPACT_PCT_OVERRIDE. A statusline cannot see maxOutput, so the hook takes the 20,000 cap; on a model whose output limit is below 20,000, Claude Code compacts slightly later than the meter's 100%. /context shows the 33k as "Autocompact buffer". The hardcoded 16.5% this replaces is 33,000 / 200,000, so it was right only on a 200K window with nothing configured.
  • Usage: current_usage.input_tokens + cache_creation_input_tokens + cache_read_input_tokens, the sum /context reports, measured against the threshold. This avoids re-scaling remaining_percentage, which is already rounded against the model window and loses up to 5,000 tokens on a 1M model.
  • Per-model setting (Claude Code 2.1.292). /autocompact now saves the window under modelSettings.<canonical model name>.autoCompactWindow, not the top-level key earlier versions wrote. Reading only the top-level key would leave this issue's headline case unfixed on a current Claude Code. The hook mirrors the binary's merge (lFt() / bv()):
    • Settings files are read lowest precedence first. Each file's per-model entries layer over the lower files' entries.
    • A file that sets a top-level autoCompactWindow replaces the default and drops the lower files' per-model entries.
    • The session model's own entry wins over the default. The model is the payload's model.id, canonicalized across its dated, [1m], Bedrock and Vertex spellings. Canonicalizing is idempotent (a key is its own key), and a key longer than 256 characters once lowercased matches no model, which bounds the work a hostile settings key can cause.
    • "auto" falls back to the model window.
  • Settings files are untrusted input. The project's .claude/ is repository content, and the statusline reads the settings files on renders that draw the meter (unless an env var already settles it). A file is read only when it is a regular file of at most 1 MiB. It is opened non-blocking and checked through its own descriptor, so a FIFO is skipped instead of blocking the render. The read stops one byte past the checked size, and a file that grew or shrank after the check is treated as absent. A symlink is followed, since a settings file linked in from a dotfiles repo is ordinary.
  • Bridge, option (a) from the bug(statusline): context meter's 100% is not the auto-compact point — reads absent context_window.total_tokens, ignores autoCompactWindow #4985 thread. remaining_percentage / used_pct are on the threshold scale, and the bridge gains used_tokens / threshold_tokens. The monitor gates on remaining_percentage. On the raw scale its 35% / 25% sit at a different distance from compaction for every configured window, and with a window well below the model's (the 650k-on-1M case) compaction fires before the warning does. On the threshold scale they measure runway to compaction whatever the window. The /context agreement fix(#2315): respect review.default_reviewers in bare convergence invocation #2451 protected moves to the token counts, which the monitor's message now quotes: CONTEXT WARNING: Usage at 65% (401k of 617k usable tokens). The monitor's 35% / 25% are unchanged; they now measure runway to compaction, per the triage scope. The token-floor idea from the issue stays out of this PR.

Root cause

Testing

How I verified the fix

The three cases from #4985's repro script, run against this branch's hooks/:

Case Compaction threshold Before After
1. autoCompactWindow: 650000 in settings, 1M model, 616,927 used 617,000 74%, monitor silent 100%, CONTEXT CRITICAL: Usage at 100% (617k of 617k usable tokens)
2. CLAUDE_CODE_AUTO_COMPACT_WINDOW=150000, 200K model, 100,000 used 117,000 100% 85%
3. CLAUDE_CODE_AUTO_COMPACT_WINDOW=650000, 1M model, 616,927 used 617,000 95% 100%

Claude Code 2.1.292's resolution, re-read from the shipped binary (minified names):

  • YV(e,n) is the threshold: e − 13000, or min(floor(e × pct/100), e − 13000) under the pct override.
  • U3 is the effective window: window − min(maxOutputTokens, 20000).
  • bv resolves the window in this order:
    1. env (≥ 100k, capped at the model window)
    2. settings: byModel[settingsKey], else the default. "auto" falls through.
    3. clientdata
    4. experiment
    5. model-default table
    6. model window
  • The compaction trigger is YV. The newer eLe (a precompute buffer fraction) only feeds the earlier summary-precompute check.

Suites at this head (d50621d0b), run locally on Linux with HOME sandboxed and CLAUDE_CONFIG_DIR cleared:

  • tests/gsd-statusline.test.cjs and tests/perf-317-context-monitor-fs.test.cjs: 394/394
  • CI's 12 tree-level required jobs, each run with its CI command, all pass: changeset-lint, docs-lint, default-flip-documentation, Detect test scope, plugin-validate, security, lint-tests (npm run lint:ci), the targeted lane (926/926), the QA ratchet (162/162), and the three unit shards, whose first also runs the integration, security, install and slow suites (44,246 tests, 0 failed, 45 skipped, all platform- or install-gated and none in this PR's files)
  • Not reproducible locally, so left to CI: the Windows and macOS conformance legs, the packed-tarball smoke installs, the merged-shard coverage gate, Stryker, and the PR-metadata checks

Regression test added?

  • Yes — added a test that would have caught this bug
  • No — explain why:

The new #4985 tests fail on the pre-fix hooks:

  • Threshold resolution: precedence, validation and caps, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, and the per-model modelSettings merge.
  • The bridge scale.
  • The monitor quoting token counts.
  • An end-to-end hook run with model.id in the payload. 617k (100%) is due. On next's hooks the meter reads 74%; before the per-model fix (13ccef5f8) the window resolved to 967k and the meter read 64%.

From the 2026-10-09 review:

  • Boundaries at limit−1, limit and limit+1 for the window bounds (100k and 1M, in the setting and the env var), the pct override (0, 1, 100, 101) and the threshold's positive edge.
  • fast-check properties for the env parser, canonicalModelKey (idempotent, spelling-invariant), the threshold (never falls as the window grows, never above the window less the 33,000 buffer, only ever lowered by the pct override), the meter (used + remaining = 100, its percentage within 0.5 below the threshold and exactly 100 at or above it) and the settings merge, which is checked against an independent reference model rather than the production helpers.
  • Hostile settings: a FIFO, a file over 1 MiB, a file that grows or shrinks after the size check, and an over-long model key.

The #2451 assertions in gsd-statusline.test.cjs and perf-317-context-monitor-fs.test.cjs are rewritten, not dropped. The bridge's used_tokens must equal what /context reports as used, and used_pct + remaining_percentage = 100.

One intermediate commit is red at its own tree. a710ed88e moves the bridge to the threshold scale, and five of perf-317's #2451 raw-bridge assertions fail there until the next commit, 0af4c9fcc, moves them to the new scale. A squash merge makes this moot; I can restructure the history if you'd rather keep the commits.

Platforms tested

  • macOS
  • Windows (including backslash path handling)
  • Linux
  • N/A (not platform-specific)

Run locally on Linux only. Both changed test files are on CI's Windows (platform-conformance-tier.generated.cjs) and macOS (macos-conformance-tier.generated.cjs) conformance lists, so those legs run them (the FIFO case skips on Windows); their result is CI's, not a local run.

Runtimes tested

  • Claude Code
  • Antigravity
  • OpenCode
  • Other: ___
  • N/A (not runtime-specific)

Checklist

  • Issue linked above with Fixes #NNN — PR will be auto-closed if missing
  • Linked issue has the confirmed-bug label
  • Fix is scoped to the reported bug — no unrelated changes included
  • Regression test added (or explained why not)
  • All existing tests pass (npm test)
  • .changeset/ fragment added if this is a user-facing fix (npm run changeset -- --type Fixed --pr <NNN> --body "...") — or no-changelog label applied
  • No unnecessary dependencies added

Breaking changes

None to an API. A reviewer will see these behaviour changes in the test diff:

  • No-window default. At 50% of a 1M window the meter reads 52%, not 60%: 100% is now 967k, not the 16.5% share.
  • Bridge scale. used_pct / remaining_percentage are on the threshold scale (option a), and the bridge gains used_tokens / threshold_tokens. The context monitor is the bridge's only runtime reader in this repo (two test files read it too).
  • Payload fallbacks.
    • A payload without current_usage recovers tokens from the percentage.
    • A payload without context_window_size falls back to Claude Code's own percentage, unscaled.
  • Project settings are read from workspace.project_dir (Claude Code's launch directory), not current_dir.
  • isAutoCompactDisabled's settings walk moved into a shared reader. It now also skips a settings file that is not a regular file, is over 1 MiB, or changes size while it is read.

Stated limits. These fall back to the model window because a statusline cannot see them:

  • the --autocompact launch flag
  • managed-policy and --settings files
  • Claude Code's clientdata, experiment and per-model default windows, which is what "auto" resolves to
  • autoCompactEnabled stored in the legacy global config (~/.claude.json), which 2.1.292 still reads as a fallback source; the disabled check reads settings files only

Domain census. Settings files are read in this order, highest precedence first:

  1. project settings.local.json
  2. project settings.json
  3. user settings.local.json
  4. user settings.json

The window merge walks them in reverse. Not reached: managed-policy and --settings files.

@tanji-dg

tanji-dg commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for carrying this forward, @0xdhx. For the record, d21d3d1 (02:38Z) and my 02:47Z comment on #4959 already addressed trek-e's 01:23Z findings; happy for #5252 to proceed as the superseding PR.

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated maintainer review of head 87d7efa7b7. Zero-tolerance policy: every finding at any severity must be resolved before approval.

Review PR #5252 — fix(#4985): statusline meter reads 100% where Claude Code auto-compacts, and the full window when auto-compact is off

Head 87d7efa (fresh gh pr view); mergeStateStatus BEHIND, mergeable MERGEABLE, not draft, reviewDecision REVIEW_REQUIRED.
CI: only non-success rollup entries are SKIPPED (auto-merge, inert CI, emitted-baseline publish); no failing checks.

Summary

Carries tanji-dg #4959 (#4844, 7 commits, authorship kept) plus 0xdhx #4985 fix: resolveAutoCompactThreshold / contextMeter / per-model autoCompactWindow merge in hooks/gsd-statusline.js; bridge moved to threshold scale (+used_tokens/threshold_tokens); monitor message quotes tokens. Root cause addressed at the right site (meter block + bridge writer). tanji-dg comment (data): consents to supersede; no effect on verdict.

Acceptance checklist

#4844 (confirmed-bug) AC:

  1. disabled => used == round(100-remaining) at mid and at 16.5 boundary: MET (isAutoCompactDisabled -> limit=model window; tests added).
  2. enabled/default unchanged incl. custom-window override: NOT MET by design (default now 52% not 60% at 50%/1M). PR body declares superseded; #4985 scope requires it.
  3. bridge stays raw: NOT MET by design (option (a)). "Out of scope" in #4844 triage lists the bridge raw contract. #4985 triage allows scaling the monitor thresholds. Option (a) was proposed by author; maintainer never replied (author said "no reply needed"). Judgment: maintainer must confirm.
    #4985 (confirmed-bug) AC:
  4. settings-only autoCompactWindow => 100% at real compaction point: MET (650k/1M, 616,927 -> 100%, test asserts threshold_tokens 617000).
  5. monitor can warn/critical before compaction at any window size: MET (bridge on threshold scale; end-to-end monitor test).
    Out of scope respected: token-floor idea excluded.
    Scope: two issues, one PR, at maintainer request (trek-e 2026-10-07 on #4959). OK vs RULESET.PR-SCOPE.

Findings

  1. MAJOR | deterministic | tests/gsd-statusline.test.cjs (new describe blocks, ~L'context meter resolves...') | RULESET.TESTS.property-based-testing: parseAutoCompactWindowEnv, canonicalModelKey, readAutoCompactWindowSetting merge and resolveAutoCompactThreshold (parser + budget/threshold) have no fast-check property. Fix: add property suite (e.g. threshold monotone in window, <= model window - 33k, used+remaining==100, canonicalModelKey idempotent and spelling-invariant, env parse never throws / result in [100k,1M] or null).
  2. MAJOR | deterministic | same tests | RULESET.TESTS.boundary-coverage / .fixtures: no limit-1/limit/limit+1 for window bounds. Existing cases use 50_000, 1_500_000, 5_000_000; none at 99_999/100_000/100_001 (setting + env) or 999_999/1_000_000/1_000_001 (setting + env); pct override not at 0/1/100/101; threshold>0 edge untested. Fix: add boundary table for isAutoCompactWindowValue and parseAutoCompactWindowEnv, and pct override edges.
  3. MINOR | judgment | hooks/gsd-statusline.js readSettingsFile / claudeSettingsPaths (project_dir/.claude/settings*.json): hostile-input rule (CONTRIBUTING.md "Security and prompt-injection surfaces", user-controlled files hostile). Settings are read with unbounded fs.readFileSync, no size cap, no lstat/symlink/non-regular-file rejection; a repo shipping .claude/settings.json as a FIFO or huge file stalls/bloats the per-turn statusline. Read is also now done for up to 4 files per render (extra reads vs prior single walk, still bounded in count). Partly inherited from #4959 but this PR now expands the surface (project_dir). Fix: lstat regular-file check + size cap (e.g. 1 MiB) -> treat as absent.
  4. MINOR | judgment | PR body/maintainer gate: option (a) bridge-scale change (reverses #2451 contract and #4844 AC3) has no explicit maintainer approval on #4985; surface in chat for confirmation before merge.
  5. NIT | deterministic | docs/context-monitor.md ~L37: one prose line far exceeds the wrapped width of neighbours ("lowered further when CLAUDE_AUTOCOMPACT_PCT_OVERRIDE sets an earlier point. With auto-compact off, it is the model\nwindow." ragged wrap). Reflow.
  6. NIT | judgment | PR template: "All existing tests pass" unchecked, Platforms Linux-only (Windows path handling for settings paths untested locally; CI matrix covers). Check or explain.
  7. NIT | deterministic | carried commit ee7ceb1 subject fix(statusline): ... lacks (#4844) scope form type(#issue):; carried unchanged by request; later commits conform. PR title conforms.
    Not findings (verified): no source-grep tests (bridge read via JSON.parse of SUT-written tmp file; RULESET.TESTS.no-source-grep applies to source paths only), no wall-clock assertions (Date.now only in unique ids), try/finally only inside module-level helper runBoundaryHook.

Blast radius

codegraph index points at the main checkout (stale vs PR; contextMeter not found) so codegraph_impact was not usable for new symbols. By inspection: callers confined to runStatusline in hooks/gsd-statusline.js; bridge file reader is hooks/gsd-context-monitor.js only (tests/security.test.cjs, install-minimal-hooks.test.cjs, perf-317 reference the path). New module.exports: isAutoCompactDisabled, AUTO_COMPACT_DISABLE_ENV_KEYS, AUTO_COMPACT_ENV_TRUTHY, resolveAutoCompactThreshold, contextMeter (additive). Behavior change: bridge used_pct/remaining_percentage scale (consumers: monitor only; stale pre-upgrade bridge files lack token fields and are handled). Monitor thresholds 35/25 now mean runway to compaction. Risk: low-medium, 2 hook files.

Co-change gaps

  • Changesets: two present, type Fixed, bold — format OK, pr: 5252 backfilled. Fine.
  • docs: Fixed type => docs exempt; PR updated docs/context-monitor.md, FEATURES.md (REQ-HOOK-07 mirrored in docs/features/hook-system.md). Parity OK.
  • docs/features/context-window-monitoring.md and docs/ARCHITECTURE.md only mention bridge path; no stale scale claims found. Translated docs (ja/zh/ko/pt) not updated: not required.
  • No hooks/dist copies; no workflows/references added => INVENTORY/manifest N/A; no gsd-core/workflows growth => no Emitted-Drift-Ack-Growth trailer needed. CONTEXT.md: no new vocabulary/module.
  • Closing keywords: "Fixes #4985 / Fixes #4844" present. Branch fix/4985-... conforms (/NNN-slug).

Behind-by / rebase

1 commit behind origin/next. git merge-tree --write-tree HEAD origin/next produced a clean tree (no conflicts). Fix: git fetch origin && git rebase origin/next, push. BEHIND is a CI condition.

Verdict

Changes requested (no Blockers; 2 Major test-rule violations, 2 Minor, 3 Nit). Substance of the fix is sound.

Memtrace Evidence

Memtrace quota was exhausted for this review run, so the graph-backed checks were NOT executed; this is stated rather than reported as passed:

  • get_impact: not run (quota). Blast radius above comes from codegraph impact/callers where present.
  • get_symbol_context: not run (quota). Callers/callees were read via codegraph where present.
  • recall_decision: not run (quota). The recorded-decision gate was NOT evaluated; this is not a finding that no decision governs the change.
  • find_code_review_issues: not run (quota). Deterministic AST/YAML/cross-module pass did not execute; every finding above is judgment-sourced unless marked deterministic.

@trek-e trek-e added the review: changes requested PR reviewed — changes required before merge label Oct 9, 2026
tanji-dg and others added 23 commits October 11, 2026 21:46
…led is false

The context meter always subtracted the ~16.5% auto-compact buffer
(or the CLAUDE_CODE_AUTO_COMPACT_WINDOW share) from `remaining` and showed
used% against the "usable" range. With `"autoCompactEnabled": false` in
settings.json that buffer is ordinary context: raw 40% read as 48%, raw
83.5% pinned the bar at 100%, and the last sixth of the window was invisible.

isAutoCompactDisabled(dir) reads, fail-soft and first-definitive-wins,
DISABLE_AUTOCOMPACT / CLAUDE_CODE_DISABLE_AUTO_COMPACT, then
<project>/.claude/settings.local.json, settings.json, then
(CLAUDE_CONFIG_DIR || ~/.claude)/settings.local.json, settings.json for the
boolean `autoCompactEnabled`; when disabled the buffer is 0 and the bar
shows round(100 - remaining). The bridge file's used_pct was already raw.

Tests: autoCompactEnabled:false / env / precedence cases; the open-gsd#2219 and
developer's real settings.json cannot steer the expected percentages.
…de actually has

Review found both names in this PR were invented, which meant the gate could
never fire for a real user — open-gsd#4844's symptom reached another way.

- The env vars are `DISABLE_AUTO_COMPACT` and `DISABLE_COMPACT` (the latter
  switches off manual /compact as well). `DISABLE_AUTOCOMPACT` and
  `CLAUDE_CODE_DISABLE_AUTO_COMPACT` do not exist: 0 occurrences in the
  shipped 2.1.282 binary, absent from code.claude.com/docs/en/env-vars.
  Truthy is `1` / `true` / `yes` / `on`, trimmed and lowercased — Claude
  Code's own accepted set, which this PR's `/^(1|true|yes)$/i` was missing
  `on` from. Precedence is unchanged and matches the host's own resolution:
  either env var wins, and only then does `autoCompactEnabled` decide.
- `context_window.total_tokens` is not a statusline payload field; the
  documented name is `context_window.context_window_size`
  (code.claude.com/docs/en/statusline). The meter read the absent field, so
  `totalCtx` always took its 1M fallback in a real session — and the
  fixtures supplied the invented name, so the dynamic-buffer assertions
  passed on a code path that could never run.

Tests: every fixture now builds its `context_window` through
`contextWindow()`, which rejects any field the host does not send, with a
positive control proving the guard fires. The gate's unit test pins the env
names and the truthy set against the host's own list, asserts the full
truthy/falsy matrix for both names, and asserts the invented name is NOT
honoured.

Both constants are exported so the assertions name one source.
… the docs-guard detector

Review nit: two consecutive `/**` lines before the constants block — an
editing leftover, now one.

CI: lint-docs-guard-registration flagged the test file because its detector
reads any `docs/` sitting between two backticks anywhere in the file as a
template literal, and the fixture helper's comment cited the statusline
reference URL between two inline-code spans. Reworded rather than exempted:
the file does not read a repo docs/ path, so an exemption marker would
record a fact that is not true.
…eset and REQ-HOOK-07

The code and PR body were corrected in a02af98, but the release note and the
feature docs still told users to export DISABLE_AUTOCOMPACT /
CLAUDE_CODE_DISABLE_AUTO_COMPACT, neither of which Claude Code reads. Now
DISABLE_AUTO_COMPACT / DISABLE_COMPACT; FEATURES.md regenerated.
…comment

After rebasing onto next, the comment's own docs/ sat between two backticks
again and lint-docs-guard-registration flagged the file.
…hy set; fix the perf-317 fixture's context_window field
…threshold

The meter treated the model window (or the env-var window) as 100%, and
never read the `autoCompactWindow` setting `/autocompact` saves. With
`/autocompact 650k` on a 1M model it read 74% at the moment Claude Code
compacted, and the context monitor, gating on the bridge's model-window
`remaining_percentage`, could not warn first.

- resolveAutoCompactThreshold(): the window from
  CLAUDE_CODE_AUTO_COMPACT_WINDOW, then settings `autoCompactWindow`, then
  the model window; capped at the model window; minus 20,000 + 13,000;
  lowered by CLAUDE_AUTOCOMPACT_PCT_OVERRIDE. Null when
  isAutoCompactDisabled() (open-gsd#4844) says compaction is off.
- contextMeter(): used tokens from current_usage (the /context count)
  against that threshold, or the model window when compaction is off.
  Falls back to Claude Code's own percentage when the window size is
  absent.
- The settings-file walk isAutoCompactDisabled() did inline is now
  readClaudeSetting(), shared by both lookups; project settings come from
  workspace.project_dir.
- Bridge: remaining_percentage / used_pct on the meter's scale, plus
  used_tokens / threshold_tokens. The monitor's 35% / 25% are unchanged and
  now measure runway to compaction; its message quotes the token counts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsvroGuHEwg1cUd6n13fHK
…to the threshold scale

open-gsd#4959 switched this file's runStatuslineHook fixture from the invented
context_window.total_tokens to context_window_size, so its open-gsd#2451 bridge
tests now reach the open-gsd#4985 meter instead of the unscaled fallback, and
they asserted the old model-window scale (used_pct 65 for remaining 35).

Under the bridge-scale choice on open-gsd#4985 (option a), used_pct and
remaining_percentage count down to the auto-compact threshold (650,000 of
967,000 → 67%), and the /context agreement open-gsd#2451 protected moves to the
bridge's used_tokens, which must equal what /context reports as used.
Same rewrite as the open-gsd#2451 test in gsd-statusline.test.cjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APo9rB1VNczs8nVkQ9G28K
…mpact saves

Claude Code 2.1.292 saves /autocompact's window per model, under
modelSettings.<canonical model name>.autoCompactWindow, not as the
top-level autoCompactWindow older versions wrote. Reading only the
top-level key left the meter on the model window for anyone who ran
/autocompact on a current Claude Code — the open-gsd#4985 headline case.

The window is now merged the way the binary's lFt()/bv() merge it:
settings files lowest precedence first, each file's per-model entries
layered over the lower files', a file's top-level autoCompactWindow
replacing the default and dropping the lower files' per-model entries,
and the session model's own entry winning over the default. The model
key is the payload's model.id, canonicalized to match its dated, [1m],
Bedrock and Vertex spellings. "auto" (Claude Code's tuned per-model
window, not visible to a statusline) falls back to the model window, as
does an invalid value or an unmatched model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APo9rB1VNczs8nVkQ9G28K
…changed when enabled"

This PR carries open-gsd#4959's commits and also changes the enabled case
(open-gsd#4985), so the fragment's closing "Unchanged when auto-compact is
enabled" no longer holds. It now credits the contribution instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APo9rB1VNczs8nVkQ9G28K
… and the tests isolated

Pre-filing review findings on the per-model window:

- readClaudeSetting stops at the first settings file that answers again;
  the shared reader had made isAutoCompactDisabled parse all four files.
- canonicalModelKey strips a Bedrock -v<n> revision only after a version
  or date segment, so anthropic.claude-v1 and claude-v2 no longer share
  the key "claude".
- Tests pin that an invalid top-level autoCompactWindow merges (keeps the
  lower files' per-model entries) while a top-level "auto" resets, as the
  binary's lFt() does; both mutants are caught.
- The perf-317 open-gsd#2451 helper pins CLAUDE_CONFIG_DIR to a scratch dir and
  clears the compaction env vars (the statusline helpers clear the pct
  override too), so a developer's own settings cannot move the threshold.
- docs/context-monitor.md and the open-gsd#4985 changeset name the
  CLAUDE_AUTOCOMPACT_PCT_OVERRIDE lowering; the open-gsd#4844 changeset states the
  disabled-case meter as the share of the model window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APo9rB1VNczs8nVkQ9G28K
Without current_usage (before the first response) the statusline
recovers the used tokens from Claude Code's percentage, so the count is
an estimate rather than the one /context shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APo9rB1VNczs8nVkQ9G28K
CONTRIBUTING.md bans try/finally inside test bodies. The
isAutoCompactDisabled precedence test now registers its cleanup with
t.after(), and the perf-317 open-gsd#2451 helper cleans its scratch config dir
after the try/catch instead of in a finally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APo9rB1VNczs8nVkQ9G28K
The open-gsd#4844 fragment was authored on open-gsd#4959, which this PR supersedes.
It now carries this PR's number; the credit to @tanji-dg is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APo9rB1VNczs8nVkQ9G28K
…le of at most 1 MiB

The statusline reads the project's .claude/settings*.json on every
render, and that directory is repository content. A FIFO there blocked
the hook until it was killed, and a huge file was read whole each turn.

readSettingsFile now opens the file non-blocking, checks it through its
own descriptor, and treats anything that is not a regular file of at
most 1 MiB as absent. A symlink is still followed: a settings file
linked in from a dotfiles repo is ordinary, and lstat would have
dropped it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVFQpCdfnwgWSkxUDF8tcF
…t-1, limit and limit+1

The autoCompactWindow setting (top-level and per model) and the
CLAUDE_CODE_AUTO_COMPACT_WINDOW env are now tested at 99,999 / 100,000 /
100,001 and 999,999 / 1,000,000 / 1,000,001, on a 2M model window so an
accepted 1M window reads differently from a rejected one. The pct
override is tested at -1 / 0 / 1 / 100 / 101, and threshold > 0 at a pct
that floors to 0 or 1 and at model windows of 32,999 / 33,000 / 33,001.

At pct = 100 the override is always above the threshold it is min'd
with, so its upper edge is not observable; the test says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVFQpCdfnwgWSkxUDF8tcF
…s own key

One pass of the rules left some ids half-canonical: `x[1m][1m]` keyed
as `x[1m]`, a date after a Bedrock revision left the revision, and a
space before `[1m]` survived the trim. Keying the result again changed
it, so a settings entry and a model id could each be canonical and
still not match.

The rules now repeat until nothing changes. Each one only removes
characters, so the loop ends. A fast-check property asserts
canonicalModelKey(canonicalModelKey(s)) === canonicalModelKey(s) over
arbitrary strings and strings built from the pieces the rules strip;
the single-pass version fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVFQpCdfnwgWSkxUDF8tcF
…eshold, meter and settings merge

TESTING-STANDARDS.md asks parsing and budget/limit logic for a
fast-check property. Eight new properties (seed and run count from
tests/helpers/fast-check-setup.cjs):

- parseAutoCompactWindowEnv never throws and returns null or an integer
  in 100k-1M; reads back any such integer, plain or digit-grouped; is
  monotone in a positive value, clamped into 100k-1M.
- canonicalModelKey keys dated, [1m], Bedrock (with and without region
  and date) and Vertex spellings, in either case, as the model.
- resolveAutoCompactThreshold is a positive integer at most
  min(window, model window) - 33,000, or none; never falls as the window
  or the model window grows; the pct override only ever lowers it.
- contextMeter: used + remaining = 100, both integers in 0-100, measured
  against the threshold or the model window, monotone in usage.
- The settings merge, over four generated settings files: the window is
  one a file set or the model window, and the highest file decides when
  it speaks - its own model entry under any spelling, else its top-level
  value, which drops the lower files' model entries.

Each was checked against a mutant of the rule it covers: single-pass
key, no [1m] strip, Bedrock :0 kept, a 19k reserve, a pct override that
raises, an unclamped meter, top-level keeping lower model entries,
reversed file order, a non-canonical spelling overriding the canonical
one, and an env window not raised to 100k. Each mutant fails.

parseAutoCompactWindowEnv and isAutoCompactWindowValue are exported for
the properties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVFQpCdfnwgWSkxUDF8tcF
…action point

One line ran past the paragraph's wrap width and left a ragged
one-word line after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVFQpCdfnwgWSkxUDF8tcF
…ck saw

fce0e58 checked the size through the open descriptor and then read it
with readFileSync(fd), which reads to EOF. A file that was 2 bytes at the
check and grew past 1 MiB before the read was parsed whole, so the cap
held only for a file nobody was writing.

The read now stops one byte past the checked size, and a file whose
length differs from that size is treated as absent - grown or shrunk.
The test mocks fstatSync to report a size the file does not have; the
readFileSync(fd) version fails its growth case, and a version that
accepts a short read fails its shrink case.

Found by the cross-AI review of this round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVFQpCdfnwgWSkxUDF8tcF
…ngs key

383215b made the key a fixpoint by re-running every rule until nothing
changed, stripping one [1m] per pass. That is quadratic: a 524 KB project
.claude/settings.json whose model key ends in 131,072 [1m] took 16.9 s
to resolve, and the statusline hook was killed before writing its bridge.

A key longer than 256 characters is now no model's and keys as '' -
no model id is near that length - and a run of [1m] suffixes is stripped
in one pass. Lowercasing moves before the loop, because it can lengthen
a string (U+0130 lowercases to two characters); inside the loop every
rule only removes characters, so the loop still ends.

The test renders that file through the hook within its normal timeout;
the pre-change key function stalls it. A second test pins the 255 / 256
/ 257-character boundary.

Found by the cross-AI review of this round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVFQpCdfnwgWSkxUDF8tcF
…el, and the meter's percentage

Two properties from cc125e5 passed on wrong code:

- The settings-merge property checked only the highest file, then that
  the window was one some file set, and it decided which spelling
  belonged to which model with canonicalModelKey itself. A merge that
  dropped the lower files' entries, and a key function that filed a
  dated Opus id under Sonnet, both passed. It now compares the window
  with a reference model of the documented merge, written out in the
  test with its own spelling-to-model table and its own window check.
- The meter property checked used + remaining = 100 and the limit, not
  the percentage: used = 0 passed. It now requires used to be within 0.5
  of usedTokens / limit x 100, and 100 at or past the limit.

Each of the four wrong versions now fails.

Found by the cross-AI review of this round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVFQpCdfnwgWSkxUDF8tcF
@0xdhx
0xdhx force-pushed the fix/4985-statusline-meter-compaction-threshold branch from 87d7efa to d50621d Compare October 12, 2026 08:45
@0xdhx

0xdhx commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All seven findings are addressed below; the branch is rebased onto next (b747aca5b) and the head is now d50621d0b.

Findings → commits

  1. MAJOR — property-based tests. Writing the properties exposed a real defect: canonicalModelKey was not idempotent (x[1m][1m] keyed as x[1m]). 383215ba8 fixes it and adds the idempotence property; cc125e505 adds the rest, for the env parser, canonicalModelKey's spelling invariance, the threshold, the meter and the settings merge. d50621d0b makes two of the properties stricter: the settings merge is now checked against an independent reference model of Claude Code's merge, which calls no production helper, and the meter property checks the percentage itself (within 0.5 below the threshold, exactly 100 at or above it).
  2. MAJOR — limit−1 / limit / limit+1. 42cf89233 adds the boundary table: 99,999 / 100,000 / 100,001 and 999,999 / 1,000,000 / 1,000,001 for the setting (top-level and per-model) and the env var, the pct override at −1 / 0 / 1 / 100 / 101, and the threshold's positive edge at 32,999 / 33,000 / 33,001. At pct 100 the override is always above the threshold it is min'd with, so 100 and 101 read the same; the test says so rather than asserting an edge that cannot be observed.
  3. MINOR — hostile settings files. fce0e58dc reads a settings file only when it is a regular file of at most 1 MiB, opened non-blocking and checked through its own descriptor, so a FIFO is skipped instead of blocking the render. A symlink is followed, since a dotfiles-linked settings.json is ordinary; rejecting symlinks via lstat would break that. 28681b591 closes the gap between the size check and the read: a file that grows or shrinks in between is treated as absent. Our own cross-review of this round then found one more cost under the cap: a 524 KB settings file whose model key repeats [1m] 131,072 times made key normalisation take 14 to 17 s, well past the hook's 4 s timeout. 7622f1b0f bounds it: a key over 256 characters once lowercased matches no model, and a run of [1m] suffixes is stripped in one pass.
  4. MINOR — the bridge scale needs a maintainer's yes. Agreed, and it is not established yet: @tanji-dg agreed to the supersede, which is a different question. Option (a) moves used_pct / remaining_percentage onto the threshold scale, reversing fix(#2315): respect review.default_reviewers in bare convergence invocation #2451's raw-bridge contract and bug(statusline): context meter subtracts the auto-compact buffer even when autoCompactEnabled is false #4844's AC3; the token counts (used_tokens / threshold_tokens) carry the /context agreement instead: used_tokens is exact when the payload has current_usage, and estimated from the percentage when it doesn't. The PR body now says it should not merge without your confirmation. If you'd rather keep the bridge raw, I can keep the raw fields and add threshold-scale ones beside them for the monitor to read.
  5. NIT — docs reflow. d33f8dab6 reflows the docs/context-monitor.md paragraph; no word changed.
  6. NIT — test checkbox and platforms. The body now reports every suite npm test runs, plus each tree-level required job, at d50621d0b, and checks the box. It states Linux as the only local platform: both changed test files are on CI's Windows and macOS conformance lists, so CI's Windows and macOS legs run them; I have not run either platform locally.
  7. NIT — carried commit subject. The first carried commit's subject is now fix(#4844): …; content and @tanji-dg's authorship are unchanged.

Rebase. 16 carried commits replayed onto b747aca5b with no conflicts; the base range touched none of this PR's files.

One intermediate commit is red at its own tree. a710ed88e moves the bridge to the threshold scale, and five of perf-317's #2451 raw-bridge assertions fail there until 0af4c9fcc moves them to the new scale. A squash merge makes it moot; I can restructure the history if you'd prefer to keep the commits.

Reversion controls. Restoring the old pathname settings reader fails the FIFO and size tests; the earlier descriptor reader without the re-check, and a variant that accepts a short read, fail the grow/shrink test; six off-by-one mutants of the window, pct and threshold bounds each fail the boundary table; the pre-7622f1b0f key function fails the hostile-key test; and dropping lower files' entries, mis-assigning a dated alias, or a meter reporting used = 0 each fail a property. Two mutants survive these controls. One is the pct upper edge from finding 2 (pct ≤ 100 → < 100), which is not observable. The other strips one [1m] per pass instead of a run, which gives identical output for every string under the 256-character cap. It is not caught by output, only by its work: counting replace calls tells the two apart (10 against 15 on x[1m][1m]). I haven't added a test that pins the call count, since the cap already bounds the cost.

Tests at d50621d0b (local, Linux, HOME sandboxed, CLAUDE_CONFIG_DIR cleared): the two changed test files 394/394; the full suite across CI's unit shards plus integration, security, install and slow, 44,246 tests, 0 failed. CI on d50621d0b as of 16:51Z, after the body edit: 42 check-run registrations, all terminal, 39 SUCCESS and 3 SKIPPED, none failed.

@trek-e trek-e left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(#4985) statusline meter at the real auto-compact threshold

Summary

All seven findings from the 2026-10-09 review are resolved at head d50621d0be. CI at this exact SHA is green (newest run of every check is success, none pending, Required tests success).

Classification & gate compliance

Bug-fix track. #4985 and #4844 are confirmed-bug, both have Fixes, the changesets are Fixed with pr: 5252, docs and FEATURES are mirrored. No shipped workflow growth, so no emitted-drift trailer.

Root-cause verdict

The meter now resolves the real threshold. The bridge writes threshold-scale used_pct and remaining_percentage plus used_tokens and threshold_tokens.

Maintainer decisions recorded

  • Option (a), the bridge scale change that reverses the #2451 raw-bridge contract and #4844 AC3, is approved. The additive-fields alternative is not needed.
  • No history rewrite is needed for a710ed88e, which is red at its own tree. The merge is a squash.
  • Following a symlinked settings file is accepted. Reading through the descriptor with a regular-file check and a 1 MiB cap meets the hostile-input goal, which was never to break dotfile-linked settings.

Blast radius

hooks/gsd-statusline.js and hooks/gsd-context-monitor.js plus their tests and docs. Blast radius was derived by diff reading and grep.

Co-change completeness

Docs, FEATURES, hook-system.md REQ-HOOK-07 and changesets are all in the diff.

Findings

None.

Verdict

Approved. The branch is behind next, so it will be brought up to date server-side, re-verified green, and merged.

Memtrace Evidence

The Memtrace MCP server was unavailable for this review (it failed to connect: memtrace not found in PATH). None of the four checks below ran, so none is a clean graph result.

  • get_impact: not run. Blast radius comes from the diff and grep.
  • get_symbol_context: not run. Callers were checked by grep.
  • recall_decision: not run. This does not show that no decision governs the change. The #2451 and #4844 contract was checked against the PR threads, and the maintainer ruled on it.
  • find_code_review_issues: not run. A manual diff review by an isolated reviewer stood in for it, and CI (lint, Required tests) is green.

@trek-e trek-e removed the review: changes requested PR reviewed — changes required before merge label Oct 14, 2026
@trek-e
trek-e merged commit 59d5840 into open-gsd:next Oct 14, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants