拷問模式:一次問一題、按按鈕作答、自動觸發、顯示預估題數,問完幫你寫成計畫(規則改寫自 mattpocock/skills 的 grilling,MIT)

讓 Claude 在你要做決定時,一題一題問到你想清楚為止,問完再幫你寫成計畫。
規則改寫自 mattpocock/skills 的 grilling skill(MIT)。和原版的差別:
拷問中 [預估問題 2/10],預估題數會隨著答案調整。在 Claude Code 終端機輸入:
/plugin install grill --marketplace Karylcode/claude-grill-mod
問要不要加入 marketplace 時按 y,範圍選 user(所有專案都能用)。
更新:
claude plugin update grill@karylcode-mods
| 做什麼 | 怎麼做 |
|---|---|
| 開始拷問 | /grill:grill 主題,或直接說「幫我決定…」「給我建議…」 |
| 中斷(Claude 問偏了) | 按輸入框上方的「中斷拷問」,Claude 會整理目前狀況,再選「繼續拷問」或「不問了」 |
| 問完 | 上方換成「編寫計畫」,選 Markdown、HTML 或繼續提問 |
| 關掉自動觸發 | /grill-auto off(on 打開) |
自動觸發:訊息裡出現「拷問、grill、幫我決定、給我建議、怎麼選、要不要、規劃、企劃」這類字時,MOD 會提醒 Claude 考慮用拷問模式;只是簡單問題的話,Claude 會直接回答。
claude plugin validate .
claude plugin test .
MIT,見 LICENSE。
hooks/register.tsx 308 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, Phase } from '../types'
5
6// 拷問規則改寫自 mattpocock/skills 的 grilling skill(MIT)。
7// 和原版的差別:一次只問一題、用 AskUserQuestion 按鈕作答、回報預估題數、選項和說明寫短。
8const RULES = `# 拷問模式(grill mod)
9
10## 什麼時候用
11使用者要你給建議、幫忙做決定、規劃或企劃一件還有好幾個未定分支的事,或直接說「拷問」「grill」時,進入拷問模式。只有一個簡單選擇的問題就直接回答,不用拷問。
12
13## 怎麼問
14- 在心裡把主題畫成決策樹:每個決定底下掛著依賴它的決定。只問前提都已經確定的題目;一個答案會改變其他題目時,先問那一題。
15- 一次只問一題,一律用 AskUserQuestion 工具:questions 只放 1 題,metadata.source 設成 "grill:<預估總題數>",例如 "grill:10"。不要在一般文字訊息裡列題目或選項。
16- 預估總題數 = 已經問過的題數 + 你估計還要問的題數。每次出題都重新估:答案多出新分支就加,排除掉分支就減。只是預估,不用精準。
17- 選項 2 到 4 個。你建議的選項放第一個,label 結尾加「(建議)」。label 1 到 5 個詞;description 一句話內,label 已經夠清楚就省略。question 一兩句話講完,需要的背景寫進 question。
18- 呼叫工具前不寫長篇說明,也不重述之前的答案,最多一行說明為什麼問這題。
19- 查得到的事實(檔案、程式碼、使用者的文件)自己查或派子代理查,不要問使用者。決定交給使用者,不能替使用者回答。
20- 使用者在「Other」打的字就是答案。回答「不知道」或跳過時,判斷是不是要先做原型才能回答,或換個角度問,不要重複問同一題。
21- grill-me、grilling 這類 skill 也照這裡的格式問,不用「❓ Q1」那種一次列一批的編號清單。
22
23## 中斷與結束
24- 使用者說「中斷拷問」:馬上停止出題。整理目前狀況:已經決定了哪些、從哪裡開始偏離主題、還剩哪些沒問。然後等使用者決定要不要繼續,不要自己接著問。
25- 使用者說「繼續拷問」:照整理後的方向,繼續一次一題問。
26- 決策樹都問完、或使用者說「收尾」時:先呼叫 mcp__grill__finish,再用條列簡短列出所有決定和還沒處理的分支,然後結束這一輪。使用者會從輸入框上方選「Markdown」「HTML」或「繼續提問」;選之前不要寫計畫、不要改檔或開始實作。
27- 使用者選「繼續提問」:往下問還沒處理的分支或需要更細的地方,照樣一次一題、回報預估總題數。`
28
29const TOOL_NOTE =
30 '拷問模式(grill mod)中:每一題都用這個工具問,questions 只放 1 題,metadata.source 設成 "grill:<預估總題數>"(例如 "grill:10");建議的選項放第一個,label 結尾加「(建議)」。'
31
32const FINISH_DESCRIPTION =
33 '拷問模式專用:決策樹都問完(或使用者說收尾)時呼叫,把使用者輸入框上方的「拷問中」換成「編寫計畫」,讓使用者選 Markdown、HTML 或繼續提問。'
34
35const FINISHED =
36 '已換成「編寫計畫」。現在用條列簡短列出所有決定和還沒處理的分支,然後結束這一輪;使用者會從輸入框上方選 Markdown、HTML 或繼續提問,選之前不要寫計畫。'
37
38const NUDGE =
39 '(grill mod)這則訊息可能是要建議或做決定。如果牽涉好幾個還沒定的分支,照系統提示的「拷問模式」做:用 AskUserQuestion 一次問一題。只是單一簡單問題就直接回答。'
40
41const INTERRUPT =
42 '中斷拷問:先不要再出題。整理目前狀況:已經決定了哪些、從哪裡開始偏離主題、還剩哪些沒問。整理完等我決定要不要繼續。'
43
44const RESUME = '繼續拷問:照剛才整理的方向,一次一題問下去。'
45
46const ENDED = '(grill mod)使用者結束了這次拷問,不要再接著出題。'
47
48const PLAN_CONTENT =
49 '內容包含:目標、已經做的決定(每項附一句理由)、還沒決定的事、下一步。檔名用主題命名,存在目前的工作資料夾,寫好告訴我路徑。'
50
51const WRITE_MARKDOWN = `編寫計畫:把這次拷問的結果整理成一份 Markdown 計畫(.md)。${PLAN_CONTENT}`
52
53const WRITE_HTML = `編寫計畫:把這次拷問的結果整理成一個 HTML 頁面(單一 .html 檔,樣式寫在檔案裡,排版清楚好讀)。${PLAN_CONTENT}`
54
55const MORE = '繼續提問:往下問還沒處理的分支或需要更細的地方,一次一題。'
56
57const TRIGGER =
58 /拷問|grill|幫我(決定|選|想想|規劃|企劃)|給(我)?(點|一些)?建議|怎麼(選|決定)|該(選|用)哪|要不要|規劃|企劃/i
59
60// metadata.source 的格式:"grill" 或 "grill:10"
61const SOURCE = /^grill(?::\s*(\d+))?$/
62
63const decisions = atom({ plugin: 'grill', key: 'decisions' } as const, [])
64const estimate = atom({ plugin: 'grill', key: 'estimate' } as const, null)
65const isActive = atom({ plugin: 'grill', key: 'isActive' } as const, false)
66const phase = atom({ plugin: 'grill', key: 'phase' } as const, 'asking' as Phase)
67const isAuto = atom({ plugin: 'grill', key: 'isAuto' } as const, true)
68
69const compactInstructions = (list: readonly Decision[]) =>
70 [
71 '這段對話有一段拷問(一問一答)。摘要時:',
72 '- 逐條照抄下面的決策清單,不要改寫或合併。',
73 '- 保留主題、使用者說過的偏好與限制、還沒問到的分支。',
74 '- 每一題的選項和說明不用保留。',
75 '',
76 '決策清單:',
77 ...list.map((one, i) => `${i + 1}. 【${one.header}】${one.question} → ${one.answer}`),
78 ].join('\n')
79
80const reset = async ($: EngineInterface) => {
81 await update($, isActive, () => false)
82 await update($, phase, () => 'asking' as Phase)
83 await update($, estimate, () => null)
84 await update($, decisions, () => [])
85}
86
87const startAsking = async ($: EngineInterface) => {
88 await update($, isActive, () => true)
89 await update($, phase, () => 'asking' as Phase)
90}
91
92export const register: Register = on => {
93 // 正在跑的那一輪,中斷拷問時要先停掉它
94 let runningTurn: string | null = null
95
96 on('session.start', async ($, e, next) => {
97 // /grill 本身是 ~/.claude/skills/grill 這個 skill,新 session 的指令清單才找得到
98 await $.command.register({
99 name: 'grill-auto',
100 description: '拷問模式的自動觸發:/grill-auto on 打開、off 關閉',
101 argumentHint: '[on | off]',
102 })
103 await $.tool.register({ name: 'finish', description: FINISH_DESCRIPTION, isDeferred: false })
104 const stored = await $.store.get('isAuto')
105 if (typeof stored === 'boolean') await update($, isAuto, () => stored)
106
107 return next(e)
108 })
109
110 on('session.end', async ($, e, next) => {
111 if (e.reason === 'clear') await reset($)
112
113 return next(e)
114 })
115
116 on('turn.start', ($, e, next) => {
117 runningTurn = e.turnId
118
119 return next(e)
120 })
121
122 on('turn.complete', ($, e, next) => {
123 runningTurn = null
124
125 return next(e)
126 })
127
128 // 規則每一輪都在系統提示詞裡,不靠模型自己決定要不要載入 skill
129 on('prompt.compose', async ($, e, next) => {
130 const composed = await next(e)
131 const isOn = (await read($, isAuto)) || (await read($, isActive))
132 if (!isOn) return composed
133
134 return {
135 sections: [
136 ...composed.sections,
137 { id: 'grill:rules', text: RULES, scope: 'session' as const },
138 ],
139 }
140 })
141
142 on('tool.describe', { tool: 'AskUserQuestion' }, async ($, e, next) => {
143 const described = await next(e)
144
145 return { ...described, description: `${described.description}\n\n${TOOL_NOTE}` }
146 })
147
148 on('prompt.submit', async ($, e, next) => {
149 if (e.origin.kind === 'plugin') return next(e)
150
151 // 停在「編寫計畫」時自己打字,代表不用那三個選項了,收起來
152 if ((await read($, isActive)) && (await read($, phase)) === 'planning') await reset($)
153
154 // 訊息像是在要建議或決定時,旁邊多塞一句提醒
155 if (!TRIGGER.test(e.text) || !(await read($, isAuto))) return next(e)
156
157 return next({ ...e, context: [...(e.context ?? []), NUDGE] })
158 })
159
160 // 出題時更新預估題數,回答後記下答案
161 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
162 const matched = SOURCE.exec(e.metadata?.source ?? '')
163 const isGrill = matched !== null || (await read($, isActive))
164 if (!isGrill) return next(e)
165
166 if (matched?.[1] !== undefined) {
167 const total = Number(matched[1])
168 await update($, estimate, () => total)
169 }
170 await startAsking($)
171
172 const ran = await next(e)
173 if (ran.deny !== undefined || ran.isError === true) return ran
174
175 const answers = (ran.result.answers ?? {}) as Record<string, string>
176 const asked: Decision[] = e.questions.map(q => ({
177 header: q.header,
178 question: q.question,
179 answer: answers[q.question] ?? ran.result.response ?? '(沒回答)',
180 }))
181 await update($, decisions, list => [...list, ...asked])
182
183 return ran
184 })
185
186 on('tool.call', { tool: 'mcp__grill__finish' }, async $ => {
187 await update($, isActive, () => true)
188 await update($, phase, () => 'planning' as Phase)
189
190 return { result: FINISHED }
191 })
192
193 // 自動壓縮時照抄決策清單
194 on('session.compact', async ($, e, next) => {
195 const list = await read($, decisions)
196 const isGrilling = e.agentId === undefined && (await read($, isActive)) && list.length > 0
197 if (!isGrilling) return next(e)
198
199 const kept = compactInstructions(list)
200 return next({ ...e, instructions: e.instructions ? `${e.instructions}\n\n${kept}` : kept })
201 })
202
203 // 打 /grill 時進入拷問中,上方列馬上出現。外掛裡的 skill 會叫 grill:grill
204 on('skill.prompt', async ($, e, next) => {
205 if (e.skill === 'grill' || e.skill === 'grill:grill') await startAsking($)
206
207 return next(e)
208 })
209
210 on('command.run', { command: 'grill-auto' }, async ($, e) => {
211 const arg = e.args.trim()
212 if (arg !== 'on' && arg !== 'off') {
213 const isOn = await read($, isAuto)
214 return { text: `自動拷問目前是${isOn ? '開' : '關'}的。用 /grill-auto on 或 off 切換。` }
215 }
216
217 const isOn = arg === 'on'
218 await update($, isAuto, () => isOn)
219 await $.store.set('isAuto', isOn)
220
221 return {
222 text: isOn
223 ? '已開啟自動拷問:要建議或做決定時會自動用拷問模式。'
224 : '已關閉自動拷問。要拷問時打 /grill 主題。',
225 }
226 })
227
228 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
229 if (e.props.hasSurvey || !(await read($, isActive))) return next(e)
230
231 const now = await read($, phase)
232 const { Box, Button, Text } = $.ui.resolve(e)
233
234 const send = (text: string) => {
235 void $.prompt.submit({ text, asUser: true })
236 }
237
238 const interrupt = async () => {
239 await update($, phase, () => 'paused' as Phase)
240 if (runningTurn !== null) {
241 try {
242 await $.turn.abort({ turnId: runningTurn })
243 } catch {
244 // 那一輪剛好結束了,直接送出就好
245 }
246 }
247 send(INTERRUPT)
248 }
249
250 const resume = async () => {
251 await update($, phase, () => 'asking' as Phase)
252 send(RESUME)
253 }
254
255 const quit = async () => {
256 await reset($)
257 try {
258 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: ENDED }] } })
259 } catch {
260 // 留不了提醒也沒關係,規則還在,下一則訊息會說清楚
261 }
262 $.ui.toast('已結束拷問')
263 }
264
265 const writePlan = (text: string) => async () => {
266 await reset($)
267 send(text)
268 }
269
270 const more = async () => {
271 await update($, phase, () => 'asking' as Phase)
272 send(MORE)
273 }
274
275 if (now === 'planning') {
276 return (
277 <Box key="grill" flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
278 <Text bold>編寫計畫</Text>
279 <Button key="markdown" hotkey="1" variant="primary" label="Markdown" onPress={writePlan(WRITE_MARKDOWN)} />
280 <Button key="html" hotkey="2" label="HTML" onPress={writePlan(WRITE_HTML)} />
281 <Button key="more" hotkey="3" label="繼續提問" onPress={more} />
282 </Box>
283 )
284 }
285
286 const answered = (await read($, decisions)).length
287 const total = await read($, estimate)
288 const shown = total === null ? '?' : String(Math.max(total, answered))
289 const progress = `[預估問題 ${answered}/${shown}]`
290 const isPaused = now === 'paused'
291
292 const buttons = isPaused
293 ? [
294 <Button key="resume" hotkey="1" variant="primary" label="繼續拷問" onPress={resume} />,
295 <Button key="quit" hotkey="2" label="不問了" onPress={quit} />,
296 ]
297 : [<Button key="interrupt" hotkey="1" label="中斷拷問" onPress={interrupt} />]
298
299 return (
300 <Box key="grill" flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
301 <Text bold>{isPaused ? '已中斷' : '拷問中'}</Text>
302 <Text dimColor>{progress}</Text>
303 {buttons}
304 </Box>
305 )
306 })
307}
308types/index.d.ts 17 lines1export type Decision = { header: string; question: string; answer: string }
2
3// asking:拷問中;paused:已中斷;planning:問完了,等使用者選怎麼寫計畫
4export type Phase = 'asking' | 'paused' | 'planning'
5
6declare module 'claude-code' {
7 interface PluginState {
8 grill: {
9 decisions: Decision[]
10 estimate: number | null
11 isActive: boolean
12 phase: Phase
13 isAuto: boolean
14 }
15 }
16}
17