SLOPSHOPPER

hone-nudge

Suggests /hone <skill> when you corrected Claude during a cj-stack skill run

newprocess
v0.1.1no licenseupdated 2026-10-13cpeaustriajc/cj-stack/mods/hone-nudge
A shopper browsing a rack in a slop shop
README

hone-nudge

A mod (a plugin that adds a hook to Claude Code) that suggests /hone <skill> after you correct Claude during a cj-stack skill. It points you to the hone skill, which turns repeated corrections into lasting fixes.

Why

You correct Claude during a skill run, and the same correction comes back next week. The skill needs a fix, but you forget to run hone.

hone-nudge reads the session transcript when a turn ends. If you corrected Claude during a cj-stack skill, it prints one log line (a line in the session output) that names the skill.

Install

/plugin marketplace add cpeaustriajc/cj-stack
/plugin install hone-nudge@cj-stack

To see that it is on, run claude plugin list and look for hone-nudge. The mod is optional: cj-stack does not install it.

Example

  1. You run /cj-stack:ui-polish. Claude works on the screen.
  2. You reply "no, wrong easing". The message counts as a correction.
  3. Claude ends its turn. The mod finds one correction during ui-polish.

You see this log line: "1 correction during ui-polish. Run /hone ui-polish?". Run the suggested command to start hone.

What it does

EventDecision
A turn endsRead the transcript and count corrections per skill.
A skill has corrections and no nudge yetPrint one log line for that skill, then record it.
A skill was nudged before in this sessionPrint nothing.
Another Stop hook blocks the stopPrint nothing.
A session startsDelete this mod's state files older than seven days.

A correction is one of these:

  • A message that starts with a word such as "no", "stop", "wrong", "wait" or "actually".
  • An interrupt of Claude, or a rejected tool call.

The mod credits a correction to the last cj-stack skill that started before it. A skill starts by the Skill tool or by a slash command. The skill names are in hooks/register.ts. A name with another prefix, such as other:ui-polish, does not count.

The mod writes { "v": 1, "nudged": ["ui-polish"] } to ~/.claude/mod-state/<session id>.hone-nudge.json. If CLAUDE_CONFIG_DIR is set, the file goes to $CLAUDE_CONFIG_DIR/mod-state.

Settings

No settings.

To turn the mod off, run claude plugin disable hone-nudge@cj-stack.

Limits

  • It finds corrections by word patterns. A correction that starts with other words slips through.
  • A correction made before any cj-stack skill ran gets no nudge.
  • If the mod cannot find a home directory, or cannot read the transcript, it prints nothing.
  • If the state file cannot be read, the hook throws "hone-nudge: state file is unreadable".
  • If the state file write fails, the hook throws "hone-nudge: could not record the nudge for this session".

More

