Turns an approved plan into a todo list above the prompt; Claude checks each step off only with verification evidence


A Claude Code mod that turns the plan you approve in plan mode into a todo list, and lets Claude check a step off only when it shows how it verified it.
━━━━──────── 1/3 Next Fix the refund logic [Show all]
At the prompt of a Claude Code terminal session:
/plugin install plan-checklist --marketplace Jh-jaehyuk/plan-checklist
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). It is active right away.
plan_check tool, and must say which verification it ran and what came out (for example pytest tests/test_refund.py → 12 passed). A check without real evidence is refused./compact, and /clear wipes it.When every step is checked you get a toast and the bar turns green.
| Command | What it does |
|---|---|
/checklist | Lists every step with the evidence Claude gave for it |
| Option | Values | Default |
|---|---|---|
language | en, ko | en |
Change it from /config, or in settings.json:
{ "pluginConfigs": { "plan-checklist": { "options": { "language": "ko" } } } }
The language applies to the band, the toasts, /checklist and the reminder Claude reads.
--resume) starts without it.See CONTRIBUTING.md. Changes are listed in CHANGELOG.md.
MIT
hooks/register.tsx 153 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { messagesFor } from './messages'
5import { bandLayout, checkItem, checklistMarkdown, doneCount, extractPlan, filledCells, reminder, stepTitle } from './plan'
6
7const plan = atom({ plugin: 'plan-checklist', key: 'plan' } as const, null)
8const isExpanded = atom({ plugin: 'plan-checklist', key: 'isExpanded' } as const, false)
9
10const CHECK_TOOL = 'mcp__plan-checklist__plan_check'
11
12export const register: Register = (on, options) => {
13 const t = messagesFor(options.language)
14
15 on('session.start', async ($, e, next) => {
16 await $.tool.register({
17 name: 'plan_check',
18 description: t.toolDescription,
19 inputSchema: {
20 type: 'object',
21 properties: {
22 item: { type: 'integer', description: t.itemParam },
23 evidence: { type: 'string', description: t.evidenceParam },
24 },
25 required: ['item', 'evidence'],
26 },
27 isDeferred: false,
28 })
29 await $.command.register({ name: 'checklist', description: t.commandDescription })
30
31 return next(e)
32 })
33
34 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
35 const ran = await next(e)
36 if (ran.deny !== undefined || ran.isError === true || ran.result.plan === null) {
37 return ran
38 }
39
40 const extracted = extractPlan(ran.result.plan, t)
41 if (extracted === null) {
42 $.ui.toast(t.noStepsFound)
43 return ran
44 }
45 await update($, plan, () => extracted)
46 $.ui.toast(t.tracking(extracted.items.length))
47
48 return ran
49 }).catch(($, e, next) => next(e))
50
51 on('tool.call', { tool: CHECK_TOOL }, async ($, e) => {
52 const input = e as unknown as { item: number; evidence: string }
53 const outcome = checkItem(await read($, plan), Number(input.item), String(input.evidence ?? ''), t)
54 if ('error' in outcome) {
55 return { result: outcome.error, isError: true as const }
56 }
57
58 await update($, plan, () => outcome.plan)
59 const done = doneCount(outcome.plan)
60 const total = outcome.plan.items.length
61 $.ui.toast(done === total ? t.finished(outcome.plan.title) : t.checked(done, total))
62
63 return { result: t.checkResult(input.item, done, total) }
64 })
65
66 on('command.run', { command: 'checklist' }, async $ => {
67 const current = await read($, plan)
68 if (current === null) {
69 return { text: t.commandNoPlan }
70 }
71 return { text: checklistMarkdown(current) }
72 })
73
74 on('prompt.compose', async ($, e, next) => {
75 const composed = await next(e)
76 const current = await read($, plan)
77 const text = current === null ? null : reminder(current, CHECK_TOOL, t)
78 if (text === null) {
79 return composed
80 }
81 return { sections: [...composed.sections, { id: 'plan-checklist:reminder', text, scope: 'session' as const }] }
82 })
83
84 on('session.end', async ($, e, next) => {
85 if (e.reason === 'clear') {
86 await update($, plan, () => null)
87 }
88 return next(e)
89 })
90
91 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92 const current = await read($, plan)
93 if (e.props.hasSurvey || current === null) {
94 return next(e)
95 }
96
97 const done = doneCount(current)
98 const total = current.items.length
99 const isDone = done === total
100 const nextItem = current.items.findIndex(one => one.evidence === null)
101 const expanded = await read($, isExpanded)
102 const layout = bandLayout(e.props.bodyColumns)
103 const filled = filledCells(done, total, layout.barWidth)
104 const { Box, Button, Text } = $.ui.resolve(e)
105
106 // Collapsed: one line with the progress and the step Claude is on, or that all are done.
107 // Expanded: the same line titled with the plan, then every step.
108 const label = expanded ? current.title : isDone ? t.allDone(total) : stepTitle(current.items[nextItem]?.text ?? '')
109 return (
110 <Box flexDirection="column">
111 <Box columnGap={layout.gap}>
112 {layout.barWidth > 0 && (
113 <Text>
114 <Text color={isDone ? 'success' : 'suggestion'}>{'━'.repeat(filled)}</Text>
115 <Text color="subtle">{'─'.repeat(layout.barWidth - filled)}</Text>
116 </Text>
117 )}
118 <Text bold color={isDone ? 'success' : undefined}>
119 {done}/{total}
120 </Text>
121 <Box flexGrow={1}>
122 <Text wrap="truncate-end" color={isDone && !expanded ? 'success' : undefined}>
123 {!expanded && !isDone && layout.showLabel && <Text dimColor>{t.next} </Text>}
124 {label}
125 </Text>
126 </Box>
127 <Button
128 key="toggle"
129 label={expanded ? t.hide : t.showAll}
130 onPress={() => update($, isExpanded, now => !now)}
131 />
132 </Box>
133 {expanded &&
134 current.items.map((one, i) => (
135 <Text key={`item-${i}`} wrap="truncate-end" dimColor={i !== nextItem} bold={i === nextItem}>
136 {' '}
137 <Text color={one.evidence !== null ? 'success' : undefined}>
138 {one.evidence !== null ? '✓' : i === nextItem ? '›' : '○'}
139 </Text>{' '}
140 {i + 1}. {stepTitle(one.text)}
141 </Text>
142 ))}
143 {expanded && (
144 <Text color="subtle">
145 {' '}
146 {t.evidenceHint}
147 </Text>
148 )}
149 </Box>
150 )
151 })
152}
153hooks/messages.ts 84 lines1export type Lang = 'en' | 'ko'
2
3export type Messages = {
4 untitled: string
5 noPlan: string
6 noSuchItem: (item: number, total: number) => string
7 needEvidence: string
8 reminderTitle: (title: string, done: number, total: number) => string
9 reminderLeft: string
10 reminderRule: (tool: string) => string
11 toolDescription: string
12 itemParam: string
13 evidenceParam: string
14 commandDescription: string
15 commandNoPlan: string
16 noStepsFound: string
17 tracking: (count: number) => string
18 checked: (done: number, total: number) => string
19 finished: (title: string) => string
20 checkResult: (item: number, done: number, total: number) => string
21 next: string
22 allDone: (total: number) => string
23 showAll: string
24 hide: string
25 evidenceHint: string
26}
27
28export const MESSAGES: Record<Lang, Messages> = {
29 en: {
30 untitled: 'Plan',
31 noPlan: 'No plan in progress.',
32 noSuchItem: (item, total) => `There is no item ${item}. Pick one from 1 to ${total}.`,
33 needEvidence: 'Evidence is required. Give the verification command you actually ran and its result.',
34 reminderTitle: (title, done, total) => `# Plan in progress: ${title} (${done}/${total})`,
35 reminderLeft: 'Remaining items:',
36 reminderRule: tool =>
37 `When you finish an item, call ${tool} with { item, evidence }. Put the verification commands you actually ran in evidence, one per line as "command → result". Do not check an item you have not verified.`,
38 toolDescription:
39 'Mark one item of the plan in progress as done. Call it only for an item you have verified, and put the verification command you ran and its result in evidence.',
40 itemParam: 'Item number (starting at 1)',
41 evidenceParam: 'One check per line, "command → result" (e.g. "pytest tests/x.py → 12 passed")',
42 commandDescription: 'Show the steps of the plan in progress with their evidence',
43 commandNoPlan: 'No plan in progress. Approve a plan in plan mode to start tracking it.',
44 noStepsFound: 'plan-checklist: no steps found in the plan (use a numbered list or checkboxes)',
45 tracking: count => `Tracking ${count} steps from the approved plan`,
46 checked: (done, total) => `Step done (${done}/${total})`,
47 finished: title => `All steps done: ${title}`,
48 checkResult: (item, done, total) => `Item ${item} checked (${done}/${total})`,
49 next: 'Next',
50 allDone: total => `✓ All ${total} steps done`,
51 showAll: 'Show all',
52 hide: 'Hide',
53 evidenceHint: '/checklist shows the evidence for each step',
54 },
55 ko: {
56 untitled: '계획',
57 noPlan: '진행 중인 계획이 없습니다.',
58 noSuchItem: (item, total) => `항목 ${item}은 없습니다. 1~${total} 중에서 고르세요.`,
59 needEvidence: '증거가 필요합니다. 실제로 돌린 검증 명령과 그 결과를 적으세요.',
60 reminderTitle: (title, done, total) => `# 진행 중인 계획: ${title} (${done}/${total})`,
61 reminderLeft: '남은 항목:',
62 reminderRule: tool =>
63 `항목을 끝내면 ${tool}를 { item, evidence }로 호출한다. evidence에는 실제로 돌린 검증을 "명령 → 결과" 형식으로 한 줄에 하나씩 적는다. 검증하지 않은 항목은 체크하지 않는다.`,
64 toolDescription:
65 '진행 중인 계획의 항목 하나를 완료로 체크한다. 실제로 검증을 끝낸 항목에만 호출하고, evidence에 돌린 검증 명령과 결과를 적는다.',
66 itemParam: '항목 번호 (1부터)',
67 evidenceParam: '검증 하나당 한 줄, "명령 → 결과" (예: "pytest tests/x.py → 12 passed")',
68 commandDescription: '진행 중인 계획의 항목과 증거를 보여준다',
69 commandNoPlan: '진행 중인 계획이 없어요. plan 모드에서 계획을 승인하면 생겨요.',
70 noStepsFound: 'plan-checklist: 계획에서 항목을 찾지 못했어요 (번호 목록이나 체크박스가 필요해요)',
71 tracking: count => `승인된 계획에서 단계 ${count}개를 추적해요`,
72 checked: (done, total) => `단계 완료 (${done}/${total})`,
73 finished: title => `모든 단계 완료: ${title}`,
74 checkResult: (item, done, total) => `항목 ${item} 체크됨 (${done}/${total})`,
75 next: '다음',
76 allDone: total => `✓ ${total}단계 모두 완료`,
77 showAll: '전체 보기',
78 hide: '접기',
79 evidenceHint: '/checklist 로 단계별 증거를 볼 수 있어요',
80 },
81}
82
83export const messagesFor = (language: unknown): Messages => MESSAGES[language === 'ko' ? 'ko' : 'en']
84hooks/plan.ts 97 lines1import type { Plan, PlanItem } from '../types'
2
3import type { Messages } from './messages'
4
5const MAX_ITEMS = 20
6const MIN_EVIDENCE = 8
7
8const clean = (text: string) => text.replace(/\*\*|`/g, '').trim()
9
10// The first format that matches becomes the steps: checkboxes, then top-level numbers, then top-level bullets.
11const PATTERNS = [/^\s*[-*]\s+\[[ xX]\]\s+(.+)$/, /^\d+[.)]\s+(.+)$/, /^[-*]\s+(.+)$/]
12
13export function extractPlan(markdown: string, t: Messages): Plan | null {
14 const lines = markdown.split('\n')
15 const title = clean(lines.find(line => /^#\s+/.test(line))?.replace(/^#\s+/, '') ?? t.untitled)
16
17 for (const pattern of PATTERNS) {
18 const items: PlanItem[] = lines
19 .map(line => pattern.exec(line)?.[1])
20 .filter((text): text is string => text !== undefined)
21 .map(text => ({ text: clean(text), evidence: null }))
22 if (items.length > 0) {
23 return { title, items: items.slice(0, MAX_ITEMS) }
24 }
25 }
26 return null
27}
28
29export type CheckOutcome = { plan: Plan } | { error: string }
30
31export function checkItem(plan: Plan | null, item: number, evidence: string, t: Messages): CheckOutcome {
32 if (plan === null) {
33 return { error: t.noPlan }
34 }
35 const target = plan.items[item - 1]
36 if (target === undefined) {
37 return { error: t.noSuchItem(item, plan.items.length) }
38 }
39 if (evidence.trim().length < MIN_EVIDENCE) {
40 return { error: t.needEvidence }
41 }
42 const items = plan.items.map((one, i) =>
43 i === item - 1 ? { ...one, evidence: evidence.trim() } : one,
44 )
45 return { plan: { ...plan, items } }
46}
47
48export const doneCount = (plan: Plan) => plan.items.filter(one => one.evidence !== null).length
49
50export function reminder(plan: Plan, tool: string, t: Messages): string | null {
51 const left = plan.items
52 .map((one, i) => ({ ...one, n: i + 1 }))
53 .filter(one => one.evidence === null)
54 if (left.length === 0) {
55 return null
56 }
57 return [
58 t.reminderTitle(plan.title, doneCount(plan), plan.items.length),
59 t.reminderLeft,
60 ...left.map(one => `${one.n}. ${one.text}`),
61 t.reminderRule(tool),
62 ].join('\n')
63}
64
65// How many of `width` cells the progress bar fills.
66export const filledCells = (done: number, total: number, width: number) =>
67 total === 0 ? 0 : Math.round((done / total) * width)
68
69export type BandLayout = { barWidth: number; gap: number; showLabel: boolean }
70
71// The band shrinks for narrow panes (tmux splits): the word label goes first, then the bar.
72export const bandLayout = (columns: number): BandLayout =>
73 columns >= 70
74 ? { barWidth: 12, gap: 2, showLabel: true }
75 : columns >= 45
76 ? { barWidth: 6, gap: 1, showLabel: false }
77 : { barWidth: 0, gap: 1, showLabel: false }
78
79// "Write the test: run pytest" → "Write the test"; a colon inside a word (localhost:8000) is not a split.
80export const stepTitle = (text: string) => text.split(/:\s/)[0]?.trim() || text
81
82export const evidenceLines = (evidence: string) =>
83 evidence
84 .split(/\n|;\s/)
85 .map(line => line.trim())
86 .filter(line => line.length > 0)
87
88export function checklistMarkdown(plan: Plan): string {
89 const nextItem = plan.items.findIndex(one => one.evidence === null)
90 const blocks = plan.items.map((one, i) => {
91 const mark = one.evidence !== null ? '✓' : i === nextItem ? '›' : '○'
92 const evidence = one.evidence === null ? [] : evidenceLines(one.evidence).map(line => ` ${line}`)
93 return [`${mark} ${i + 1}. ${stepTitle(one.text)}`, ...evidence].join('\n')
94 })
95 return [`${plan.title} · ${doneCount(plan)}/${plan.items.length}`, ...blocks].join('\n\n')
96}
97types/index.d.ts 10 lines1export type PlanItem = { text: string; evidence: string | null }
2
3export type Plan = { title: string; items: PlanItem[] }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'plan-checklist': { plan: Plan | null; isExpanded: boolean }
8 }
9}
10