Claude proposes a new mod; nothing is written until you approve it in a dialog. Also suggests a mod when the same tool-call sequence repeats

Claude extends itself, with your approval. It registers a tool forge (always in the tool list) that the model calls with { name, purpose, files } to propose a new mod. Nothing is written until you press Approve.
Mods are not sandboxed. A mod's hooks run inside Claude Code with the same reach as the CLI: files, processes, the network, your tool calls. Approval is the only safety boundary, and the dialog is where it holds: read every file before you press Approve. Forge never installs or enables what it writes.
a-z0-9- (40 characters at most); paths relative, no .., no empty or . segments, no .git, .env* or node_modules; only .ts, .tsx, .json and .md files (so .d.ts too); at most 24 files, 120,000 characters each, 400,000 in all; every .json parses; .claude-plugin/plugin.json exists and carries the same name. A failed check goes back to Claude as an error and no dialog opens.a) and Reject (r). Escape closes it without deciding; /forge-show reopens it.forgeDir (default ~/.claude/forged-mods/<name>/), then claude plugin validate <dir> runs and its verdict goes to Claude with the line that loads it: claude --plugin-dir <dir>, or CLAUDE_CODE_PLUGIN_DIRS=<dir> where no flag can be given. An existing folder is never overwritten.A hook has about ten seconds of its own time, and a person often needs longer. The call therefore waits approvalWaitSeconds (default 7, never more than the hook has left) for your press. Answer in time and the result comes back in the same call. Otherwise it returns PENDING, telling Claude to stop and ask you to answer the dialog. The dialog stays open; when you press Approve or Reject later, or run /forge-approve or /forge-reject, the result goes to you as a toast and to Claude as a hidden message it reads on its next turn. Set the seconds to 0 to always work this way. One proposal is open at a time.
Shell calls (Bash) and MCP calls the model makes are normalized (quoted strings, numbers, hashes and paths blurred) and kept per session. Calls another mod makes (a gauge polling an MCP server) and subagent calls are left out, and so are reads, edits and searches: that loop is ordinary work. At the end of a turn, a sequence of 2 to 8 different calls that has run 3 times without overlap shows a row above the prompt, shortened to its smallest repeating unit: forge: Claude ran git status → npm run build 3 times. Bundle it into one tool? Draft a tool submits a prompt asking Claude to judge whether it is worth automating and, if so, to propose it through forge. Each sequence is offered once.
| Field | Default | Meaning |
|---|---|---|
forgeDir | ~/.claude/forged-mods/<name>/ | Where approved mods go; <name> is replaced (appended when missing), ~ is your home |
suggestRepeats | true | Offer a mod when a sequence repeats |
approvalWaitSeconds | 7 | How long the call holds for your answer before returning PENDING |
No model calls of its own. The tool's schema and description sit in every request's tool list (about 400 tokens), and each proposal is the tokens Claude spends writing the files. The repetition scan is local string work.
claude plugin validate accepts, not what the code does. A valid mod can still do harm.hooks/register.tsx 272 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, NextBudget, Register } from 'claude-code'
3
4import type { ForgeProposal, ForgeSuggestion } from '../types'
5import { checkProposal, clip, expandDir, loadHint } from './proposal'
6import { HISTORY_CAP, describeSequence, findRepeat, normalizeCall, suggestionPrompt } from './repeats'
7
8const PANE = 'forge'
9const TOOL = 'forge'
10
11const proposalAtom = atom({ plugin: 'forge', key: 'proposal' } as const, null)
12const suggestionAtom = atom({ plugin: 'forge', key: 'suggestion' } as const, null)
13
14// Per-session bookkeeping; a reload starts it over (a pending proposal then stays in state and /forge-approve still completes it).
15type Waiting = { id: string; resolve: ((text: string) => void) | undefined; isDeciding: boolean }
16type Ctx = {
17 dirTemplate: string
18 waitMs: number
19 isSuggesting: boolean
20 history: string[]
21 suggested: string[][]
22 waiting: Waiting | undefined
23}
24
25const TOOL_DESCRIPTION =
26 'Propose a new Claude Code mod (a plugin whose hooks are one TypeScript module) for the person to review. ' +
27 'Nothing is written until they approve it in a dialog, and you are told the outcome. ' +
28 'Give `name` (a-z, 0-9, "-"), `purpose` (what it does and why, a few sentences) and `files`: an object of relative path to full content, ' +
29 'only .ts, .tsx, .json and .md files. Required: ".claude-plugin/plugin.json" (its "name" equal to `name`), "hooks/hooks.json" ' +
30 '({"modules":["./register.tsx"]}) and "hooks/register.tsx" exporting `register`. Keep the mod small and say in `purpose` what it can touch. ' +
31 'If the result says PENDING, stop and tell the person to answer the dialog; do not call forge again for it.'
32
33async function openPane($: EngineInterface): Promise<boolean> {
34 const opened = await $.ui.open({ id: PANE, title: 'forge', focus: true, closeOnEscape: true })
35 return opened.isPlaced
36}
37
38// Writes the approved files, then asks `claude plugin validate` about them. Never installs or enables anything.
39async function writeMod($: EngineInterface, p: ForgeProposal): Promise<{ isOk: boolean; text: string }> {
40 if (await $.fs.exists(p.dir)) return { isOk: false, text: `${p.dir} already exists, so nothing was written. Propose again under another name.` }
41 try {
42 for (const f of p.files) await $.fs.write(`${p.dir}/${f.path}`, f.content)
43 } catch (err) {
44 return { isOk: false, text: `Writing to ${p.dir} failed (${String(err)}). The folder may hold some of the files; the person should remove it.` }
45 }
46 const v = await $.process.run(['claude', 'plugin', 'validate', p.dir], { timeoutMs: 60_000 }).catch(() => undefined)
47 const verdict = !v
48 ? '`claude plugin validate` could not be run.'
49 : `\`claude plugin validate\` ${v.exitCode === 0 ? 'passed' : `failed (exit ${v.exitCode})`}:\n${clip((v.stdout + v.stderr).trim(), 2500)}`
50 return {
51 isOk: true,
52 text:
53 `Approved. ${p.files.length} files written to ${p.dir}. The mod is not installed or enabled.\n${verdict}\n` +
54 `The person loads it with: ${loadHint(p.dir)}` +
55 (v && v.exitCode !== 0 ? '\nValidation failed: propose a fixed version under a new name (the folder is never overwritten).' : ''),
56 }
57}
58
59// One decision per proposal: the dialog's buttons and the two commands all end here.
60async function decide($: EngineInterface, ctx: Ctx, isApproved: boolean): Promise<string> {
61 const p = await read($, proposalAtom)
62 const w = ctx.waiting
63 if (!p || p.status !== 'pending') return 'Nothing is waiting for approval.'
64 if (w?.isDeciding) return 'A decision is already being carried out.'
65 if (w) w.isDeciding = true
66
67 let text: string
68 let status: ForgeProposal['status']
69 if (isApproved) {
70 const done = await writeMod($, p)
71 text = done.text
72 status = done.isOk ? 'approved' : 'failed'
73 } else {
74 text = `The person rejected the proposal "${p.name}". Nothing was written. Do not propose it again unless they ask.`
75 status = 'rejected'
76 }
77 await update($, proposalAtom, cur => (cur && cur.id === p.id ? { ...cur, status, result: text } : cur))
78 ctx.waiting = undefined
79
80 if (w?.resolve) w.resolve(text)
81 else {
82 // The tool call returned "pending" long ago: tell the person now, and the model on its next turn.
83 $.ui.toast(`forge: ${status === 'approved' ? `${p.name} written to ${p.dir}` : `${p.name} ${status}`}`, { timeoutMs: 8000 })
84 await $.session
85 .append({ message: { type: 'user', content: [{ type: 'text', text: `forge: ${text}` }] } })
86 .catch(() => undefined)
87 }
88 return text
89}
90
91async function propose($: EngineInterface, ctx: Ctx, input: Record<string, unknown>, budget: NextBudget, signal: AbortSignal): Promise<{ result: string } | { deny: string }> {
92 if (ctx.waiting) return { deny: 'forge: another proposal is still waiting for the person. Wait for its outcome.' }
93 const checked = checkProposal(input)
94 if (!checked.ok) return { deny: `forge: the proposal was refused before anyone saw it: ${checked.error}.` }
95 const { name, purpose, files } = checked.proposal
96
97 const home = await $.env.get('HOME')
98 const dir = expandDir(ctx.dirTemplate, name, home)
99 if (dir === undefined) return { deny: 'forge: the forgeDir setting does not resolve to an absolute folder.' }
100 if (await $.fs.exists(dir)) return { deny: `forge: ${dir} already exists and is never overwritten. Use another name.` }
101
102 const id = `${name}-${await $.clock.now()}`
103 const waiting: Waiting = { id, resolve: undefined, isDeciding: false }
104 const answer = new Promise<string>(resolve => (waiting.resolve = resolve))
105 // An interrupted call no longer listens: the outcome then goes out as a message.
106 signal.addEventListener('abort', () => (waiting.resolve = undefined))
107 ctx.waiting = waiting
108 await update($, proposalAtom, () => ({ id, name, purpose, dir, files, status: 'pending', result: '' }))
109 const isShown = await openPane($)
110
111 // A hook holds about ten seconds of its own time; wait inside that, then hand the answer to a message.
112 const room = budget.ms === 0 ? ctx.waitMs : Math.min(ctx.waitMs, budget.remainingMs - 2500)
113 if (room > 0) {
114 const text = await Promise.race([answer, $.clock.sleep(room).then(() => undefined)])
115 if (text !== undefined) return { result: text }
116 }
117 waiting.resolve = undefined
118 return {
119 result:
120 `PENDING: the person has not answered yet and nothing has been written. The proposal "${name}" is open in the forge dialog` +
121 `${isShown ? '' : ' (run /forge-show to open it)'}; they approve with the Approve button or /forge-approve. ` +
122 'Do not call forge again for it. Tell the person to answer the dialog, then stop; the outcome reaches you as a message.',
123 }
124}
125
126async function scanRepeats($: EngineInterface, ctx: Ctx): Promise<void> {
127 const found = findRepeat(ctx.history, ctx.suggested)
128 if (!found || ctx.waiting) return
129 if (await read($, suggestionAtom)) return
130 ctx.suggested.push(found)
131 await update($, suggestionAtom, () => ({ sequence: found, count: 3 }) satisfies ForgeSuggestion)
132}
133
134async function askForTool($: EngineInterface, sequence: string[]) {
135 await update($, suggestionAtom, () => null)
136 await $.prompt.submit({ text: suggestionPrompt(sequence) })
137}
138
139const isPerson = (kind: string) => kind === 'composer' || kind === 'bridge'
140
141export const register: Register = (on, options) => {
142 const ctx: Ctx = {
143 dirTemplate: typeof options.forgeDir === 'string' ? options.forgeDir : '',
144 waitMs: typeof options.approvalWaitSeconds === 'number' ? Math.max(0, options.approvalWaitSeconds) * 1000 : 7000,
145 isSuggesting: options.suggestRepeats !== false,
146 history: [],
147 suggested: [],
148 waiting: undefined,
149 }
150
151 on('session.start', async ($, e, next) => {
152 ctx.history = []
153 ctx.suggested = []
154 ctx.waiting = undefined
155 await update($, proposalAtom, () => null)
156 await update($, suggestionAtom, () => null)
157 await $.tool.register({
158 name: TOOL,
159 description: TOOL_DESCRIPTION,
160 isDeferred: false,
161 inputSchema: {
162 type: 'object',
163 properties: {
164 name: { type: 'string', description: 'Mod name: a-z, 0-9 and "-".' },
165 purpose: { type: 'string', description: 'What the mod does, why, and what it can touch.' },
166 files: { type: 'object', additionalProperties: { type: 'string' }, description: 'Relative path to full file content.' },
167 },
168 required: ['name', 'purpose', 'files'],
169 },
170 })
171 await $.command.register({ name: 'forge-approve', description: 'Approve the mod Claude proposed with forge' })
172 await $.command.register({ name: 'forge-reject', description: 'Reject the mod Claude proposed with forge' })
173 await $.command.register({ name: 'forge-show', description: 'Open the forge dialog for the pending proposal' })
174 return next(e)
175 })
176
177 // The only path that can write a file: the person's answer comes first.
178 on('tool.call', { tool: 'mcp__forge__forge' }, ($, e, next) => propose($, ctx, e as unknown as Record<string, unknown>, next.budget, next.signal)).catch(
179 ($, e, next) => (next.called ? next(e) : { deny: 'forge: its handler failed; nothing was written.' }),
180 )
181
182 // Every other shell or MCP call joins the history the repetition scan reads.
183 on('tool.call', async ($, e, next) => {
184 const ran = await next(e)
185 // Only the model's own calls count: another mod's background calls (a gauge polling MCP) are not work to automate.
186 if (!ctx.isSuggesting || ran.deny !== undefined || e.agentId !== undefined || next.origin.plugin !== 'engine') return ran
187 const key = normalizeCall(String(e.tool), e as unknown as { command?: unknown })
188 if (key) ctx.history = [...ctx.history, key].slice(-HISTORY_CAP)
189 return ran
190 }).catch(($, e, next) => next(e))
191
192 on('turn.complete', async ($, e, next) => {
193 if (ctx.isSuggesting && e.agentId === undefined && e.reason === 'answer') await scanRepeats($, ctx).catch(() => undefined)
194 return next(e)
195 })
196
197 // Only the person approves: a command the model, a peer or a schedule raised is refused.
198 on('command.run', { command: 'forge-approve' }, async ($, e) =>
199 isPerson(e.origin.kind) ? { text: await decide($, ctx, true) } : { text: 'forge: only the person can approve, from the prompt or the dialog.' },
200 )
201 on('command.run', { command: 'forge-reject' }, async $ => ({ text: await decide($, ctx, false) }))
202 on('command.run', { command: 'forge-show' }, async $ => {
203 const p = await read($, proposalAtom)
204 if (!p) return { text: 'No proposal to show.' }
205 await openPane($)
206 return { text: `Opened the forge dialog for "${p.name}".` }
207 })
208
209 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
210 const rest = await next(e)
211 const p = await read($, proposalAtom)
212 const suggestion = await read($, suggestionAtom)
213 const isPending = p?.status === 'pending'
214 if (e.props.hasSurvey || (!isPending && !suggestion)) return rest
215 const { Box, Text, Button } = $.ui.resolve(e)
216 return (
217 <Box flexDirection="column">
218 {isPending && (
219 <Box>
220 <Text>forge: "{p.name}" awaits your approval </Text>
221 <Button key="review" label="Review" variant="primary" onPress={() => openPane($)} />
222 </Box>
223 )}
224 {!isPending && suggestion && (
225 <Box>
226 <Text>
227 forge: Claude ran {describeSequence(suggestion.sequence)} {suggestion.count} times. Bundle it into one tool?{' '}
228 </Text>
229 <Button key="forge-it" label="Draft a tool" variant="primary" onPress={() => askForTool($, suggestion.sequence)} />
230 <Button key="dismiss" label="Dismiss" onPress={() => update($, suggestionAtom, () => null)} />
231 </Box>
232 )}
233 {rest}
234 </Box>
235 )
236 })
237
238 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
239 const { Box, Text, Button, Code } = $.ui.resolve(e)
240 const p = await read($, proposalAtom)
241 if (!p) return <Text dimColor>No proposal.</Text>
242 const isPending = p.status === 'pending'
243 return (
244 <Box flexDirection="column">
245 <Text bold>Forge: {p.name}</Text>
246 <Text>{p.purpose}</Text>
247 <Text dimColor>
248 Writes {p.files.length} files to {p.dir}. A mod runs inside Claude Code with no sandbox: read every file before you approve.
249 </Text>
250 {isPending ? (
251 <Box>
252 <Button key="approve" label="Approve" hotkey="a" variant="primary" onPress={() => void decide($, ctx, true)} />
253 <Button key="reject" label="Reject" hotkey="r" onPress={() => void decide($, ctx, false)} />
254 </Box>
255 ) : (
256 <Box flexDirection="column">
257 <Text bold>{p.status}</Text>
258 <Text>{p.result}</Text>
259 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
260 </Box>
261 )}
262 {p.files.map(f => (
263 <Box key={f.path} flexDirection="column" marginTop={1}>
264 <Text bold>{f.path}</Text>
265 <Code source={f.content} path={f.path} startLine={1} />
266 </Box>
267 ))}
268 </Box>
269 )
270 })
271}
272hooks/proposal.ts 88 lines1import type { ForgeFile } from '../types'
2
3const MAX_FILES = 24
4const MAX_FILE_CHARS = 120_000
5const MAX_TOTAL_CHARS = 400_000
6const MAX_PURPOSE = 2000
7const ALLOWED_EXT = /\.(ts|tsx|json|md)$/ // covers .d.ts
8const NAME = /^[a-z0-9][a-z0-9-]{0,39}$/
9const SEGMENT = /^[A-Za-z0-9._-]+$/
10const FORBIDDEN_SEGMENT = /^(\.git|\.env.*|node_modules|\.ssh)$/i
11
12export type Proposal = { name: string; purpose: string; files: ForgeFile[] }
13export type Checked = { ok: true; proposal: Proposal } | { ok: false; error: string }
14
15export function checkPath(path: string): string | undefined {
16 if (path.length === 0 || path.length > 120) return `path "${path.slice(0, 40)}" is empty or longer than 120 characters`
17 if (path.startsWith('/') || path.includes('\\') || /^[A-Za-z]:/.test(path)) return `path "${path}" must be relative and use /`
18 const segments = path.split('/')
19 for (const s of segments) {
20 if (s === '' || s === '.' || s === '..') return `path "${path}" may not contain empty, "." or ".." segments`
21 if (!SEGMENT.test(s)) return `path "${path}" uses characters outside letters, digits, . _ -`
22 if (FORBIDDEN_SEGMENT.test(s)) return `path "${path}" names a folder or file a mod has no reason to hold (${s})`
23 }
24 if (!ALLOWED_EXT.test(path)) return `path "${path}" must end in .ts, .tsx, .json or .md`
25 return undefined
26}
27
28// Everything a proposal is held to before the person sees it.
29export function checkProposal(raw: Record<string, unknown>): Checked {
30 const name = raw.name
31 if (typeof name !== 'string' || !NAME.test(name)) return { ok: false, error: 'name must be 1-40 characters of a-z, 0-9 and "-", starting with a letter or digit' }
32 const purpose = raw.purpose
33 if (typeof purpose !== 'string' || purpose.trim() === '') return { ok: false, error: 'purpose must say what the mod is for' }
34 if (purpose.length > MAX_PURPOSE) return { ok: false, error: `purpose is longer than ${MAX_PURPOSE} characters` }
35 const map = raw.files
36 if (typeof map !== 'object' || map === null || Array.isArray(map)) return { ok: false, error: 'files must be an object of path to file content' }
37 const entries = Object.entries(map as Record<string, unknown>)
38 if (entries.length === 0) return { ok: false, error: 'files is empty' }
39 if (entries.length > MAX_FILES) return { ok: false, error: `more than ${MAX_FILES} files` }
40
41 const files: ForgeFile[] = []
42 const seen = new Set<string>()
43 let total = 0
44 for (const [path, content] of entries) {
45 const bad = checkPath(path)
46 if (bad) return { ok: false, error: bad }
47 if (typeof content !== 'string') return { ok: false, error: `content of ${path} must be a string` }
48 if (content.length > MAX_FILE_CHARS) return { ok: false, error: `${path} is longer than ${MAX_FILE_CHARS} characters` }
49 const lower = path.toLowerCase()
50 if (seen.has(lower)) return { ok: false, error: `${path} appears twice (paths compare without case)` }
51 seen.add(lower)
52 total += content.length
53 if (path.endsWith('.json')) {
54 try {
55 JSON.parse(content)
56 } catch {
57 return { ok: false, error: `${path} is not valid JSON` }
58 }
59 }
60 files.push({ path, content })
61 }
62 if (total > MAX_TOTAL_CHARS) return { ok: false, error: `the files total more than ${MAX_TOTAL_CHARS} characters` }
63
64 const manifest = files.find(f => f.path === '.claude-plugin/plugin.json')
65 if (!manifest) return { ok: false, error: 'files must include .claude-plugin/plugin.json' }
66 const declared = (JSON.parse(manifest.content) as { name?: unknown }).name
67 if (declared !== name) return { ok: false, error: `.claude-plugin/plugin.json "name" must be "${name}"` }
68 return { ok: true, proposal: { name, purpose: purpose.trim(), files } }
69}
70
71// `~` is the home folder; `<name>` is the mod's name, appended when the template lacks it.
72export function expandDir(template: string, name: string, home: string | undefined): string | undefined {
73 let dir = template.trim() || '~/.claude/forged-mods/<name>/'
74 dir = dir.includes('<name>') ? dir.replaceAll('<name>', name) : `${dir.replace(/\/+$/, '')}/${name}`
75 if (dir === '~' || dir.startsWith('~/')) {
76 if (!home) return undefined
77 dir = home.replace(/\/+$/, '') + dir.slice(1)
78 }
79 if (!dir.startsWith('/') || dir.split('/').includes('..')) return undefined
80 return dir.replace(/\/+$/, '')
81}
82
83export function loadHint(dir: string): string {
84 return `claude --plugin-dir ${dir} (or CLAUDE_CODE_PLUGIN_DIRS=${dir} in the environment of a session that cannot take the flag)`
85}
86
87export const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}\n... (${text.length - max} more characters)` : text)
88hooks/repeats.ts 76 lines1const MIN = 2
2const MAX = 8
3const TIMES = 3
4export const HISTORY_CAP = 400
5
6// A call joins the history only when it is shell or MCP work: the loop of reading, editing and testing is normal work, not something to automate.
7export function normalizeCall(tool: string, input: { command?: unknown }): string | undefined {
8 if (tool === 'mcp__forge__forge') return undefined
9 if (tool.startsWith('mcp__')) return tool
10 if (tool !== 'Bash' || typeof input.command !== 'string') return undefined
11 const first = (input.command.split('\n')[0] ?? '').replace(/^\s*cd\s+\S+\s*&&\s*/, '')
12 const key = first
13 .replace(/"[^"]*"|'[^']*'/g, '"…"')
14 .replace(/\b[0-9a-f]{7,40}\b/g, '<sha>')
15 .replace(/(?:~|[\w.@+-]*)(?:\/[\w.@+-]+)+/g, '<path>')
16 .replace(/\d+/g, '#')
17 .replace(/\s+/g, ' ')
18 .trim()
19 return key === '' ? undefined : key.slice(0, 80)
20}
21
22const contains = (haystack: readonly string[], needle: readonly string[]) => {
23 for (let i = 0; i + needle.length <= haystack.length; i += 1) if (needle.every((x, j) => haystack[i + j] === x)) return true
24 return false
25}
26
27function occurrences(history: readonly string[], gram: readonly string[]): number {
28 let count = 0
29 for (let i = 0; i + gram.length <= history.length; ) {
30 if (gram.every((x, j) => history[i + j] === x)) {
31 count += 1
32 i += gram.length
33 } else i += 1
34 }
35 return count
36}
37
38// A run like a→b→a→b is the unit a→b repeated; the unit is what gets suggested.
39const isPeriodic = (gram: readonly string[]) => {
40 for (let p = 1; p < gram.length; p += 1) if (gram.every((x, i) => x === gram[i % p])) return true
41 return false
42}
43
44// The longest sequence of 2-8 calls (two or more different ones) that ran 3 times without overlapping, unless an
45// earlier suggestion already covers it. `seen` holds what was suggested.
46export function findRepeat(history: readonly string[], seen: readonly (readonly string[])[]): string[] | undefined {
47 for (let len = Math.min(MAX, Math.floor(history.length / TIMES)); len >= MIN; len -= 1) {
48 const tried = new Set<string>()
49 for (let i = 0; i + len <= history.length; i += 1) {
50 const gram = history.slice(i, i + len)
51 const key = gram.join('\u0001')
52 if (tried.has(key)) continue
53 tried.add(key)
54 if (new Set(gram).size < 2 || isPeriodic(gram)) continue
55 if (seen.some(s => contains(s, gram))) continue
56 if (occurrences(history, gram) >= TIMES) return gram
57 }
58 }
59 return undefined
60}
61
62// mcp__server__tool reads as its tool name; long shell lines are cut.
63export const describeSequence = (sequence: readonly string[]) =>
64 sequence.map(s => (s.startsWith('mcp__') ? (s.split('__').slice(2).join('__') || s) : s.length > 32 ? `${s.slice(0, 31)}…` : s)).join(' → ')
65
66export function suggestionPrompt(sequence: readonly string[]): string {
67 return (
68 `The same sequence of ${sequence.length} tool calls has now run ${TIMES} times in this session:\n\n` +
69 sequence.map((s, i) => `${i + 1}. ${s}`).join('\n') +
70 '\n\nIf this is worth automating, call the `forge` tool once to propose a Claude Code mod for it ' +
71 '(a plugin whose hooks module is one TypeScript file; give name, purpose and every file). ' +
72 'Write nothing yourself: the person approves the proposal in a dialog first. ' +
73 'If it is not worth automating, say why in one line and do not call forge.'
74 )
75}
76types/index.d.ts 20 lines1export type ForgeFile = { path: string; content: string }
2
3export type ForgeProposal = {
4 id: string
5 name: string
6 purpose: string
7 dir: string
8 files: ForgeFile[]
9 status: 'pending' | 'approved' | 'rejected' | 'failed'
10 result: string
11}
12
13export type ForgeSuggestion = { sequence: string[]; count: number }
14
15declare module 'claude-code' {
16 interface PluginState {
17 forge: { proposal: ForgeProposal | null; suggestion: ForgeSuggestion | null }
18 }
19}
20