SLOPSHOPPER

harness-coach

Teaches Claude Code while you work: small timed tips (toasts) about commands, keys, skills and the harness, keyed to what the session is doing. /coach for more.

newguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-25pnshad/claude-code-mods/harness-coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · harness-coach
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ harness-coach │ ⏺ Read(src/auth.ts) │ 💡 First failed tool call: Claude usually │ ⎿ Read 6 lines │ self-corrects. If it loops, press Esc and │ ⏺ Update(src/auth.ts) │ say what you saw. │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ harness-coach │ │ 💡 Shift+Tab cycles permission mode: │ ● Done. refresh now rejects expired claims and logs an audit event. │ normal → accept edits → plan mode. Plan │ │ mode = read-only thinking first. │ ✻ Worked for 42s · done 4:20 PM ╰────────────────────────────────────────────╯ › /coach ⎿ harness-coach: 💡 Shift+Tab cycles permission mode: normal → accept edits → plan mode. Plan mode = read-only thinking first. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods (plugins), as one local marketplace: cc-mods.

ModWhat it does
discuss-firstAdds a "Discuss first" choice to every Q&A (AskUserQuestion) question. Pick it, talk the question through in chat, and Claude re-asks it with the agreed answer as option 1 "(Agreed)". With 4 options already, type discuss under Other.
harness-coachTeaches Claude Code while you work: one small toast every 3 turns (💡, 9 s) with a tip about keys, slash commands, skills or the harness, chosen by what the turn did (shell, edits, subagents, a long turn, a full context). First-time situations get a one-time nudge at once. Every tip is shown once before any repeats, across sessions. /coach = a tip now · list · off / on · reset · every <n> · find <word>.
checklistA checklist band above the prompt for what waits on you: decisions, approvals, answers, steps. Claude adds items with its add tool whenever a reply hands you something, grouped ("Change requests waiting for your approval"), each with an Open button to the sub-session where you work on it; it ticks them when you answer. Click an item to tick it. /checklist = full list · add <text> · clear. One list per session, kept across restarts.

Install on a machine

git clone https://github.com/pnshad/claude-code-mods.git ~/claude-code-mods
claude plugin marketplace add ~/claude-code-mods
claude plugin install discuss-first@cc-mods --scope user
claude plugin install harness-coach@cc-mods --scope user
claude plugin install checklist@cc-mods --scope user

The plugin is read from this folder: after editing, run /reload-plugins in a session.

Test

