SLOPSHOPPER

mod-scout

Mod 探勘:讀最近的對話紀錄,找出重複交代的事、讓你緊張的動作、固定檢查成果的動作,排除 Hook/Skill 就能解決的,提出最多 5 個 Mod 提案並附原話證據;/mod-scout

commandtoaststatusprocesstimer
v0.1.0no licenseupdated 2026-10-17ricky94511/claude-mods/mod-scout
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-scout
› fix the failing auth test and add an audit log call ⏺ 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 › /mod-scout ⎿ mod-scout: Mod 探勘:開始整理最近 30 次對話,整理完會自動送出分析。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Ricky 的 Claude Code mods。

handoff:一鍵交接

上下文快滿時,把目前的工作整理成交接摘要,清空對話後當成第一句送出,標題自動加「(續N)」。雲端對話改成開一個新的雲端對話送出摘要、再封存舊的(原地清空會在容器重啟時被還原)。

用法

  1. 開始交接:說「幫我交接」(各種說法都可以,由 Claude 判斷),或輸入 /handoff
  2. Claude 在對話裡寫出交接預覽(目標、已完成、還沒做的、改過的檔案、要注意的地方)
  3. 確認:說「送出」(或 /handoff 送出);不要了就說「取消」(或 /handoff 取消);想改內容就直接說要改什麼
  4. 確認後自動 /clear、改標題、把摘要送出

卡住的話用 /handoff 重來。

自動詢問

  • 上下文用到門檻(預設 15 萬 token)時跳出對話框:現在交接/稍後(再多用 1.5 萬 token 時再問)/這個對話不要再問
  • 摘要寫好後再跳一次:送出並交接/取消/直接寫修改意見
  • 對話框在雲端(桌機 app 看雲端對話)也看得到;本機終端機另外有輸入框上方的按鈕

設定(/config 裡的 handoff 項目)

  • thresholdTokens:門檻 token 數,預設 150000。用 token 數而不是百分比,是因為本機上限約 20 萬、雲端 100 萬,同一個百分比差很多
  • autoSend:跳過預覽直接交接,預設關

mod-scout:Mod 探勘

讀最近的對話紀錄(本機 ~/.claude/projects/ 加上雲端帳號的其他對話),找出一直重複交代的事、讓你緊張的動作、每次檢查成果的固定動作,排除 Hook 或 Skill 就能解決的,提出最多 5 個 Mod 提案,每個附原話證據。

用法

  • /mod-scout:開始探勘。先由程式把你親手打的字整理成 ~/.claude/mod-scout/digest-日期.md(金鑰、token 先遮蔽),再自動送出分析要求
  • 報告出來後跳對話框問要做哪一個;選了就送出「幫我做第 N 個」(先給設計稿、做完問要不要永久保存)
  • /mod-scout 做 N:之後再做上次報告的第 N 個
  • 距離上次探勘超過 90 天,開新對話時提醒再跑一次

設定(/config 裡的 mod-scout 項目)

  • sessions:讀最近幾次對話,預設 30
  • remindDays:幾天提醒一次,預設 90,0 表示不提醒

本機讀紀錄要有 python3;沒有的話只讀雲端對話。

安裝

桌機(終端機或桌面 app)

/plugin marketplace add ricky94511/claude-mods
/plugin install handoff@claude-mods
/plugin install mod-scout@claude-mods

桌面 app:點輸入框旁的 + → Plugins → Add marketplace,輸入 ricky94511/claude-mods,再 Add plugin。

雲端(claude.ai/code)

雲端環境會跳過 marketplace,所以改用 setup script 把 repo clone 進容器,再用 CLAUDE_CODE_PLUGIN_DIRS 載入。 在環境設定的 Setup script 最後加上這段(失敗也不會讓 session 起不來):

if [ -d /home/user/plugins/claude-mods/.git ]; then
  git -C /home/user/plugins/claude-mods fetch -q --depth 1 origin main && git -C /home/user/plugins/claude-mods reset -q --hard origin/main || true
else
  GIT_TERMINAL_PROMPT=0 git clone -q --depth 1 https://github.com/ricky94511/claude-mods /home/user/plugins/claude-mods || true
fi

