SLOPSHOPPER

forge

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

newpanebandguardcommandtoast
v0.1.1MITupdated 2026-10-19Egoushka/claude-mods/mods/forge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · forge
│ ┃ forge ✕ › fix the failing auth test and add an audit log call │ ┃ No proposal. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /forge-approve │ ⎿ forge: Nothing is waiting for approval. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · forge
No proposal.
README

forge

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.

What happens

  1. The proposal is checked before you see it: name 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.
  2. A dialog pane opens with the purpose, the target folder and each file in a code view, with Approve (a) and Reject (r). Escape closes it without deciding; /forge-show reopens it.
  3. On Approve the files are written under 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.

How the tool call waits

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.

Repetition

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.

Settings

FieldDefaultMeaning
forgeDir~/.claude/forged-mods/<name>/Where approved mods go; <name> is replaced (appended when missing), ~ is your home
suggestRepeatstrueOffer a mod when a sequence repeats
approvalWaitSeconds7How long the call holds for your answer before returning PENDING

Cost

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.

Limits

  • Validation checks the shape of a proposal and what claude plugin validate accepts, not what the code does. A valid mod can still do harm.
  • A write that fails halfway leaves a partial folder; Claude is told, and the folder is yours to remove.
  • A mod already approved is not changed by a second proposal under its name: use a new name.
Source 4 files
hooks/register.tsx 272 lines
1import { 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}
272
hooks/proposal.ts 88 lines
1import 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)
88
hooks/repeats.ts 76 lines
1const 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}
76
types/index.d.ts 20 lines
1export 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