Claude Code mod: context/token band, escalation signals and per-turn effort log.

Mod per Claude Code (>= 2.1.287): banda sopra il prompt con contesto e token, segnale di escalation dopo turni "faticosi" consecutivi, registro per turno con /effort-log. Solo osservazione, non blocca nulla.
npm test, ng test, mvn, gradle, pytest, go test, cargo, dotnet, make, tsc…) che contiene un fallimento (FAIL, N failing, BUILD FAILURE…) anche se l'exit code è 0. I comandi dei sub-agent non contano.ctx N% | last turn N tok | struggling xN/soglia sopra il prompt./effort-log: ultimi 50 turni; il registro è salvato nello store del plugin e resta tra una sessione e l'altra.| Opzione | Default | Descrizione |
|---|---|---|
threshold | 3 | Turni faticosi consecutivi prima del toast (1–20). Si cambia dal menu /config del plugin. |
/plugin marketplace add stefanochieli/claude-effort-guard
/plugin install effort-guard@effort-guard
/reload-plugins
claude --plugin-dir .
claude plugin validate .
claude plugin test .
Struttura: .claude-plugin/ (manifest + marketplace), hooks/ (modulo), types/ (stato), tests/. Flusso di lavoro e release: vedi CONTRIBUTING.md.
protect-main.json (richiede il check checks, prodotto da ci.yml)RELEASE_PLEASE_TOKEN (PAT o GitHub App); senza, la CI sulla PR di release viene lanciata da release-please.yml con workflow_dispatchmain, con la versione minima di Claude Code (checks, richiesto dal ruleset); il job compat-latest prova anche l'ultima versione senza bloccare.feat:, fix:), apre la release PR, aggiorna versione in plugin.json e marketplace.json, CHANGELOG, tag e GitHub Release al merge.hooks/module.mjs 146 lines1import { atom, read, update } from 'claude-code'
2import { looksLikeTestFailure } from './detect.mjs'
3
4const DEFAULT_THRESHOLD = 3
5const LOG_LIMIT = 500
6const LOG_KEY = 'log'
7
8const metrics = atom(
9 { plugin: 'effort-guard', key: 'metrics' },
10 { contextPercent: null, lastTurnTokens: null, consecutiveStruggles: 0 },
11)
12const turn = atom({ plugin: 'effort-guard', key: 'turn' }, { struggled: false, reason: null })
13
14export function register(on, options) {
15 const threshold = Math.max(1, Math.floor(Number(options?.threshold)) || DEFAULT_THRESHOLD)
16
17 on('session.start', async ($, e, next) => {
18 await $.command.register({
19 name: 'effort-log',
20 description: 'Print the effort-guard per-turn context/struggle log',
21 })
22
23 return next(e)
24 })
25
26 on('prompt.submit', async ($, e, next) => {
27 await update($, turn, () => ({ struggled: false, reason: null }))
28
29 return next(e)
30 })
31
32 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
33 const ran = await next(e)
34
35 // A subagent's commands are not the user-visible turn's struggle.
36 if (e.agentId) {
37 return ran
38 }
39
40 if (ran.isError) {
41 await update($, turn, () => ({ struggled: true, reason: 'bash_error' }))
42 } else if (looksLikeTestFailure(e.command, ran.text)) {
43 await update($, turn, () => ({ struggled: true, reason: 'test_failure_pattern' }))
44 }
45
46 return ran
47 })
48
49 on('turn.complete', async ($, e, next) => {
50 if (e.agentId) {
51 // Subagent turn: don't count it toward the user-visible streak.
52 return next(e)
53 }
54
55 const usage = e.usage
56 const lastTurnTokens = usage
57 ? usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
58 : null
59
60 // Struggle tracking and the toast must not depend on context-usage
61 // reporting succeeding, so a failure here degrades to an unknown percent
62 // instead of skipping the rest of the hook.
63 let contextPercent = null
64 try {
65 const sessionUsage = await $.session.usage()
66 contextPercent = sessionUsage.context.percent ?? null
67 } catch {}
68
69 const { struggled, reason } = await read($, turn)
70 const prev = await read($, metrics)
71 const consecutiveStruggles = struggled ? prev.consecutiveStruggles + 1 : 0
72
73 await update($, metrics, () => ({ contextPercent, lastTurnTokens, consecutiveStruggles }))
74 await update($, turn, () => ({ struggled: false, reason: null }))
75
76 const entry = {
77 ts: new Date().toISOString(),
78 contextPercent,
79 lastTurnTokens,
80 struggled,
81 reason,
82 consecutiveStruggles,
83 }
84
85 // The log outlives the session. A store failure must not cost the toast.
86 try {
87 const stored = await $.store.get(LOG_KEY)
88 const list = Array.isArray(stored) ? stored : []
89 await $.store.set(LOG_KEY, [...list, entry].slice(-LOG_LIMIT))
90 } catch {}
91
92 // Once per streak: fires when the streak reaches the threshold, and again
93 // only after a clean turn has reset it. Derived from $.state, so a hot
94 // reload cannot make it fire twice.
95 if (consecutiveStruggles === threshold) {
96 $.ui.toast(
97 `effort-guard: ${consecutiveStruggles} struggling turns in a row (failed commands/tests). ` +
98 `Consider switching to a stronger model or raising effort one level.`,
99 )
100 }
101
102 return next(e)
103 })
104
105 on('command.run', { command: 'effort-log' }, async $ => {
106 let list = []
107 try {
108 const stored = await $.store.get(LOG_KEY)
109 list = Array.isArray(stored) ? stored : []
110 } catch {}
111
112 if (list.length === 0) {
113 return { text: 'effort-guard: no turns logged yet.' }
114 }
115
116 const lines = list.slice(-50).map(e => {
117 const flag = e.struggled ? `STRUGGLE(${e.reason})` : 'ok'
118
119 return `${e.ts} ctx=${e.contextPercent ?? '?'}% tok=${e.lastTurnTokens ?? '?'} streak=${e.consecutiveStruggles} ${flag}`
120 })
121
122 return { text: lines.join('\n') }
123 })
124
125 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
126 const m = await read($, metrics)
127
128 if (m.contextPercent === null && m.lastTurnTokens === null && m.consecutiveStruggles === 0) {
129 return next(e)
130 }
131
132 const { Box, Text } = $.ui.resolve(e)
133 const struggleText = m.consecutiveStruggles > 0 ? ` | struggling x${m.consecutiveStruggles}/${threshold}` : ''
134
135 return h(
136 Box,
137 null,
138 h(
139 Text,
140 { dimColor: true },
141 `ctx ${m.contextPercent ?? '?'}% | last turn ${m.lastTurnTokens ?? '?'} tok${struggleText}`,
142 ),
143 )
144 })
145}
146hooks/detect.mjs 31 lines1// Commands whose output is worth scanning for swallowed test/build failures.
2// Anything else (cat, grep, git log...) can print "FAIL" without meaning it.
3const TEST_COMMAND = new RegExp(
4 [
5 String.raw`(?:^|[\s;&|(])(?:\./)?(?:mvnw?|gradlew?|jest|vitest|karma|pytest|tox|tsc|make)(?=\s|$)`,
6 String.raw`\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|build|lint|e2e)\b`,
7 String.raw`\bng\s+(?:test|build|lint|e2e)\b`,
8 String.raw`\b(?:go|cargo|dotnet)\s+(?:test|build)\b`,
9 String.raw`\bclaude\s+plugin\s+test\b`,
10 ].join('|'),
11)
12
13// Text a runner prints when it failed but the exit code was swallowed
14// (`npm test || true`) or the failure is reported without a non-zero exit.
15const FAILURE_PATTERNS = [
16 /\bFAIL(ED|URE)?\b/,
17 /\d+\s+failing/i,
18 /tests?:\s*\d+\s*failed/i,
19 /assertionerror/i,
20 /BUILD FAILURE/i,
21 /[✗✖]/,
22]
23
24export function isTestCommand(command) {
25 return typeof command === 'string' && TEST_COMMAND.test(command)
26}
27
28export function looksLikeTestFailure(command, text) {
29 return isTestCommand(command) && !!text && FAILURE_PATTERNS.some(re => re.test(text))
30}
31types/index.d.ts 26 lines1export type Metrics = {
2 contextPercent: number | null
3 lastTurnTokens: number | null
4 consecutiveStruggles: number
5}
6
7export type Turn = {
8 struggled: boolean
9 reason: 'bash_error' | 'test_failure_pattern' | null
10}
11
12export type LogEntry = {
13 ts: string
14 contextPercent: number | null
15 lastTurnTokens: number | null
16 struggled: boolean
17 reason: Turn['reason']
18 consecutiveStruggles: number
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'effort-guard': { metrics: Metrics; turn: Turn }
24 }
25}
26