SLOPSHOPPER

jr-vinci

An opt-in sparring partner: after each answer, one line above the prompt with the sharpest challenge to what was just decided. /jrvinci on, off, or the history.

newpanebandcommandmodeltimer
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-vinci
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-vinci
│ ┃ jr vinci ✕ › fix the failing auth test and add an audit log call │ ┃ ⚔ jr vinci off · /jrvinci on to start │ ┃ No challenge yet. ⏺ 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 │ │ › /jrvinci │ ⎿ jr-vinci: jr vinci opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr vinci
⚔ jr vinci off · /jrvinci on to start No challenge yet.
README

jr-vinci

A sparring partner that pushes back. When it is on, after each of Claude's answers it asks one question of the conversation: what is the weakest assumption, the biggest risk, or the question whose answer would change the outcome? The answer shows as one line above the prompt.

⚔ vinci  Who pays when the free tier is abused by scripted sign-ups?
  • Off by default. /jrvinci on starts it, /jrvinci off stops it; the choice is kept across sessions.
  • One sentence, no praise, in the language of the conversation. When nothing deserves a challenge, nothing is shown.
  • /jrvinci opens the history of this session's challenges.
  • It runs after the answer, never inside it: your turn is not slowed down.

Commands

Command
/jrvinci onStart challenging after each answer
/jrvinci offStop
/jrvinciOpen the history

Cost

One extra model call per answer, on the session's own model, over the conversation as it stands. The conversation prefix is normally served from the prompt cache, so the call costs mostly its cached read plus a short reply; on a long session with a cold cache it can cost as much as a regular turn. Leave it off when you do not need it.

Install

/plugin install jr-vinci --marketplace jrlprost/claude-code-mods

Access

Asks the model one question over the session's own conversation (no tools). Keeps the on/off choice in the plugin's own store. Reads no files, runs nothing.

Source 5 files
hooks/register.tsx 115 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { challengeOf, VINCI_PROMPT } from './vinci'
5import { gradientText } from './jr/draw'
6import { GRADIENTS } from './jr/gradient'
7
8const PANE = 'jr-vinci'
9const isOn = atom({ plugin: 'jr-vinci', key: 'isOn' } as const, false)
10const history = atom({ plugin: 'jr-vinci', key: 'history' } as const, [])
11const isThinking = atom({ plugin: 'jr-vinci', key: 'isThinking' } as const, false)
12const boot = { isDone: false }
13
14/** Command and saved on/off state, once: at session start or first thing after a mid-session install. */
15async function ensureReady($: EngineInterface) {
16  if (boot.isDone) return
17  boot.isDone = true
18  await $.command.register({ name: 'jrvinci', description: 'Sparring partner: /jrvinci on, /jrvinci off, /jrvinci to see the challenges' })
19  if ((await $.store.get('isOn')) === true) await update($, isOn, () => true)
20}
21
22/** Asks the session's own transcript for one challenge; runs after the turn, never inside it. */
23async function challenge($: EngineInterface) {
24  await update($, isThinking, () => true)
25  try {
26    const reply = await $.model.fork({ prompt: VINCI_PROMPT })
27    if (!reply.isAnswered) return
28    const text = challengeOf(reply.text)
29    if (!text) return
30    const at = await $.clock.now()
31    await update($, history, list => [...list, { at, text }].slice(-30))
32  } finally {
33    await update($, isThinking, () => false)
34  }
35}
36
37export const register: Register = on => {
38  on('session.start', async ($, e, next) => {
39    const started = await next(e)
40    await ensureReady($)
41    return started
42  })
43
44  on('turn.complete', async ($, e, next) => {
45    const done = await next(e)
46    await ensureReady($)
47    const isFinished = e.agentId === undefined && !e.isAborted && e.reason === 'answer'
48    if (isFinished && (await read($, isOn))) {
49      $.clock.after(0, () => {
50        void challenge($)
51      })
52    }
53    return done
54  })
55
56  on('command.run', { command: 'jrvinci' }, async ($, e) => {
57    await ensureReady($)
58    const arg = e.args.trim()
59    if (arg === 'on' || arg === 'off') {
60      await $.store.set('isOn', arg === 'on')
61      await update($, isOn, () => arg === 'on')
62      return { text: arg === 'on' ? 'jr vinci · on: one challenge after each answer (one extra model call per turn)' : 'jr vinci · off' }
63    }
64    await $.ui.open({ id: PANE, title: 'jr vinci', rows: 14 })
65    return { text: 'jr vinci opened.' }
66  })
67
68  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
69    const below = await next(e)
70    if (!boot.isDone) {
71      $.clock.after(0, () => {
72        void ensureReady($)
73      })
74    }
75    if (e.props.hasSurvey || !(await read($, isOn))) return below
76    const list = await read($, history)
77    const latest = list[list.length - 1]
78    const thinking = await read($, isThinking)
79    if (!latest && !thinking) return below
80    const { Box, Text } = $.ui.resolve(e)
81    return (
82      <Box flexDirection="column">
83        <Text wrap="truncate-end">
84          <Text color="#ff5ca8">{'⚔ '}</Text>
85          {gradientText(Text, 'vinci ', GRADIENTS.brand)}
86          {thinking ? <Text dimColor>thinking…</Text> : <Text>{latest?.text ?? ''}</Text>}
87        </Text>
88        {below}
89      </Box>
90    )
91  })
92
93  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
94    const { Box, Text } = $.ui.resolve(e)
95    const list = await read($, history)
96    const isEnabled = await read($, isOn)
97    return (
98      <Box flexDirection="column">
99        <Box>
100          <Text color="#ff5ca8">{'⚔ '}</Text>
101          {gradientText(Text, 'jr vinci', GRADIENTS.brand)}
102          <Text dimColor>{isEnabled ? '   on · one challenge after each answer' : '   off · /jrvinci on to start'}</Text>
103        </Box>
104        {list.length === 0 && <Text dimColor>No challenge yet.</Text>}
105        {[...list].reverse().map(item => (
106          <Text>
107            <Text dimColor>{`${new Date(item.at).toTimeString().slice(0, 5)}  `}</Text>
108            {item.text}
109          </Text>
110        ))}
111      </Box>
112    )
113  })
114}
115
hooks/vinci.ts 16 lines
1export const VINCI_PROMPT = `Act as a demanding sparring partner, not an assistant. Look at what was just decided or built in this conversation.
2Give the single sharpest challenge: the weakest assumption, the biggest risk, or the one question whose answer would change the outcome.
3One sentence, at most 30 words, in the language of the conversation. No praise, no preamble, no list. If nothing deserves a challenge, reply exactly: nothing to challenge.`
4
5/** One line, no quotes or markdown; null when the model found nothing to challenge. */
6export const challengeOf = (text: string) => {
7  const line = text
8    .trim()
9    .split('\n')[0]!
10    .replace(/^[-*>#\s"“]+|["”]+$/g, '')
11    .replace(/\*\*/g, '')
12    .trim()
13  if (!line || /^nothing to challenge\.?$/i.test(line)) return null
14  return line.length > 220 ? `${line.slice(0, 217)}…` : line
15}
16
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
types/index.d.ts 8 lines
1export type JrVinciChallenge = { at: number; text: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'jr-vinci': { isOn: boolean; history: JrVinciChallenge[]; isThinking: boolean }
6  }
7}
8