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.

Claude Code mods (plugins), as one local marketplace: cc-mods.
| Mod | What it does |
|---|---|
discuss-first | Adds 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-coach | Teaches 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>. |
checklist | A 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. |
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.
claude plugin test discuss-first
claude plugin test harness-coach
claude plugin test checklisthooks/register.ts 258 lines1import 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}
258hooks/tips.ts 91 lines1// 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