SLOPSHOPPER

mod-scout

Scans your recent Claude Code sessions and ranks the mods you would use most, with a bundled skill that designs and builds them.

newpanecommandtoastprocesstimer
★ 3v0.1.0MITupdated 2026-10-01LeeHigma0201/claude-code-mods/mods/mod-scout
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-scout
│ ┃ mod-scout ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No scan yet. Run /mod-scout. │ mod-scout │ │ ⏺ Read(src/auth.ts) │ mod-scout: scanning the last 14 days of │ │ ⎿ Read 6 lines │ transcripts… │ │ ⏺ 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 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mod-scout
No scan yet. Run /mod-scout.
README

mod-scout

A Claude Code mod that reads how you actually use Claude Code and tells you which mods you'd use most. Then it hands Claude a skill that knows how to design and build them.

/mod-scout
mod-scout: 302 sessions, 1,201 prompts, 14 days. 11 candidates. Report: ~/.claude/mod-scout/report-2026-10-01.md
1. [9] Check ledger: which tests/type-checks ran, which edits came after  (mod; 57 verify-style follow-ups; 440 test/typecheck/build commands)
2. [9] Usage meter: context %, 5h/7d plan windows, cost  (mod; 7 usage-limit mentions, 494 compactions)
3. [6] Pre-flight note on tool calls that keep getting denied  (mod; 261 permission denials, 18 hook blocks)
...
/mod-scout design 3

What it does

  1. Scans your transcripts in ~/.claude/projects (last 14 days by default) with a bundled Node script. It streams each file, skips subagent transcripts, and keeps only aggregate counts: prompts you type again and again, how sessions end, verify-style and correction follow-ups, permission denials by tool, Bash command families (git push, pnpm typecheck, curl…), compactions, model switches, slash commands.
  2. Scores each signal against a catalog of mod shapes and ranks them by value × reliability. It also says which candidates should not be mods (a skill, a settings hook, a scheduled task) and flags any that touch money, posting, legal data or production deploys, with the gate the design must keep.
  3. Writes ~/.claude/mod-scout/report-<date>.md and opens a pane with the ranked list.
  4. /mod-scout design 3 sends Claude one prompt: use the bundled design-mods skill on the report, design the top three, and ask before building. The skill (/mod-scout:design-mods, in skills/design-mods/SKILL.md) holds the container test, the scoring rubric, the gate rules, the spec template, and the build checklist.

Nothing leaves the machine. The scanner reads transcripts; the mod writes only under ~/.claude/mod-scout.

Commands

CommandDoes
/mod-scoutScan, write the report, open the pane
/mod-scout showReopen the pane from the last scan
/mod-scout design <n>Ask Claude to design the top n with the skill (asks before building)
/mod-scout helpUsage

The pane isn't drawn on Remote Control; the command's text reply carries the top five, and the report has the rest.

Options

Set them in /plugin configure mod-scout@claude-code-mods or --config on install:

OptionDefaultUse
days14How far back to scan
exclude""Comma-separated project path substrings to skip, e.g. private-project,client-x
examplestrueKeep 110-character prompt snippets in the report. Turn off before sharing a report

MOD_SCOUT_DIR in the environment moves the output directory.

Privacy

The report is about you. Snippets of your prompts appear in it when examples is on. Exclude private projects, turn examples off for anything you'll share, and share the mod, not your report.

Requirements

Claude Code 2.1.287+, Node 18+ on PATH (the scanner). The scan of 2.3 GB of transcripts took 7 s on an M4.

Install / uninstall

claude plugin marketplace add LeeHigma0201/claude-code-mods
claude plugin install mod-scout@claude-code-mods --config exclude=private-project
# remove:
claude plugin uninstall mod-scout@claude-code-mods

Try without installing: claude --plugin-dir ./mods/mod-scout.

