SLOPSHOPPER

matchmaker

Mod: classifies each prompt (cheap model) and tells Claude which agent/model fits; band with off switch

newbandstatuspromptmodel
★ 1v0.2.0MITupdated 2026-10-07danielriddell21/skills/plugins/matchmaker
A shopper browsing a rack in a slop shop
README

matchmaker

Mod. A cheap classifier tags each prompt (search / implement / design / review / trivial) and adds a hidden hint naming the agent to use. Shows route: <label> in the status line and an Off button above the prompt (remembered across sessions).

Options: enabled (true), minChars (40). Costs one small model call per prompt; routing rules live in the hey-you-do-it skill.

Besides agent routes, three labels attach skill hints: destructive-or-ambiguous (follow ask-dont-guess), compare-or-explain-flow (follow show-dont-tell), second-opinion (follow phone-a-friend / ask-the-audience). This is how skills get triggered on implicit requests.

The chip reads ✦ design → spy [Off] (label, then who gets the work). Small chips from different plugins share one row above the prompt instead of stacking.

Source 3 files
hooks/register.tsx 112 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { RANK, chipKey, injectChips, isEmpty } from './chips'
5
6const route = atom({ plugin: 'matchmaker', key: 'route' } as const, null)
7const isOff = atom({ plugin: 'matchmaker', key: 'isOff' } as const, false)
8
9export const LABELS = [
10  'search',
11  'implement',
12  'design',
13  'review',
14  'trivial',
15  'destructive-or-ambiguous',
16  'compare-or-explain-flow',
17  'second-opinion',
18] as const
19export const HINT: Record<string, string> = {
20  search: 'Route: read/search-heavy. Delegate the sweep to the `scout` agent (haiku); take back a summary. See skill hey-you-do-it.',
21  implement: 'Route: clear implementation. Use `engineer` (sonnet) if the change spans several files.',
22  design: 'Route: ambiguous/cross-cutting. Plan with `spy` (opus) first, then implement.',
23  review: 'Route: review. Use `sniper` (opus) on the diff.',
24  trivial: 'Route: trivial. Do it inline; no subagent.',
25  'destructive-or-ambiguous':
26    'Route: this request may be destructive, irreversible or ambiguous. If the ask-dont-guess skill is available, follow it: list assumptions and confirm with the user (mcp__your-call__ask) before acting.',
27  'compare-or-explain-flow':
28    'Route: comparison or structure. If the show-dont-tell skill is available, follow it: answer with a chart or diagram (a fenced viz block) rather than prose alone.',
29  'second-opinion':
30    'Route: the user wants a second opinion. If the phone-a-friend or ask-the-audience skill is available, follow it.',
31}
32
33/** Who the hint sends the work to, for the chip. */
34export const AGENT: Record<string, string> = {
35  search: 'scout',
36  implement: 'engineer',
37  design: 'spy',
38  review: 'sniper',
39  trivial: 'inline',
40  'destructive-or-ambiguous': 'ask first',
41  'compare-or-explain-flow': 'chart it',
42  'second-opinion': 'second opinion',
43}
44
45export const chipText = (label: string): string => `✦ ${label} → ${AGENT[label] ?? label}`
46
47export const shouldClassify = (text: string, hasOrigin: boolean, isOffNow: boolean, minChars = 40): boolean => {
48  const t = text.trim()
49  return !hasOrigin && !isOffNow && !t.startsWith('/') && t.length >= minChars
50}
51
52export const register: Register = (on, options) => {
53  const minChars = typeof options.minChars === 'number' ? options.minChars : 40
54
55  on('session.start', async ($, e, next) => {
56    const saved = await $.store.get('isOff')
57    await update($, isOff, () => saved === true || options.enabled === false)
58    return next(e)
59  })
60
61  on('prompt.submit', async ($, e, next) => {
62    const t = e.text.trim()
63
64    if (!shouldClassify(t, e.origin !== undefined, await read($, isOff), minChars)) return next(e)
65
66    const label = await $.model.classify(t, LABELS).catch(() => undefined)
67
68    if (!label) return next(e)
69
70    await update($, route, () => label)
71    $.ui.status(`✦ ${label}`)
72
73    return next({ ...e, context: [...(e.context ?? []), HINT[label] ?? ''] })
74  })
75
76  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
77    const r = await read($, route)
78    const off = await read($, isOff)
79
80    if (e.props.hasSurvey || (r === null && !off)) return next(e)
81
82    const { Box, Text, Button } = $.ui.resolve(e)
83    const rest = await next(e)
84    const chip = (
85      <Box key={chipKey(RANK.route, 'route')}>
86        <Text key="mm" color={off ? undefined : 'magenta'} dimColor={off}>
87          {off ? '✦ matchmaker off ' : `${chipText(r ?? '')} `}
88        </Text>
89        <Button
90          key="toggle"
91          label={off ? 'On' : 'Off'}
92          onPress={async () => {
93            const v = await update($, isOff, x => !x)
94            await $.store.set('isOff', v)
95          }}
96        />
97      </Box>
98    )
99    const joined = injectChips(rest, chip)
100
101    if (joined) return joined as never
102    if (isEmpty(rest)) return <Box key="chips" flexWrap="wrap">{chip}</Box>
103
104    return (
105      <Box flexDirection="column">
106        <Box key="chips" flexWrap="wrap">{chip}</Box>
107        {rest}
108      </Box>
109    )
110  })
111}
112
hooks/chips.ts 42 lines
1// Chip row: small status chips from different plugins share one row above the prompt.
2// - The row is a Box with props.key === 'chips' (wrapping, so a narrow terminal gets a second line
3//   instead of cut-off buttons).
4// - Each chip is a Box keyed `chip:<rank>:<name>`; chips sort by rank so the order is the same
5//   whichever plugin happens to draw first: gate 5, context 10, route 20, result 30.
6// Plain data in, plain data out, so it works on whatever `next(e)` returns.
7export type Node = { type?: string; props?: Record<string, unknown>; children?: unknown[] }
8
9export const CHIPS_KEY = 'chips'
10export const GAP = '   '
11export const RANK = { gate: 5, context: 10, route: 20, result: 30 } as const
12
13export const chipKey = (rank: number, name: string): string => `chip:${String(rank).padStart(3, '0')}:${name}`
14
15const isNode = (n: unknown): n is Node => typeof n === 'object' && n !== null
16
17const keyOf = (n: unknown): string => (isNode(n) && typeof n.props?.key === 'string' ? n.props.key : '')
18
19/** Chips in rank order with a gap between each. */
20export const arrange = (chips: unknown[]): unknown[] =>
21  [...chips]
22    .sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
23    .flatMap((c, i) => (i === 0 ? [c] : [GAP, c]))
24
25/** Add `chip` to the chips row inside `node`; undefined when there is none. */
26export const injectChips = (node: unknown, chip: unknown): Node | undefined => {
27  if (!isNode(node)) return undefined
28  if (node.props?.key === CHIPS_KEY) {
29    const existing = (node.children ?? []).filter(c => c !== GAP)
30    return { ...node, children: arrange([...existing, chip]) }
31  }
32  const kids = node.children ?? []
33  for (let i = kids.length - 1; i >= 0; i--) {
34    const hit = injectChips(kids[i], chip)
35    if (hit) return { ...node, children: kids.map((c, j) => (j === i ? hit : c)) }
36  }
37  return undefined
38}
39
40/** True when the band beneath drew nothing. */
41export const isEmpty = (node: unknown): boolean => node == null || (isNode(node) && (node.children ?? []).length === 0)
42
types/index.d.ts 8 lines
1export type Route = string | null
2
3declare module 'claude-code' {
4  interface PluginState {
5    'matchmaker': { route: Route; isOff: boolean }
6  }
7}
8