SLOPSHOPPER

jr-decisions

A decision journal per project: /jrdecide or a prompt starting with "decision:" records one, /jrdecisions lists them and exports docs/decisions.md.

newpanecommandtoastprompt
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-decisions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-decisions
│ ┃ jr decisions ✕ › fix the failing auth test and add an audit log call │ ┃ jr decisions app · 0 │ ┃ No decisions yet. /jrdecide <decision>, or ⏺ Read(src/auth.ts) │ ┃ start a prompt with "decision:". ⎿ Read 6 lines │ ┃ /jrdecisions export writes docs/decisions.md ⏺ 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 │ │ › /jrdecide │ ⎿ jr-decisions: jr decisions · usage: /jrdecide <decision> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr decisions
jr decisions app · 0 No decisions yet. /jrdecide <decision>, or start a prompt with "decision:". /jrdecisions export writes docs/decisions.md
README

jr-decisions

A decision journal per project. Record a decision the moment you take it, find it again in a pane, and export the journal as docs/decisions.md.

jr decisions  shop · 3
  #3 2026-10-16  Never deploy on Fridays
  #2 2026-10-15  Use Stripe Checkout rather than Elements
  #1 2026-10-15  Keep Postgres
/jrdecisions export writes docs/decisions.md

Two ways to record one:

  • /jrdecide Keep Postgres
  • a prompt that starts with decision: (or décision :): the decision is recorded, and the prompt still goes to Claude as written.

Commands

Command
/jrdecide <decision>Record a decision
/jrdecisionsOpen the journal, newest first
/jrdecisions exportWrite docs/decisions.md in the project: one numbered entry per decision, with its date and status

