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

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.
| Lane | When | Default effort |
|---|---|---|
| judgment | The 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) |
| mechanical | After 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 push | high |
| polling | The 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.
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.
<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.
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.
claude plugin validate mods/effort-phase
claude plugin test mods/effort-phase
mods/typecheck.sh mods/effort-phasehooks/register.ts 87 lines1import 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}
87hooks/phase.ts 93 lines1export 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