How it's built

  • scripts/scan.mjs: facts only. Streams JSONL, no dependencies.
  • hooks/analyze.ts: pure functions, scan → candidates → report. Tested on a fixture.
  • hooks/register.tsx: session.start (registers the command, restores the last scan from $.store), command.run, ui.render for the pane. Calls: $.process.run, $.fs.read/write, $.store, $.env.get, $.ui.open/toast, $.prompt.submit (only from design, on $.clock.after).
  • tests/: claude plugin test . (6 tests): ranking and gating, report content, the scan command end to end with stubs, options, scanner failure, and that help/show never submit while design submits once.

Hooks and limits: a hook has 10 s of its own time; process.run is capped at 10 min; fs.read at 4 MiB (the scanner caps its JSON well under that).

Source 2 files
hooks/register.tsx 121 lines
1import type { Register } from 'claude-code'
2
3import { analyze, paneLines, reportMarkdown } from './analyze'
4import type { Candidate, Scan } from './analyze'
5
6const PANE = 'mod-scout'
7const STORE_KEY = 'mod-scout:last'
8const HELP = [
9  'mod-scout: finds the mods you would use most, from how you actually use Claude Code.',
10  '',
11  '/mod-scout            scan the last N days of transcripts, write a report, open the pane',
12  '/mod-scout show       open the pane from the last scan (no rescan)',
13  '/mod-scout design 3   hand the report to Claude: design the top 3 with the design-mods skill, ask before building',
14  '/mod-scout help       this text',
15  '',
16  'Options (/plugin configure mod-scout): days (default 14), exclude (project substrings to skip),',
17  'examples (keep short prompt snippets in the report; off for a report you will share).',
18  'Report: ~/.claude/mod-scout/report-<date>.md. Nothing leaves the machine.',
19].join('\n')
20
21type Last = { at: string; report: string; json: string; sessions: number; candidates: Candidate[] }
22
23// Module state mirrors $.store so the pane survives a reload.
24let last: Last | undefined
25
26function designPrompt(l: Last, n: number): string {
27  return [
28    `Use the mod-scout plugin's design-mods skill (/mod-scout:design-mods) on ${l.report}.`,
29    `Design the top ${n} mod candidates: for each, write the spec the skill asks for (purpose, evidence, hooks and API calls, gates, failure modes, tests, uninstall), say which should NOT be mods and why, then stop and ask me which to build.`,
30  ].join(' ')
31}
32
33export const register: Register = (on, options) => {
34  const days = Number(options.days ?? 14) || 14
35  const exclude = String(options.exclude ?? '')
36  const examples = options.examples === undefined ? true : options.examples === true || options.examples === 'true'
37
38  on('session.start', async ($, e, next) => {
39    await $.command.register({
40      name: 'mod-scout',
41      description: 'Scan your recent sessions and rank the mods you would use most',
42      argumentHint: '[show | design <n> | help]',
43    })
44    const saved = (await $.store.get(STORE_KEY)) as Last | undefined
45    if (saved && Array.isArray(saved.candidates)) last = saved
46    return next(e)
47  })
48
49  on('command.run', { command: 'mod-scout' }, async ($, e) => {
50    const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
51
52    if (verb === 'help') return { text: HELP }
53
54    if (verb === 'show') {
55      if (!last) return { text: 'mod-scout: no scan yet. Run /mod-scout first.' }
56      const opened = await $.ui.open({ id: PANE, title: 'mod-scout' })
57      return { text: opened.isPlaced ? `mod-scout: pane opened (${last.candidates.length} candidates from ${last.at.slice(0, 10)}).` : `mod-scout: ${paneLines(last.candidates, 100, last.at).join('\n')}` }
58    }
59
60    if (verb === 'design') {
61      if (!last) return { text: 'mod-scout: no scan yet. Run /mod-scout first.' }
62      const n = Math.min(10, Math.max(1, Number(arg) || 3))
63      const l = last
64      // A submit from inside command.run would wait on the turn this hook holds; send it after.
65      $.clock.after(0, () => {
66        $.prompt.submit({ text: designPrompt(l, n), asUser: true }).catch(err => $.ui.toast(`mod-scout: could not send the design prompt: ${String(err)}`))
67      })
68      return { text: `mod-scout: asking Claude to design the top ${n} from ${l.report}.` }
69    }
70
71    // Scan.
72    const home = (await $.env.get('HOME')) ?? '~'
73    const day = new Date(await $.clock.now()).toISOString().slice(0, 10)
74    // MOD_SCOUT_DIR moves the report somewhere else (a sandbox, a CI run).
75    const dir = (await $.env.get('MOD_SCOUT_DIR')) || `${home}/.claude/mod-scout`
76    const json = `${dir}/scan-${day}.json`
77    const report = `${dir}/report-${day}.md`
78    $.ui.toast(`mod-scout: scanning the last ${days} days of transcripts…`, { timeoutMs: 8000 })
79    const argv = ['node', `${$.plugin.root}/scripts/scan.mjs`, '--root', `${home}/.claude/projects`, '--days', String(days), '--out', json, '--examples', examples ? '1' : '0']
80    if (exclude) argv.push('--exclude', exclude)
81    let ran
82    try {
83      ran = await $.process.run(argv, { timeoutMs: 600_000 })
84    } catch (err) {
85      return { text: `mod-scout: could not run the scanner (${String(err)}). It needs Node 18+ on PATH.` }
86    }
87    if (ran.exitCode !== 0) return { text: `mod-scout: scanner failed (exit ${ran.exitCode}).\n${ran.stderr.slice(-800)}` }
88
89    const scan = JSON.parse(await $.fs.read(json)) as Scan
90    const candidates = analyze(scan)
91    await $.fs.write(report, reportMarkdown(scan, candidates, { json }))
92    last = { at: scan.generatedAt, report, json, sessions: scan.sessionsScanned, candidates }
93    // The store is a convenience (pane after reload); a read-only store must not lose the report.
94    await $.store.set(STORE_KEY, last).catch(err => $.ui.toast(`mod-scout: could not save the scan (${String(err)}); the report is on disk.`))
95
96    const opened = await $.ui.open({ id: PANE, title: 'mod-scout' })
97    const top = candidates.slice(0, 5).map((c, i) => `${i + 1}. [${c.score}] ${c.title}${c.gate ? ' ⚠ gate' : ''}  (${c.container}; ${c.evidence})`)
98    return {
99      text: [
100        `mod-scout: ${scan.sessionsScanned} sessions, ${scan.prompts.total} prompts, ${days} days. ${candidates.length} candidates. Report: ${report}`,
101        ...top,
102        opened.isPlaced ? 'Full list in the pane. `/mod-scout design 3` to have Claude design the top three.' : '`/mod-scout design 3` to have Claude design the top three.',
103      ].join('\n'),
104    }
105  })
106
107  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
108    const { Box, Text } = $.ui.resolve(e)
109    const width = e.props.bodyColumns
110    if (!last) return <Text dimColor>No scan yet. Run /mod-scout.</Text>
111    const lines = paneLines(last.candidates, width, last.at)
112    return (
113      <Box flexDirection="column">
114        {lines.map((line, i) => (
115          <Text key={String(i)} dimColor={i > 0 && line.startsWith('     ')}>{line || ' '}</Text>
116        ))}
117      </Box>
118    )
119  })
120}
121
hooks/analyze.ts 343 lines
1// Turns a scan (facts about how Claude Code was used) into ranked mod candidates.
2// Pure functions: no `$`, so tests run them on fixtures and the pane reads their output.
3
4// solo: how many of those sessions had this as their only prompt (a script sent it, nobody typed it).
5export type Cluster = { key: string; count: number; sessions: number; solo?: number; example: string; gate: boolean }
6export type Scan = {
7  generatedAt: string
8  days: number
9  sessionsScanned: number
10  prompts: { total: number; clusters: Cluster[]; openings: Cluster[] }
11  followups: { verify: number; correction: number; verifyExamples: string[]; correctionExamples: string[] }
12  tools: { byName: Record<string, { calls: number; errors: number }>; bashFamilies: Record<string, number> }
13  denials: { permission: number; hookBlock: number; byTool: Record<string, number> }
14  sessions: {
15    interrupted: number; cutOff: number; compactions: number; continuedFromSummary: number; remoteControl: number
16    usageLimitHits: number; modelSwitches: number; long: number; withSubagents: number; slashCommands: Record<string, number>
17  }
18  models: Record<string, number>
19  projects: { name: string; sessions: number; prompts: number }[]
20}
21
22export type Container = 'mod' | 'settings hook' | 'skill' | 'scheduled task' | 'not a mod'
23export type Candidate = {
24  id: string
25  title: string
26  evidence: string
27  value: 1 | 2 | 3
28  reliability: 1 | 2 | 3
29  score: number
30  container: Container
31  hooks: string[]
32  api: string[]
33  gate?: string
34  sketch: string
35}
36
37const GATE_WORDS = /\b(push|deploy|prod(uction)?|publish|post|tweet|send|email|outreach|pay|refund|invoice|stripe|legal|court|custody|case[- ]file|credential|password|token)\b/i
38
39function tier(n: number, low: number, high: number): 1 | 2 | 3 {
40  return n >= high ? 3 : n >= low ? 2 : 1
41}
42// Sent by a script: in (nearly) every session it appears in, it was the only prompt.
43function isScripted(c: Cluster): boolean {
44  return (c.solo ?? 0) >= 2 && (c.solo ?? 0) >= c.sessions * 0.8
45}
46function make(c: Omit<Candidate, 'score'>): Candidate {
47  return { ...c, score: c.value * c.reliability }
48}
49function sum(fams: Record<string, number>, test: RegExp): number {
50  return Object.entries(fams).filter(([k]) => test.test(k)).reduce((n, [, v]) => n + v, 0)
51}
52function gateFor(text: string): string | undefined {
53  const m = GATE_WORDS.exec(text)
54  if (!m) return undefined
55  const w = m[1]!.toLowerCase()
56  if (/push|deploy|prod/.test(w)) return 'production deploy: annotate or ask only; never approve the call'
57  if (/publish|post|tweet|send|email|outreach/.test(w)) return 'public posting/outreach: draft only; the send stays a human yes'
58  if (/pay|refund|invoice|stripe/.test(w)) return 'money: read-only'
59  if (/legal|court|custody|case/.test(w)) return 'legal/case data: keep out of shared reports; prefer a repo git hook'
60  return 'credentials: never read or display them'
61}
62
63export function analyze(scan: Scan): Candidate[] {
64  const out: Candidate[] = []
65  const S = Math.max(1, scan.sessionsScanned)
66  const fam = scan.tools.bashFamilies
67
68  // 1. Prompts typed again and again: each is a /command a mod can register (no Claude turn to
69  //    type it) or a prompt.submit macro. Reliability is high when the text is stable.
70  const scripted: Cluster[] = []
71  for (const c of scan.prompts.clusters.slice(0, 8)) {
72    if (c.count < 3 || c.sessions < 2) continue
73    if (isScripted(c)) { scripted.push(c); continue }
74    out.push(make({
75      id: `macro:${c.key.replace(/\s+/g, '-').slice(0, 30)}`,
76      title: `/command for "${c.key}…"`,
77      evidence: `typed ${c.count} times in ${c.sessions} sessions${c.example ? `; e.g. "${c.example}"` : ''}`,
78      value: tier(c.count, 5, 15),
79      reliability: c.gate ? 1 : 3,
80      container: 'mod',
81      hooks: ['session.start', 'command.run'],
82      api: ['$.command.register', '$.prompt.submit (on $.clock.after, never inside command.run)'],
83      gate: c.gate ? gateFor(c.key + ' ' + c.example) : undefined,
84      sketch: `Register /${c.key.split(' ').slice(0, 2).join('-')} that submits the full prompt text with the current cwd and branch filled in, so the ritual is one command instead of retyped prose.`,
85    }))
86  }
87
88  if (scripted.length) {
89    out.push(make({
90      id: 'scripted-prompts',
91      title: 'One-shot prompts a script already sends (not a habit to automate)',
92      evidence: scripted.map(c => `"${c.key}…" ${c.solo}/${c.sessions} single-prompt sessions`).join('; '),
93      value: 1,
94      reliability: 3,
95      container: 'not a mod',
96      hooks: [],
97      api: [],
98      sketch: 'These arrive as the only prompt of a session, so a harness or cron sends them; a /command would save nobody a keystroke. Tune the script that sends them instead.',
99    }))
100  }
101
102  // 2. The same opening prompt: a session.start hook can draft it.
103  const open = scan.prompts.openings.find(c => !isScripted(c))
104  if (open && open.count >= 3 && open.sessions >= 3) {
105    out.push(make({
106      id: 'opening-ritual',
107      title: 'Session-start draft of the usual opening prompt',
108      evidence: `${open.count} sessions opened with "${open.key}…"`,
109      value: tier(open.count, 5, 12),
110      reliability: 2,
111      container: 'mod',
112      hooks: ['session.start'],
113      api: ['$.prompt.fill', '$.session.cwd'],
114      gate: open.gate ? gateFor(open.key + ' ' + open.example) : undefined,
115      sketch: 'On session.start, if the project matches, fill the prompt box with the opening text (never submit). Enter sends it; anything else replaces it.',
116    }))
117  }
118
119  // 3. "Did you verify?" follow-ups: a ledger of real checks beats memory.
120  if (scan.followups.verify >= 5) {
121    out.push(make({
122      id: 'check-ledger',
123      title: 'Check ledger: which tests/type-checks ran, which edits came after',
124      evidence: `${scan.followups.verify} verify-style follow-ups; ${sum(fam, /\b(test|typecheck|type-check|build|lint|vitest|jest|pytest|tsc)\b/)} test/typecheck/build commands`,
125      value: tier(scan.followups.verify, 10, 40),
126      reliability: 3,
127      container: 'mod',
128      hooks: ['tool.call (Bash, Edit, Write)', 'command.run'],
129      api: ['$.ui.status', '$.tool.register (an evidence tool Claude reads before claiming tested)'],
130      sketch: 'Observe Bash calls that are real checks and record pass/fail; record edits; report the files changed after the last passing check and the strongest status word the evidence supports. (Built in claude-mods as check-ledger.)',
131    }))
132  }
133
134  // 4. Corrections ("no, wrong, still broken"): usually a prompt/skill problem, not a mod.
135  if (scan.followups.correction >= 8) {
136    out.push(make({
137      id: 'correction-loop',
138      title: 'Correction loop: tighten the skill or CLAUDE.md rule, not a mod',
139      evidence: `${scan.followups.correction} correction follow-ups${scan.followups.correctionExamples[0] ? `; e.g. "${scan.followups.correctionExamples[0]}"` : ''}`,
140      value: 2,
141      reliability: 1,
142      container: 'skill',
143      hooks: [],
144      api: [],
145      sketch: 'Read the correction examples, group them by root cause, and fix the instruction that let it happen. A prompt.submit hook that appends reminders would paper over it.',
146    }))
147  }
148
149  // 5. Permission denials and hook blocks by tool: annotate before the prompt appears.
150  if (scan.denials.permission + scan.denials.hookBlock >= 5) {
151    const top = Object.entries(scan.denials.byTool).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${k} ${v}`).join(', ')
152    out.push(make({
153      id: 'denial-preflight',
154      title: 'Pre-flight note on tool calls that keep getting denied',
155      evidence: `${scan.denials.permission} permission denials, ${scan.denials.hookBlock} hook blocks (${top || 'by tool unknown'})`,
156      value: tier(scan.denials.permission + scan.denials.hookBlock, 10, 30),
157      reliability: 2,
158      container: 'mod',
159      hooks: ['tool.call'],
160      api: ['$.ui.notice (a line under the permission dialog)', '$.ui.status'],
161      sketch: 'On tool.call for the tools above, add a notice naming the rule that usually denies this and what would pass instead. Never approve or answer the call; the permission prompt stays as is.',
162    }))
163  }
164
165  // 6. Pushes and deploys: show branch, HEAD and dirty state before the yes.
166  const pushes = sum(fam, /^git push/)
167  const deploys = sum(fam, /^(vercel|firebase deploy|eas|netlify|fly|wrangler)/)
168  if (pushes + deploys >= 5) {
169    out.push(make({
170      id: 'deploy-preflight',
171      title: 'Push/deploy preflight: branch, HEAD, dirty files, last check, in the dialog',
172      evidence: `${pushes} git push and ${deploys} deploy-family commands`,
173      value: tier(pushes + deploys, 20, 100),
174      reliability: 2,
175      container: 'mod',
176      hooks: ['tool.call (Bash matching push/deploy)'],
177      api: ['$.process.run (git status/branch)', '$.ui.notice'],
178      gate: 'production deploy: annotate or ask only; never approve the call',
179      sketch: 'Before the permission prompt, run git branch/status in the command\'s cwd and show "branch X, HEAD abc123, 3 dirty files, last typecheck PASS 4m ago" under the dialog. Add nothing else; the decision is the human\'s.',
180    }))
181  }
182
183  // 7. Live checks by curl: a last-status strip.
184  const curls = sum(fam, /^curl/)
185  if (curls >= 20) {
186    out.push(make({
187      id: 'live-check-strip',
188      title: 'Live-check strip: last URL checked and its status code',
189      evidence: `${curls} curl commands`,
190      value: 2,
191      reliability: 3,
192      container: 'mod',
193      hooks: ['tool.call (Bash matching curl)'],
194      api: ['$.ui.status'],
195      sketch: 'After a curl Bash call returns, parse the status line and show "example.com 200 · 12s ago" in the status line, so "is it live" has a visible answer.',
196    }))
197  }
198
199  // 8. Usage limits and compaction pressure.
200  if (scan.sessions.usageLimitHits >= 1 || scan.sessions.compactions + scan.sessions.continuedFromSummary >= 10) {
201    out.push(make({
202      id: 'usage-meter',
203      title: 'Usage meter: context %, 5h/7d plan windows, cost',
204      evidence: `${scan.sessions.usageLimitHits} usage-limit mentions, ${scan.sessions.compactions} compactions, ${scan.sessions.continuedFromSummary} continued-from-summary starts`,
205      value: tier(scan.sessions.usageLimitHits * 5 + scan.sessions.compactions, 10, 40),
206      reliability: 3,
207      container: 'mod',
208      hooks: ['session.measure', 'command.run'],
209      api: ['$.session.usage', '$.ui.status', '$.ui.toast'],
210      sketch: 'Status line with context fill and plan-window percentages; a toast at 80% and 95%; a /command for Remote Control where the status line is not drawn. (Built in claude-mods as usage-meter.)',
211    }))
212  }
213
214  // 9. Sessions cut off mid-turn.
215  if (scan.sessions.cutOff >= 3) {
216    out.push(make({
217      id: 'resume-nudge',
218      title: 'Resume nudge: draft the pick-up prompt after a cut-off',
219      evidence: `${scan.sessions.cutOff} of ${S} sessions ended mid-turn (unanswered prompt, tool result or pending tool call)`,
220      value: tier(scan.sessions.cutOff, 5, 20),
221      reliability: 2,
222      container: 'mod',
223      hooks: ['session.start', 'command.run'],
224      api: ['$.session.messages', '$.prompt.fill'],
225      sketch: 'On session.start read the last message; if the turn never finished, fill the prompt box with the standard pick-up text. Send only on Enter or an explicit /command. (Built in claude-mods as resume-nudge.)',
226    }))
227  }
228
229  // 10. Model switching: show model and effort where the eye already is.
230  if (scan.sessions.modelSwitches >= 3 || Object.keys(scan.models).length >= 3) {
231    out.push(make({
232      id: 'model-strip',
233      title: 'Model and effort in the status line',
234      evidence: `${scan.sessions.modelSwitches} in-session model switches across ${Object.keys(scan.models).length} models`,
235      value: 2,
236      reliability: 3,
237      container: 'mod',
238      hooks: ['session.start', 'turn.step (observe)'],
239      api: ['$.session.model', '$.ui.status'],
240      sketch: 'Show "opus · medium" in the status line and update it on a switch. Fold into the usage meter rather than a separate mod.',
241    }))
242  }
243
244  // 11. Tools with a high error rate.
245  for (const [name, t] of Object.entries(scan.tools.byName)) {
246    if (t.calls >= 20 && t.errors / t.calls >= 0.2) {
247      out.push(make({
248        id: `tool-errors:${name}`,
249        title: `${name} fails ${Math.round((100 * t.errors) / t.calls)}% of the time: annotate or pre-check`,
250        evidence: `${t.errors} errors in ${t.calls} calls`,
251        value: tier(t.errors, 10, 50),
252        reliability: 2,
253        container: 'mod',
254        hooks: [`tool.call (${name})`],
255        api: ['$.ui.notice', 'next(e) with corrected arguments only when the fix is mechanical'],
256        sketch: `Read the common error texts for ${name} first. If the fix is mechanical (a flag, a path form), rewrite the call; otherwise show a notice with the usual cause.`,
257      }))
258    }
259  }
260
261  // 12. Remote Control sessions: a design rule for every mod, not a mod.
262  if (scan.sessions.remoteControl >= 2) {
263    out.push(make({
264      id: 'mobile-rule',
265      title: 'Design rule: every mod needs a /command text form for Remote Control',
266      evidence: `${scan.sessions.remoteControl} sessions driven from a phone`,
267      value: 2,
268      reliability: 3,
269      container: 'not a mod',
270      hooks: [],
271      api: [],
272      sketch: 'Panes and status lines are not drawn on the phone. Give every mod a /command that answers in text, and say so in its README.',
273    }))
274  }
275
276  // 13. Heavily used slash commands that are skills: stay skills unless they need state or UI.
277  const topSkill = Object.entries(scan.sessions.slashCommands).filter(([k]) => !/^(clear|compact|resume|help|plugin|login|model|cost|config|status|init|exit|quit|diff|context|memory|mcp)$/.test(k)).sort((a, b) => b[1] - a[1])[0]
278  if (topSkill && topSkill[1] >= 10) {
279    out.push(make({
280      id: `skill:${topSkill[0]}`,
281      title: `/${topSkill[0]} stays a skill unless it needs state, UI or a no-turn command`,
282      evidence: `run ${topSkill[1]} times`,
283      value: 1,
284      reliability: 3,
285      container: 'skill',
286      hooks: [],
287      api: [],
288      sketch: 'A mod earns its place when something must persist across hooks, draw live, or run without a Claude turn. Otherwise the skill is cheaper to maintain.',
289    }))
290  }
291
292  return out.sort((a, b) => b.score - a.score || b.value - a.value)
293}
294
295export function reportMarkdown(scan: Scan, cands: Candidate[], paths: { json: string }): string {
296  const L: string[] = []
297  const day = scan.generatedAt.slice(0, 10)
298  L.push(`# mod-scout report, ${day}`, '')
299  L.push(`Scanned ${scan.sessionsScanned} sessions, ${scan.prompts.total} human prompts, last ${scan.days} days. Raw signals: \`${paths.json}\`.`, '')
300  L.push('Scores are value × reliability (1–3 each; 9 is best). "Gate" marks a candidate that touches money, posting, legal data or production deploys: it may only annotate, draft or ask, never approve or send.', '')
301  L.push('## Mod candidates', '', '| # | Candidate | Score | Container | Evidence | Gate |', '|---|---|---|---|---|---|')
302  cands.forEach((c, i) => L.push(`| ${i + 1} | ${c.title} | ${c.score} | ${c.container} | ${c.evidence} | ${c.gate ?? ''} |`))
303  L.push('', '## Designs', '')
304  for (const c of cands.filter(c => c.container === 'mod')) {
305    L.push(`### ${c.title}`, '', `- Evidence: ${c.evidence}`, `- Hooks: ${c.hooks.join(', ') || 'none'}`, `- API: ${c.api.join(', ') || 'none'}`)
306    if (c.gate) L.push(`- Gate: ${c.gate}`)
307    L.push(`- Sketch: ${c.sketch}`, '')
308  }
309  const rest = cands.filter(c => c.container !== 'mod')
310  if (rest.length) {
311    L.push('## Not mods', '')
312    for (const c of rest) L.push(`- **${c.title}** (${c.container}): ${c.sketch}`)
313    L.push('')
314  }
315  L.push('## Signals', '')
316  L.push(`- Verify follow-ups: ${scan.followups.verify}; corrections: ${scan.followups.correction}`)
317  L.push(`- Denials: ${scan.denials.permission} permission, ${scan.denials.hookBlock} hook`)
318  L.push(`- Sessions: ${scan.sessions.cutOff} cut off, ${scan.sessions.interrupted} interrupted by Esc, ${scan.sessions.compactions} compactions, ${scan.sessions.continuedFromSummary} continued from summary, ${scan.sessions.remoteControl} on Remote Control, ${scan.sessions.long} long (40+ prompts), ${scan.sessions.withSubagents} with subagents`)
319  L.push(`- Models: ${Object.entries(scan.models).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'}; ${scan.sessions.modelSwitches} switches`)
320  L.push('', '### Repeated prompts (first six words)', '')
321  for (const c of scan.prompts.clusters.slice(0, 15)) L.push(`- ${c.count}× in ${c.sessions} sessions: "${c.key}"${c.gate ? ' (gate)' : ''}`)
322  L.push('', '### Bash families', '')
323  for (const [k, v] of Object.entries(scan.tools.bashFamilies).slice(0, 25)) L.push(`- ${v} ${k}`)
324  L.push('', '### Tools', '')
325  for (const [k, t] of Object.entries(scan.tools.byName).slice(0, 15)) L.push(`- ${k}: ${t.calls} calls, ${t.errors} errors`)
326  L.push('', '### Slash commands', '')
327  for (const [k, v] of Object.entries(scan.sessions.slashCommands).slice(0, 15)) L.push(`- ${v} /${k}`)
328  L.push('', '## Next', '', 'Run `/mod-scout design 3` and Claude designs the top three with the bundled mod-scout skill, then asks before building.', '')
329  return L.join('\n')
330}
331
332export function paneLines(cands: Candidate[], width: number, scannedAt: string): string[] {
333  const w = Math.max(30, width)
334  const lines = [`mod-scout · ${scannedAt.slice(0, 16).replace('T', ' ')}`.slice(0, w)]
335  cands.slice(0, 12).forEach((c, i) => {
336    const gate = c.gate ? ' ⚠' : ''
337    lines.push(`${String(i + 1).padStart(2)}. [${c.score}] ${c.title}${gate}`.slice(0, w))
338    lines.push(`     ${c.container} · ${c.evidence}`.slice(0, w))
339  })
340  lines.push(''.padEnd(0), '/mod-scout design 3 · /mod-scout show · /mod-scout help'.slice(0, w))
341  return lines
342}
343