After each turn, 2 or 3 likely next prompts above the prompt, from the session's own model with lets-cook's routes and your past picks. Press 1, 2 or 3 in an…

My Claude Code setup: global instructions, skills, agents, and output styles. ~/.claude is the git repo itself; .gitignore is a whitelist, so sessions, credentials, caches, memory, and anything that has to name my employer never get committed.
Day to day I work in the compound-engineering workflow: ce-brainstorm to scope, ce-plan to plan, ce-work to build, ce-code-review before a PR, ce-commit-push-pr to ship, ce-compound to capture what was learned. It is installed as a plugin, so its skills are not in this repo; settings.json enables it and plugins.json pins the version and commit.
The skills in this repo sit around that loop. Lost? /lets-cook <task> replies in five lines with which skill to use and whether a multi-agent fan-out is worth it.
grill-me (sharpen a plan), why (the history behind existing code).aaron-review (the quality bar), test-designer (which tests earn their keep), blast-radius (what a diff breaks elsewhere), no-comments (runs before every PR).diagnosing-bugs.unslop.Above the loop, for an epic too big for one session: wayfinder from mattpocock/skills, installed as the mattpocock-skills plugin so it tracks upstream, with only wayfinder, grilling, domain-modeling, research, prototype, and writing-for-agents switched on (skillOverrides in settings.json turns the other 19 off). Its map uses the local-markdown tracker on the epic branch.
Sources: why, blast-radius, no-comments, unslop from cursor/plugins pstack; grill-me, diagnosing-bugs from mattpocock/skills, copied and adapted rather than linked because they carry local changes. All ported to Claude Code and trimmed with Matt's writing-for-agents skill.
| Path | What |
|---|---|
CLAUDE.md | Global agent directives |
settings.json | Permissions, hooks, plugins, marketplaces |
agents/ | Custom subagents (comment-sicko) |
skills/ | Skills: why, blast-radius, no-comments, grill-me, diagnosing-bugs (from cursor/plugins pstack and mattpocock/skills, ported to Claude Code), unslop, repo-explorer, aaron-review, test-designer, webapp-testing |
output-styles/ | eli5, aaron-terse, ste |
plugins.json | Installed plugins pinned to version and commit, with marketplace source. Regenerate with scripts/export-plugins.sh |
statusline.sh | Status line |
hooks/, commands/ and mcp-servers.json stay local and are not backed up here: each one has to name my employer's repos, Jira prefix or vendors to do its job.
git clone git@github.com:aaronchu415/claude-config.git ~/.claude
cd ~/.claude/skills/webapp-testing && uv venv --python 3.13 .venv && .venv/bin/pip install playwright && .venv/bin/playwright install chromium
Then re-add the MCP servers with claude mcp add and log in to the plugins listed in settings.json.
~/.claude/scripts/push.sh "what changed"
It commits, pushes as my personal GitHub account, and switches gh back to the account it found active.
hooks/register.tsx 204 lines1// Next Steps: after each turn, 2 or 3 likely next prompts above the input.
2// In an empty prompt, 1 to 3 drafts one (nothing sends on its own) and 0 dismisses.
3// Forked from hamzafer/claude-code-mods (MIT): asks the session's own model over the whole
4// transcript, routes with lets-cook's table, and stays idle where nothing draws (T3 Code, claude -p).
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { NextSteps, Tally } from '../types'
9
10const MAX_ITEMS = 3
11const MAX_CHARS = 60 // what the model is asked for
12const MAX_KEPT = 120 // a longer line is dropped, never cut: a cut draft would send half a prompt
13const MIN_ANSWER = 20 // a reply shorter than this is not worth a model call
14const ROUTES = '.claude/skills/lets-cook/SKILL.md'
15
16// Held by the host, so the list survives a hot reload of this file.
17const steps = atom({ plugin: 'next-steps', key: 'steps' } as const, null as NextSteps | null)
18// True while the list is on screen. where-am-i reads it and leaves out its own "next" meanwhile.
19const active = atom({ plugin: 'next-steps', key: 'active' } as const, false)
20
21let routes: string | null = null
22let counts: Tally | null = null
23
24export const register: Register = on => {
25 // The turn a suggestion may still be shown for. A submit or a new turn moves it on.
26 let latest = ''
27 let submits = 0
28 // A survey answers bare digits in the band: leave them to it.
29 let hasSurvey = false
30 let picked: number | null = null
31
32 on('prompt.submit', async ($, e, next) => {
33 latest = `submit-${++submits}`
34 const shown = await read($, steps)
35 if (shown && e.origin?.kind === 'composer') void count($, picked !== null).catch(() => {})
36 picked = null
37 await setSteps($, () => null)
38 return next(e)
39 })
40
41 on('turn.start', async ($, e, next) => {
42 latest = e.turnId
43 await setSteps($, () => null)
44 return next(e)
45 })
46
47 on('turn.complete', async ($, e, next) => {
48 const r = await next(e)
49 if (e.agentId) return r // a subagent's turn, not the person's
50 latest = e.turnId // also for a turn that raised no turn.start, and after a hot reload
51 if (e.reason !== 'answer' || e.answer.trim().length < MIN_ANSWER) return r
52 void suggest($, e.turnId, e.answer, () => latest).catch(() => {}) // in the background, so the turn ends at once
53 return r
54 })
55
56 // A digit typed into an empty prompt picks a suggestion instead of landing as text.
57 // A paste carries no key, so a pasted "1" lands as typed.
58 on('prompt.edit', async ($, e, next) => {
59 if (hasSurvey || !e.key || e.key.ctrl || e.key.meta || e.text !== '' || e.start !== 0 || e.end !== 0 || !/^[0-9]$/.test(e.inputText)) return next(e)
60 const s = await read($, steps)
61 if (!s) return next(e)
62 if (e.inputText === '0') {
63 await setSteps($, () => null)
64 return { text: '', cursor: 0 } // consumed: the box stays empty
65 }
66 const index = Number(e.inputText) - 1
67 const pick = s.items[index]
68 if (!pick) return next(e)
69 picked = index
70 return next({ ...e, inputText: pick }) // a draft: the person edits it or presses Enter
71 })
72
73 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
74 const rest = await next(e) // what other mods and Claude Code draw here stays
75 hasSurvey = e.props.hasSurvey
76 const s = await read($, steps)
77 if (e.props.hasSurvey || e.props.isWorking || !s || s.items.length === 0) return rest
78
79 const { Box, Text } = $.ui.resolve(e)
80 const sep = <Text dimColor>{' · '}</Text>
81 const item = (text: string, i: number) => [
82 <Text color="cyan" bold>{`${i + 1} `}</Text>,
83 <Text>{text}</Text>,
84 ]
85 const used = tally()
86 const dismiss = [<Text color="cyan" bold>{'0 '}</Text>, <Text dimColor>dismiss</Text>, ...(used ? [<Text dimColor>{used}</Text>] : [])]
87
88 const lines =
89 // One per row needs a row each; a short band gets the one row, cut at its end.
90 fitsOnOneLine(s.items, e.props.bodyColumns, used.length) || e.props.maxRows < s.items.length + 2 ? (
91 <Text wrap="truncate-end">
92 <Text dimColor>{'next: '}</Text>
93 {s.items.flatMap((t, i) => [...item(t, i), sep])}
94 {dismiss}
95 </Text>
96 ) : (
97 <Box flexDirection="column">
98 {s.items.map((t, i) => (
99 <Text wrap="truncate-end">
100 <Text dimColor>{i === 0 ? 'next: ' : ' '}</Text>
101 {item(t, i)}
102 {i === s.items.length - 1 && sep}
103 {i === s.items.length - 1 && dismiss}
104 </Text>
105 ))}
106 </Box>
107 )
108
109 return (
110 <Box flexDirection="column">
111 <Box paddingX={1}>{lines}</Box>
112 {rest}
113 </Box>
114 )
115 })
116}
117
118// Writes the list, then whether it shows, for where-am-i.
119async function setSteps($: EngineInterface, fn: (before: NextSteps | null) => NextSteps | null) {
120 const after = await update($, steps, fn)
121 const showing = after !== null && after.items.length > 0
122 if ((await read($, active)) !== showing) await update($, active, () => showing)
123}
124
125// The whole list on one row, or one suggestion per row when it would not fit.
126export function fitsOnOneLine(items: string[], columns: number, extra = 0) {
127 const width = 'next: '.length + items.reduce((n, t) => n + 2 + t.length + 5, 0) + '0 dismiss'.length + extra
128 return width <= columns - 2 // the band's padding
129}
130
131function tally() {
132 return counts && counts.shown > 0 ? ` (${counts.picked}/${counts.shown} used)` : ''
133}
134
135// How often a showing list was used, across every session on the machine.
136async function count($: EngineInterface, isPicked: boolean) {
137 const before = await loadCounts($)
138 counts = { shown: before.shown + 1, picked: before.picked + (isPicked ? 1 : 0) }
139 await $.store.set('tally', counts)
140}
141
142async function loadCounts($: EngineInterface): Promise<Tally> {
143 const saved = (await $.store.get('tally').catch(() => undefined)) as Tally | undefined
144 return saved ?? { shown: 0, picked: 0 }
145}
146
147async function loadRoutes($: EngineInterface) {
148 if (routes !== null) return routes
149 const home = await $.env.get('HOME')
150 const skill = await $.fs.read(`${home}/${ROUTES}`).catch(() => '')
151 const start = skill.indexOf('## Routing table')
152 routes = start === -1 ? '' : skill.slice(start)
153 return routes
154}
155
156async function suggest($: EngineInterface, turnId: string, answer: string, current: () => string) {
157 if ((await $.session.surfaces()).length === 0) return // nothing would draw the list
158 // Wait for background agents: the main agent picks up again once they finish.
159 const agents = await $.agent.list().catch(() => [])
160 if (agents.some(a => a.status === 'running')) return
161
162 const [table, saved] = await Promise.all([loadRoutes($), loadCounts($)])
163 counts = saved
164 const r = await $.model.fork({ prompt: forkPrompt(answer, table) })
165 if (!r.isAnswered) return
166 const items = parseSteps(r.text)
167 // Checked inside the write, so a submit or a new turn that lands meanwhile always wins.
168 await setSteps($, before => (current() !== turnId ? before : items.length > 0 ? { turnId, items } : null))
169}
170
171export function forkPrompt(answer: string, table: string) {
172 return [
173 `<latest_reply>\n${answer.slice(0, 2000)}\n</latest_reply>`,
174 'This message is not from the person, and your reply is never shown to them. Set the task aside and ignore the output style. ' +
175 `Predict the 2 or 3 prompts the person is most likely to type next. Each is an instruction under ${MAX_CHARS} characters, ` +
176 'specific to this session: name the file, PR, ticket or skill. When the stage of work makes one of their routes the obvious next step, ' +
177 'suggest that skill as a slash command, such as /ce-commit-push-pr. Plain words, no em dashes.',
178 table && `Their routes, by stage of work:\n<routes>\n${table}\n</routes>`,
179 'Reply with only the lines, each starting with "NEXT: ". If nothing sensible follows, reply NONE.',
180 ]
181 .filter(Boolean)
182 .join('\n\n')
183}
184
185// Only the NEXT: lines, cleaned: no bullets, bold, quotes or em dashes; at most three.
186// A line too long to draft whole is dropped.
187export function parseSteps(text: string): string[] {
188 const out: string[] = []
189 for (const line of text.split('\n')) {
190 const m = /^\s*(?:[-*•]\s*)?(?:\*\*)?NEXT:(?:\*\*)?\s*(.*)$/i.exec(line)
191 if (!m) continue
192 const clean = m[1]
193 .replace(/\*\*/g, '')
194 .replace(/^["'`]+|["'`,]+$/g, '')
195 .replace(/\s*—\s*/g, ', ')
196 .replace(/\.$/, '')
197 .trim()
198 if (clean === '' || clean.length > MAX_KEPT) continue
199 if (!out.some(o => o.toLowerCase() === clean.toLowerCase())) out.push(clean)
200 if (out.length === MAX_ITEMS) break
201 }
202 return out
203}
204types/index.d.ts 9 lines1export type NextSteps = { turnId: string; items: string[] }
2export type Tally = { shown: number; picked: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'next-steps': { steps: NextSteps | null; active: boolean }
7 }
8}
9