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 (un toast, una sola volta per serie) dopo 3 turni "faticosi" consecutivi (comando Bash in errore o output con test falliti), registro per turno con /effort-log. Solo osservazione, non blocca nulla.
/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/.
protect-main.json (richiede il check checks, prodotto da ci.yml)main.feat:, fix:), apre la release PR, aggiorna versione in plugin.json e marketplace.json, CHANGELOG, tag e GitHub Release al merge.hooks/module.mjs 151 lines1import { atom, read, update } from 'claude-code'
2
3const THRESHOLD = 3
4const LOG_LIMIT = 500
5
6const metrics = atom(
7 { plugin: 'effort-guard', key: 'metrics' },
8 { contextPercent: null, lastTurnTokens: null, consecutiveStruggles: 0 },
9)
10const log = atom({ plugin: 'effort-guard', key: 'log' }, [])
11
12// ponytail: regex heuristic over the Bash tool's model-visible text, not a
13// real test-runner parser — catches swallowed exit codes (`npm test || true`);
14// upgrade to per-runner parsing if it misses or over-fires.
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
24function looksLikeTestFailure(text) {
25 return !!text && FAILURE_PATTERNS.some(re => re.test(text))
26}
27
28export function register(on) {
29 let turnStruggled = false
30 let turnReason = null
31
32 on('session.start', async ($, e, next) => {
33 await $.command.register({
34 name: 'effort-log',
35 description: 'Print the effort-guard per-turn context/struggle log',
36 })
37
38 return next(e)
39 })
40
41 on('prompt.submit', async ($, e, next) => {
42 turnStruggled = false
43 turnReason = null
44
45 return next(e)
46 })
47
48 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
49 const ran = await next(e)
50
51 if (ran.isError) {
52 turnStruggled = true
53 turnReason = 'bash_error'
54 } else if (looksLikeTestFailure(ran.text)) {
55 turnStruggled = true
56 turnReason = 'test_failure_pattern'
57 }
58
59 return ran
60 })
61
62 on('turn.complete', async ($, e, next) => {
63 if (e.agentId) {
64 // Subagent turn: don't count it toward the user-visible streak.
65 return next(e)
66 }
67
68 const usage = e.usage
69 const lastTurnTokens = usage
70 ? usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
71 : null
72
73 // Struggle tracking and the toast must not depend on context-usage
74 // reporting succeeding, so a failure here degrades to an unknown percent
75 // instead of skipping the rest of the hook.
76 let contextPercent = null
77 try {
78 const sessionUsage = await $.session.usage()
79 contextPercent = sessionUsage.context.percent ?? null
80 } catch {}
81
82 const struggled = turnStruggled
83 const prev = await read($, metrics)
84 const consecutiveStruggles = struggled ? prev.consecutiveStruggles + 1 : 0
85
86 await update($, metrics, () => ({ contextPercent, lastTurnTokens, consecutiveStruggles }))
87
88 const entry = {
89 ts: new Date().toISOString(),
90 contextPercent,
91 lastTurnTokens,
92 struggled,
93 reason: turnReason,
94 consecutiveStruggles,
95 }
96 await update($, log, list => [...list, entry].slice(-LOG_LIMIT))
97
98 // Once per streak: fires when the streak reaches the threshold, and again
99 // only after a clean turn has reset it. Derived from $.state, so a hot
100 // reload cannot make it fire twice.
101 if (consecutiveStruggles === THRESHOLD) {
102 $.ui.toast(
103 `effort-guard: ${consecutiveStruggles} struggling turns in a row (failed commands/tests). ` +
104 `Consider switching to a stronger model or raising effort one level.`,
105 )
106 }
107
108 turnStruggled = false
109 turnReason = null
110
111 return next(e)
112 })
113
114 on('command.run', { command: 'effort-log' }, async $ => {
115 const list = await read($, log)
116
117 if (list.length === 0) {
118 return { text: 'effort-guard: no turns logged yet.' }
119 }
120
121 const lines = list.slice(-50).map(e => {
122 const flag = e.struggled ? `STRUGGLE(${e.reason})` : 'ok'
123
124 return `${e.ts} ctx=${e.contextPercent ?? '?'}% tok=${e.lastTurnTokens ?? '?'} streak=${e.consecutiveStruggles} ${flag}`
125 })
126
127 return { text: lines.join('\n') }
128 })
129
130 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
131 const m = await read($, metrics)
132
133 if (m.contextPercent === null && m.lastTurnTokens === null && m.consecutiveStruggles === 0) {
134 return next(e)
135 }
136
137 const { Box, Text } = $.ui.resolve(e)
138 const struggleText = m.consecutiveStruggles > 0 ? ` | struggling x${m.consecutiveStruggles}` : ''
139
140 return h(
141 Box,
142 null,
143 h(
144 Text,
145 { dimColor: true },
146 `ctx ${m.contextPercent ?? '?'}% | last turn ${m.lastTurnTokens ?? '?'} tok${struggleText}`,
147 ),
148 )
149 })
150}
151types/index.d.ts 21 lines1export type Metrics = {
2 contextPercent: number | null
3 lastTurnTokens: number | null
4 consecutiveStruggles: number
5}
6
7export type LogEntry = {
8 ts: string
9 contextPercent: number | null
10 lastTurnTokens: number | null
11 struggled: boolean
12 reason: string | null
13 consecutiveStruggles: number
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'effort-guard': { metrics: Metrics; log: LogEntry[] }
19 }
20}
21