SLOPSHOPPER

effort-phase

Sets main-loop effort per model request from the phase of the skill flow (judgment, mechanical, polling) and writes a per-step usage ledger

newstatusprompt
★ 1v0.1.0MITupdated 2026-10-09alienfast/claude/mods/effort-phase
A shopper browsing a rack in a slop shop
README

effort-phase

A hooks-only mod that sets the main loop's effort per model request from the phase of the skill flow, and writes one ledger row per request. Subagent requests are never touched.

Lanes

LaneWhenDefault effort
judgmentThe turn's default, and again after any skill not listed as mechanical expands (start, spec, quality-review, auto, full, do, ...)the session's own (session)
mechanicalAfter a mechanical skill expands in the turn (finish, checkpoint, pr-update, exec-summary, merge-queue, reap-worktrees, reap-tmp, update, fleet-status, fleet-stop), and for the one request that follows a Bash command naming finish-*.sh, linear-post.sh, linear-set-state.sh, mark-ready-for-release.sh, or git pushhigh
pollingThe request that follows a step made only of wait-shaped tool calls (Monitor, ScheduleWakeup, TaskStop, or a Bash command with sleep, until [, tail -f, wait)low

The phase resets to judgment when a main-loop turn completes, not when one starts: a typed /finish expands before turn.start fires, so a reset there would undo the phase the skill had just set (the first live run showed every step left in judgment for that reason). The signal is the skill.prompt event, which fires whether the skill was typed as /finish or called through the Skill tool, so no text classifier and no model call is involved.

Configuration

userConfig in .claude-plugin/plugin.json: enabled, judgment_effort (session or a level), mechanical_effort, polling_effort, max_switches_per_turn (0 = unlimited), ledger. The effort-probe mod measured an effort switch as cache-safe on Opus 5.5 with Claude Code 2.1.295, which is why the cap ships at 0.

Ledger

<session root>/tmp/effort-ledger-<sessionId>.jsonl, one row per main-loop request:

{"ts":"...","session":"...","event":"step","turnId":"...","index":3,"lane":"mechanical","effortIn":"xhigh","effortOut":"high","model":"claude-opus-5-5[1m]","answeredBy":"claude-opus-5-5","usage":{"input_tokens":2,"output_tokens":74,"cache_read_input_tokens":59638,"cache_creation_input_tokens":3644,"model":"claude-opus-5-5"},"tools":["Bash"],"commands":["git push origin main"]}

Each skill expansion adds a skill row ({"event":"skill","skill":"finish","lane":"mechanical"}), which the retro skips when counting requests. The root is resolved once at session start, so a later worktree move does not split the file. The reaper ages it out with ordinary scratch.

Loading

Installed user-wide by update.sh on every machine that pulls the repo, and loaded in place from this folder, so an edit takes effect at the next session start or /reload-plugins. On a machine without the install, claude --plugin-dir ~/.claude/mods/effort-phase loads it for one session. The retro reads the ledger: fleet-metrics.py reports an Effort lanes line, effort_lanes in its JSON, and a rewr% column in the cross-run trend.

Checks

claude plugin validate mods/effort-phase
claude plugin test mods/effort-phase
mods/typecheck.sh mods/effort-phase
Source 2 files
hooks/register.ts 87 lines
1import type { Register } from 'claude-code'
2import { POLL_STREAK, commandsOf, effortFor, isMechanicalCommand, isWaitShaped, phaseForSkill, readOptions, type Lane, type Phase } from './phase'
3
4let phase: Phase = 'judgment'
5let waitStreak = 0
6let mechanicalNext = false
7let lastSent: unknown = undefined
8let switches = 0
9let sessionId = ''
10let ledgerPath = ''
11let rows: string[] = []
12let active = false
13
14type Writer = { readonly fs: { readonly write: (path: string, text: string) => Promise<void> } }
15
16async function record($: Writer, row: Record<string, unknown>): Promise<void> {
17  if (!active) return
18  rows.push(JSON.stringify({ ts: new Date().toISOString(), session: sessionId, ...row }))
19  await $.fs.write(ledgerPath, rows.join('\n') + '\n')
20}
21
22export const register: Register = (on, options) => {
23  const opts = readOptions(options)
24  active = opts.enabled && opts.ledger
25
26  on('session.start', async ($, e, next) => {
27    sessionId = await $.session.id()
28    if (active) {
29      // Resolved once: a worktree move later in the session would otherwise split the ledger across two tmp/ dirs.
30      ledgerPath = `${await $.session.root()}/tmp/effort-ledger-${sessionId}.jsonl`
31      if (await $.fs.exists(ledgerPath)) rows = (await $.fs.read(ledgerPath)).split('\n').filter((line) => line !== '')
32    }
33    return next(e)
34  })
35
36  on('turn.start', async ($, e, next) => {
37    waitStreak = 0
38    mechanicalNext = false
39    lastSent = undefined
40    switches = 0
41    return next(e)
42  })
43
44  // A typed `/finish` expands before turn.start, so the phase is reset when the turn ends, never when it begins.
45  on('turn.complete', async ($, e, next) => {
46    if (e.agentId === undefined) phase = 'judgment'
47    return next(e)
48  })
49
50  on('skill.prompt', async ($, e, next) => {
51    const next_ = phaseForSkill(e.skill)
52    if (next_ !== phase) {
53      phase = next_
54      if (opts.enabled) $.ui.status(`effort-phase: ${phase} (${e.skill})`)
55    }
56    await record($, { event: 'skill', skill: e.skill, lane: phase })
57    return next(e)
58  })
59
60  on('turn.step', async function* ($, e, next) {
61    if (e.agentId !== undefined || !opts.enabled) return yield* next(e)
62    const lane: Lane = waitStreak >= POLL_STREAK ? 'polling' : mechanicalNext ? 'mechanical' : phase
63    const want = effortFor(lane, opts.map, e.effort) ?? e.effort
64    let effortOut = want
65    if (opts.cap > 0 && lastSent !== undefined && want !== lastSent && switches >= opts.cap) effortOut = lastSent as typeof want
66    if (lastSent !== undefined && effortOut !== lastSent) switches += 1
67    const result = yield* next(effortOut === e.effort ? e : { ...e, effort: effortOut })
68    lastSent = effortOut
69    waitStreak = isWaitShaped(result.toolUses) ? waitStreak + 1 : 0
70    mechanicalNext = isMechanicalCommand(result.toolUses)
71    await record($, {
72      event: 'step',
73      turnId: e.turnId,
74      index: e.index,
75      lane,
76      effortIn: e.effort ?? null,
77      effortOut: effortOut ?? null,
78      model: e.model,
79      answeredBy: result.usage?.model ?? null,
80      usage: result.usage,
81      tools: result.toolUses.map((t) => t.name),
82      commands: commandsOf(result.toolUses),
83    })
84    return result
85  })
86}
87
hooks/phase.ts 93 lines
1export type Phase = 'judgment' | 'mechanical'
2export type Lane = Phase | 'polling'
3export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
4export type ToolUse = { readonly name: string; readonly input: unknown }
5
6export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
7
8// Skills whose expansion marks the rest of the turn as bookkeeping: Linear posts, git, merges, reaping. Everything else
9// (start, spec, quality-review, auto, full, do, ...) is judgment, so an unknown skill never lowers effort.
10export const MECHANICAL_SKILLS: ReadonlySet<string> = new Set([
11  'finish',
12  'checkpoint',
13  'pr-update',
14  'exec-summary',
15  'merge-queue',
16  'reap-worktrees',
17  'reap-tmp',
18  'update',
19  'fleet-status',
20  'fleet-stop',
21])
22
23export function phaseForSkill(skill: string): Phase {
24  return MECHANICAL_SKILLS.has(skill) ? 'mechanical' : 'judgment'
25}
26
27// The polling lane needs this many consecutive wait-shaped steps first: the step right after a single wait is usually
28// the one that reads the result and acts on it, and only a sustained loop is the blind-sleep signature.
29export const POLL_STREAK = 2
30
31const WAIT_TOOLS: ReadonlySet<string> = new Set(['Monitor', 'ScheduleWakeup', 'TaskStop'])
32// A wait as a command of its own, at the start or after a separator, so `grep wait file` or a path carrying the word does not match.
33const WAIT_COMMAND = /(^|[;&|(]\s*)(do\s+|then\s+)?(sleep\s+\d|until\s+\[|tail\s+-f|wait\b)/
34
35function commandOf(input: unknown): string {
36  if (typeof input !== 'object' || input === null) return ''
37  const command = (input as { command?: unknown }).command
38  return typeof command === 'string' ? command : ''
39}
40
41export function isWaitShaped(uses: readonly ToolUse[]): boolean {
42  if (uses.length === 0) return false
43  return uses.every((u) => WAIT_TOOLS.has(u.name) || (u.name === 'Bash' && WAIT_COMMAND.test(commandOf(u.input))))
44}
45
46// A bookkeeping command outside any mechanical skill (a Linear post from /start, a push from an interactive turn):
47// the request that reads its result is mechanical, and the one after that returns to the phase.
48const MECHANICAL_COMMAND = /(^|[\s\/;&|(])(finish-[a-z-]+\.sh|linear-post\.sh|linear-set-state\.sh|mark-ready-for-release\.sh)\b|\bgit\s+push\b/
49
50export function isMechanicalCommand(uses: readonly ToolUse[]): boolean {
51  return uses.some((u) => u.name === 'Bash' && MECHANICAL_COMMAND.test(commandOf(u.input)))
52}
53
54export function commandsOf(uses: readonly ToolUse[]): string[] {
55  return uses.filter((u) => u.name === 'Bash').map((u) => commandOf(u.input).slice(0, 160))
56}
57
58export type EffortMap = {
59  readonly judgment: Effort | 'session'
60  readonly mechanical: Effort
61  readonly polling: Effort
62}
63
64export function effortFor(lane: Lane, map: EffortMap, session: Effort | number | undefined): Effort | number | undefined {
65  const pick = map[lane]
66  return pick === 'session' ? session : pick
67}
68
69export function asEffort(value: unknown, fallback: Effort): Effort {
70  return typeof value === 'string' && (EFFORTS as readonly string[]).includes(value) ? (value as Effort) : fallback
71}
72
73export type Options = {
74  readonly enabled: boolean
75  readonly map: EffortMap
76  readonly cap: number
77  readonly ledger: boolean
78}
79
80export function readOptions(raw: Readonly<Record<string, unknown>>): Options {
81  const judgment = raw.judgment_effort
82  return {
83    enabled: raw.enabled !== false,
84    map: {
85      judgment: typeof judgment === 'string' && (EFFORTS as readonly string[]).includes(judgment) ? (judgment as Effort) : 'session',
86      mechanical: asEffort(raw.mechanical_effort, 'high'),
87      polling: asEffort(raw.polling_effort, 'low'),
88    },
89    cap: typeof raw.max_switches_per_turn === 'number' && raw.max_switches_per_turn > 0 ? Math.floor(raw.max_switches_per_turn) : 0,
90    ledger: raw.ledger !== false,
91  }
92}
93