SLOPSHOPPER

jr-goal

A goal per project with steps you tick off, shown above the prompt with a progress gauge. /jrgoal to set it.

newbandcommandtimer
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-goal
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-goal
› 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 › /jrgoal ⎿ jr-goal: jr goal · no goal yet: /jrgoal <goal> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

jr-goal

One goal per project, broken into steps you tick off, always in view above the prompt.

                     goal Ship the billing page ███··· 2/5 · next: Wire Stripe webhooks

One quiet row, right-aligned, out of the way of your eye.

Commands

Command
/jrgoal <goal>Set the goal for this project (replaces the previous one)
/jrgoal step <text>Add a step
/jrgoal done <n>Tick step n
/jrgoal undo <n>Untick step n
/jrgoalShow the goal and its steps
/jrgoal clearRemove the goal
/jrgoal hide / /jrgoal showHide or show the band, in every session; the goal is kept

The goal is kept per project (the session's root folder) and comes back in every session opened there.

Settings (/config)

OptionDefault
Show the bandonThe line above the prompt

Install

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

Access

Keeps goals in the plugin's own store. Reads nothing else, runs nothing, sends nothing over the network.

Source 5 files
hooks/register.tsx 107 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrGoal } from '../types'
5import { applyCommand, progress } from './goal'
6import { gauge } from './jr/draw'
7import { GRADIENTS } from './jr/gradient'
8
9const goal = atom({ plugin: 'jr-goal', key: 'goal' } as const, null)
10const bandHidden = atom({ plugin: 'jr-goal', key: 'isBandHidden' } as const, false)
11
12const bandState = { isLoaded: false }
13
14/** True while the band is hidden; the saved choice is read once per load of the mod. */
15async function isBandHidden($: EngineInterface) {
16  if (!bandState.isLoaded) {
17    bandState.isLoaded = true
18    if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
19  }
20  return read($, bandHidden)
21}
22
23/** Hides or shows the band for every session, kept across restarts. */
24async function setBand($: EngineInterface, hide: boolean, name: string, command: string) {
25  await $.store.set('isBandHidden', hide)
26  await update($, bandHidden, () => hide)
27  return { text: hide ? `${name} · hidden (${command} to bring it back)` : `${name} · shown` }
28}
29const live = { isLoaded: false, project: '' }
30
31async function projectKey($: EngineInterface) {
32  const root = await $.session.root()
33  return `goal:${root}`
34}
35
36/** Loads this project's goal into the drawing state, once. */
37async function ensureLoaded($: EngineInterface) {
38  if (live.isLoaded) return
39  live.isLoaded = true
40  live.project = await projectKey($)
41  const saved = (await $.store.get(live.project)) as JrGoal | undefined
42  await update($, goal, () => saved ?? null)
43}
44
45export const register: Register = (on, options) => {
46  const showBand = options.showBand !== false
47
48  on('session.start', async ($, e, next) => {
49    const started = await next(e)
50    await $.command.register({ name: 'jrgoal', description: 'Project goal and steps: /jrgoal <goal>, step <text>, done <n>, clear, hide, show' })
51    await ensureLoaded($)
52    return started
53  })
54
55  on('command.run', { command: 'jrgoal' }, async ($, e) => {
56    await ensureLoaded($)
57    const word = e.args.trim().toLowerCase()
58    if (word === 'hide' || word === 'show') return setBand($, word === 'hide', 'jr goal band', '/jrgoal show')
59    const result = applyCommand(await read($, goal), e.args, await $.clock.now())
60    if (result.goal) await $.store.set(live.project, result.goal)
61    else await $.store.delete(live.project)
62    await update($, goal, () => result.goal)
63    return { text: result.text }
64  })
65
66  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
67    const below = await next(e)
68    if (!live.isLoaded) {
69      $.clock.after(0, () => {
70        void ensureLoaded($)
71      })
72    }
73    // Never write state while drawing: the saved choice is loaded just after.
74    if (!bandState.isLoaded) {
75      $.clock.after(0, () => {
76        void isBandHidden($)
77      })
78    }
79    if (await read($, bandHidden)) return below
80    const g = await read($, goal)
81    if (!showBand || !g || e.props.hasSurvey) return below
82    const { Box, Text } = $.ui.resolve(e)
83    const done = g.steps.filter(s => s.isDone).length
84    const ratio = g.steps.length ? done / g.steps.length : 0
85    const upcoming = g.steps.find(s => !s.isDone)
86    return (
87      <Box flexDirection="column">
88        <Box justifyContent="flex-end">
89          <Text wrap="truncate-end">
90            <Text dimColor>{'goal '}</Text>
91            <Text>{g.goal}</Text>
92            {g.steps.length > 0 && (
93              <Text>
94                <Text>{' '}</Text>
95                {gauge(Text, ratio, 6, GRADIENTS.money)}
96                <Text>{` ${progress(g)}`}</Text>
97              </Text>
98            )}
99            {upcoming && <Text dimColor>{` · next: ${upcoming.text}`}</Text>}
100          </Text>
101        </Box>
102        {below}
103      </Box>
104    )
105  })
106}
107
hooks/goal.ts 33 lines
1import type { JrGoal } from '../types'
2
3export const USAGE =
4  'jr goal · usage: /jrgoal <goal>, /jrgoal step <text>, /jrgoal done <n>, /jrgoal undo <n>, /jrgoal clear, /jrgoal (show)'
5
6/** Applies one /jrgoal command to the current goal: the new goal (null when cleared) and what to say. */
7export const applyCommand = (current: JrGoal | null, args: string, now: number): { goal: JrGoal | null; text: string } => {
8  const trimmed = args.trim()
9  const [verb = '', ...rest] = trimmed.split(/\s+/)
10  const arg = rest.join(' ')
11  if (!trimmed) return { goal: current, text: current ? describe(current) : 'jr goal · no goal yet: /jrgoal <goal>' }
12  if (verb === 'clear') return { goal: null, text: 'jr goal · cleared' }
13  if (verb === 'step') {
14    if (!current) return { goal: current, text: 'jr goal · set a goal first: /jrgoal <goal>' }
15    if (!arg) return { goal: current, text: USAGE }
16    const goal = { ...current, steps: [...current.steps, { text: arg, isDone: false }], updatedAt: now }
17    return { goal, text: `jr goal · step ${goal.steps.length} added` }
18  }
19  if (verb === 'done' || verb === 'undo') {
20    const n = Number.parseInt(arg, 10)
21    if (!current || !Number.isInteger(n) || n < 1 || n > current.steps.length) return { goal: current, text: USAGE }
22    const steps = current.steps.map((s, i) => (i === n - 1 ? { ...s, isDone: verb === 'done' } : s))
23    const goal = { ...current, steps, updatedAt: now }
24    return { goal, text: `jr goal · ${progress(goal)}` }
25  }
26  return { goal: { goal: trimmed, steps: [], updatedAt: now }, text: `jr goal · set: ${trimmed}` }
27}
28
29export const progress = (g: JrGoal) => `${g.steps.filter(s => s.isDone).length}/${g.steps.length}`
30
31export const describe = (g: JrGoal) =>
32  [`jr goal · ${g.goal} · ${progress(g)}`, ...g.steps.map((s, i) => `  ${i + 1}. ${s.isDone ? '✓' : '○'} ${s.text}`)].join('\n')
33
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 10 lines
1export type JrGoalStep = { text: string; isDone: boolean }
2
3export type JrGoal = { goal: string; steps: JrGoalStep[]; updatedAt: number }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'jr-goal': { goal: JrGoal | null; isBandHidden: boolean }
8  }
9}
10