Source 1 files
hooks/register.ts 152 lines
1import type { Hook, Register } from 'claude-code'
2
3type Api = Parameters<Hook<'classic.Stop'>>[0]
4
5const MOD = 'hone-nudge'
6const WEEK = 7 * 86_400_000
7// Keep SHIPPED in step with the skills array of the cj-stack plugin in marketplace.json.
8const SHIPPED = new Set([
9  'vision', 'shape', 'tickets', 'triage', 'project-knowledge', 'hone', 'break-it', 'ui-polish',
10  'feature-files', 'playbooks', 'interrogate', 'commit', 'create-pr',
11])
12// Keep this detection in step with skills/knowledge/hone/scripts/corrections.py, which a separate plugin cannot import.
13const CORRECTION = /^\s*(no\b|nope|why\b|stop\b|don'?t\b|do not\b|i already|i said|i meant|that's not|wrong|huh|wait\b|actually\b|\?\?)/i
14const INTERRUPT = '[Request interrupted by user'
15const COMMAND = /<command-name>\s*([^<\s]+)\s*<\/command-name>/
16
17type Entry = { type?: string; isSidechain?: boolean; message?: { content?: unknown } }
18type Block = { type?: string; text?: string; name?: string; id?: string; tool_use_id?: string; input?: { skill?: unknown }; content?: unknown }
19
20// A prefix other than cj-stack means someone else's skill of the same name.
21function shippedName(raw: string): string | undefined {
22  const parts = raw.trim().replace(/^\//, '').split(':')
23  const name = parts[parts.length - 1]
24  const prefixOk = parts.length === 1 || parts[0] === 'cj-stack'
25  return prefixOk && SHIPPED.has(name) ? name : undefined
26}
27
28function textOf(content: unknown): string {
29  if (typeof content === 'string') return content
30  if (!Array.isArray(content)) return ''
31  return (content as Block[]).filter(b => b && b.type === 'text').map(b => b.text ?? '').join('\n')
32}
33
34function parse(line: string): Entry | undefined {
35  try {
36    const entry: unknown = JSON.parse(line)
37    return entry && typeof entry === 'object' ? (entry as Entry) : undefined
38  } catch {
39    return undefined
40  }
41}
42
43function rejected(result: string): boolean {
44  return result.includes("doesn't want to proceed") || result.slice(0, 200).toLowerCase().includes('rejected')
45}
46
47function correctionsBySkill(transcript: string): Map<string, number> {
48  const counts = new Map<string, number>()
49  let active: string | undefined
50  const hit = () => {
51    if (active) counts.set(active, (counts.get(active) ?? 0) + 1)
52  }
53  for (const line of transcript.split('\n')) {
54    const entry = parse(line)
55    if (!entry || entry.isSidechain) continue
56    const content = entry.message?.content
57    const blocks = Array.isArray(content) ? (content as Block[]).filter(b => b && typeof b === 'object') : []
58    if (entry.type === 'assistant') {
59      for (const b of blocks) {
60        if (b.type !== 'tool_use') continue
61        if (b.name === 'Skill' && typeof b.input?.skill === 'string') active = shippedName(b.input.skill)
62      }
63      continue
64    }
65    if (entry.type !== 'user') continue
66    const started = COMMAND.exec(textOf(content))
67    if (started) {
68      active = shippedName(started[1])
69      continue
70    }
71    for (const b of blocks) {
72      if (b.type !== 'tool_result') continue
73      const result = typeof b.content === 'string' ? b.content : textOf(b.content)
74      if (rejected(result)) hit()
75    }
76    const said = textOf(content)
77    if (!said.trim() || said.startsWith('<') || said.length > 2500) continue
78    if (said.includes(INTERRUPT) || CORRECTION.test(said)) hit()
79  }
80  return counts
81}
82
83async function stateDir($: Api): Promise<string | undefined> {
84  const config = await $.env.get('CLAUDE_CONFIG_DIR')
85  if (config) return `${config}/mod-state`
86  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
87  return home ? `${home}/.claude/mod-state` : undefined
88}
89
90async function readNudged($: Api, path: string): Promise<string[]> {
91  if (!(await $.fs.exists(path))) return []
92  try {
93    const parsed = JSON.parse(await $.fs.read(path)) as { nudged?: unknown }
94    return Array.isArray(parsed.nudged) ? parsed.nudged.filter((s): s is string => typeof s === 'string') : []
95  } catch (cause) {
96    throw new Error(`${MOD}: state file is unreadable: ${path}`, { cause })
97  }
98}
99
100async function pruneState($: Api): Promise<void> {
101  try {
102    const dir = await stateDir($)
103    if (!dir || !(await $.fs.exists(dir))) return
104    const now = await $.clock.now()
105    const stale = (await $.fs.list(dir)).filter(
106      f => f.kind === 'file' && f.name.endsWith(`.${MOD}.json`) && now - f.mtimeMs > WEEK,
107    )
108    if (stale.length > 0) await $.process.run(['rm', '-f', '--', ...stale.map(f => `${dir}/${f.name}`)])
109  } catch (cause) {
110    throw new Error(`${MOD}: could not prune old state files`, { cause })
111  }
112}
113
114export const register: Register = on => {
115  on('session.start', async ($, e, next) => {
116    const result = await next(e)
117    await pruneState($)
118    return result
119  })
120
121  on('classic.Stop', async ($, e, next) => {
122    const result = await next(e)
123    if (result.block !== undefined || result.preventContinuation) return result
124
125    let transcript: string
126    try {
127      transcript = await $.fs.read(e.transcript_path)
128    } catch {
129      return result
130    }
131    const found = [...correctionsBySkill(transcript)].filter(([, n]) => n > 0)
132    if (found.length === 0) return result
133
134    const dir = await stateDir($)
135    if (!dir) return result
136    const path = `${dir}/${e.session_id}.${MOD}.json`
137    const nudged = await readNudged($, path)
138    const fresh = found.filter(([skill]) => !nudged.includes(skill))
139    if (fresh.length === 0) return result
140
141    try {
142      await $.fs.write(path, JSON.stringify({ v: 1, nudged: [...nudged, ...fresh.map(([s]) => s)] }))
143    } catch (cause) {
144      throw new Error(`${MOD}: could not record the nudge for this session`, { cause })
145    }
146    for (const [skill, n] of fresh) {
147      $.ui.log(`${n} ${n === 1 ? 'correction' : 'corrections'} during ${skill}. Run /hone ${skill}?`)
148    }
149    return result
150  })
151}
152