SLOPSHOPPER

effort-guard

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

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · effort-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /effort-log ⎿ effort-guard: 2026-10-07T15:21:59.285Z ctx=49% tok=98880 streak=1 STRUGGLE(bash_error) ctx 49% | last turn 98880 tok | struggling x1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 49% | last turn 98880 tok | struggling x1
README

effort-guard

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.

Installazione

/plugin marketplace add stefanochieli/claude-effort-guard
/plugin install effort-guard@effort-guard
/reload-plugins

Sviluppo

claude --plugin-dir .
claude plugin validate .
claude plugin test .

Struttura: .claude-plugin/ (manifest + marketplace), hooks/ (modulo), types/ (stato), tests/.

Impostazioni repo da attivare una volta

  • Settings → General → Pull Requests: Allow auto-merge e Automatically delete head branches
  • Settings → Actions → General → Workflow permissions: Read and write + Allow GitHub Actions to create and approve pull requests (serve a release-please)
  • Settings → Rules → Rulesets: importa protect-main.json (richiede il check checks, prodotto da ci.yml)

Automazioni

  • CI: validate + test su ogni PR e push su main.
  • Release: release-please legge i Conventional Commits (feat:, fix:), apre la release PR, aggiorna versione in plugin.json e marketplace.json, CHANGELOG, tag e GitHub Release al merge.
  • PR title lint: i titoli PR devono essere Conventional Commits (squash merge → commit corretti).
  • Dependabot: aggiorna le GitHub Actions, auto-merge se la CI passa.
Source 2 files
hooks/module.mjs 151 lines
1import { 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}
151
types/index.d.ts 21 lines
1export 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