/quest: the task list as a quest log (✓ done, ▶ in progress, · to do, the last one the BOSS), kept across /compact and into the next session in the same project

/quest opens the task list as a quest log:
QUEST LOG 2/4 클리어 █████░░░░░
✓ 저장소 구조 파악
▶ 토큰 만료 단위 수정
· README 갱신
· 전체 테스트 통과 BOSS
The list is the one Claude keeps: TodoWrite, or TaskCreate and TaskUpdate. ✓ done, ▶ in progress, · to do; the last quest is the BOSS, and ALL CLEAR! shows when every quest is done.
It is kept for the project: /compact keeps it, and the next session in the same folder starts from it (지난 세션에서 이어짐) until Claude makes a new list. /quest clear empties it.
Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.291.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-quest@bitgame-mods
intensity (casual) | off · casual · hardcore (adds what the quest in progress is doing) |
palette (nes) | nes · gameboy · amber · theme |
Claude의 할 일 목록을 퀘스트 로그로 보여줍니다(✓ 완료, ▶ 진행, · 남음, 마지막은 BOSS). 프로젝트별로 저장되어 /compact 뒤에도, 다음 세션에서도 이어집니다.
hooks/register.tsx 168 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Quest, QuestLog } from '../types'
5import { bar, intensityOf, paletteOf } from './palette'
6
7// GAME MODE · QUEST
8//
9// /quest opens the task list as a quest log:
10// QUEST LOG · 2/4 클리어 ████░░░░░░
11// ✓ 저장소 구조 파악
12// ▶ 토큰 만료 단위 수정 · 단위를 맞추는 중
13// · README 갱신
14// · 전체 테스트 통과 BOSS
15// The list is the one Claude keeps (TodoWrite, or TaskCreate and TaskUpdate). It is kept
16// for the project, so /compact and the next session in the same folder start from it.
17
18const PANE = 'quest'
19const EMPTY: QuestLog = { quests: [], isCarried: false, updatedAt: 0 }
20const log = atom({ plugin: 'game-quest', key: 'log' } as const, EMPTY)
21
22export const register: Register = (on, options) => {
23 const intensity = intensityOf(options.intensity)
24 const pal = paletteOf(options.palette)
25 let key = 'log'
26
27 on('session.start', async ($, e, next) => {
28 const result = await next(e)
29 key = `log:${await $.session.root()}`
30 await $.command.register({ name: 'quest', description: 'GAME MODE quest log: the task list as quests', argumentHint: '[clear]' })
31 const kept = logOf(await $.store.get(key))
32 if (kept.quests.length > 0 && (await read($, log)).quests.length === 0) {
33 await update($, log, () => ({ ...kept, isCarried: true }))
34 }
35 return result
36 })
37
38 on('tool.call', async ($, e, next) => {
39 const ran = await next(e)
40 if (e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
41 const now = await $.clock.now()
42 let change: ((l: QuestLog) => QuestLog) | undefined
43 if (e.tool === 'TodoWrite') {
44 const quests = todosOf(e.todos)
45 change = () => ({ quests, isCarried: false, updatedAt: now })
46 } else if (e.tool === 'TaskCreate') {
47 const id = createdId(ran.result)
48 if (id !== undefined) {
49 const quest: Quest = { id, text: e.subject, doing: e.activeForm ?? '', status: 'pending' }
50 change = l => ({ quests: [...(l.isCarried ? [] : l.quests), quest], isCarried: false, updatedAt: now })
51 }
52 } else if (e.tool === 'TaskUpdate') {
53 const { taskId, status, subject, activeForm } = e
54 change = l => ({
55 ...l,
56 updatedAt: now,
57 quests:
58 status === 'deleted'
59 ? l.quests.filter(q => q.id !== taskId)
60 : l.quests.map(q =>
61 q.id === taskId ? { ...q, status: status ?? q.status, text: subject ?? q.text, doing: activeForm ?? q.doing } : q,
62 ),
63 })
64 }
65 if (change !== undefined) await keep($, key, change).catch(() => undefined)
66 return ran
67 }).catch(($, e, next) => next(e))
68
69 on('command.run', { command: 'quest' }, async ($, e) => {
70 if (e.args.trim().toLowerCase() === 'clear') {
71 await keep($, key, () => EMPTY)
72 return { text: 'quest log cleared' }
73 }
74 if (intensity !== 'off') await $.ui.open({ id: PANE, title: 'QUEST' })
75 return { text: questText(await read($, log)) }
76 })
77
78 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
79 const { Box, Text } = $.ui.resolve(e)
80 const l = await read($, log)
81 const done = l.quests.filter(q => q.status === 'completed').length
82 const total = l.quests.length
83 const width = Math.max(20, e.props.bodyColumns)
84 return (
85 <Box flexDirection="column">
86 <Box flexDirection="row" gap={2}>
87 <Text color={pal.title} bold>
88 QUEST LOG
89 </Text>
90 <Text color={pal.text}>{total === 0 ? '퀘스트 없음' : `${done}/${total} 클리어`}</Text>
91 {total > 0 && <Text color={pal.ok}>{bar((done / total) * 100, Math.min(10, Math.max(4, width - 30)))}</Text>}
92 {l.isCarried && <Text color={pal.dim}>지난 세션에서 이어짐</Text>}
93 </Box>
94 {total === 0 && <Text color={pal.dim}>Claude가 할 일 목록을 만들면 여기 퀘스트로 나타납니다.</Text>}
95 {l.quests.map((q, i) => {
96 const isBoss = i === total - 1 && total > 1
97 const mark = q.status === 'completed' ? '✓' : q.status === 'in_progress' ? '▶' : '·'
98 const tone = q.status === 'completed' ? pal.ok : q.status === 'in_progress' ? pal.title : pal.text
99 return (
100 <Box key={q.id} flexDirection="row" gap={1}>
101 <Text color={tone} bold>
102 {mark}
103 </Text>
104 <Box flexGrow={1} flexShrink={1}>
105 <Text color={q.status === 'completed' ? pal.dim : tone} wrap="truncate-end">
106 {q.text + (intensity === 'hardcore' && q.status === 'in_progress' && q.doing !== '' ? ` · ${q.doing}` : '')}
107 </Text>
108 </Box>
109 {isBoss && (
110 <Text color={pal.bad} bold>
111 BOSS
112 </Text>
113 )}
114 </Box>
115 )
116 })}
117 {total > 0 && done === total && (
118 <Text color={pal.title} bold>
119 ALL CLEAR!
120 </Text>
121 )}
122 </Box>
123 )
124 })
125}
126
127/** Changes the log and keeps it for the project. */
128async function keep($: EngineInterface, key: string, change: (l: QuestLog) => QuestLog): Promise<void> {
129 await update($, log, change)
130 const now = await read($, log)
131 await $.store.set(key, { quests: now.quests, updatedAt: now.updatedAt })
132}
133
134export function todosOf(todos: unknown): Quest[] {
135 if (!Array.isArray(todos)) return []
136 return todos.flatMap((t, i) => {
137 if (t === null || typeof t !== 'object') return []
138 const o = t as { content?: unknown; status?: unknown; activeForm?: unknown }
139 if (typeof o.content !== 'string') return []
140 const status = o.status === 'completed' || o.status === 'in_progress' ? o.status : 'pending'
141 return [{ id: `todo-${i}`, text: o.content, doing: typeof o.activeForm === 'string' ? o.activeForm : '', status }]
142 })
143}
144
145function createdId(result: unknown): string | undefined {
146 const task = result !== null && typeof result === 'object' ? (result as { task?: { id?: unknown } }).task : undefined
147 return typeof task?.id === 'string' ? task.id : undefined
148}
149
150export function logOf(value: unknown): QuestLog {
151 if (value === null || typeof value !== 'object') return EMPTY
152 const o = value as { quests?: unknown; updatedAt?: unknown }
153 const quests = Array.isArray(o.quests)
154 ? o.quests.filter((q): q is Quest => q !== null && typeof q === 'object' && typeof (q as Quest).id === 'string' && typeof (q as Quest).text === 'string')
155 : []
156 return { quests, isCarried: false, updatedAt: typeof o.updatedAt === 'number' ? o.updatedAt : 0 }
157}
158
159export function questText(l: QuestLog): string {
160 if (l.quests.length === 0) return 'QUEST LOG · 퀘스트 없음'
161 const done = l.quests.filter(q => q.status === 'completed').length
162 const lines = l.quests.map((q, i) => {
163 const mark = q.status === 'completed' ? '✓' : q.status === 'in_progress' ? '▶' : '·'
164 return `${mark} ${q.text}${i === l.quests.length - 1 && l.quests.length > 1 ? ' BOSS' : ''}`
165 })
166 return [`QUEST LOG · ${done}/${l.quests.length} 클리어${l.isCarried ? ' · 지난 세션에서 이어짐' : ''}`, ...lines].join('\n')
167}
168hooks/palette.ts 124 lines1// GAME MODE shared look (copied into each GAME MODE plugin: a hooks module
2// may only import files of its own plugin). Change every copy together;
3// scripts/check.sh fails when the copies differ.
4//
5// nes / gameboy / amber are raw colors made for dark terminals, taken from
6// the design boards. `theme` uses Claude Code's theme keys, so it follows the
7// person's theme: pick it on a light background. Its `win` is empty: windows
8// draw with no fill there.
9
10export type PaletteName = 'nes' | 'gameboy' | 'amber' | 'theme'
11
12export type Palette = {
13 /** Headings, gold, the level. */
14 title: string
15 ok: string
16 warn: string
17 bad: string
18 info: string
19 dim: string
20 text: string
21 /** A window's border. */
22 frame: string
23 /** A window's fill; '' for none. */
24 win: string
25 /** The bright inner edge of a window, and a highlighted badge's text. */
26 edge: string
27 /** A bar's empty cells. */
28 off: string
29}
30
31const PALETTES: Record<PaletteName, Palette> = {
32 nes: {
33 title: '#f8d830',
34 ok: '#58d854',
35 warn: '#ff8a6a',
36 bad: '#f83800',
37 info: '#3cbcfc',
38 dim: '#b8b8f0',
39 text: '#fcfcfc',
40 frame: '#5060d8',
41 win: '#1c2a8a',
42 edge: '#fcfcfc',
43 off: '#2c3a98',
44 },
45 gameboy: {
46 title: '#e0f8d0',
47 ok: '#9bbc0f',
48 warn: '#e0f8d0',
49 bad: '#f8f8a0',
50 info: '#c4e890',
51 dim: '#8bac0f',
52 text: '#e0f8d0',
53 frame: '#9bbc0f',
54 win: '#1e4a1e',
55 edge: '#e0f8d0',
56 off: '#306230',
57 },
58 amber: {
59 title: '#ffd060',
60 ok: '#ffb000',
61 warn: '#ff7040',
62 bad: '#ff5a2a',
63 info: '#ffe0a0',
64 dim: '#c89040',
65 text: '#ffd9a0',
66 frame: '#ffb000',
67 win: '#2a1700',
68 edge: '#ffe0a0',
69 off: '#4a2a00',
70 },
71 theme: {
72 title: 'claude',
73 ok: 'success',
74 warn: 'warning',
75 bad: 'error',
76 info: 'suggestion',
77 dim: 'subtle',
78 text: 'text',
79 frame: 'promptBorder',
80 win: '',
81 edge: 'text',
82 off: 'subtle',
83 },
84}
85
86export function paletteOf(name: unknown): Palette {
87 return name === 'gameboy' || name === 'amber' || name === 'theme' ? PALETTES[name] : PALETTES.nes
88}
89
90export type Intensity = 'off' | 'casual' | 'hardcore'
91
92export function intensityOf(value: unknown): Intensity {
93 return value === 'off' || value === 'hardcore' ? value : 'casual'
94}
95
96/** A bar of `cells` blocks, `percent` of them full. */
97export function bar(percent: number, cells: number): string {
98 const full = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
99 return '█'.repeat(full) + '░'.repeat(cells - full)
100}
101
102/** Box props for a window in this palette: a double border and, where the palette has one, a fill. */
103export function windowProps(pal: Palette): { borderStyle: 'double'; borderColor: string; backgroundColor?: string; paddingX: number } {
104 return pal.win === ''
105 ? { borderStyle: 'double', borderColor: pal.frame, paddingX: 1 }
106 : { borderStyle: 'double', borderColor: pal.frame, backgroundColor: pal.win, paddingX: 1 }
107}
108
109/** `m:ss`, or `h:mm:ss` past an hour. */
110export function clock(ms: number): string {
111 const s = Math.max(0, Math.round(ms / 1000))
112 const h = Math.floor(s / 3600)
113 const m = Math.floor((s % 3600) / 60)
114 const sec = s % 60
115 const two = (n: number) => String(n).padStart(2, '0')
116 return h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${m}:${two(sec)}`
117}
118
119/** A cost in the chosen currency: `$1.23`, or `₩1,722` at `krwPerUsd`. */
120export function money(usd: number, currency: 'usd' | 'krw', krwPerUsd: number): string {
121 if (currency === 'krw') return '₩' + String(Math.round(usd * krwPerUsd)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
122 return '$' + usd.toFixed(2)
123}
124types/index.d.ts 25 lines1/** One quest: a todo item or a task, as Claude keeps them. */
2export type Quest = {
3 /** The task's id (TaskCreate), or `todo-<n>` for a TodoWrite item. */
4 id: string
5 text: string
6 /** What it is doing, while in progress (the item's activeForm). */
7 doing: string
8 status: 'pending' | 'in_progress' | 'completed'
9}
10
11/** The log as drawn and as kept for the project. */
12export type QuestLog = {
13 quests: Quest[]
14 /** The list came from an earlier session in this project and no new list replaced it. */
15 isCarried: boolean
16 /** When the list last changed, in $.clock.now() time; 0 for never. */
17 updatedAt: number
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'game-quest': { log: QuestLog }
23 }
24}
25