SLOPSHOPPER

session-coach

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…

newpaneguardcommandmodel
v0.1.0MITupdated 2026-10-13kamalpreet11/claude-mods/session-coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-coach
│ ┃ Session coach ✕ › fix the failing auth test and add an audit log call │ ┃ Session 1 turns, 0 tools, 0 edits, 0 checks, │ ┃ failed, 0 questions, 0 aborted ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Reviewing the session… ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Recent turns ⏺ Bash(bun test) │ ┃ #1 42s talk ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-coach │ ⎿ session-coach: Session coach is reviewing; see the pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session coach
Session 1 turns, 0 tools, 0 edits, 0 checks, 0 failed, 0 que 0 aborted Reviewing the session… Recent turns #1 42s talk
README

claude-mods

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.

Install

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.

prompt-coach

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.

  • Prompts of six or more words are graded in the background by one Sonnet call at low effort. Slash commands and short follow-ups are skipped and the prompt is never delayed.
  • History survives a crash or --resume: it is saved per session in the plugin's own store under ~/.claude, last 30 sessions kept.

How a prompt is scored

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:

KindWhat it isWhat a good one needs
taskasks for work: build, fix, change, write, runfiles or module, the symptom, what done looks like, what not to touch
questionasks to know or understand somethingone question, bounded to one thing, clear what a satisfying answer contains
decisionanswers a question Claude asked, or picks an optiona plain, unambiguous answer

Five criteria, each 0 to 20, summed to the score:

CriterionAsks
ClarityIs the ask unambiguous, with the task stated up front?
ContextDoes Claude have what it needs, from this prompt or earlier turns: what is known, where things are, what was tried?
CriteriaIs it clear what "done" looks like, how to verify, or what to return?
ScopeIs the work bounded: which files, which platform, what not to touch?
ConstraintsAre 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:

ScoreMeans
80 and upClaude can start without guessing
60 to 79one or two things left for Claude to assume
40 to 59Claude will guess at something material
under 40expect 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.

session-coach

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.
  • The pane shows the gauge and score, a summary, what is going well, what to do differently, the wasted turn numbers, the one habit to change next session, and the recent turns tagged talk, edit, check or abort.
  • The log and the last review persist per session like prompt-coach's. Subagent activity is not counted.

How a session is scored

Five parts of 20, summed to the score:

CriterionAsks
GoalWas the goal stated early and kept in view?
SteeringWere course corrections early and specific, or late and vague?
VerificationDid you ask for, or get, tests, builds or checks before moving on?
ScopeWere turns bounded, or did asks sprawl and get re-done?
EfficiencyHow 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.

Develop

claude plugin validate <mod>
claude plugin test <mod>
claude --plugin-dir <mod>

License

MIT, see LICENSE.

Source 2 files
hooks/register.tsx 328 lines
1import { 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}
328
types/index.d.ts 50 lines
1export 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