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

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.
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.
/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.
/cj-stack:ui-polish. Claude works on the screen.ui-polish.You see this log line: "1 correction during ui-polish. Run /hone ui-polish?". Run the suggested command to start hone.
| Event | Decision |
|---|---|
| A turn ends | Read the transcript and count corrections per skill. |
| A skill has corrections and no nudge yet | Print one log line for that skill, then record it. |
| A skill was nudged before in this session | Print nothing. |
| Another Stop hook blocks the stop | Print nothing. |
| A session starts | Delete this mod's state files older than seven days. |
A correction is one of these:
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.
No settings.
To turn the mod off, run claude plugin disable hone-nudge@cj-stack.
hooks/register.ts 152 lines1import 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