claude plugin test discuss-first
claude plugin test harness-coach
claude plugin test checklist
Source 2 files
hooks/register.ts 258 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { TIPS, type Context, type Tip } from './tips'
4
5// harness-coach: one small toast every few turns, chosen by what the turn did.
6// Shown tips are remembered across sessions in $.store, so each tip comes once
7// before any repeats. `/coach` gives the next tip now, lists, pauses, resets.
8
9const STORE_SEEN = 'seen' // Record<tipId, isoDate>
10const STORE_PAUSED = 'paused' // boolean
11const STORE_EVERY = 'every' // number: turns between tips
12const STORE_HELLO = 'hello' // isoDate of the last welcome toast
13const STORE_FIRSTS = 'firsts' // Record<contextKey, isoDate>: one-time "aha" toasts
14
15const DEFAULT_EVERY = 3
16const TOAST_MS = 9000
17const LONG_TURN_MS = 180_000
18const BUSY_TOOLS = 12
19const CONTEXT_PERCENT = 70
20
21// One-time nudges the first time a situation shows up, ever. Short, concrete.
22const FIRSTS: Record<string, string> = {
23  agent: 'First subagent: it runs with its own fresh context and hands back one report. /agents lists the types.',
24  error: 'First failed tool call: Claude usually self-corrects. If it loops, press Esc and say what you saw.',
25  web: 'First web fetch: allow a site once with a rule like WebFetch(domain:example.com) in /permissions.',
26  long: 'Long turn: you can keep typing, the message queues. Ctrl+B backgrounds a running shell command.',
27  context: 'Context is over 70% full. /compact with a focus keeps what matters; /clear starts clean for a new task.',
28  skill: 'A skill just ran: a SKILL.md picked by its description. Yours live in ~/.claude/skills and .claude/skills.',
29}
30
31type Seen = Record<string, string>
32const asRecord = (value: unknown): Record<string, string> =>
33  value && typeof value === 'object' ? (value as Record<string, string>) : {}
34
35const toolContexts = (tool: string): Context[] => {
36  switch (tool) {
37    case 'Bash':
38      return ['bash']
39    case 'Edit':
40    case 'Write':
41    case 'MultiEdit':
42    case 'NotebookEdit':
43      return ['edit']
44    case 'Read':
45    case 'Grep':
46    case 'Glob':
47      return ['read']
48    case 'Agent':
49    case 'Task':
50      return ['agent']
51    case 'WebFetch':
52    case 'WebSearch':
53      return ['web']
54    case 'Skill':
55      return ['skill']
56    default:
57      return []
58  }
59}
60
61const isGit = (command: string) => /(^|\s|&&|;|\|)\s*(git|gh)\s/.test(` ${command}`)
62
63// Picks the tip to show: unseen and matching the turn first, then any unseen,
64// then the least recently seen. `dynamic` are the session's own slash commands.
65const pick = (seen: Seen, contexts: Set<Context>, dynamic: readonly Tip[]): Tip | undefined => {
66  const all = [...TIPS, ...dynamic]
67  const matches = (tip: Tip) => tip.when.some(w => w !== 'any' && contexts.has(w))
68  const unseen = all.filter(t => seen[t.id] === undefined)
69
70  const contextual = unseen.filter(matches)
71  if (contextual.length > 0) return contextual[0]
72  // Dynamic tips (your own skills) before the generic bank.
73  const ownUnseen = unseen.filter(t => t.id.startsWith('cmd:'))
74  if (ownUnseen.length > 0) return ownUnseen[0]
75  if (unseen.length > 0) return unseen[0]
76
77  return [...all].sort((a, b) => (seen[a.id] ?? '').localeCompare(seen[b.id] ?? ''))[0]
78}
79
80const shorten = (text: string, max: number) => {
81  const oneLine = text.replace(/\s+/g, ' ').trim()
82  if (oneLine.length <= max) return oneLine
83  const cut = oneLine.slice(0, max)
84  const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf(', '), cut.lastIndexOf(' '))
85  return `${cut.slice(0, stop > max / 2 ? stop : max).trim()}…`
86}
87
88
89let dynamic: Tip[] = []
90
91async function now($: EngineInterface): Promise<string> {
92  return new Date(await $.clock.now()).toISOString()
93}
94
95async function markSeen($: EngineInterface, id: string): Promise<void> {
96  const seen = asRecord(await $.store.get(STORE_SEEN))
97  seen[id] = await now($)
98  await $.store.set(STORE_SEEN, seen)
99}
100
101function show($: EngineInterface, text: string): void {
102  $.ui.toast(`💡 ${text}`, { timeoutMs: TOAST_MS })
103}
104
105// Builds tips for the slash commands this install actually has (your own
106// skills first), so the coach teaches what is on this machine, not a manual.
107async function learnCommands($: EngineInterface): Promise<void> {
108  try {
109    const list = await $.command.list()
110    dynamic = list
111      .filter(c => (c.source === 'user' || c.source === 'plugin') && c.name !== 'coach' && c.description)
112      .map(c => ({
113        id: `cmd:${c.name}`,
114        text: `You have /${c.name}: ${shorten(c.description, 95)}`,
115        when: ['skill', 'any'] as const,
116      }))
117  } catch {
118    dynamic = []
119  }
120}
121
122export const register: Register = on => {
123  // Per-session (reset on reload): what this turn did, and turns since a tip.
124  let contexts = new Set<Context>()
125  let toolsThisTurn = 0
126  let turnsSinceTip = 0
127  let isInteractive = true
128
129  on('session.start', async ($, e, next) => {
130    isInteractive = e.isInteractive
131    await $.command.register({
132      name: 'coach',
133      description: 'Harness coach: a Claude Code tip now; list | off | on | reset | every <n> | find <word>',
134      argumentHint: '[list|off|on|reset|every n|find word]',
135    })
136    await learnCommands($)
137
138    if (e.isInteractive && !(await $.store.get(STORE_PAUSED))) {
139      const today = (await now($)).slice(0, 10)
140      if ((await $.store.get(STORE_HELLO)) !== today) {
141        await $.store.set(STORE_HELLO, today)
142        const seen = asRecord(await $.store.get(STORE_SEEN))
143        const total = TIPS.length + dynamic.length
144        const left = total - Object.keys(seen).filter(id => TIPS.some(t => t.id === id) || dynamic.some(t => t.id === id)).length
145        show($, `Coach on: a tip every few turns. ${left} of ${total} still new. /coach for one now, /coach off to pause.`)
146      }
147    }
148
149    return next(e)
150  })
151
152  on('prompt.submit', ($, e, next) => {
153    contexts = new Set<Context>()
154    toolsThisTurn = 0
155    return next(e)
156  })
157
158  on('tool.call', async ($, e, next) => {
159    toolsThisTurn += 1
160    for (const c of toolContexts(e.tool)) contexts.add(c)
161    if (e.tool === 'Bash' && typeof e.command === 'string' && isGit(e.command)) contexts.add('git')
162    if (toolsThisTurn >= BUSY_TOOLS) contexts.add('busy')
163
164    const ran = await next(e)
165    if (ran.deny === undefined && ran.isError === true) contexts.add('error')
166
167    return ran
168  })
169
170  on('turn.complete', async ($, e, next) => {
171    // Subagent turns and headless runs teach nobody.
172    if (e.agentId !== undefined || !isInteractive) return next(e)
173
174    if (e.durationMs >= LONG_TURN_MS) contexts.add('long')
175    try {
176      const usage = await $.session.usage()
177      if ((usage.context.percent ?? 0) >= CONTEXT_PERCENT) contexts.add('context')
178    } catch {
179      // no usage on this host: fine
180    }
181
182    if (await $.store.get(STORE_PAUSED)) return next(e)
183
184    // 1. A first-time "aha" for a situation never seen before, outside the cadence.
185    const firsts = asRecord(await $.store.get(STORE_FIRSTS))
186    for (const [key, text] of Object.entries(FIRSTS)) {
187      if (contexts.has(key as Context) && firsts[key] === undefined) {
188        firsts[key] = await now($)
189        await $.store.set(STORE_FIRSTS, firsts)
190        show($, text)
191        turnsSinceTip = 0
192        return next(e)
193      }
194    }
195
196    // 2. The regular cadence.
197    turnsSinceTip += 1
198    const every = Number((await $.store.get(STORE_EVERY)) ?? DEFAULT_EVERY)
199    if (turnsSinceTip < every) return next(e)
200
201    const seen = asRecord(await $.store.get(STORE_SEEN))
202    const tip = pick(seen, contexts, dynamic)
203    if (tip) {
204      await markSeen($, tip.id)
205      show($, tip.text)
206      turnsSinceTip = 0
207    }
208
209    return next(e)
210  })
211
212  on('command.run', { command: 'coach' }, async ($, e) => {
213    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
214    const arg = rest.join(' ')
215    const seen = asRecord(await $.store.get(STORE_SEEN))
216    const all = [...TIPS, ...dynamic]
217
218    switch (verb) {
219      case 'off':
220        await $.store.set(STORE_PAUSED, true)
221        return { text: 'Coach paused. /coach on resumes; /coach still gives a tip on demand.' }
222      case 'on':
223        await $.store.set(STORE_PAUSED, false)
224        return { text: 'Coach on.' }
225      case 'reset':
226        await $.store.set(STORE_SEEN, {})
227        await $.store.set(STORE_FIRSTS, {})
228        return { text: `Forgot every seen tip: ${all.length} tips are new again.` }
229      case 'every': {
230        const n = Number(arg)
231        if (!Number.isInteger(n) || n < 1 || n > 50) return { text: 'Usage: /coach every <1-50>  (turns between tips)' }
232        await $.store.set(STORE_EVERY, n)
233        return { text: `A tip every ${n} turn${n === 1 ? '' : 's'}.` }
234      }
235      case 'list': {
236        const lines = all.map(t => `${seen[t.id] ? '✓' : '·'} ${t.text}`)
237        const seenCount = all.filter(t => seen[t.id]).length
238        return { text: [`${seenCount}/${all.length} seen (✓). Every ${Number((await $.store.get(STORE_EVERY)) ?? DEFAULT_EVERY)} turns.`, '', ...lines].join('\n') }
239      }
240      case 'find': {
241        const q = arg.toLowerCase()
242        if (!q) return { text: 'Usage: /coach find <word>' }
243        const hits = all.filter(t => t.text.toLowerCase().includes(q) || t.id.includes(q))
244        return { text: hits.length ? hits.map(t => `• ${t.text}`).join('\n') : `No tip mentions "${arg}". Ask Claude; it knows the harness.` }
245      }
246      case '': {
247        const tip = pick(seen, contexts, dynamic)
248        if (!tip) return { text: 'No tips loaded.' }
249        await markSeen($, tip.id)
250        show($, tip.text)
251        return { text: `💡 ${tip.text}` }
252      }
253      default:
254        return { text: 'Usage: /coach [list | off | on | reset | every <n> | find <word>]' }
255    }
256  })
257}
258
hooks/tips.ts 91 lines
1// The tip bank. Each tip is one line, under ~120 characters, true for Claude Code 2.1.x.
2// `when` names the situations the tip fits; 'any' fits every turn.
3
4export type Context =
5  | 'any'
6  | 'bash'
7  | 'edit'
8  | 'read'
9  | 'agent'
10  | 'web'
11  | 'error'
12  | 'long'
13  | 'busy'
14  | 'skill'
15  | 'git'
16  | 'context'
17  | 'start'
18
19export type Tip = { id: string; text: string; when: readonly Context[] }
20
21export const TIPS: readonly Tip[] = [
22  // Keys at the prompt
23  { id: 'key-shift-tab', text: 'Shift+Tab cycles permission mode: normal → accept edits → plan mode. Plan mode = read-only thinking first.', when: ['edit', 'any'] },
24  { id: 'key-esc', text: 'Esc stops the current turn at once. What was done so far stays; just tell Claude the new direction.', when: ['long', 'busy', 'any'] },
25  { id: 'key-esc-esc', text: 'Esc Esc opens Rewind: jump back to an earlier message, and optionally restore the files as they were then.', when: ['edit', 'error', 'any'] },
26  { id: 'key-bang', text: 'Start a prompt with ! to run a shell command yourself (no model call): `!git status`.', when: ['bash', 'any'] },
27  { id: 'key-at', text: 'Type @ and a path to attach a file or folder to the prompt, with autocomplete.', when: ['read', 'edit', 'any'] },
28  { id: 'key-hash', text: 'Start a line with # to save that line as a memory note (CLAUDE.md). Good for "always do X here".', when: ['any'] },
29  { id: 'key-ctrl-r', text: 'Ctrl+R searches your prompt history, like in a shell.', when: ['any'] },
30  { id: 'key-ctrl-v', text: 'Paste an image into the prompt with Ctrl+V (not Cmd+V) in the terminal; Claude can read screenshots.', when: ['any'] },
31  { id: 'key-ctrl-b', text: 'A long shell command? Press Ctrl+B while it runs to send it to the background and keep working.', when: ['bash', 'long'] },
32  { id: 'key-tab', text: 'Tab accepts the inline suggestion or completes a slash command / file path.', when: ['any'] },
33  { id: 'key-newline', text: 'Multi-line prompt: Shift+Enter in the desktop app; in a terminal run /terminal-setup once to enable it.', when: ['any'] },
34  { id: 'key-drag', text: 'Drag a file onto the terminal window to drop its path into the prompt.', when: ['read', 'any'] },
35
36  // Slash commands: context and cost
37  { id: 'cmd-context', text: '/context shows what fills the context window: system prompt, tools, memory, messages. Big = slower and pricier.', when: ['context', 'long', 'any'] },
38  { id: 'cmd-compact', text: '/compact summarises the conversation to free context. Add a focus: `/compact keep the price decisions`.', when: ['context', 'long', 'busy'] },
39  { id: 'cmd-clear', text: '/clear starts a fresh conversation in the same folder. Cheaper than carrying an old thread into a new task.', when: ['context', 'any'] },
40  { id: 'cmd-cost', text: '/cost shows what this session has spent so far; /usage shows your plan rate-limit windows.', when: ['long', 'busy', 'any'] },
41  { id: 'cmd-model', text: '/model switches model mid-session. Use a cheaper model for mechanical work, the top model for design.', when: ['any'] },
42  { id: 'cmd-fast', text: '/fast toggles fast mode: same Opus-class model, faster output. Good for long mechanical edits.', when: ['edit', 'busy'] },
43  { id: 'cmd-resume', text: 'Closed a session by mistake? `claude --resume` picks a past session; `claude -c` continues the last one.', when: ['any'] },
44
45  // Slash commands: setup and configuration
46  { id: 'cmd-init', text: '/init writes a CLAUDE.md for a repo: build commands, conventions, layout. Claude reads it every session.', when: ['read', 'any'] },
47  { id: 'cmd-memory', text: '/memory opens the CLAUDE.md files in play: global (~/.claude), project, and local. Edit them like docs.', when: ['any'] },
48  { id: 'cmd-permissions', text: '/permissions edits allow/deny rules, e.g. allow `Bash(npm test:*)` so that command never asks again.', when: ['bash', 'any'] },
49  { id: 'cmd-config', text: '/config holds theme, notifications, auto-compact, verbose output and every plugin option.', when: ['any'] },
50  { id: 'cmd-mcp', text: '/mcp lists connected MCP servers and their tools; `claude mcp add` connects a new one.', when: ['web', 'any'] },
51  { id: 'cmd-hooks', text: '/hooks: shell commands that run before/after tool calls, on prompts, on stop. Automate "always do X".', when: ['bash', 'edit', 'any'] },
52  { id: 'cmd-agents', text: '/agents manages subagents: Markdown files in .claude/agents with their own prompt, tools and model.', when: ['agent', 'any'] },
53  { id: 'cmd-statusline', text: '/statusline sets a custom status line: model, context %, git branch, cost — any script output.', when: ['any'] },
54  { id: 'cmd-doctor', text: 'Something odd? /doctor checks the install, settings and MCP servers and names what is broken.', when: ['error', 'any'] },
55  { id: 'cmd-add-dir', text: '/add-dir gives this session a second folder to read and edit, without restarting.', when: ['read', 'any'] },
56  { id: 'cmd-export', text: '/export saves the whole conversation to a file, handy for a handoff or a notes file.', when: ['long', 'any'] },
57  { id: 'cmd-reload-plugins', text: 'Edited a plugin or skill? /reload-plugins re-reads them without restarting the session.', when: ['any'] },
58  { id: 'cmd-keybindings', text: 'Keys can be rebound in ~/.claude/keybindings.json; the keybindings-help skill explains the format.', when: ['any'] },
59
60  // Bundled skills (slash commands this install has)
61  { id: 'cmd-code-review', text: 'After a change: /code-review reviews the diff for bugs. /code-review high finds more; ultra runs in the cloud.', when: ['edit', 'git'] },
62  { id: 'cmd-simplify', text: '/simplify reviews the changed code for duplication and over-complexity, then applies the cleanups.', when: ['edit'] },
63  { id: 'cmd-security-review', text: '/security-review checks the pending changes for security issues before they ship.', when: ['edit', 'git'] },
64  { id: 'cmd-loop', text: '/loop 5m /my-check repeats a command on an interval; without an interval Claude paces itself.', when: ['bash', 'any'] },
65  { id: 'cmd-schedule', text: '/schedule creates a cloud routine on a cron: "every morning at 7 run X" without your Mac being awake.', when: ['any'] },
66  { id: 'cmd-fewer-prompts', text: '/fewer-permission-prompts scans past sessions and proposes an allowlist for the read-only commands you run.', when: ['bash'] },
67  { id: 'cmd-update-config', text: 'Want "from now on, whenever X happens…"? That is a hook in settings.json; the update-config skill writes it.', when: ['any'] },
68
69  // Concepts
70  { id: 'con-plan-mode', text: 'Plan mode (Shift+Tab twice) lets Claude explore and propose before touching files. Use it for big changes.', when: ['edit', 'any'] },
71  { id: 'con-subagents', text: 'Subagents run with a fresh context and report back one summary. Good for broad searches and parallel work.', when: ['agent'] },
72  { id: 'con-agent-cost', text: 'Each subagent is a new context: it costs tokens to brief and its mistakes come back confident. Use for parallel work.', when: ['agent'] },
73  { id: 'con-worktree', text: 'Parallel sessions on one repo? Each can run in its own git worktree, so edits never collide.', when: ['git', 'agent'] },
74  { id: 'con-claude-md', text: 'CLAUDE.md is read every session: keep it short and rule-like. Long prose there costs context on every turn.', when: ['context', 'any'] },
75  { id: 'con-skills', text: 'Skills are folders with a SKILL.md in ~/.claude/skills or .claude/skills. Claude loads one when its description matches.', when: ['skill', 'any'] },
76  { id: 'con-skill-creator', text: 'The skill-creator skill builds a new skill from a description and can run evals on how well it triggers.', when: ['skill'] },
77  { id: 'con-headless', text: '`claude -p "question"` runs one prompt headless and prints the answer: usable from scripts and cron.', when: ['bash', 'any'] },
78  { id: 'con-settings-tiers', text: 'Settings stack: ~/.claude/settings.json (you) → .claude/settings.json (repo) → settings.local.json (you, this repo).', when: ['any'] },
79  { id: 'con-allow-prefix', text: 'Permission rules take prefixes: `Bash(git log:*)` allows every git log; `WebFetch(domain:docs.x.com)` one site.', when: ['bash', 'web'] },
80  { id: 'con-thinking', text: 'Say "think hard about this" for deeper reasoning on a tricky design; keep it off for mechanical edits.', when: ['any'] },
81  { id: 'con-git-commits', text: 'Claude can commit and open PRs with gh. Say "commit this" or "open a PR"; it writes the message from the diff.', when: ['git', 'edit'] },
82  { id: 'con-background-task', text: 'Say "run the tests in the background" and keep talking: Claude is told when the task finishes.', when: ['bash', 'long'] },
83  { id: 'con-remote-control', text: 'Remote Control lets your phone drive this same session from the Claude app. Toggle in the desktop app sidebar.', when: ['any'] },
84  { id: 'con-plugins', text: '/plugin browses marketplaces; a plugin bundles skills, hooks, agents and MCP servers in one install.', when: ['any'] },
85  { id: 'con-mods', text: 'The plugin-authoring skill makes a "mod": a pane, a toast, a slash command or a hook, in one TypeScript file.', when: ['skill', 'any'] },
86  { id: 'con-artifacts', text: 'In the desktop app /artifacts lists pages Claude published; reports and dashboards live there, not in chat.', when: ['any'] },
87  { id: 'con-interrupt-redirect', text: 'You can type while Claude works: the message is queued and read at the next step. No need to wait.', when: ['long', 'busy'] },
88  { id: 'con-errors-rewind', text: 'A tool call failed? Often best to let Claude retry; if it loops, Esc, then explain what you saw.', when: ['error'] },
89  { id: 'con-name-the-file', text: 'Naming the exact file in the prompt (with @) skips a search and saves context.', when: ['read', 'start'] },
90]
91