diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index a70aa44002..019cac0693 100644 --- a/plugins/context-guard/.claude-plugin/plugin.json +++ b/plugins/context-guard/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "context-guard", - "version": "0.12.1", + "version": "0.12.2", "description": "Per-session context-window observability. A mod writes each session's context-window figures to a snapshot file and tells Claude its context zone (smart, acceptable or dumb, from bands in zones.json) at each crossing, approach and compaction; the person gets a toast and a transcript line. /context-guard prints status and toggles an optional band row. It answers a status tool. Optional blocking mode gates new mutating work in a blocked zone, except handoffs. A PostCompact hook leaves a marker.", "author": { "name": "Melodic Software", diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index a6010d623e..c3c659bc2c 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to the `context-guard` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.2] - 2026-10-04 + +### Changed + +- **Shorter lines to Claude.** Zone lines drop "Continuing is the user's call." unless `zone_line_data` adds a figure, and the gate denial is one short sentence with no off-switch hint: `context-guard: Write denied: dumb zone, grace budget of 20 calls spent. Reads, Bash, Skill and handoff-path writes still run; /session-flow:handoff (if installed) writes a save-point.` The README keeps the switches. +- **No smart verdict after a resume.** A resume or `/branch` restates the verdict only when it is past smart, as a reload already did. +- **Debug log mirror.** Each line sent to Claude, and each gate denial, is written as sent to the debug log. +- **Shorter status tool description.** + ## [0.12.1] - 2026-10-04 ### Changed diff --git a/plugins/context-guard/README.md b/plugins/context-guard/README.md index dae70835a5..25e7fb81e8 100644 --- a/plugins/context-guard/README.md +++ b/plugins/context-guard/README.md @@ -51,23 +51,24 @@ options and `zones.json`: | When | Line | |---|---| -| The session first reaches a worse zone this cycle | once per zone, "acceptable zone (2 of 3). Continuing is the user's call." | -| The session comes within `approach_margin` points (5) of a zone edge or a `zones.json` threshold; where a token band edge decides the crossing, within that many points of the window in tokens | once per boundary, "acceptable zone (2 of 3), nearing dumb. Continuing is the user's call." | +| The session first reaches a worse zone this cycle | once per zone, "acceptable zone (2 of 3)." | +| The session comes within `approach_margin` points (5) of a zone edge or a `zones.json` threshold; where a token band edge decides the crossing, within that many points of the window in tokens | once per boundary, "acceptable zone (2 of 3), nearing dumb." | | The session passes a `zones.json` threshold | once per threshold, "past an operator threshold", with the threshold's action | -| After a compaction (not the precompute kind), and after `/resume` or `/branch` | the verdict, once; after a compaction it is `dumb zone (3 of 3, compacted)` | +| After a compaction (not the precompute kind), and after `/resume` or `/branch` | the verdict, once, only when it is past `smart`; after a compaction it is `dumb zone (3 of 3, compacted)` | | When the module loads into a session that already has turns (a `--resume` launch, a reload after an options change, a hooks-worker restart) | the verdict, once, only when it is past `smart` | | After `/clear` | nothing: the new session starts in `smart` and a fresh cycle | A dip below a boundary sends nothing and starts no new cycle; only a return to `smart` does. An `unknown` reading sends nothing and changes nothing. Every line carries only the verdict: the zone -word, its rank of three, and "Continuing is the user's call." A crossing or restatement inside the +word and its rank of three. A crossing or restatement inside the approach margin of the next zone adds ", nearing ", and an approach line that would repeat it is not sent. A crossing or restatement in `dumb` also carries the save-state note. Lines due at one carrier: a crossing or restatement recorded before a pending restatement merges into it; a crossing recorded after it is the newer verdict and replaces it. `zone_line_data` adds figures (percent, tokens, -window); by default a line carries none, and it never carries a session id. A configured action's sentence +window) and then "Continuing is the user's call."; by default a line carries neither, and it never carries a session id. A configured action's sentence (`zones.json` `actions` and `thresholds`, see the [reader contract](reference/reader-contract.md)) -appears at its crossing, never before. Subagents get no line. +appears at its crossing, never before. Subagents get no line. Each line sent to Claude, and each +gate denial, is also written as sent to the debug log (`claude --debug`). At a crossing the person gets the continuation menu (continue, `/compact`, `/clear`, `/session-flow:handoff` then `/clear`) as a 4-second toast, such as diff --git a/plugins/context-guard/hooks/context-guard.test.ts b/plugins/context-guard/hooks/context-guard.test.ts index c5d6e9fe07..e7033f8824 100644 --- a/plugins/context-guard/hooks/context-guard.test.ts +++ b/plugins/context-guard/hooks/context-guard.test.ts @@ -6,7 +6,6 @@ const HOME = '/srv/u' const CTX = '/srv/u/.claude/context-guard/context' const ZONES = '/srv/u/.claude/context-guard/zones.json' const SAVE = 'context-guard (default for the dumb zone): compaction distance is short, so each expensive conclusion goes to a durable note as it stabilizes.' -const CALL = "Continuing is the user's call." // Each zone with its rank of three, as the lines name it. const VERDICT = { smart: 'smart zone (1 of 3)', @@ -15,7 +14,7 @@ const VERDICT = { degraded: 'dumb zone (3 of 3, compacted)', } as const type Verdict = keyof typeof VERDICT -const crossing = (to: Verdict) => `context-guard: ${VERDICT[to]}. ${CALL}` +const crossing = (to: Verdict) => `context-guard: ${VERDICT[to]}.` type World = { percent: number | undefined @@ -267,7 +266,7 @@ test('lines: the default line carries the verdict and its rank, and no figure, s expect(line.replace(/\(\d of 3\)/, '')).not.toMatch(/\d/) expect(line).not.toContain('sess-1') expect(line).not.toContain('/') - expect(line).toContain(CALL) + expect(line).not.toContain("user's call") } }) @@ -303,7 +302,7 @@ test( // ---- Approach lines, zones.json additions and line data -------------------------------------- -const approach = (zone: Verdict, toward: string) => `context-guard: ${VERDICT[zone]}, nearing ${toward}. ${CALL}` +const approach = (zone: Verdict, toward: string) => `context-guard: ${VERDICT[zone]}, nearing ${toward}.` const zonesFile = (w: World, zones: unknown, mtimeMs = 1) => { w.files[ZONES] = { text: typeof zones === 'string' ? zones : JSON.stringify(zones), mtimeMs } } @@ -403,7 +402,7 @@ test('zones.json: action none at the dumb zone drops the save-state note', async test('zones.json: an extra threshold gets its approach line and its line once per cycle', async ($, on) => { const { w } = world(on) zonesFile(w, { thresholds: [{ at_percent: 60, action: 'handoff' }] }) - const passed = `context-guard: acceptable zone (2 of 3), past an operator threshold. ${CALL} context-guard (operator setting for a threshold): hand off at the next clean stopping point.` + const passed = `context-guard: acceptable zone (2 of 3), past an operator threshold. context-guard (operator setting for a threshold): hand off at the next clean stopping point.` expect(await walk($, w, [30, 52, 56, 60, 58, 61, 30, 61])).toEqual([ [], [crossing('acceptable')], @@ -501,7 +500,7 @@ test('zones.json: invalid additions fall back to the defaults', async ($, on) => test('line data: percent, tokens and window are carried when configured', { options: { zone_line_data: 'zone, percent, tokens, window' } }, async ($, on) => { const { w } = world(on) expect((await walk($, w, [30, 60])).flat()).toEqual([ - `context-guard: acceptable zone (2 of 3), 60% of the window used, 120000 tokens in context, a 200000-token window. ${CALL}`, + `context-guard: acceptable zone (2 of 3), 60% of the window used, 120000 tokens in context, a 200000-token window. Continuing is the user's call.`, ]) }) @@ -524,7 +523,7 @@ test('switch: context_guard_hooks_enabled false sends no line', { options: { con // ---- Restatement: compaction, resume, reload, /clear ------------------------------------------- -const restated = (zone: Verdict, tail = '') => `context-guard: ${VERDICT[zone]}. ${CALL}${tail}` +const restated = (zone: Verdict, tail = '') => `context-guard: ${VERDICT[zone]}.${tail}` const compact = ($: any, trigger: string, extra: object = {}) => $.session.compact({ trigger, messages: MESSAGES, ...extra } as any) test('compaction: the verdict is restated once as the evidence-degraded dumb zone, with the save-state note', async ($, on) => { @@ -569,12 +568,21 @@ test('compaction: the settings hook marker alone forces the degraded dumb zone, expect((await walk($, w, [10, 12])).flat()).toEqual([`${crossing('degraded')} ${SAVE}`]) }) -test('resume: the verdict is restated once after an in-process resume', async ($, on) => { +test('resume: a smart verdict is not restated after an in-process resume', async ($, on) => { const { w } = world(on) await walk($, w, [30]) await $.session.end({ reason: 'resume', sessionId: 'sess-1', resume: { id: 'sess-1' } } as any) w.sid = 'sess-2' - expect(own((await prompt($)).context)).toEqual([restated('smart')]) + expect(own((await prompt($)).context)).toEqual([]) + expect(own((await bash($)).context)).toEqual([]) +}) + +test('resume: a verdict past smart is restated once after an in-process resume', async ($, on) => { + const { w } = world(on) + await walk($, w, [30, 60]) + await $.session.end({ reason: 'resume', sessionId: 'sess-1', resume: { id: 'sess-1' } } as any) + w.sid = 'sess-2' + expect(own((await prompt($)).context)).toEqual([restated('acceptable')]) expect(own((await prompt($)).context)).toEqual([]) }) @@ -615,7 +623,7 @@ test('/clear: nothing on the first prompt, and the new session starts a fresh cy const BLOCKING = (grace: number, extra: object = {}) => ({ options: { zone_hook_mode: 'blocking', zone_gate_grace_calls: grace, ...extra } }) const denial = (tool: string, grace: number) => - `context-guard blocking mode (operator setting for the dumb zone): this session is in the dumb context zone and the grace budget of ${grace} matched calls is spent, so new ${tool} work is denied. Handoff-path writes, read-only tools, Bash and Skill calls stay allowed; /session-flow:handoff (if installed) writes a save-point this gate exempts. zone_hook_mode advisory turns the gate off.` + `context-guard: ${tool} denied: dumb zone, grace budget of ${grace} calls spent. Reads, Bash, Skill and handoff-path writes still run; /session-flow:handoff (if installed) writes a save-point.` const writes = async ($: any, n: number, path?: string) => { const out: (string | undefined)[] = [] for (let i = 0; i < n; i += 1) out.push((await write($, path)).deny) @@ -821,7 +829,38 @@ test('gate: a block action in zones.json arms it without zone_hook_mode, and an await prompt($, 'composer') expect((await write($)).deny).toBeUndefined() expect((await writes($, 19)).every(d => d === undefined)).toBe(true) - expect((await write($)).deny).toContain('the grace budget of 20 matched calls is spent') + expect((await write($)).deny).toBe(denial('Write', 20)) +}) + +// The debug log holds exactly what Claude was told, line for line. +const debugWorld = (on: any, init: object = {}) => { + const debug: string[] = [] + const { w } = world(on, init, { HOME }, ['ui.log']) + on('ui.log', ($: unknown, e: { text: string; to?: string }) => { + ;(e.to === 'debug' ? debug : w.logs).push(e.text) + return { value: undefined } + }) + return { w, debug } +} + +test('debug mirror: each line sent to Claude at a tool call or a prompt is written to the debug log as sent', async ($, on) => { + const { w, debug } = debugWorld(on) + await bash($) + w.percent = 60 + const atTool = own((await bash($)).context) + w.percent = 80 + const atPrompt = own((await prompt($)).context) + expect(atTool).toEqual([crossing('acceptable')]) + expect(atPrompt).toEqual([`${crossing('dumb')} ${SAVE}`]) + expect(debug.filter(l => l.startsWith('context-guard: ') && l.includes('zone ('))).toEqual([...atTool, ...atPrompt]) +}) + +test('debug mirror: a gate denial is written to the debug log as sent', BLOCKING(0), async ($, on) => { + const { debug } = debugWorld(on, { percent: 80 }) + await prompt($, 'composer') + const deny = (await write($)).deny + expect(deny).toBe(denial('Write', 0)) + expect(debug).toContain(deny) }) test('gate: an action other than block at the dumb zone in zones.json leaves blocking mode inert', BLOCKING(0), async ($, on) => { @@ -835,7 +874,7 @@ test('gate: a block action at the acceptable zone gates there', async ($, on) => const { w } = world(on, { percent: 60 }) zonesFile(w, { actions: { acceptable: { action: 'block' } } }) await prompt($, 'composer') - expect(await writes($, 21)).toEqual([...Array(20).fill(undefined), expect.stringContaining('this session is in the acceptable context zone')]) + expect(await writes($, 21)).toEqual([...Array(20).fill(undefined), denial('Write', 20).replace('dumb zone', 'acceptable zone')]) }) test('gate: blocking mode adds its sentence to the dumb-zone line, before the save-state note', BLOCKING(20), async ($, on) => { @@ -906,9 +945,9 @@ test('operator mode: two held lines show as one row with one prefix, and reach C w.percent = 61 await bash($) await $.turn.complete({ text: 'done', reason: 'answer' } as any) - const passed = `context-guard: acceptable zone (2 of 3), past an operator threshold. ${CALL} context-guard (operator setting for a threshold): hand off at the next clean stopping point.` + const passed = `context-guard: acceptable zone (2 of 3), past an operator threshold. context-guard (operator setting for a threshold): hand off at the next clean stopping point.` const row = await notice($) - expect(row).toBe(`FYI, ${crossing('acceptable')} acceptable zone (2 of 3), past an operator threshold. ${CALL} context-guard (operator setting for a threshold): hand off at the next clean stopping point.`) + expect(row).toBe(`FYI, ${crossing('acceptable')} acceptable zone (2 of 3), past an operator threshold. context-guard (operator setting for a threshold): hand off at the next clean stopping point.`) expect(prefixes(row)).toBe(1) expect(own((await prompt($, 'scheduled-trigger')).context)).toEqual([crossing('acceptable'), passed]) }) @@ -1693,8 +1732,8 @@ test('fallback: where $.process.run is unavailable, nothing is written, it is lo expect(called.text).toBe('ok') expect(own(called.context)).toEqual([crossing('acceptable')]) // A stub that throws is skipped, so the engine answers that nothing implements process.run. - expect(debug).toHaveLength(1) - expect(debug[0]).toMatch(/^context-guard: snapshot write did not run: /) + expect(debug.filter(l => l.includes('snapshot'))).toHaveLength(1) + expect(debug.find(l => l.includes('snapshot'))).toMatch(/^context-guard: snapshot write did not run: /) }) test('snapshot: a failed write is logged once; a skip by rule is not', async ($, on) => { @@ -1712,7 +1751,7 @@ test('snapshot: a failed write is logged once; a skip by rule is not', async ($, w.percent = p await bash($) } - expect(debug).toEqual(['context-guard: snapshot write failed (exit 1): write-snapshot: rename failed']) + expect(debug.filter(l => l.includes('snapshot'))).toEqual(['context-guard: snapshot write failed (exit 1): write-snapshot: rename failed']) }) test('fail open: a throw after the tool ran leaves its result and the context beneath, and the tool runs once', async ($, on) => { diff --git a/plugins/context-guard/hooks/register.tsx b/plugins/context-guard/hooks/register.tsx index eb12900198..ba90d345f4 100644 --- a/plugins/context-guard/hooks/register.tsx +++ b/plugins/context-guard/hooks/register.tsx @@ -2,7 +2,6 @@ import type { EngineInterface, PromptOrigin, Register, Timer, ToolCallInput } fr import { RANK, readBands, resolveZone, tokenShape, type Bands, type TokenShape, type Zone } from './zone.ts' const CONTRACT_DIR = 'context-guard' -const CALL = "Continuing is the user's call." const PREFIX = 'context-guard: ' const NEXT_ZONE: Partial> = { smart: 'acceptable', acceptable: 'dumb' } const SESSION_ID = /^[A-Za-z0-9_-]+$/ @@ -67,7 +66,7 @@ type Session = { approached: Set compacted: boolean pending: Event[] - restate: 'all' | 'loud' | undefined + restate: boolean reading: Reading | undefined grace: number written: { sig: string; at: number } | undefined @@ -90,7 +89,7 @@ type State = { writing: Promise loggedOnce: Set sessions: Map - carry: 'all' | undefined + carry: boolean settings: Settings zonesText: string | null zonesKey: string | undefined @@ -206,9 +205,9 @@ export const recordReading = (s: Session, reading: Reading, settings: Settings) s.reading = reading const { zone, percent } = reading if (zone === undefined) return - if (s.restate !== undefined) { - if (s.restate === 'all' || zone !== 'smart') s.pending.push({ kind: 'restate', zone, degraded: reading.degraded }) - s.restate = undefined + if (s.restate) { + if (zone !== 'smart') s.pending.push({ kind: 'restate', zone, degraded: reading.degraded }) + s.restate = false s.armed = Math.max(s.armed, RANK[zone]) s.last = zone for (const t of settings.thresholds) if (percent !== undefined && percent >= t.at) s.fired.add(t.at) @@ -278,24 +277,22 @@ const sentence = (source: string, rule: Rule) => { // The rules a zone carries: its zones.json action, else blocking mode's block at the dumb zone, // then the dumb zone's default save-state note. -const zoneRules = (zone: Zone, settings: Settings, cfg: Config): { source: string; rule: Rule; via: 'zones' | 'option' | 'default' }[] => { +const zoneRules = (zone: Zone, settings: Settings, cfg: Config): { source: string; rule: Rule }[] => { const set = settings.actions[zone] - if (set) return [{ source: `operator setting for the ${zone} zone`, rule: set, via: 'zones' }] + if (set) return [{ source: `operator setting for the ${zone} zone`, rule: set }] if (zone !== 'dumb') return [] return [ - ...(cfg.blocking ? [{ source: 'operator setting for the dumb zone', rule: { action: 'block' as const }, via: 'option' as const }] : []), - { source: 'default for the dumb zone', rule: { action: 'save-state' as const }, via: 'default' as const }, + ...(cfg.blocking ? [{ source: 'operator setting for the dumb zone', rule: { action: 'block' as const } }] : []), + { source: 'default for the dumb zone', rule: { action: 'save-state' as const } }, ] } -// The block in force for this session now, if any: its zone's, or a passed threshold's. +// The zone whose block is in force for this session now, if any: its zone's, or a passed threshold's. const blockFor = (s: Session, settings: Settings, cfg: Config) => { const zone = s.reading?.zone if (zone === undefined) return undefined - const rule = zoneRules(zone, settings, cfg).find(r => r.rule.action === 'block') - if (rule) return { zone, source: rule.source, via: rule.via } - const t = settings.thresholds.find(th => th.action === 'block' && s.fired.has(th.at)) - return t ? { zone, source: 'operator setting for a threshold', via: 'zones' as const } : undefined + if (zoneRules(zone, settings, cfg).some(r => r.rule.action === 'block')) return zone + return settings.thresholds.some(th => th.action === 'block' && s.fired.has(th.at)) ? zone : undefined } export const renderEvent = (e: Event, s: Session, cfg: Config, settings: Settings) => { @@ -304,7 +301,9 @@ export const renderEvent = (e: Event, s: Session, cfg: Config, settings: Setting zoneRules(zone, settings, cfg) .map(z => sentence(z.source, z.rule)) .join('') - const line = (zone: Zone, degraded: boolean, hint: string) => `context-guard: ${verdictText(zone, degraded)}${data}${hint}. ${CALL}` + // A line that surfaces a count pairs it with a reassurance (I23's Remediate clause); the verdict alone carries none. + const line = (zone: Zone, degraded: boolean, hint: string) => + `context-guard: ${verdictText(zone, degraded)}${data}${hint}.${data === '' ? '' : " Continuing is the user's call."}` // Within the approach margin of the next zone's boundary, the verdict says which zone is near. const near = (zone: Zone) => { const toward = NEXT_ZONE[zone] @@ -328,6 +327,12 @@ const renderAll = (events: Event[], s: Session, cfg: Config, settings: Settings) return lines.filter(l => !lines.some(o => o !== l && o.startsWith(l))) } +// Appends lines to what Claude reads and writes each to the debug log, so the log holds what Claude was told. +const withLines = ($: EngineInterface, e: T, lines: readonly string[]): T => { + for (const line of lines) $.ui.log(line, { to: 'debug' }) + return lines.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...lines] } +} + const homeDir = async ($: EngineInterface) => (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || undefined // zones.json, re-read only when it appears, disappears or its mtime moves. @@ -353,7 +358,7 @@ const sessionFor = (st: State, sid: string) => { let s = st.sessions.get(sid) if (s === undefined) { s = newSession(st.carry) - st.carry = undefined + st.carry = false st.sessions.set(sid, s) } return s @@ -686,9 +691,7 @@ async function registerSurfaces($: EngineInterface, st: State) { $.tool.register({ name: 'status', description: - "Read-only. Returns this session's context-window figures as the last API response reported them, with " + - "context-guard's zone (smart, acceptable, dumb or unknown), whether a compaction degraded the evidence, the " + - 'bands in force and the gate state, as JSON. Takes no input.', + "Read-only. This session's context-window figures, context-guard's zone, bands and gate state, as JSON.", inputSchema: { type: 'object', properties: {}, additionalProperties: false }, }), $.command.register({ @@ -721,12 +724,10 @@ async function gate($: EngineInterface, st: State, cfg: Config, e: ToolCallInput if (/handoff/i.test(target)) return undefined s.grace += 1 if (s.grace <= cfg.grace) return undefined - await emitTelemetry($, 'zone-gate', 'blocked', { zone: block.zone, grace: cfg.grace, calls_seen: s.grace }, fire) - const off = block.via === 'option' ? 'zone_hook_mode advisory turns the gate off.' : 'The actions entry in zones.json sets this gate.' + await emitTelemetry($, 'zone-gate', 'blocked', { zone: block, grace: cfg.grace, calls_seen: s.grace }, fire) return ( - `context-guard blocking mode (${block.source}): this session is in the ${block.zone} context zone and the grace budget of ` + - `${cfg.grace} matched calls is spent, so new ${e.tool} work is denied. Handoff-path writes, read-only tools, Bash and Skill ` + - `calls stay allowed; /session-flow:handoff (if installed) writes a save-point this gate exempts. ${off}` + `${PREFIX}${e.tool} denied: ${block} zone, grace budget of ${cfg.grace} calls spent. ` + + 'Reads, Bash, Skill and handoff-path writes still run; /session-flow:handoff (if installed) writes a save-point.' ) } @@ -745,7 +746,7 @@ export const register: Register = (on, options) => { writing: Promise.resolve(), loggedOnce: new Set(), sessions: new Map(), - carry: undefined, + carry: false, settings: parseSettings(null), zonesText: null, zonesKey: undefined, @@ -762,7 +763,7 @@ export const register: Register = (on, options) => { if ((await $.session.turns()) > 0) { const s = await current($, st) s.pending = [] - s.restate = 'loud' + s.restate = true } return next(e) }).catch(($, e, next) => next(e)) @@ -774,7 +775,7 @@ export const register: Register = (on, options) => { // a fresh read: usage at exit can come back empty, and a session never refreshed writes nothing. await queueWrite($, st) st.sessions.delete(e.sessionId) - st.carry = e.reason === 'resume' ? 'all' : undefined + st.carry = e.reason === 'resume' st.origin = undefined st.recheckTool = true return next(e) @@ -785,7 +786,7 @@ export const register: Register = (on, options) => { if (e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result && result.skip)) { const s = await current($, st) s.compacted = true - s.restate = 'all' + s.restate = true s.grace = 0 } return result @@ -815,8 +816,7 @@ export const register: Register = (on, options) => { if (tools !== undefined && !tools.some(t => t.name === `mcp__${$.plugin.name}__status`)) await registerSurfaces($, st) } await refresh($, st) - const lines = await takeLines($, st, cfg, { event: 'prompt.submit', startMs }) - return next(lines.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...lines] }) + return next(withLines($, e, await takeLines($, st, cfg, { event: 'prompt.submit', startMs }))) }).catch(($, e, next) => next(e)) on('session.measure', async ($, e, next) => { @@ -896,7 +896,10 @@ export const register: Register = (on, options) => { if (e.tool === `mcp__${$.plugin.name}__status`) return { result: await statusJson($, st, cfg) } const fire: Fire = { event: 'tool.call', startMs: await $.clock.now(), toolUseId: (e as { tool_use_id?: unknown }).tool_use_id, agentId: e.agentId } const deny = await gate($, st, cfg, e, fire) - if (deny !== undefined) return { deny } + if (deny !== undefined) { + $.ui.log(deny, { to: 'debug' }) + return { deny } + } const result = await next(e) // The gate's envelope timed the call before the tool ran; the line work after it starts its own clock. const after: Fire = { ...fire, startMs: await $.clock.now() } @@ -904,7 +907,6 @@ export const register: Register = (on, options) => { await queueWrite($, st) if (e.agentId !== undefined) return result if (result.deny !== undefined || result.isError) return result - const lines = await takeLines($, st, cfg, after) - return lines.length === 0 ? result :{ ...result, context: [...(result.context ?? []), ...lines] } + return withLines($, result, await takeLines($, st, cfg, after)) }).catch(($, e, next) => next(e)) } diff --git a/plugins/context-guard/reference/reader-contract.md b/plugins/context-guard/reference/reader-contract.md index 6cc7bd5ca5..efcc871e4a 100644 --- a/plugins/context-guard/reference/reader-contract.md +++ b/plugins/context-guard/reference/reader-contract.md @@ -244,14 +244,15 @@ skill's module check), none of the following runs except the PostCompact marker. - **Zone lines** (on each tool result of the main conversation and each prompt): on a transition into a zone worse than any this session has already reported, report the crossing on **two channels with two audiences**. The **model channel** (the `context` a `tool.call` or - `prompt.submit` hook adds) carries the verdict only: the zone word and its rank of three, and - "Continuing is the user's call." on crossing, restatement, approach and threshold lines. In `dumb` it also + `prompt.submit` hook adds) carries the verdict only: the zone word and its rank of three. In `dumb` it also carries the save-state note, labeled as the dumb zone's default. A line carries no figure - unless `zone_line_data` adds one (percent, tokens, window), and never a session id. Beside the + unless `zone_line_data` adds one (percent, tokens, window), and a line with a figure ends + "Continuing is the user's call."; it never carries a session id. Beside the crossings the module sends one approach line per boundary per cycle (`approach_margin` - percentage points before it), one line per `thresholds` entry passed, the verdict restated once - after a compaction (not a `precompute` one) and after an in-process resume, and, on a reload or a - worker respawn (a load with earlier turns), the verdict only when it is past `smart`. Lines due + percentage points before it), one line per `thresholds` entry passed, and the verdict restated + once, only when it is past `smart`, after a compaction (not a `precompute` one), after an + in-process resume, and on a reload or a worker respawn (a load with earlier turns). Each line + sent, and each gate denial, is also written as sent to the debug log. Lines due at one carrier: a crossing or restatement recorded before a pending restatement merges into it; a crossing recorded after it is the newer verdict and replaces it. After `/clear` it sends nothing: the new session starts in `smart`. Lines go to the main conversation @@ -279,13 +280,13 @@ skill's module check), none of the following runs except the PostCompact marker. pointer ever reaches the model channel.** A menu injected into model context manufactures the model's own initiative to stop, summarize, or hand off. That is a live finding under I23 of `/harness-config:audit-instructions`, - whose Remediate clause says that where the harness must surface a budget, it pairs it with a - reassurance rather than with an exit menu. The module sends the verdict, one reassurance - clause, "Continuing is the user's call.", in `dumb` the save-state note that zone carries by - default, and any operator-configured `zones.json` action; it sends no counter-steer rule about - what a zone means. The + whose Remediate clause says that where the harness must surface a count, it pairs it with a + reassurance rather than with an exit menu. By default the module surfaces no count: it sends the + verdict, in `dumb` the save-state note that zone carries by default, and any operator-configured + `zones.json` action; a count `zone_line_data` adds is paired with "Continuing is the user's + call."; it sends no counter-steer rule about what a zone means. The measurement decides only *when to ask*; the model still decides whether to stop. The model - channel states that continuing is the user's call, never that the user has seen the menu. No documented hook behavior tells a hook whether an operator is present, so a delivery + channel never says the user has seen the menu. No documented hook behavior tells a hook whether an operator is present, so a delivery claim would be a fact the hook cannot know. Silent while the zone is unchanged, improving, or `unknown`. **Hysteresis**: the gate is the worst zone already *reported*, not the zone last *seen*. That marker decays only when the session returns to `smart`, the bottom of the ladder. diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index aec5f7a411..d2decf1972 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rate-limit-guard", - "version": "0.14.0", + "version": "0.14.1", "description": "Shared rate-limit guard for loop lanes and Claude: a hooks module writes the subscription rate-limit windows to a fixed machine-scope file and tells Claude where they stand at boundaries, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index e1aaa6c12b..9c77ceb42f 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.14.1] - 2026-10-04 + +### Changed + +- **No quiet window restated.** After a compaction, a resume or `/branch`, only windows at or above the approach mark are restated; a session with only quiet windows gets nothing. +- **Debug log mirror.** Each line sent to Claude is written as sent to the debug log. +- **Shorter status tool description.** + ## [0.14.0] - 2026-10-04 ### Changed diff --git a/plugins/rate-limit-guard/README.md b/plugins/rate-limit-guard/README.md index 80272d230c..27a0e005bd 100644 --- a/plugins/rate-limit-guard/README.md +++ b/plugins/rate-limit-guard/README.md @@ -52,7 +52,7 @@ of a prompt; a crossing seen when a turn ends reaches Claude with the next promp | The window reaches the approach mark (90%) | once per window, "nearing the 95% pause edge" | | The window reaches the line threshold (95%) | once per window, "at the 95% pause edge", with the reset time | | The window resets (its reset time passes, or it leaves the reading) after reaching the threshold | once; the approach and threshold lines can then fire again | -| After a compaction (not the precompute kind), and after `/resume` or `/branch` | the verdict for every window, once | +| After a compaction (not the precompute kind), and after `/resume` or `/branch` | the verdict for each window at or above the approach mark, once; nothing when none is | | After `/clear`, and when the module loads into a session that already has turns (a `--resume` launch, a reload after an options change, a hooks-worker restart) | the verdict for each window at or above the threshold, once; nothing when none is | Use only rises within a window, so a reading that dips below a mark sends nothing and does not @@ -61,7 +61,8 @@ line, its verdict only, for example `rate-limit-guard: 5-hour window at the 95% at 2026-10-03 21:00 UTC.`. Every line carries its verdict; `rate_limit_line_data` chooses what goes with it: the window's name (`window`), its use (`percent`) and its reset time (`reset`). Without `window` the line says "a rate-limit window". A line never carries the account email or the session name. Subagents get no -line. The line threshold is a line setting only: the loop lanes' pause edge stays 95% (see the +line. Each line sent to Claude is also written as sent to the debug log (`claude --debug`). The +line threshold is a line setting only: the loop lanes' pause edge stays 95% (see the [reader contract](reference/reader-contract.md)). ### Window changes shown to you diff --git a/plugins/rate-limit-guard/hooks/rate-limit-guard.test.ts b/plugins/rate-limit-guard/hooks/rate-limit-guard.test.ts index a35eb83266..fdecd147c0 100644 --- a/plugins/rate-limit-guard/hooks/rate-limit-guard.test.ts +++ b/plugins/rate-limit-guard/hooks/rate-limit-guard.test.ts @@ -242,29 +242,53 @@ test('lines: a crossing seen at session.measure reaches Claude at the next promp ]) }) -test('lines: restated after a compaction, not after a precompute one', NO_WRITES, async ($, on) => { +test('lines: a compaction restates no quiet window', NO_WRITES, async ($, on) => { world(on) await bash($) + await $.session.compact({ trigger: 'manual', messages: MESSAGES } as any) + expect(ownLines((await prompt($)).context)).toEqual([]) + expect(ownLines((await bash($)).context)).toEqual([]) +}) + +test('lines: a window past quiet is restated once after a compaction, not after a precompute one', NO_WRITES, async ($, on) => { + const { w } = world(on) + await bash($) + w.limits = limits(96) + expect(ownLines((await bash($)).context)).toEqual([EDGE_5H]) await $.session.compact({ trigger: 'precompute', messages: MESSAGES } as any) expect(ownLines((await prompt($)).context)).toEqual([]) await $.session.compact({ trigger: 'manual', messages: MESSAGES } as any) - expect(ownLines((await prompt($)).context)).toEqual([ - `rate-limit-guard: 5-hour window below the 95% pause edge, resets at 2026-10-03 21:00 UTC.`, - `rate-limit-guard: 7-day window below the 95% pause edge, resets at 2026-10-08 09:00 UTC.`, - ]) + expect(ownLines((await prompt($)).context)).toEqual([EDGE_5H]) expect(ownLines((await prompt($)).context)).toEqual([]) }) -test('lines: restated once after a resume', NO_WRITES, async ($, on) => { +test('lines: restated once after a resume, with no quiet window', NO_WRITES, async ($, on) => { const { w } = world(on, { limits: limits(96) }) await bash($) await $.session.end({ reason: 'resume', sessionId: 'sess-1', resume: { id: 'sess-1' } } as any) w.sid = 'sess-2' - expect(ownLines((await prompt($)).context)).toEqual([ - `rate-limit-guard: 5-hour window at the 95% pause edge, resets at 2026-10-03 21:00 UTC.`, - `rate-limit-guard: 7-day window below the 95% pause edge, resets at 2026-10-08 09:00 UTC.`, - ]) + expect(ownLines((await prompt($)).context)).toEqual([EDGE_5H]) + expect(ownLines((await prompt($)).context)).toEqual([]) +}) + +test('lines: a resume with only quiet windows restates nothing', NO_WRITES, async ($, on) => { + const { w } = world(on) + await bash($) + await $.session.end({ reason: 'resume', sessionId: 'sess-1', resume: { id: 'sess-1' } } as any) + w.sid = 'sess-2' expect(ownLines((await prompt($)).context)).toEqual([]) + expect(ownLines((await bash($)).context)).toEqual([]) +}) + +test('debug mirror: each line sent to Claude is written to the debug log as sent', NO_WRITES, async ($, on) => { + const { w } = world(on) + await bash($) + w.limits = limits(96) + const atTool = ownLines((await bash($)).context) + w.limits = limits(96, 97) + const atPrompt = ownLines((await prompt($)).context) + expect([...atTool, ...atPrompt]).toEqual([EDGE_5H, 'rate-limit-guard: 7-day window at the 95% pause edge, resets at 2026-10-08 09:00 UTC.']) + expect(w.logs.filter(l => l.to === 'debug' && l.text.includes(' window ')).map(l => l.text)).toEqual([...atTool, ...atPrompt]) }) // session.start with earlier turns is a fresh load in a running session: a reload, a worker respawn, @@ -1168,7 +1192,7 @@ test('pull tool: a refused registration logs one line, and lines, band and write expect((await ui.find({ type: 'Text', text: BAND_ROW }))?.text).toBe('5h 96% | 7d 7%') await ui.unmount() expect(bodies(w).map(b => b.rate_limits.five_hour.used_percentage)).toEqual([96]) - const failures = logs.filter(l => !l.endsWith('ยท more: /rate-limit-guard')) + const failures = logs.filter(l => l.startsWith('rate-limit-guard: the status pull tool')) expect(failures).toHaveLength(1) expect(failures[0]).toMatch(/^rate-limit-guard: the status pull tool could not register: \S/) }) @@ -1440,7 +1464,7 @@ test('lines: a reading whose every window has passed its reset time still sends // A /branch or an in-process /resume: the new session's first prompt can come before any reading. const BRANCH = { reason: 'resume', sessionId: 'sess-1', resume: { id: 'sess-2' } } as any -test('lines: after a branch, a carrier with no reading keeps the restatement, and the first carrier with one restates each window once', NO_WRITES, async ($, on) => { +test('lines: after a branch, a carrier with no reading keeps the restatement, and the first carrier with one restates each window past quiet once', NO_WRITES, async ($, on) => { const { w } = world(on, { limits: limits(96) }) await bash($) await $.session.end(BRANCH) @@ -1448,10 +1472,7 @@ test('lines: after a branch, a carrier with no reading keeps the restatement, an w.limits = [] expect(ownLines((await prompt($)).context)).toEqual([]) w.limits = limits(96) - expect(ownLines((await bash($)).context)).toEqual([ - `rate-limit-guard: 5-hour window at the 95% pause edge, resets at 2026-10-03 21:00 UTC.`, - `rate-limit-guard: 7-day window below the 95% pause edge, resets at 2026-10-08 09:00 UTC.`, - ]) + expect(ownLines((await bash($)).context)).toEqual([EDGE_5H]) expect(ownLines((await bash($)).context)).toEqual([]) }) diff --git a/plugins/rate-limit-guard/hooks/register.tsx b/plugins/rate-limit-guard/hooks/register.tsx index 43921a170c..80b8731dfa 100644 --- a/plugins/rate-limit-guard/hooks/register.tsx +++ b/plugins/rate-limit-guard/hooks/register.tsx @@ -197,10 +197,11 @@ export const recordCrossings = (st: State, reading: Reading, cfg: Config, nowMs: // The events due now, one per window, without consuming them. const dueEvents = (st: State): [string, Event][] => { - // After /clear or a fresh load mid-session, only a window at the edge is restated. + // After /clear or a fresh load mid-session, only a window at the edge is restated; after a + // compaction, a resume or a /branch, each window past quiet. A quiet window is never restated. const atEdge = st.restateIfLoud ? [...st.levels].filter(([, w]) => w.level === 'edge').map(([kind]) => kind) : [] return st.restate - ? [...(st.reading ?? new Map()).keys()].map(kind => [kind, st.levels.get(kind)?.level ?? 'quiet']) + ? [...st.levels].filter(([kind, w]) => w.level !== 'quiet' && st.reading?.has(kind)).map(([kind, w]) => [kind, w.level]) : [...new Map([...st.pending.entries(), ...atEdge.map(kind => [kind, 'edge'] as const)])] } @@ -211,6 +212,12 @@ const dueLines = (st: State, cfg: Config): string[] => { .map(([kind, event]) => `rate-limit-guard: ${clause(kind, event, reading.get(kind), cfg)}.`) } +// Appends lines to what Claude reads and writes each to the debug log, so the log holds what Claude was told. +const withLines = ($: EngineInterface, e: T, lines: readonly string[]): T => { + for (const line of lines) $.ui.log(line, { to: 'debug' }) + return lines.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...lines] } +} + const consume = (st: State) => { st.pending.clear() st.restate = false @@ -255,7 +262,7 @@ async function takeLines($: EngineInterface, st: State, cfg: Config): Promise 0 && st.notice?.kind === 'operator') { releaseHeld(st, st.rowSeen) @@ -534,9 +541,7 @@ export const register: Register = (on, options) => { $.tool.register({ name: 'status', description: - "Read-only. Returns this session's rate-limit windows as the last API response reported them " + - '(five_hour, seven_day, and a gateway spend_limit when present) with rate-limit-guard\'s verdict ' + - 'for each, as JSON. Takes no input.', + "Read-only. This session's rate-limit windows and spend limit, with rate-limit-guard's verdict for each, as JSON.", inputSchema: { type: 'object', properties: {}, additionalProperties: false }, }), $.command.register({ @@ -611,7 +616,7 @@ export const register: Register = (on, options) => { const held = cfg.lines && (await operatorHolds($, st, cfg)) const lines = await takeLines($, st, cfg) await flushToasts($, st, cfg, held) - return next(lines.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...lines] }) + return next(withLines($, e, lines)) }).catch(($, e, next) => next(e)) on('turn.start', async ($, e, next) => { @@ -664,7 +669,7 @@ export const register: Register = (on, options) => { const held = cfg.lines && (await operatorHolds($, st, cfg)) const lines = await takeLines($, st, cfg) await flushToasts($, st, cfg, held) - return lines.length === 0 ? result : { ...result, context: [...(result.context ?? []), ...lines] } + return withLines($, result, lines) }).catch(($, e, next) => next(e)) on('command.run', { command: 'rate-limit-guard' }, async ($, e, next) => {