Suggests 3 valuable things to build for the current project, each with a Build button

hooks/register.tsx 230 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Suggests three things worth building for the project in the session's folder,
4// shown in a pane, each with a Build button that hands the job to Claude.
5
6const PANE = 'feature-ideas'
7const MODEL = 'sonnet'
8const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.venv', '__pycache__', '.DS_Store'])
9const TEXT_FILE = /\.(md|txt|json|ya?ml|toml|csv)$/i
10const KEY_FILE = /^(readme|claude|agents|package\.json|pyproject\.toml|cargo\.toml|go\.mod)/i
11const MAX_ENTRIES = 400
12const MAX_FILE_CHARS = 4000
13const MAX_FILES_READ = 6
14// Notes a session leaves about what it built. Read with a bigger budget than
15// key files, so the model sees what is already done before it suggests anything.
16const NOTE_FILES = ['docs/CHAT_HANDOFF.md', 'CHAT_HANDOFF.md', 'HANDOFF.md', 'docs/HANDOFF.md', 'CONTINUITY.md', 'CHANGELOG.md']
17const MAX_NOTES_READ = 2
18const MAX_NOTE_CHARS = 12000
19const GIT_LOG_COUNT = '60'
20
21type Idea = { title: string; description: string; why: string; buildPrompt: string }
22type Saved = {
23 status: 'idle' | 'thinking' | 'ready' | 'error'
24 ideas: Idea[]
25 error?: string
26 builtTitles?: string[]
27}
28
29const keyFor = (cwd: string) => `ideas:${cwd}`
30
31async function load($: EngineInterface, cwd: string): Promise<Saved> {
32 const saved = (await $.store.get(keyFor(cwd))) as Saved | undefined
33 return saved ?? { status: 'idle', ideas: [] }
34}
35
36async function save($: EngineInterface, cwd: string, patch: Partial<Saved>) {
37 const current = await load($, cwd)
38 await $.store.set(keyFor(cwd), { ...current, ...patch })
39}
40
41// A snapshot of the project: its file tree two levels deep and the opening
42// of a few key text files. Nothing leaves the machine but this, sent to the model.
43async function snapshot($: EngineInterface, cwd: string): Promise<string> {
44 const lines: string[] = []
45 const toRead: string[] = []
46
47 const walk = async (dir: string, depth: number) => {
48 if (lines.length >= MAX_ENTRIES) return
49 const entries = await $.fs.list(dir).catch(() => [])
50 for (const entry of entries) {
51 if (SKIP.has(entry.name) || entry.name.startsWith('~$')) continue
52 if (lines.length >= MAX_ENTRIES) return
53 const path = dir === cwd ? entry.name : `${dir.slice(cwd.length + 1)}/${entry.name}`
54 lines.push(`${' '.repeat(depth)}${entry.name}${entry.kind === 'dir' ? '/' : ''}`)
55 if (entry.kind === 'dir' && depth < 1) {
56 await walk(`${dir}/${entry.name}`, depth + 1)
57 } else if (entry.kind === 'file' && TEXT_FILE.test(entry.name)) {
58 if (KEY_FILE.test(entry.name)) toRead.unshift(path)
59 else if (depth === 0) toRead.push(path)
60 }
61 }
62 }
63 await walk(cwd, 0)
64
65 const files: string[] = []
66 for (const path of toRead.slice(0, MAX_FILES_READ)) {
67 const text = await $.fs.read(`${cwd}/${path}`).catch(() => '')
68 if (text) files.push(`--- ${path} ---\n${text.slice(0, MAX_FILE_CHARS)}`)
69 }
70
71 return `Folder: ${cwd}\n\nFiles:\n${lines.join('\n')}\n\n${files.join('\n\n')}${await doneSoFar($, cwd)}`
72}
73
74// What the project already has: recent commits and any handoff notes.
75// Both are optional; a folder that is not a git repo just has no log.
76async function doneSoFar($: EngineInterface, cwd: string): Promise<string> {
77 const parts: string[] = []
78
79 const log = await $.process
80 .run(['git', 'log', `-n${GIT_LOG_COUNT}`, '--date=short', '--format=%h %ad %s'], { cwd })
81 .catch(() => undefined)
82 if (log && log.exitCode === 0 && log.stdout.trim()) {
83 parts.push(`--- recent commits (git log, newest first) ---\n${log.stdout.trim()}`)
84 }
85
86 let notesRead = 0
87 for (const path of NOTE_FILES) {
88 if (notesRead >= MAX_NOTES_READ) break
89 const text = await $.fs.read(`${cwd}/${path}`).catch(() => '')
90 if (!text) continue
91 const cut = text.length > MAX_NOTE_CHARS ? `${text.slice(0, MAX_NOTE_CHARS)}\n[…cut]` : text
92 parts.push(`--- ${path} (handoff notes) ---\n${cut}`)
93 notesRead++
94 }
95
96 return parts.length ? `\n\nAlready done in this project:\n\n${parts.join('\n\n')}` : ''
97}
98
99const SYSTEM = `You look at a project folder and suggest the three most valuable things
100Claude Code could build in it next. The project may be software, or a folder of
101documents for a business, government or consulting programme; fit the ideas to
102what is actually there. Each idea must be concrete, buildable in one or a few
103Claude Code sessions, and add clear value. Do not suggest things that already exist.
104The snapshot may end with recent commits and handoff notes: they record work
105already done. Never suggest something they show as built or mostly built, even
106when a spec, design or handoff folder for it is still in the tree; suggest what
107they list as open, next or missing instead.
108
109Reply with JSON only, no prose and no code fence:
110[{"title": "...", "description": "one or two sentences: what it is",
111 "why": "one or two sentences: the value it adds for this project",
112 "buildPrompt": "a complete instruction Claude Code can act on to build it, naming the files or folders to start from"}]`
113
114function parseIdeas(text: string): Idea[] {
115 const start = text.indexOf('[')
116 const end = text.lastIndexOf(']')
117 if (start < 0 || end < start) throw new Error('The reply held no list of ideas.')
118 const list = JSON.parse(text.slice(start, end + 1)) as Idea[]
119 const ideas = list
120 .filter(i => i && i.title && i.description && i.why && i.buildPrompt)
121 .slice(0, 3)
122 if (ideas.length === 0) throw new Error('The reply held no usable ideas.')
123 return ideas
124}
125
126async function suggest($: EngineInterface) {
127 const cwd = await $.session.cwd()
128 const before = await load($, cwd)
129 if (before.status === 'thinking') return
130 await save($, cwd, { status: 'thinking', error: undefined })
131
132 const context = await snapshot($, cwd)
133 const avoid = before.ideas.length
134 ? `\n\nSuggest different ideas from these earlier ones: ${before.ideas.map(i => i.title).join('; ')}`
135 : ''
136 const result = await $.model.complete({
137 model: MODEL,
138 system: SYSTEM,
139 prompt: context + avoid,
140 maxTokens: 2000,
141 timeoutMs: 120_000,
142 })
143
144 if (!result.isAnswered) {
145 await save($, cwd, { status: 'error', error: `The model did not answer (${result.reason}).` })
146 return
147 }
148 try {
149 await save($, cwd, { status: 'ready', ideas: parseIdeas(result.text), builtTitles: [] })
150 } catch (err) {
151 await save($, cwd, { status: 'error', error: (err as Error).message })
152 }
153}
154
155async function build($: EngineInterface, idea: Idea) {
156 const cwd = await $.session.cwd()
157 const saved = await load($, cwd)
158 await save($, cwd, { builtTitles: [...(saved.builtTitles ?? []), idea.title] })
159 await $.prompt.submit({
160 text: `Build this for the project: ${idea.title}\n\n${idea.buildPrompt}\n\nStart with a short plan, then build it.`,
161 })
162 $.ui.toast(`Asked Claude to build: ${idea.title}`)
163}
164
165export const register: Register = on => {
166 on('session.start', async ($, e, next) => {
167 await $.command.register({
168 name: 'ideas',
169 description: 'Suggest 3 valuable things to build for this project',
170 })
171 return next(e)
172 })
173
174 on('command.run', { command: 'ideas' }, async $ => {
175 await $.ui.open({ id: PANE, title: 'Feature ideas' })
176 const saved = await load($, await $.session.cwd())
177 if (saved.ideas.length === 0 && saved.status !== 'thinking') void suggest($)
178 return { text: 'Feature ideas pane opened.' }
179 })
180
181 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
182 const { Box, Text, Button } = $.ui.resolve(e)
183 const saved = await load($, await $.session.cwd())
184 const built = new Set(saved.builtTitles ?? [])
185
186 return (
187 <Box flexDirection="column">
188 {saved.status === 'thinking' && <Text>Reading your project and thinking up ideas…</Text>}
189 {saved.status === 'error' && <Text color="red">Could not get ideas: {saved.error}</Text>}
190 {saved.status === 'idle' && saved.ideas.length === 0 && (
191 <Text>No ideas yet. Press Suggest ideas to start.</Text>
192 )}
193
194 {saved.ideas.map((idea, n) => (
195 <Box key={`idea-${n}`} flexDirection="column" borderStyle="round" paddingX={1} marginTop={1}>
196 <Text bold>
197 {n + 1}. {idea.title}
198 </Text>
199 <Text>{idea.description}</Text>
200 <Box marginTop={1}>
201 <Text>
202 <Text bold>Why: </Text>
203 {idea.why}
204 </Text>
205 </Box>
206 <Box marginTop={1}>
207 <Button
208 key={`build-${n}`}
209 label={built.has(idea.title) ? 'Sent to Claude ✓' : 'Build it'}
210 variant="primary"
211 hotkey={String(n + 1)}
212 onPress={() => build($, idea)}
213 />
214 </Box>
215 </Box>
216 ))}
217
218 <Box marginTop={1}>
219 <Button
220 key="refresh"
221 label={saved.ideas.length ? 'Suggest 3 new ideas' : 'Suggest ideas'}
222 hotkey="r"
223 onPress={() => suggest($)}
224 />
225 </Box>
226 </Box>
227 )
228 })
229}
230