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

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
~/.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.~/.claude/mod-scout/report-<date>.md and opens a pane with the ranked list./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.
| Command | Does |
|---|---|
/mod-scout | Scan, write the report, open the pane |
/mod-scout show | Reopen 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 help | Usage |
The pane isn't drawn on Remote Control; the command's text reply carries the top five, and the report has the rest.
Set them in /plugin configure mod-scout@claude-code-mods or --config on install:
| Option | Default | Use |
|---|---|---|
days | 14 | How far back to scan |
exclude | "" | Comma-separated project path substrings to skip, e.g. private-project,client-x |
examples | true | Keep 110-character prompt snippets in the report. Turn off before sharing a report |
MOD_SCOUT_DIR in the environment moves the output directory.
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.
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.
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.
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).
hooks/register.tsx 121 lines1import 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}
121hooks/analyze.ts 343 lines1// 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