if [ -d /home/user/plugins/claude-mods ]; then
python3 - <<'PY'
import json, pathlib
root = pathlib.Path("/home/user/plugins/claude-mods")
p = pathlib.Path.home() / ".claude/settings.json"
d = json.loads(p.read_text()) if p.exists() else {}
env = d.setdefault("env", {})
dirs = [x for x in env.get("CLAUDE_CODE_PLUGIN_DIRS", "").split(":") if x]
# repo 裡每個有 plugin.json 的資料夾都載入,之後加新 mod 不用再改 setup script
for mod in sorted(root.glob("*/.claude-plugin/plugin.json")):
    path = str(mod.parent.parent)
    if path not in dirs:
        dirs.append(path)
env["CLAUDE_CODE_PLUGIN_DIRS"] = ":".join(dirs)
cmd = "git -C /home/user/plugins/claude-mods pull -q --ff-only origin main 2>/dev/null || true"
starts = d.setdefault("hooks", {}).setdefault("SessionStart", [])
if not any(h.get("command") == cmd for s in starts for h in s.get("hooks", [])):
    starts.append({"hooks": [{"type": "command", "command": cmd}]})
p.write_text(json.dumps(d, indent=2))
PY
fi

exit 0

改完開新的雲端對話才會生效。

Source 3 files
hooks/register.tsx 231 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Phase, Proposal } from '../types'
5import {
6  analysisPrompt,
7  buildDigest,
8  buildPrompt,
9  humanFromEvents,
10  parseJson,
11  parseProposals,
12  pickNumber,
13  projectOf,
14  type Inventory,
15  type Session,
16} from './lib'
17
18const phase = atom({ plugin: 'mod-scout', key: 'phase' } as const, 'idle' as Phase)
19
20const REMOTE = 'claude-code-remote'
21const DAY = 24 * 60 * 60 * 1000
22// 一次對話最多翻幾頁(每頁 100 筆,tool_result 也算在內)
23const MAX_PAGES = 8
24const SKIP = '先不做'
25const USAGE = '用法:/mod-scout(開始探勘)、/mod-scout 做 N(做上次報告的第 N 個提案)'
26
27type Report = { at: number; proposals: Proposal[] }
28
29// 指令和 turn.complete 的 hook 裡不能等人回答或送出 prompt,等 hook 結束後再用計時器開始
30function detach($: EngineInterface, work: () => Promise<void>) {
31  $.clock.after(50, () =>
32    void work().catch(async error => {
33      await update($, phase, () => 'idle')
34      $.ui.status(undefined)
35      $.ui.toast(`Mod 探勘中途出錯:${String(error).slice(0, 200)}`)
36    }),
37  )
38}
39
40async function mcpJson($: EngineInterface, tool: string, args: Record<string, unknown>) {
41  const { content, isError } = await $.mcp.call(REMOTE, tool, args)
42  if (isError) return null
43  const text = content.find(c => c.type === 'text' && c.text)?.text
44  if (!text) return null
45  const data = parseJson(text)
46  return (data?.ccr ?? data) as Record<string, unknown> | null
47}
48
49// 本機:~/.claude/projects/ 底下的紀錄檔可能超過 $.fs 的 4 MiB 上限,交給腳本逐行讀
50async function collectLocal($: EngineInterface, limit: number, exclude: string) {
51  const script = `${$.plugin.root}/scripts/collect.py`
52  const run = await $.process.run(['python3', script, '--limit', String(limit), '--exclude', exclude], { timeoutMs: 120_000 })
53  if (run.exitCode !== 0 || run.isStdoutTruncated) throw new Error(run.stderr.slice(0, 300) || '本機紀錄讀取失敗')
54  const data = JSON.parse(run.stdout) as { sessions: Omit<Session, 'source'>[]; inventory: Inventory }
55  return { sessions: data.sessions.map(s => ({ ...s, source: '本機' as const })), inventory: data.inventory }
56}
57
58async function remoteSession($: EngineInterface, session: Record<string, unknown>): Promise<Session | null> {
59  const id = String(session.id)
60  const messages = []
61  let before: string | undefined
62  for (let page = 0; page < MAX_PAGES; page++) {
63    const data = await mcpJson($, 'list_events', { session_id: id, kinds: ['user'], limit: 100, ...(before ? { before_id: before } : {}) })
64    if (!data) break
65    messages.unshift(...humanFromEvents((data.data ?? []) as never[]))
66    if (!data.has_more || !data.first_id) break
67    before = String(data.first_id)
68  }
69  if (messages.length === 0) return null
70  return {
71    id,
72    title: String(session.title ?? ''),
73    project: projectOf(session),
74    start: String(session.created_at ?? '').slice(0, 16),
75    source: '雲端',
76    messages,
77  }
78}
79
80// 雲端:用 claude-code-remote 讀自己帳號的其他對話;沒有這個 server(例如純本機終端機)就略過
81async function collectRemote($: EngineInterface, limit: number, exclude: string) {
82  let listed: Record<string, unknown> | null
83  try {
84    listed = await mcpJson($, 'list_sessions', { limit: Math.min(limit + 10, 100), mine: true })
85  } catch {
86    return null
87  }
88  if (!listed) return null
89  const rows = ((listed.data ?? []) as Record<string, unknown>[]).filter(s => s.id !== exclude).slice(0, limit)
90  const sessions: Session[] = []
91  for (let i = 0; i < rows.length; i += 4) {
92    $.ui.status(`Mod 探勘:讀雲端對話 ${Math.min(i + 4, rows.length)}/${rows.length}…`)
93    const batch = await Promise.all(rows.slice(i, i + 4).map(row => remoteSession($, row).catch(() => null)))
94    for (const session of batch) if (session) sessions.push(session)
95  }
96  return sessions
97}
98
99async function currentRemoteId($: EngineInterface) {
100  try {
101    const data = await mcpJson($, 'get_session', {})
102    return data?.id ? String(data.id) : ''
103  } catch {
104    return ''
105  }
106}
107
108async function scout($: EngineInterface, limit: number) {
109  $.ui.status('Mod 探勘:整理本機對話紀錄…')
110  const notes: string[] = []
111  const localId = await $.session.id()
112  let local: Awaited<ReturnType<typeof collectLocal>> | null = null
113  try {
114    local = await collectLocal($, limit, localId)
115  } catch (error) {
116    notes.push(`本機紀錄沒讀到(${String(error).slice(0, 120)}),只用雲端對話。`)
117  }
118
119  const remote = await collectRemote($, limit, await currentRemoteId($))
120  if (remote === null) notes.push('這個環境連不到雲端對話(claude-code-remote),只用本機紀錄。')
121
122  // 遠端遙控的本機對話會同時出現在兩邊:第一句的時間和內容一樣就當同一個
123  const seen = new Set((local?.sessions ?? []).flatMap(s => s.messages.slice(0, 3).map(m => `${m.at}|${m.text.slice(0, 40)}`)))
124  const remoteOnly = (remote ?? []).filter(s => !s.messages.slice(0, 3).some(m => seen.has(`${m.at}|${m.text.slice(0, 40)}`)))
125  const sessions = [...(local?.sessions ?? []), ...remoteOnly]
126    .sort((a, b) => (b.messages.at(-1)?.at ?? b.start).localeCompare(a.messages.at(-1)?.at ?? a.start))
127    .slice(0, limit)
128
129  if (sessions.length === 0) {
130    await update($, phase, () => 'idle')
131    $.ui.status(undefined)
132    $.ui.toast('Mod 探勘:找不到任何對話紀錄,先累積一些對話再來')
133    return
134  }
135
136  const home = (await $.env.get('HOME')) ?? ''
137  const date = new Date(await $.clock.now()).toISOString().slice(0, 10)
138  const digest = `${home}/.claude/mod-scout/digest-${date}.md`
139  await $.fs.write(digest, buildDigest(sessions, local?.inventory ?? null, notes))
140
141  await update($, phase, () => 'analyzing')
142  $.ui.status(`Mod 探勘:分析 ${sessions.length} 次對話中…`)
143  await $.prompt.submit({ text: analysisPrompt(digest, sessions.length) })
144}
145
146async function build($: EngineInterface, proposal: Proposal) {
147  $.ui.status(undefined)
148  await $.prompt.submit({ text: buildPrompt(proposal) })
149}
150
151async function choose($: EngineInterface, report: Report) {
152  if (report.proposals.length === 0) {
153    $.ui.toast('Mod 探勘:這次沒有需要做成 Mod 的,報告在對話裡')
154    return
155  }
156  const options = [...report.proposals.slice(0, 3).map(p => `${p.number}. ${p.name}`), SKIP]
157  let choice: string
158  try {
159    choice = await $.ui.ask('提案出來了,要做哪一個?(其他編號可以直接打數字)', { header: 'Mod 探勘', options })
160  } catch {
161    return
162  }
163  const number = choice === SKIP ? null : pickNumber(choice)
164  const proposal = report.proposals.find(p => p.number === number)
165  if (!proposal) {
166    $.ui.toast('之後想做的話,輸入 /mod-scout 做 N')
167    return
168  }
169  await build($, proposal)
170}
171
172export const register: Register = (on, options) => {
173  const limit = Number(options.sessions ?? 30)
174  const remindDays = Number(options.remindDays ?? 90)
175
176  on('session.start', async ($, e, next) => {
177    const result = await next(e)
178    await $.command.register({
179      name: 'mod-scout',
180      description: 'Mod 探勘:讀最近的對話,提出最多 5 個值得做的 Mod(附原話證據)',
181      argumentHint: '[做 N]',
182    })
183    const last = (await $.store.get('last')) as Report | undefined
184    const now = await $.clock.now()
185    if (remindDays > 0 && last && now - last.at > remindDays * DAY) {
186      $.ui.toast(`上次 Mod 探勘是 ${Math.floor((now - last.at) / DAY)} 天前,可以輸入 /mod-scout 再跑一次`)
187    }
188    return result
189  })
190
191  on('command.run', { command: 'mod-scout' }, async ($, e) => {
192    const args = e.args.trim()
193    if (args === 'help' || args === '說明') return { text: USAGE }
194
195    if (args) {
196      const number = pickNumber(args)
197      const last = (await $.store.get('last')) as Report | undefined
198      const proposal = last?.proposals.find(p => p.number === number)
199      if (!proposal) return { text: `找不到第 ${number ?? '?'} 個提案。先輸入 /mod-scout 跑一次探勘。\n${USAGE}` }
200      detach($, () => build($, proposal))
201      return { text: `接著做第 ${proposal.number} 個提案:${proposal.name}` }
202    }
203
204    if ((await read($, phase)) !== 'idle') {
205      await update($, phase, () => 'idle')
206      $.ui.status(undefined)
207      return { text: '上一次探勘還沒結束(或卡住了),已重設。再輸入一次 /mod-scout 重新開始。' }
208    }
209    await update($, phase, () => 'collecting')
210    detach($, () => scout($, limit))
211    return { text: `Mod 探勘:開始整理最近 ${limit} 次對話,整理完會自動送出分析。` }
212  })
213
214  on('turn.complete', async ($, e, next) => {
215    const result = await next(e)
216    if (e.agentId || (await read($, phase)) !== 'analyzing') return result
217    await update($, phase, () => 'idle')
218    $.ui.status(undefined)
219    if (e.reason !== 'answer') return result
220    const proposals = parseProposals(e.answer)
221    if (proposals === null) {
222      $.ui.toast('Mod 探勘:沒找到提案段落,報告直接看對話;要做的話說「幫我做第 N 個」')
223      return result
224    }
225    const report: Report = { at: await $.clock.now(), proposals }
226    await $.store.set('last', report)
227    detach($, () => choose($, report))
228    return result
229  })
230}
231
hooks/lib.ts 161 lines
1import type { Proposal } from '../types'
2
3export type Message = { at: string; text: string }
4export type Session = { id: string; title: string; project: string; start: string; source: '本機' | '雲端'; messages: Message[] }
5export type Inventory = { hooks: string[]; pluginDirs: string[]; skills: string[] }
6
7export const START = '<<<Mod 提案開始>>>'
8export const END = '<<<Mod 提案結束>>>'
9const MAX_CHARS = 500
10
11// 跟 scripts/collect.py 的 SECRET 同一套:金鑰、token、密碼先遮掉,模型看不到原文
12const SECRET = new RegExp(
13  [
14    'sk-[A-Za-z0-9_\\-]{12,}',
15    'gh[pousr]_[A-Za-z0-9]{20,}',
16    'github_pat_[A-Za-z0-9_]{20,}',
17    'xox[abprs]-[A-Za-z0-9\\-]{10,}',
18    'AKIA[0-9A-Z]{16}',
19    'AIza[0-9A-Za-z_\\-]{30,}',
20    'eyJ[A-Za-z0-9_\\-]{10,}\\.[A-Za-z0-9_\\-]{10,}\\.[A-Za-z0-9_\\-]{10,}',
21    '[Bb]earer\\s+[A-Za-z0-9._\\-]{16,}',
22    '(?:[Pp]assword|[Pp]asswd|[Ss]ecret|[Tt]oken|[Aa]pi[_-]?[Kk]ey)\\s*[:=]\\s*\\S+',
23    '\\b[A-Fa-f0-9]{32,}\\b',
24    '\\b[A-Za-z0-9+/]{40,}={0,2}',
25  ].join('|'),
26  'g',
27)
28
29export const redact = (text: string) => text.replace(SECRET, '<已遮蔽>')
30
31// 系統或外掛塞進對話的內容,不是使用者打的
32const NOISE = ['<', 'Caveat:', '[Request interrupted', 'This is how Claude Code surfaces', 'The handoff plugin sent']
33
34export function cleanHuman(text: string): string | null {
35  const plain = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
36  if (!plain || NOISE.some(prefix => plain.startsWith(prefix))) return null
37  return redact(plain).slice(0, MAX_CHARS)
38}
39
40// MCP 回傳的文字可能被包在說明文字裡,取最外層的 JSON
41export function parseJson(text: string): Record<string, unknown> | null {
42  const start = text.indexOf('{')
43  const end = text.lastIndexOf('}')
44  if (start < 0 || end <= start) return null
45  try {
46    return JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
47  } catch {
48    return null
49  }
50}
51
52type Event = { created_at?: string; user?: { internal_anthropic_catchall?: Record<string, unknown> } }
53
54// list_events(kinds: user)的一頁:tool_result 也算 user,只留人打的字
55export function humanFromEvents(events: readonly Event[]): Message[] {
56  const out: Message[] = []
57  for (const event of events) {
58    const row = event.user?.internal_anthropic_catchall
59    if (!row || row.isSynthetic || row.isMeta) continue
60    const origin = row.origin as { kind?: string } | undefined
61    if (origin?.kind && origin.kind !== 'human') continue
62    const content = (row.message as { content?: unknown } | undefined)?.content
63    let text: string | null = null
64    if (typeof content === 'string') text = content
65    else if (Array.isArray(content) && !content.some(b => (b as { type?: string })?.type === 'tool_result')) {
66      text = content
67        .filter(b => (b as { type?: string })?.type === 'text')
68        .map(b => String((b as { text?: unknown }).text ?? ''))
69        .join('\n')
70    }
71    const clean = text === null ? null : cleanHuman(text)
72    if (clean) out.push({ at: String(event.created_at ?? '').slice(0, 16), text: clean })
73  }
74  return out
75}
76
77// 「https://github.com/ricky94511/shorts-studio」→「shorts-studio」;沒有 repo 就用標題的 [類別]
78export function projectOf(session: Record<string, unknown>): string {
79  const context = session.session_context as { sources?: { git_repository?: { url?: string } }[] } | undefined
80  const url = context?.sources?.[0]?.git_repository?.url
81  if (url) return url.replace(/\.git$/, '').split('/').pop() ?? url
82  const category = String(session.title ?? '').match(/^\[([^\]]+)\]/)
83  return category ? category[1]! : '(無專案)'
84}
85
86// 本機路徑只留最後一段,比較好讀
87const shortProject = (project: string) => project.split(/[\\/]/).filter(Boolean).pop() ?? project
88
89export function buildDigest(sessions: readonly Session[], inventory: Inventory | null, notes: readonly string[]): string {
90  const lines = [
91    '# Mod 探勘:最近對話的原話整理',
92    '',
93    `共 ${sessions.length} 次對話、${sessions.reduce((n, s) => n + s.messages.length, 0)} 句使用者訊息。只收使用者親手打的字;金鑰、token、密碼已遮蔽成 <已遮蔽>。`,
94    '每句格式:`- 日期時間|原話`,標題列有專案與來源。',
95    ...notes.map(note => `> ${note}`),
96    '',
97  ]
98  if (inventory) {
99    lines.push('## 已經有的 Hook/外掛/Skill(提案不要跟這些重複)', '')
100    lines.push(`- 設定裡的 Hook 事件:${inventory.hooks.join('、') || '(無)'}`)
101    lines.push(`- 載入的外掛:${inventory.pluginDirs.map(shortProject).join('、') || '(無)'}`)
102    lines.push(`- 使用者的 Skill:${inventory.skills.join('、') || '(無)'}`, '')
103  }
104  for (const session of sessions) {
105    lines.push(`## ${session.start.slice(0, 10)}|${shortProject(session.project)}|${session.title || '(無標題)'}(${session.source})`, '')
106    for (const message of session.messages) lines.push(`- ${message.at.replace('T', ' ')}|${message.text.replace(/\s*\n\s*/g, ' / ')}`)
107    lines.push('')
108  }
109  return lines.join('\n')
110}
111
112export function analysisPrompt(digestPath: string, count: number): string {
113  return `請幫我找出最值得做的 Claude Code mod。不要寫任何程式,只做分析。
114
1151. 讀我最近 ${count} 次對話的原話整理:${digestPath}(Mod 探勘已經從對話紀錄抽好,只有我親手打的字;整份讀完再分析)。
1162. 找三種訊號:
117 - 我一直重複交代的事:同一個提醒或要求出現 3 次以上(不能只用關鍵字精確比對,同義和變體說法要算同一件,例如「我說過」「我也說過」「講過了」)
118 - 讓我緊張的動作:我要求你先停下來、先給我看、或事後追問「你剛剛刪了什麼」的動作,例如刪檔、推 code、改設定
119 - 我檢查成果的固定動作:例如每次都叫你跑測試、問花了多少、問改了哪些檔
1203. 每個訊號先判斷:用 Hook 或 Skill 能不能做到?能的話列在「不需要 mod」,說明用哪個就好、為什麼。整理檔開頭列了我已經有的 Hook/外掛/Skill,已經解決的不要再提。
1214. 剩下的,提出最多 5 個 mod 提案,每個寫:
122 - 名稱與一句話功能
123 - 證據:引用 2 句以上我在對話裡說過的原話,標明是哪一天、哪個專案
124 - 插手位置:動作前/取代動作/動作後/前後包住,以及插手後要放行、改內容再放行、還是擋下
125 - 用到的能力:在畫面上多一塊/記得前面發生的事/停下來問我/改掉你的輸出或行為
126 - 為什麼 Hook 或 Skill 做不到
127 - 資料要不要跨對話保存
1285. 依「每週能幫我省下幾次重複動作」排序,每個提案寫出估計的次數。
129
130讀紀錄時遇到金鑰、密碼、token 這類內容,不要引用也不要列出。
131
132格式:先寫「不需要 mod」那一段,再把提案整段放在 ${START} 和 ${END} 之間,每個提案用「### 提案 N:名稱」當標題(N 從 1 開始)。全部用繁體中文。寫完就停,先不要做任何一個,我挑了再做。`
133}
134
135export function buildPrompt(proposal: Proposal): string {
136  return `幫我做第 ${proposal.number} 個 Mod 提案「${proposal.name}」,內容照上面 Mod 探勘報告裡的提案。
137
138- 先載入 plugin-authoring skill,先給我設計稿(畫面、資料存哪、什麼時候插手),我確認後再寫
139- 對話標題改成「[Mod] ${proposal.name}」,加 mod、claude-設定 標籤
140- 寫完要跑 validate 和測試,結果照實跟我說
141- 做完之後,用選項問我要不要永久保存(推到 GitHub 的 claude-mods repo,雲端 setup script 也要加上),我說要才推`
142}
143
144export function parseProposals(answer: string): Proposal[] | null {
145  const start = answer.indexOf(START)
146  const end = answer.indexOf(END, start + START.length)
147  if (start < 0 || end < 0) return null
148  const proposals: Proposal[] = []
149  for (const match of answer.slice(start + START.length, end).matchAll(/^#{2,4}\s*提案\s*(\d+)\s*[::]\s*(.+?)\s*$/gm)) {
150    const name = match[2]!.replace(/[*`]/g, '').trim()
151    if (name) proposals.push({ number: Number(match[1]), name })
152  }
153  return proposals.slice(0, 5)
154}
155
156// 「做 3」「3」「第3個」→ 3
157export function pickNumber(args: string): number | null {
158  const match = args.match(/(\d+)/)
159  return match ? Number(match[1]) : null
160}
161
types/index.d.ts 12 lines
1export type Phase = 'idle' | 'collecting' | 'analyzing'
2
3export type Proposal = { number: number; name: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'mod-scout': {
8      phase: Phase
9    }
10  }
11}
12