Reviews how you ran the whole session, not one prompt: a 1 to 100 score on a red-to-green gauge, what went well, what to do differently, and the turns that…

Mods for Claude Code: small plugins that change Claude's own interface or behaviour. Both run entirely on your machine; the only network call is the grader request through your own Claude Code session, so on a subscription it draws from your plan's allowance and nothing is billed separately.
At the prompt of a Claude Code terminal session:
/plugin install prompt-coach --marketplace kamalpreet11/claude-mods
/plugin install session-coach --marketplace kamalpreet11/claude-mods
Answer y to add the marketplace the first time, then pick the user scope so they load in every project.
Scores every prompt you send from 1 to 100 and shows it on a red-to-green gauge above the prompt box, with one tip. /prompt-coach opens a pane with the rubric, a rewrite of your prompt the way you should have sent it, and a clickable history.
--resume: it is saved per session in the plugin's own store under ~/.claude, last 30 sessions kept.The grader first reads the conversation so far: your last three prompts and a slice of Claude's last reply. Anything Claude already knows from earlier turns counts as given, so a short follow-up that is clear in context is not punished for leaving it out.
It then decides what kind of message you sent and grades it by that kind's own standard:
| Kind | What it is | What a good one needs |
|---|---|---|
| task | asks for work: build, fix, change, write, run | files or module, the symptom, what done looks like, what not to touch |
| question | asks to know or understand something | one question, bounded to one thing, clear what a satisfying answer contains |
| decision | answers a question Claude asked, or picks an option | a plain, unambiguous answer |
Five criteria, each 0 to 20, summed to the score:
| Criterion | Asks |
|---|---|
| Clarity | Is the ask unambiguous, with the task stated up front? |
| Context | Does Claude have what it needs, from this prompt or earlier turns: what is known, where things are, what was tried? |
| Criteria | Is it clear what "done" looks like, how to verify, or what to return? |
| Scope | Is the work bounded: which files, which platform, what not to touch? |
| Constraints | Are rules, examples, style or decisions already made named where they matter? |
Score anchors per criterion: 20 means nothing more could reasonably be asked for a task of this size; 16 to 19 covered, one small thing could be sharper; 11 to 15 partly covered; 5 to 10 mostly missing; 0 to 4 absent. Points are never withheld for details you could not know yet (the root cause, which fix is right) or for brevity when the ask is already clear.
Rough reading of the total:
| Score | Means |
|---|---|
| 80 and up | Claude can start without guessing |
| 60 to 79 | one or two things left for Claude to assume |
| 40 to 59 | Claude will guess at something material |
| under 40 | expect a clarifying question or the wrong thing built |
Calibration runs with the real grader: a prompt of "fix it please it is broken" scores about 13; a prompt naming the file, the symptom, the test to add and the command to run scores 77 to 85; the same prompt scored three times lands within one or two points. Alongside the score the pane gives one tip, up to three bullets, and "Try instead": your ask rewritten the way you should have sent it, in your own voice.
Reviews how you ran the whole session, not one prompt. It logs every turn (your prompt, duration, tool calls, edits, shell runs, test or build runs, failures, questions asked, aborts) and has Sonnet judge the developer, not the assistant.
/session-coach or the pane's "Review now" runs a review; it also runs by itself every 10 turns.Five parts of 20, summed to the score:
| Criterion | Asks |
|---|---|
| Goal | Was the goal stated early and kept in view? |
| Steering | Were course corrections early and specific, or late and vague? |
| Verification | Did you ask for, or get, tests, builds or checks before moving on? |
| Scope | Were turns bounded, or did asks sprawl and get re-done? |
| Efficiency | How many turns did real work versus clarification, repeats or undo? |
The same 0 to 20 anchors as prompt-coach apply. The reviewer judges from the log, not from what it cannot know: a turn with a test or build run counts for verification, a prompt naming files or a platform counts for scope, an early turn stating the outcome counts for goal. Repeated prompts on the same thing, aborted turns and turns spent answering Claude's questions count against steering and efficiency. A session under five turns is scored on what is there and the summary says the sample is small.
Calibration: a well-run eight-turn session (clear goal, test each step, bounded asks, commits) scores 85 to 86; a messy one ("make the app better", abort, undo everything, "just commit it") scores 20 to 23 with most turns flagged as wasted. Repeat runs land within one to three points.
A shell command counts as a check when it runs tests, a build, a lint or a type check: gradle or gradlew with test, npm or pnpm or yarn or bun test, check, lint or build, pytest, jest, vitest, mocha, cargo test, go test, swift test, xcodebuild test, mvn test, tsc, flutter test, dotnet test, make test or check.
claude plugin validate <mod>
claude plugin test <mod>
claude --plugin-dir <mod>
MIT, see LICENSE.
hooks/register.tsx 328 lines1import { atom, read, update } from 'claude-code'
2import type { Register, Hook, Color } from 'claude-code'
3
4import type { Current, Review, Turn } from '../types'
5
6const PANE = 'session-coach'
7const KEEP = 200
8const EVERY = 10
9const GAUGE_CELLS = 20
10
11const turns = atom({ plugin: 'session-coach', key: 'turns' } as const, [])
12const current = atom({ plugin: 'session-coach', key: 'current' } as const, null)
13const review = atom({ plugin: 'session-coach', key: 'review' } as const, null)
14const isReviewing = atom({ plugin: 'session-coach', key: 'isReviewing' } as const, false)
15const lastError = atom({ plugin: 'session-coach', key: 'lastError' } as const, null)
16const sessionId = atom({ plugin: 'session-coach', key: 'sessionId' } as const, null)
17
18const KEEP_SESSIONS = 30
19
20/** The store keys to drop so at most `keep` sessions stay, oldest first. */
21export const staleKeys = (keys: readonly string[], keep: number): string[] => {
22 const mine = keys.filter(k => k.startsWith('session:'))
23 return mine.length <= keep ? [] : mine.slice(0, mine.length - keep)
24}
25
26type Saved = { turns: Turn[]; review: Review | null }
27
28/** Writes this session's log and review to the plugin's own store, so a resumed session gets them back. */
29async function persist($: Parameters<Hook<'turn.complete'>>[0]) {
30 const id = await read($, sessionId)
31 if (id === null) return
32 const saved: Saved = { turns: await read($, turns), review: await read($, review) }
33 await $.store.set(`session:${id}`, saved)
34}
35
36
37const RULES = `You coach a software developer on how they ran a working session with an AI coding assistant.
38You get the session's turn log: for each turn the developer's prompt (truncated), how long the assistant worked, how many tool calls it made, how many file edits, shell runs, test or build runs, failed tool calls, clarifying questions it had to ask, and whether the developer aborted it.
39Judge the developer, not the assistant. Score 1 to 100 on five parts of 20:
40- goal: was the goal stated early and kept in view?
41- steering: were course corrections early and specific, or late and vague?
42- verification: did the developer ask for or get tests, builds or checks before moving on?
43- scope: were turns bounded, or did asks sprawl and get re-done?
44- efficiency: how many turns did real work versus clarification, repeats or undo?
45Scoring anchors, apply them literally: 20 = nothing more could reasonably be asked of the developer on this part; 16-19 = done well, one small thing could be sharper; 11-15 = partly; 5-10 = mostly missing; 0-4 = absent. Judge from the log you have, not from what is unknowable: a turn with checks run counts for verification, a prompt naming files or a platform counts for scope, an early turn that states the outcome counts for goal. Repeated prompts on the same thing, aborted turns, and turns the assistant spent asking questions count against steering and efficiency. A session of fewer than five turns has little evidence; score what is there, do not default to a middling number, and say in the summary that the sample is small. Leave "strengths" or "improve" empty when nothing material fits.
46Answer with JSON only, no prose and no code fence:
47{"goal":n,"steering":n,"verification":n,"scope":n,"efficiency":n,"summary":"two sentences on how the session went","strengths":["up to three short bullets"],"improve":["up to three short bullets, each a concrete habit"],"wasted":[turn numbers that added nothing, may be empty],"nextTime":"one sentence: the single habit to change next session"}`
48
49const TEST_RUN = /\b(gradlew?|gradle)\b[^|;&]*\btest\b|\bnpm (run )?(test|check|lint|build)\b|\b(pnpm|yarn|bun) (test|check|lint|build)\b|\b(pytest|jest|vitest|mocha|cargo test|go test|swift test|xcodebuild[^|;&]*\btest\b|mvn[^|;&]*\btest\b|tsc\b|flutter test|dotnet test|make (test|check))/i
50
51const clamp = (n: unknown, max: number): number => {
52 const v = typeof n === 'number' && Number.isFinite(n) ? Math.round(n) : 0
53 return Math.max(0, Math.min(max, v))
54}
55
56/** True when a shell command is a test, build, lint or type check. */
57export const isVerification = (command: string): boolean => TEST_RUN.test(command)
58
59/** One line per turn, the log the reviewer reads. */
60export const describeTurns = (list: readonly Turn[]): string =>
61 list
62 .map(t => {
63 const flags = [
64 t.isAborted ? 'ABORTED' : null,
65 t.reason !== 'answer' && !t.isAborted ? t.reason.toUpperCase() : null,
66 ].filter(Boolean)
67 return `#${t.n} (${Math.round(t.durationMs / 1000)}s, tools ${t.tools}, edits ${t.edits}, shell ${t.bash}, checks ${t.tests}, failed ${t.failures}, questions ${t.questions}${flags.length ? ', ' + flags.join(' ') : ''}): "${t.prompt}"`
68 })
69 .join('\n')
70
71/** The reviewer's JSON, tolerant of a fence or stray prose around it. */
72export const parseReview = (text: string, turnCount: number, at: number): Review | null => {
73 const start = text.indexOf('{')
74 const end = text.lastIndexOf('}')
75 if (start < 0 || end <= start) return null
76 let raw: Record<string, unknown>
77 try {
78 raw = JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
79 } catch {
80 return null
81 }
82 const sum = clamp(raw.goal, 20) + clamp(raw.steering, 20) + clamp(raw.verification, 20) + clamp(raw.scope, 20) + clamp(raw.efficiency, 20)
83 const strings = (v: unknown, max: number): string[] =>
84 Array.isArray(v) ? v.filter((b): b is string => typeof b === 'string').map(b => b.trim()).filter(Boolean).slice(0, max) : []
85 const wasted = Array.isArray(raw.wasted) ? raw.wasted.filter((n): n is number => typeof n === 'number' && Number.isFinite(n)).map(n => Math.round(n)) : []
86 return {
87 at,
88 turns: turnCount,
89 score: Math.max(1, Math.min(100, sum)),
90 summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
91 strengths: strings(raw.strengths, 3),
92 improve: strings(raw.improve, 3),
93 wasted,
94 nextTime: typeof raw.nextTime === 'string' ? raw.nextTime.trim() : '',
95 }
96}
97
98/** Red at 1, amber at 50, green at 100, as a hex colour. */
99export const colorFor = (score: number): Color => {
100 const t = Math.max(0, Math.min(1, (score - 1) / 99))
101 const mix = (a: number, b: number, k: number) => Math.round(a + (b - a) * k)
102 const [r, g, b] =
103 t < 0.5
104 ? [mix(0xd9, 0xe6, t * 2), mix(0x3c, 0xa8, t * 2), mix(0x3c, 0x2e, t * 2)]
105 : [mix(0xe6, 0x2e, (t - 0.5) * 2), mix(0xa8, 0xb8, (t - 0.5) * 2), mix(0x2e, 0x5a, (t - 0.5) * 2)]
106 const hex = (n: number) => n.toString(16).padStart(2, '0')
107 return `#${hex(r)}${hex(g)}${hex(b)}`
108}
109
110async function runReview($: Parameters<Hook<'command.run'>>[0], at: number) {
111 const list = await read($, turns)
112 if (list.length === 0) {
113 await update($, lastError, () => 'nothing to review yet')
114 return
115 }
116 await update($, isReviewing, () => true)
117 const r = await $.model.complete({
118 model: 'sonnet',
119 effort: 'medium',
120 maxTokens: 900,
121 timeoutMs: 60000,
122 system: [{ text: RULES, cache: true }],
123 prompt: `Session turn log (${list.length} turns):\n${describeTurns(list)}`,
124 })
125 await update($, isReviewing, () => false)
126 if (!r.isAnswered) {
127 await update($, lastError, () => `reviewer ${r.reason}`)
128 return
129 }
130 const parsed = parseReview(r.text, list.length, at)
131 if (parsed === null) {
132 await update($, lastError, () => 'reviewer returned no JSON')
133 return
134 }
135 await update($, lastError, () => null)
136 await update($, review, () => parsed)
137 await persist($)
138}
139
140export const register: Register = on => {
141 on('session.start', async ($, e, next) => {
142 const id = await $.session.id()
143 await update($, sessionId, () => id)
144 const saved = (await $.store.get(`session:${id}`)) as Saved | undefined
145 if (saved !== undefined && Array.isArray(saved.turns)) {
146 await update($, turns, () => saved.turns)
147 await update($, review, () => saved.review ?? null)
148 } else {
149 await $.store.set(`session:${id}`, { turns: [], review: null })
150 }
151 for (const stale of staleKeys(await $.store.keys(), KEEP_SESSIONS)) await $.store.delete(stale)
152 await $.command.register({
153 name: 'session-coach',
154 description: 'Review how you have run this session: score, strengths, what to change, wasted turns',
155 })
156 void $.ui.open({ id: PANE, title: 'Session coach' })
157
158 return next(e)
159 })
160
161 on('command.run', { command: 'session-coach' }, async $ => {
162 const opened = await $.ui.open({ id: PANE, title: 'Session coach', focus: true })
163 const at = await $.clock.now()
164 void runReview($, at).catch(() => update($, isReviewing, () => false))
165
166 return { text: opened.isPlaced ? 'Session coach is reviewing; see the pane.' : 'Session coach is reviewing; widen the window to see the pane.' }
167 })
168
169 on('turn.start', async ($, e, next) => {
170 const fresh: Current = { turnId: e.turnId, prompt: e.text.replace(/\s+/g, ' ').slice(0, 160), tools: 0, edits: 0, bash: 0, tests: 0, failures: 0, questions: 0 }
171 await update($, current, () => fresh)
172
173 return next(e)
174 })
175
176 on('tool.call', async ($, e, next) => {
177 const ran = await next(e)
178 // Bookkeeping only. The tool has run by now, so nothing below may throw
179 // into the chain: a counting failure is swallowed and the result returned.
180 try {
181 if (e.agentId === undefined) {
182 const tool = e.tool
183 const command = tool === 'Bash' && typeof (e as { command?: unknown }).command === 'string' ? (e as { command: string }).command : ''
184 const failed = ran.deny === undefined && ran.isError === true
185 await update($, current, c =>
186 c === null
187 ? c
188 : {
189 ...c,
190 tools: c.tools + 1,
191 edits: c.edits + (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit' ? 1 : 0),
192 bash: c.bash + (tool === 'Bash' ? 1 : 0),
193 tests: c.tests + (tool === 'Bash' && isVerification(command) ? 1 : 0),
194 failures: c.failures + (failed ? 1 : 0),
195 questions: c.questions + (tool === 'AskUserQuestion' ? 1 : 0),
196 },
197 )
198 }
199 } catch {
200 // counted nothing this time
201 }
202
203 return ran
204 }).catch(($, e, next) => next(e)) // replay-safe: after `called`, next(e) is the result already obtained
205
206 on('turn.complete', async ($, e, next) => {
207 if (e.agentId !== undefined) return next(e)
208 const c = await read($, current)
209 const list = await read($, turns)
210 const at = await $.clock.now()
211 const done: Turn = {
212 n: list.length + 1,
213 at,
214 prompt: c?.prompt ?? '',
215 durationMs: e.durationMs,
216 tools: c?.tools ?? 0,
217 edits: c?.edits ?? 0,
218 bash: c?.bash ?? 0,
219 tests: c?.tests ?? 0,
220 failures: c?.failures ?? 0,
221 questions: c?.questions ?? 0,
222 isAborted: e.isAborted,
223 reason: e.reason,
224 }
225 await update($, turns, old => [...old, done].slice(-KEEP))
226 await update($, current, () => null)
227 await persist($)
228 if (done.n % EVERY === 0) {
229 void runReview($, at).catch(() => update($, isReviewing, () => false))
230 }
231
232 return next(e)
233 })
234
235 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
236 const { Box, Button, Text } = $.ui.resolve(e)
237 const list = await read($, turns)
238 const rev = await read($, review)
239 const busy = await read($, isReviewing)
240 const error = await read($, lastError)
241 const rows = e.viewport?.rows ?? 24
242
243 const totals = list.reduce(
244 (s, t) => ({ tools: s.tools + t.tools, edits: s.edits + t.edits, tests: s.tests + t.tests, failures: s.failures + t.failures, aborted: s.aborted + (t.isAborted ? 1 : 0), questions: s.questions + t.questions }),
245 { tools: 0, edits: 0, tests: 0, failures: 0, aborted: 0, questions: 0 },
246 )
247 const refresh = (
248 <Button key="refresh" label={busy ? 'Reviewing…' : 'Review now'} onPress={async () => { const at = await $.clock.now(); await runReview($, at) }} />
249 )
250
251 return (
252 <Box flexDirection="column" gap={1}>
253 <Box flexDirection="row" gap={1}>
254 <Text bold>Session</Text>
255 <Text dimColor>
256 {String(list.length)} turns, {String(totals.tools)} tools, {String(totals.edits)} edits, {String(totals.tests)} checks, {String(totals.failures)} failed, {String(totals.questions)} questions, {String(totals.aborted)} aborted
257 </Text>
258 {refresh}
259 </Box>
260 {rev === null && !busy && (
261 <Text dimColor>No review yet. Press Review now, or wait: it runs itself every {String(EVERY)} turns.</Text>
262 )}
263 {busy && <Text dimColor>Reviewing the session…</Text>}
264 {error !== null && !busy && <Text color="warning">{error}</Text>}
265 {rev !== null && (
266 <Box flexDirection="column" gap={1}>
267 <Box flexDirection="row" gap={1}>
268 <Gauge Text={Text} Box={Box} score={rev.score} />
269 <Text bold color={colorFor(rev.score)}>{String(rev.score)}</Text>
270 <Text dimColor>after {String(rev.turns)} turns</Text>
271 </Box>
272 <Text wrap="wrap">{rev.summary}</Text>
273 {rev.strengths.length > 0 && (
274 <Box flexDirection="column">
275 <Text bold color="success">Going well</Text>
276 {rev.strengths.map(s => <Text wrap="wrap">{' + '}{s}</Text>)}
277 </Box>
278 )}
279 {rev.improve.length > 0 && (
280 <Box flexDirection="column">
281 <Text bold color="warning">Do differently</Text>
282 {rev.improve.map(s => <Text wrap="wrap">{' - '}{s}</Text>)}
283 </Box>
284 )}
285 {rev.wasted.length > 0 && (
286 <Text dimColor wrap="wrap">Wasted turns: {rev.wasted.map(n => `#${n}`).join(', ')}</Text>
287 )}
288 {rev.nextTime !== '' && (
289 <Box flexDirection="column">
290 <Text bold>Next session</Text>
291 <Text wrap="wrap">{rev.nextTime}</Text>
292 </Box>
293 )}
294 </Box>
295 )}
296 {list.length > 0 && (
297 <Box flexDirection="column">
298 <Text bold>Recent turns</Text>
299 {list.slice(-Math.max(3, rows - 22)).reverse().map(t => (
300 <Box flexDirection="row" gap={1}>
301 <Text dimColor>{`#${t.n}`.padStart(4)}</Text>
302 <Text dimColor>{`${Math.round(t.durationMs / 1000)}s`.padStart(5)}</Text>
303 <Text color={t.failures > 0 || t.isAborted ? 'warning' : t.tests > 0 ? 'success' : 'text'}>
304 {t.isAborted ? 'abort' : t.tests > 0 ? 'check' : t.edits > 0 ? 'edit ' : 'talk '}
305 </Text>
306 <Text dimColor wrap="truncate-end">{t.prompt}</Text>
307 </Box>
308 ))}
309 </Box>
310 )}
311 </Box>
312 )
313 })
314}
315
316/** A bar of cells, each coloured where it sits on the red-to-green run, filled up to the score. */
317function Gauge(props: { Text: any; Box: any; score: number; cells?: number }) {
318 const { Text, Box, score } = props
319 const cells = props.cells ?? GAUGE_CELLS
320 const filled = Math.round((score / 100) * cells)
321 const parts = []
322 for (let i = 0; i < cells; i += 1) {
323 const at = Math.round(((i + 1) / cells) * 100)
324 parts.push(i < filled ? <Text color={colorFor(at)}>▰</Text> : <Text dimColor>▱</Text>)
325 }
326 return <Box flexDirection="row">{parts}</Box>
327}
328types/index.d.ts 50 lines1export type Turn = {
2 n: number
3 at: number
4 prompt: string
5 durationMs: number
6 tools: number
7 edits: number
8 bash: number
9 tests: number
10 failures: number
11 questions: number
12 isAborted: boolean
13 reason: string
14}
15
16export type Current = {
17 turnId: string
18 prompt: string
19 tools: number
20 edits: number
21 bash: number
22 tests: number
23 failures: number
24 questions: number
25}
26
27export type Review = {
28 at: number
29 turns: number
30 score: number
31 summary: string
32 strengths: string[]
33 improve: string[]
34 wasted: number[]
35 nextTime: string
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 'session-coach': {
41 turns: Turn[]
42 current: Current | null
43 review: Review | null
44 sessionId: string | null
45 isReviewing: boolean
46 lastError: string | null
47 }
48 }
49}
50