SLOPSHOPPER

next-steps

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…

newbandpromptmodel
v0.2.0no licenseupdated 2026-10-08aaronchu415/claude-config/mods/next-steps
A shopper browsing a rack in a slop shop
README

claude-config

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.

Workflow

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.

  • Before writing: grill-me (sharpen a plan), why (the history behind existing code).
  • While reviewing: 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).
  • When it breaks: diagnosing-bugs.
  • Any prose: 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.

What is here

PathWhat
CLAUDE.mdGlobal agent directives
settings.jsonPermissions, 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.jsonInstalled plugins pinned to version and commit, with marketplace source. Regenerate with scripts/export-plugins.sh
statusline.shStatus 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.

Restore on a new machine

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.

Update

~/.claude/scripts/push.sh "what changed"

It commits, pushes as my personal GitHub account, and switches gh back to the account it found active.

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