The journal is kept per project (the session's root folder).

Install

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

Access

Keeps decisions in the plugin's own store. Reads prompts only to spot a leading decision:. Writes docs/decisions.md in the project only when you run the export. Sends nothing over the network.

Source 5 files
hooks/register.tsx 129 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrDecision } from '../types'
5import { day, decisionInPrompt, toMarkdown } from './journal'
6import { gradientText } from './jr/draw'
7import { GRADIENTS } from './jr/gradient'
8
9const PANE = 'jr-decisions'
10const decisions = atom({ plugin: 'jr-decisions', key: 'decisions' } as const, [])
11const project = atom({ plugin: 'jr-decisions', key: 'project' } as const, '')
12const live = { isLoaded: false, root: '' }
13
14/** Loads this project's journal once: at session start, or at the first event after a mid-session install. */
15async function ensureLoaded($: EngineInterface) {
16  if (live.isLoaded) return
17  live.isLoaded = true
18  live.root = await $.session.root()
19  const saved = (await $.store.get(`decisions:${live.root}`)) as JrDecision[] | undefined
20  await update($, decisions, () => saved ?? [])
21  await update($, project, () => live.root.split('/').pop() || live.root)
22}
23
24async function add($: EngineInterface, text: string, source: JrDecision['source']) {
25  await ensureLoaded($)
26  const at = await $.clock.now()
27  const next = [...(await read($, decisions)), { at, text, source }]
28  await $.store.set(`decisions:${live.root}`, next)
29  await update($, decisions, () => next)
30  return next.length
31}
32
33/**
34 * The entry `name` in `dir` as it stands, a link not followed: 'missing' when absent, null when
35 * the folder cannot be listed (treated as unsafe by the caller).
36 */
37async function entryIn($: EngineInterface, dir: string, name: string) {
38  try {
39    const entries = await $.fs.list(dir)
40    return entries.find(e => e.name === name) ?? 'missing'
41  } catch {
42    return null
43  }
44}
45
46/**
47 * Writes docs/decisions.md only where it really lies inside the project. A repository could make
48 * docs/ or the file a symbolic link (even a dangling one) pointing anywhere on the machine, and the
49 * write would follow it: so both are checked without following links, and any doubt refuses.
50 */
51async function exportJournal($: EngineInterface) {
52  await ensureLoaded($)
53  const list = await read($, decisions)
54  const target = `${live.root}/docs/decisions.md`
55  const refuse = (why: string) => ({ target, count: list.length, refused: why })
56  const docs = await entryIn($, live.root, 'docs')
57  if (docs === null) return refuse('the project folder could not be listed')
58  if (docs !== 'missing' && (docs.isLink || docs.kind !== 'dir')) return refuse('docs/ is a link or not a folder')
59  if (docs !== 'missing') {
60    const file = await entryIn($, `${live.root}/docs`, 'decisions.md')
61    if (file === null) return refuse('docs/ could not be listed')
62    if (file !== 'missing' && (file.isLink || file.kind !== 'file')) return refuse('docs/decisions.md is a link or not a file')
63  }
64  await $.fs.write(target, toMarkdown(await read($, project), list))
65  return { target, count: list.length, refused: undefined }
66}
67
68export const register: Register = on => {
69  on('session.start', async ($, e, next) => {
70    const started = await next(e)
71    await $.command.register({ name: 'jrdecide', description: 'Record a decision for this project: /jrdecide <decision>' })
72    await $.command.register({ name: 'jrdecisions', description: 'Decision journal: open it, or /jrdecisions export to docs/decisions.md' })
73    await ensureLoaded($)
74    return started
75  })
76
77  on('command.run', { command: 'jrdecide' }, async ($, e) => {
78    const text = e.args.trim()
79    if (!text) return { text: 'jr decisions · usage: /jrdecide <decision>' }
80    const n = await add($, text, 'command')
81    return { text: `jr decisions · #${n} recorded` }
82  })
83
84  on('command.run', { command: 'jrdecisions' }, async ($, e) => {
85    await ensureLoaded($)
86    if (e.args.trim() === 'export') {
87      const { target, count, refused } = await exportJournal($)
88      if (refused) return { text: `jr decisions · not written: ${refused}` }
89      return { text: `jr decisions · ${count} written to ${target}` }
90    }
91    const count = (await read($, decisions)).length
92    await $.ui.open({ id: PANE, title: 'jr decisions', rows: Math.min(count + 3, 30) })
93    return { text: `jr decisions · ${count} in this project` }
94  })
95
96  // "decision: we keep Postgres" in a prompt is recorded, and the prompt still goes to Claude.
97  on('prompt.submit', async ($, e, next) => {
98    const text = e.command || e.isMeta ? null : decisionInPrompt(e.text)
99    if (text) {
100      const n = await add($, text, 'prompt')
101      $.ui.toast(`jr decisions · #${n} recorded`)
102    }
103    return next(e)
104  }).catch(($, e, next) => next(e))
105
106  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
107    const { Box, Text } = $.ui.resolve(e)
108    const list = [...(await read($, decisions))].reverse()
109    const name = await read($, project)
110    return (
111      <Box flexDirection="column">
112        <Text>
113          {gradientText(Text, 'jr decisions', GRADIENTS.brand)}
114          <Text dimColor>{`  ${name} · ${list.length}`}</Text>
115        </Text>
116        {list.length === 0 && <Text dimColor>No decisions yet. /jrdecide &lt;decision&gt;, or start a prompt with "decision:".</Text>}
117        {list.map((d, i) => (
118          <Text wrap="truncate-end">
119            <Text color="#ff5ca8" bold>{`#${list.length - i} `.padStart(5)}</Text>
120            <Text dimColor>{`${day(d.at)}  `}</Text>
121            <Text>{d.text}</Text>
122          </Text>
123        ))}
124        <Text dimColor>/jrdecisions export writes docs/decisions.md</Text>
125      </Box>
126    )
127  })
128}
129
hooks/journal.ts 23 lines
1import type { JrDecision } from '../types'
2
3/** A prompt that opens with "decision:" (or the French "décision :") records the rest as a decision. */
4export const decisionInPrompt = (text: string) => {
5  const match = text.match(/^\s*d[ée]cision\s*:\s*(.+)$/is)
6  return match?.[1]?.trim() || null
7}
8
9export const day = (at: number) => {
10  const d = new Date(at)
11  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
12}
13
14/** docs/decisions.md: one numbered entry per decision, oldest first, ADR-lite. */
15export const toMarkdown = (project: string, decisions: readonly JrDecision[]) => {
16  const ordered = [...decisions].sort((a, b) => a.at - b.at)
17  const entries = ordered.map((d, i) => {
18    const [title = d.text, ...rest] = d.text.split(/(?<=[.!?])\s+/)
19    return [`## ${String(i + 1).padStart(3, '0')}. ${title.replace(/[.!?]$/, '')}`, '', `- Date: ${day(d.at)}`, '- Status: accepted', ...(rest.length ? ['', rest.join(' ')] : []), ''].join('\n')
20  })
21  return [`# Decisions: ${project}`, '', 'Kept by jr-decisions. Newest last.', '', ...entries].join('\n')
22}
23
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 JrDecision = { at: number; text: string; source: 'command' | 'prompt' }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'jr-decisions': { decisions: JrDecision[]; project: string }
6  }
7}
8