From eff70b32da69d86f419146582b2125f8aab4f6d7 Mon Sep 17 00:00:00 2001 From: Riccardo Bartoli Date: Sat, 24 Oct 2026 03:35:12 +0100 Subject: [PATCH] feat: /agent-guard simulate shows what a rule does --- CHANGELOG.md | 10 ++++++ README.md | 3 +- TESTING.md | 2 +- core/commands.ts | 8 +++++ core/index.ts | 1 + core/simulate.ts | 77 ++++++++++++++++++++++++++++++++++++++++++++ core/status.ts | 1 + hooks/register.ts | 28 ++++++++++++++++ tests/agents.test.ts | 23 +++++++++++++ tests/core.test.ts | 17 ++++++++++ 10 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 core/simulate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f0ec6..052778d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ judge for any reason is now refused, so Claude starts it again; before, only an overrun was. Tool calls and prompts still go ahead when the guard fails. +- **`/agent-guard simulate ` shows what a rule does.** It builds a + made-up state that trips the rule and runs the real gate on it. Where + someone can answer, the real question opens in Claude Code's dialog, marked + as a simulation; elsewhere it prints the refusal Claude would read or the + reason a prompt would be held. Nothing is held, nothing in the session + changes, the journal records nothing, and no usage is spent. The rules are + `concurrency`, `starts`, `agent-tokens`, `burn`, `limit`, `limit-deny`, + `depth`, `retry`, `tool-budget`, `prompt-context` and `dormant`. The + README's install steps now end with one, so a new user sees the guard ask + within a minute. ## 1.1.1 — 2026-10-23 diff --git a/README.md b/README.md index 528bf01..9d09306 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Run these commands inside Claude Code v2.1.287 or later: /reload-plugins ``` -Then run `/agent-guard` to see what it sees. Mods are on by default; the [overview](https://code.claude.com/docs/en/plugins/mods/overview#turn-mods-on-or-off) lists the settings that turn them off. +Then run `/agent-guard` to see what it sees, and `/agent-guard simulate concurrency` to see the question it asks when too many agents run, with nothing held or spent. Mods are on by default; the [overview](https://code.claude.com/docs/en/plugins/mods/overview#turn-mods-on-or-off) lists the settings that turn them off. ## Why this exists @@ -85,6 +85,7 @@ Agents covers subagents, agent-team teammates, workflow agents, and finished sub /agent-guard pause [minutes] the same as allow, for every limit /agent-guard resume end an allow early, lift the agent pause, and let declined questions ask again /agent-guard report [days] what it did on this machine (default: 7 days) +/agent-guard simulate show what a rule does, with nothing held or spent /agent-guard help these commands ``` diff --git a/TESTING.md b/TESTING.md index 42d3a37..3ac7763 100644 --- a/TESTING.md +++ b/TESTING.md @@ -44,7 +44,7 @@ npx -p typescript@5.9 tsc -p tsconfig.json | Tool gate | Retry fuse on identical failures in a row only, so test, edit, re-run is not a loop; a permission refusal is not a failure; the heavy-context budget asks once, stops the turn, or compacts after it; subagents judged on their own context; `SubagentHandback` and `TaskStop` never held; compaction, or a context reading below the threshold, clears the budget | | Prompt gate | Heavy prompt asks, compacts then resends as the user's own words, or cancels back into the input box; a compaction that fails or a hook vetoes puts the prompt back instead; headless holds with a reason; notifications pass untouched; dormant resumes ask or are capped across sessions; the context warning is given once, on a prompt the user typed; from 95% of a plan window, read in any session, each session is told once to wind down, and again only in a new window; a session compacted while idle is not asked | | Denials | Each refusal of a condition reads differently and escalates; separate conditions count separately; the fuse pauses spawns and clears, and `resume` lifts it; a Stop hook reopening the turn changes the wording | -| Overrides | `/agent-guard allow`, `resume`, the old markers sent alone, and the off switch; `allow` and `pause` refused when another plugin runs them | +| Overrides | `simulate` trips every rule with the defaults, opens the real question or prints the refusal, and changes nothing; `/agent-guard allow`, `resume`, the old markers sent alone, and the off switch; `allow` and `pause` refused when another plugin runs them | | Status line | Redrawn when a plan window it shows resets or an allow ends, in a session where nothing else happens | | Journal | Lockouts classified (usage, weekly, spend, one model) and transient rate limits ignored; each locked window counted once, across sessions and retries; report across sessions; retention and stale-record cleanup; rows hold no prompt text | diff --git a/core/commands.ts b/core/commands.ts index 551a3d8..d773a29 100644 --- a/core/commands.ts +++ b/core/commands.ts @@ -3,6 +3,7 @@ // runs, so an override is always the user's. The model does read the // command's reply with its next request. +import { SIMULATED, type SimulatedRule } from './simulate.ts' import { MAIN, type Scope, type SessionState, loopOf } from './state.ts' import { duration, sentence, shortClock } from './text.ts' import { USAGE, scopeName } from './status.ts' @@ -12,6 +13,7 @@ export type Command = | { action: 'allow'; scope: Scope; minutes: number } | { action: 'resume' } | { action: 'report'; days: number } + | { action: 'simulate'; rule: SimulatedRule } | { action: 'help'; error?: string } export const DEFAULT_ALLOW_MINUTES = 10 @@ -30,6 +32,12 @@ export function parseCommand(args: string): Command { if (!Number.isInteger(days) || days < 1 || days > MAX_REPORT_DAYS) return { action: 'help', error: `"${rest[0]}" is not a number of days` } return { action: 'report', days } } + if (verb === 'simulate') { + const rule = SIMULATED.find((r) => r === rest[0]) + if (rule) return { action: 'simulate', rule } + const rules = SIMULATED.join(', ') + return { action: 'help', error: rest[0] === undefined ? `name a rule to simulate: ${rules}` : `"${rest[0]}" is not a rule; the rules are ${rules}` } + } if (verb === 'allow' || verb === 'pause') { let scope: Scope = 'all' let minutes = DEFAULT_ALLOW_MINUTES diff --git a/core/index.ts b/core/index.ts index 90de945..daa8381 100644 --- a/core/index.ts +++ b/core/index.ts @@ -48,6 +48,7 @@ export { peerRecord, prune, } from './state.ts' +export { SIMULATED, type SimulatedRule, simulate } from './simulate.ts' export { USAGE, statusLine, statusLineExpiry, statusReport } from './status.ts' export { canonical, contextBucket, fingerprint } from './text.ts' export { type HeavyOutcome, type ToolRequest, resolveHeavyAsk, toolGate } from './tools.ts' diff --git a/core/simulate.ts b/core/simulate.ts new file mode 100644 index 0000000..149dec4 --- /dev/null +++ b/core/simulate.ts @@ -0,0 +1,77 @@ +// `/agent-guard simulate `: what a rule does, on made-up state, with the +// real gates. Nothing it builds touches the session's own state. + +import { type AgentRequest, agentGate } from './agents.ts' +import type { Config } from './config.ts' +import { promptGate } from './prompts.ts' +import { type LimitReading, MAIN, loopOf, newSession } from './state.ts' +import { toolGate } from './tools.ts' +import type { Verdict } from './verdict.ts' + +export const SIMULATED = [ + 'concurrency', + 'starts', + 'agent-tokens', + 'burn', + 'limit', + 'limit-deny', + 'depth', + 'retry', + 'tool-budget', + 'prompt-context', + 'dormant', +] as const + +export type SimulatedRule = (typeof SIMULATED)[number] + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE + +/** The verdict `rule` gives on state built to trip it, as a session where `canAsk` holds would see it. */ +export function simulate(rule: SimulatedRule, config: Config, now: number, canAsk: boolean): Verdict { + const state = newSession('simulation', canAsk) + const agent: AgentRequest = { loop: MAIN, depth: 1, resume: false } + const fiveHour = (percent: number, at = now): LimitReading => ({ at, kind: 'five_hour', percent, resetsAt: now + 3 * HOUR }) + const main = loopOf(state, MAIN) + switch (rule) { + case 'concurrency': + for (let i = 0; i < config.agentMax; i++) { + state.agents[`simulated-${i}`] = { id: `simulated-${i}`, kind: 'subagent', depth: 1, running: true, startedAt: now } + } + return agentGate(state, [], config, now, agent) + case 'starts': + state.starts = Array.from({ length: config.rollingMax }, () => now - MINUTE) + return agentGate(state, [], config, now, agent) + case 'agent-tokens': + state.agentTokens = [[now - MINUTE, config.agentTokensMax]] + return agentGate(state, [], config, now, agent) + case 'burn': { + const low = fiveHour(30, now - 5 * MINUTE) + state.limits.five_hour = fiveHour(30 + config.burnPercent) + state.limitHistory = [low, state.limits.five_hour] + return agentGate(state, [], config, now, agent) + } + case 'limit': + state.limits.five_hour = fiveHour(config.limitAskPercent) + return agentGate(state, [], config, now, agent) + case 'limit-deny': + state.limits.five_hour = fiveHour(config.limitDenyPercent) + return agentGate(state, [], config, now, agent) + case 'depth': + return agentGate(state, [], config, now, { ...agent, depth: config.depthMax + 1 }) + case 'retry': + state.failures.simulated = { count: config.failureMax, last: now - MINUTE, loop: MAIN } + return toolGate(state, config, now, { loop: MAIN, tool: 'Bash', fingerprint: 'simulated', label: 'this conversation' }) + case 'tool-budget': + main.context = config.toolContext + 50_000 + main.heavyCalls = Array.from({ length: config.toolMax }, () => now - MINUTE) + return toolGate(state, config, now, { loop: MAIN, tool: 'Read', fingerprint: 'simulated', label: 'this conversation' }) + case 'prompt-context': + main.context = config.contextHard + 20_000 + return promptGate(state, [], config, now, { source: 'user', text: '' }) + case 'dormant': + main.context = Math.min(config.dormantContext + 10_000, config.contextHard - 1) + state.dormantResumes = Array.from({ length: config.dormantMax }, () => now - MINUTE) + return promptGate(state, [], config, now, { source: 'user', text: '', idleMs: config.dormantMs + HOUR }) + } +} diff --git a/core/status.ts b/core/status.ts index 3a7d7a4..80e203e 100644 --- a/core/status.ts +++ b/core/status.ts @@ -103,5 +103,6 @@ export const USAGE = [ ' /agent-guard pause [minutes] same as allow, for every limit', ' /agent-guard resume end an allow or pause early, and clear declined questions', ' /agent-guard report [days] what the guard did on this machine (default 7 days)', + ' /agent-guard simulate show what a rule does, with nothing held or spent', ' /agent-guard status this summary', ].join('\n') diff --git a/hooks/register.ts b/hooks/register.ts index 49b3784..845375b 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -17,6 +17,7 @@ import { type PromptSource, SIGNATURE, type SessionState, + type SimulatedRule, type ToolRequest, type Verdict, agentGate, @@ -59,6 +60,7 @@ import { resolveHeavyAsk, resolvePromptAsk, resumeGuard, + simulate, splitJournalKeys, statusLine, statusLineExpiry, @@ -435,6 +437,7 @@ export function register(on: On): void { await refreshStatus($) return { text } } + if (command.action === 'simulate') return { text: await simulation($, command.rule, now) } if (command.action === 'resume') { const text = resumeGuard(session) await refreshStatus($) @@ -683,6 +686,31 @@ async function oneAtATime(key: string, held: () => T, run: () => Promise): } } +/** + * Shows what a rule does on made-up state: the real question in the dialog + * where someone can answer, else the refusal or the held prompt's reason. The + * reply opens by saying it is a simulation, because Claude reads it too. + */ +async function simulation($: Api, rule: SimulatedRule, now: number): Promise { + const verdict = simulate(rule, config, now, canAsk()) + const opening = `Simulation of ${rule}` + const aside = `${opening}: nothing was held, and none of this applies to the current work.` + if (verdict.kind === 'ask') { + if (canAsk()) { + try { + const answer = await $.ui.ask(`Simulation, nothing changes: ${verdict.question}`, { options: verdict.options, header: HEADER }) + return `${opening}: you answered "${answer}". Nothing was held, nothing changed and no usage was spent.` + } catch { + // Dismissed, or the dialog could not open: show the question instead. + } + } + return `${aside} The guard would ask:\n\n${verdict.question}\nOptions: ${verdict.options.join(' · ')}` + } + if (verdict.kind === 'deny') return `${aside} Claude would read this refusal:\n\n${verdict.message}` + if (verdict.kind === 'drop') return `${aside} A prompt you send would be held:\n\n${verdict.message}` + return `${opening}: with your settings, this rule would not trip.` +} + /** Compacts the conversation. Returns why it did not, or undefined once it has. */ async function compact($: Api): Promise { try { diff --git a/tests/agents.test.ts b/tests/agents.test.ts index ebe3327..e38b2d8 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -206,6 +206,29 @@ describe('agent gate', () => { }) }) +describe('simulate', () => { + test('a simulated question opens the real dialog, and nothing changes whatever the answer', async ($, on) => { + const w = world(on, { answers: ["Don't start it"] }) + await start($, w, true) + const result = await $.command.run({ command: 'agent-guard', args: 'simulate concurrency', origin: TYPED } as never) + expect(w.questions[0]).toMatch(/^Simulation, nothing changes: 4 agents are running on this machine \(limit 4\)/) + expect(result.text).toBe('Simulation of concurrency: you answered "Don\'t start it". Nothing was held, nothing changed and no usage was spent.') + expect((await spawn($, w)).agentId).toBeDefined() + expect(journalRows(w)).toEqual([]) + }) + + test('where nobody can answer, it prints the refusal Claude would read or the reason a prompt would be held', async ($, on) => { + const w = world(on) + await start($, w, false) + const retry = await $.command.run({ command: 'agent-guard', args: 'simulate retry', origin: TYPED } as never) + expect(retry.text).toMatch(/^Simulation of retry: nothing was held, and none of this applies to the current work\. Claude would read this refusal:\n\nThis exact Bash call failed 3 times in a row/) + const prompt = await $.command.run({ command: 'agent-guard', args: 'simulate prompt-context', origin: TYPED } as never) + expect(prompt.text).toMatch(/A prompt you send would be held:\n\nagent-usage-guard: this session holds 520k context tokens/) + expect(w.questions).toEqual([]) + expect(journalRows(w)).toEqual([]) + }) +}) + describe('plan-window gate', () => { const measure = (percent: number, resetsAt = '2027-01-15T15:00:00.000Z', kind = 'five_hour') => ({ context: { window: 1_000_000 }, diff --git a/tests/core.test.ts b/tests/core.test.ts index a7fde06..33deb7a 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -17,6 +17,8 @@ import { parseConfig, peerRecord, prune, + SIMULATED, + simulate, splitJournalKeys, } from '../core/index.ts' @@ -57,6 +59,7 @@ describe('commands', () => { expect(parseCommand('pause 20')).toEqual({ action: 'allow', scope: 'all', minutes: 20 }) expect(parseCommand('report 30')).toEqual({ action: 'report', days: 30 }) expect(parseCommand('resume')).toEqual({ action: 'resume' }) + expect(parseCommand('simulate Concurrency')).toEqual({ action: 'simulate', rule: 'concurrency' }) }) test('rejects what it cannot read instead of guessing', () => { @@ -65,6 +68,20 @@ describe('commands', () => { expect(parseCommand('allow 9999')).toMatchObject({ action: 'help' }) expect(parseCommand('report -1')).toMatchObject({ action: 'help' }) expect(parseCommand('frobnicate')).toEqual({ action: 'help', error: 'unknown subcommand "frobnicate"' }) + expect(parseCommand('simulate')).toMatchObject({ action: 'help', error: expect.stringMatching(/^name a rule to simulate: concurrency, starts, /) }) + expect(parseCommand('simulate everything')).toMatchObject({ action: 'help', error: expect.stringMatching(/^"everything" is not a rule/) }) + }) +}) + +describe('simulate', () => { + test('every rule trips with the default settings, asking where someone can answer and refusing or holding where nobody can', () => { + for (const rule of SIMULATED) { + for (const canAsk of [true, false]) { + const verdict = simulate(rule, defaultConfig(), NOW, canAsk) + expect([rule, verdict.kind]).not.toEqual([rule, 'allow']) + if (!canAsk) expect([rule, verdict.kind]).not.toEqual([rule, 'ask']) + } + } }) })