SLOPSHOPPER

goal-meter

Live overall-goal progress bar above the prompt, from the task tools plus Claude's per-turn goal estimate

newbandrowsguardpromptprocess
v0.1.1MITupdated 2026-10-09sruthik27/claude-foundry/mods/goal-meter
A shopper browsing a rack in a slop shop
README

goal-meter

Shows goal estimates and task progress above the prompt. Claude can report a goal, phase and percentage; task completion also advances the bar. No direct auxiliary model calls.

Install

claude plugin install goal-meter --marketplace sruthik27/claude-foundry

Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.

Try it

Ask Claude to complete a task with tracked steps.

Details

MIT licensed. Maintained by Sruthik Issac.

Source 4 files
hooks/register.tsx 174 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Meter } from '../types'
5import {
6  EMPTY, bar, dots, elapsed, gradient, onTaskCreate, onTaskUpdate, onTodoWrite, percent,
7  freshness, quietTurn, readEstimate, stripTags, withEstimate,
8} from './meter.js'
9
10/** Records what the bar now shows, so later changes never draw it lower. */
11const settle = (m: Meter): Meter => ({ ...m, shown: percent(m) })
12
13const meter = atom({ plugin: 'goal-meter', key: 'meter' } as const, EMPTY)
14
15const GUIDE = `# Goal progress reporting (goal-meter mod: display only)
16At the end of any turn where you are working toward a multi-step goal, emit one self-closing tag on its own line:
17<progress goal="3-6 word name of the overall goal" phase="N/M" name="current phase name" pct="0-100"/>
18pct is your honest estimate of how much of the WHOLE goal is complete, including phases you have not planned yet. phase/name are optional when there are no phases. Lower pct if the scope grew. Keep the same goal text while it is the same goal; a new goal text starts a fresh bar.
19When the goal is finished or abandoned, emit it once more with pct="100" (finished) and then stop emitting. Omit the tag entirely for conversation with no goal; the bar fades on its own when you stop reporting.
20This is reporting only: never change how you plan, split, track or sequence work because of it.`
21
22export const register: Register = on => {
23
24  on('prompt.compose', async ($, e, next) => {
25    const r = await next(e)
26    return { ...r, sections: [...r.sections, { id: 'goal-meter:guide', text: GUIDE, scope: 'session' }] }
27  })
28
29  on('session.start', async ($, e, next) => {
30    const r = await next(e)
31    let saved = (await $.store.get(`meter:${await $.session.id()}`)) as Meter | undefined
32    // A resume that came back under a new id: carry over the session it continued from.
33    if (!saved) { const prev = await predecessorOf($, e.cwd); if (prev) saved = (await $.store.get(`meter:${prev}`)) as Meter | undefined }
34    if (saved && (await read($, meter)).steps.length === 0) await update($, meter, () => saved)
35    return r
36  })
37
38  on('classic.SessionStart', async ($, e, next) => {
39    const r = await next(e)
40    if (!['clear', 'resume', 'fork'].includes(e.source)) return r
41    let saved = e.source === 'clear' ? undefined : await $.store.get(`meter:${await $.session.id()}`) as Meter | undefined
42    if (!saved && e.source !== 'clear' && e.cwd) { const prev = await predecessorOf($, e.cwd); if (prev) saved = await $.store.get(`meter:${prev}`) as Meter | undefined }
43    await update($, meter, () => saved ?? EMPTY)
44    return r
45  })
46
47  // ---- observe the task tools: read what they did, never change it ----
48  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
49    const r = await next(e)
50    if (r.deny === undefined && !r.isError && r.result) {
51      const m = await update($, meter, x => settle({ ...onTaskCreate(x, r.result.task.id, e.subject, e.activeForm), startedAt: x.startedAt ?? Date.now(), quiet: 0 }))
52      await $.store.set(`meter:${await $.session.id()}`, m)
53    }
54    return r
55  })
56  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
57    const r = await next(e)
58    if (r.deny === undefined && !r.isError) {
59      const m = await update($, meter, x => settle({ ...onTaskUpdate(x, e.taskId, e), quiet: 0 }))
60      await $.store.set(`meter:${await $.session.id()}`, m)
61    }
62    return r
63  })
64  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
65    const r = await next(e)
66    if (r.deny === undefined && !r.isError) {
67      const m = await update($, meter, x => settle({ ...onTodoWrite(x, e.todos), startedAt: x.startedAt ?? Date.now(), quiet: 0 }))
68      await $.store.set(`meter:${await $.session.id()}`, m)
69    }
70    return r
71  })
72
73  on('turn.complete', async ($, e, next) => {
74    const r = await next(e)
75    if (e.agentId) return r
76    const est = readEstimate(e.answer)
77    const m = est
78      ? await update($, meter, x => settle(withEstimate(x, est)))
79      : await update($, meter, x => (x.estimate || x.steps.length ? quietTurn(x) : x))
80    await $.store.set(`meter:${await $.session.id()}`, m)
81    return r
82  })
83
84  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
85    if (!e.props.text.includes('<progress')) return next(e)
86    return next({ ...e, props: { ...e.props, text: stripTags(e.props.text) } })
87  })
88
89  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
90    const m = await read($, meter)
91    const fresh = freshness(m)
92    if (e.props.hasSurvey || (m.steps.length === 0 && !m.estimate) || fresh === 'hidden') return next(e)
93    const downstream = await next(e)
94    const isStale = fresh === 'stale'
95
96    const { Box, Text } = $.ui.resolve(e)
97    const pct = percent(m)
98
99    const cols = e.props.bodyColumns
100    const est = m.estimate
101    const goal = est?.goal ?? 'Goal'
102    const isDone = pct >= 100
103    const active = m.steps.find(s => s.status === 'in_progress')
104    const doneN = m.steps.filter(s => s.status === 'completed').length
105    const time = m.startedAt ? `⏱ ${elapsed(Date.now() - m.startedAt)}` : ''
106    const pctText = isDone ? '✔ done' : isStale ? `${pct}% · paused` : `${pct}%`
107    const accent = isStale ? '#6b7280' : gradient(pct / 100)
108
109    // Narrow: one line.
110    if (cols < 70) {
111      const b = bar(pct, Math.max(8, cols - pctText.length - 6))
112      return (
113        <Box flexDirection="column">{downstream}<Box paddingX={1}>
114          <Text color={accent} bold>◆ </Text>
115          {b.runs.map((r, i) => <Text key={`r${i}`} color={isStale ? '#4b5563' : r.color}>{r.text}</Text>)}
116          <Text color={accent}>{b.tip}</Text>
117          <Text color="#3a3f4b">{b.rest}</Text>
118          <Text color={accent} bold> {pctText}</Text>
119        </Box></Box>
120      )
121    }
122
123    const label = goal.slice(0, Math.max(12, Math.floor(cols * 0.32)))
124    const barW = Math.max(10, cols - label.length - pctText.length - 8)
125    const b = bar(pct, barW)
126    const phase = est?.phase && est.phases ? `phase ${est.phase}/${est.phases}${est.phaseName ? ` · ${est.phaseName}` : ''}` : ''
127    const step = m.steps.length > 0 ? `step ${Math.min(doneN + (active ? 1 : 0), m.steps.length)}/${m.steps.length}` : ''
128    const doing = active ? ` · ${active.active ?? active.title}` : ''
129    const detail = `${step}${doing}`.slice(0, Math.max(10, cols - phase.length - time.length - 30))
130
131    return (
132      <Box flexDirection="column" paddingX={1}>
133        {downstream}
134        <Box flexDirection="row">
135          <Text color={accent} bold>{isDone ? '✔' : '◆'} </Text>
136          <Text bold>{label}</Text>
137          <Text>  </Text>
138          {b.runs.map((r, i) => <Text key={`r${i}`} color={isStale ? '#4b5563' : r.color}>{r.text}</Text>)}
139          <Text color={accent}>{b.tip}</Text>
140          <Text color="#3a3f4b">{b.rest}</Text>
141          <Text color={accent} bold>  {pctText}</Text>
142        </Box>
143        <Box flexDirection="row" justifyContent="space-between">
144          <Box flexDirection="row" columnGap={2}>
145            {phase !== '' && <Text color="#a78bfa">  {phase}</Text>}
146            {m.steps.length > 0 && <Text color={accent}>{dots(m.steps)}</Text>}
147            {detail !== '' && <Text dimColor>{detail}</Text>}
148          </Box>
149          {time !== '' && <Text dimColor>{time}</Text>}
150        </Box>
151      </Box>
152    )
153  })
154}
155
156/* A resumed session can come back under a new id with the old history copied in. This finds the
157   session it continued from: the other transcript in the same project that holds the same first
158   message. Mods key their saved state by session id, so they look there when their own is empty. */
159async function predecessorOf($: import('claude-code').EngineInterface, cwd: string): Promise<string | undefined> {
160  try {
161    const home = (await $.env.get('HOME')) ?? ''
162    const dir = `${home}/.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}`
163    const sid = await $.session.id()
164    const r = await $.process.run(['sh', '-c',
165      'f="$1/$2.jsonl"; [ -f "$f" ] || exit 0; u=$(grep -m1 -o \'"uuid":"[^"]*"\' "$f") || exit 0; ' +
166      'ls -t "$1"/*.jsonl 2>/dev/null | head -40 | grep -v "/$2.jsonl" | xargs grep -l -F "$u" 2>/dev/null | head -1',
167      'prev', dir, sid], { timeoutMs: 20000 })
168    const hit = r.stdout.trim().split('/').pop()?.replace(/\.jsonl$/, '')
169    return hit && hit !== sid ? hit : undefined
170  } catch {
171    return undefined
172  }
173}
174
hooks/meter.ts 154 lines
1import type { Estimate, Meter, Step, StepStatus } from '../types'
2import { outsideCode, stripOutsideCode } from './metadata.js'
3
4export const EMPTY: Meter = { steps: [], doneAtEstimate: 0, shown: 0 }
5
6const TAG = /<progress\b([^>]*?)\/?>(?:<\/progress>)?/g
7
8/** The last <progress .../> tag in a reply, if any. */
9export function readEstimate(text: string): Estimate | undefined {
10  let last: Estimate | undefined
11  for (const m of outsideCode(text, TAG)) {
12    const a: Record<string, string> = {}
13    for (const p of (m[1] ?? '').matchAll(/([a-z]+)="([^"]*)"/g)) a[p[1] ?? ''] = p[2] ?? ''
14    const pct = Number(a.pct)
15    if (!Number.isFinite(pct)) continue
16    const est: Estimate = { pct: Math.max(0, Math.min(100, Math.round(pct))) }
17    if (a.goal) est.goal = a.goal.slice(0, 60)
18    const ph = /^(\d+)\s*\/\s*(\d+)$/.exec(a.phase ?? '')
19    if (ph) { est.phase = Number(ph[1]); est.phases = Number(ph[2]) }
20    if (a.name) est.phaseName = a.name.slice(0, 40)
21    last = est
22  }
23  return last
24}
25
26export function stripTags(text: string): string {
27  return stripOutsideCode(text, TAG)
28}
29
30const done = (s: readonly Step[]) => s.filter(x => x.status === 'completed').length
31
32/**
33 * The bar's value. With an estimate: the estimate, plus credit for steps finished since it,
34 * spread over the room left in the current phase (or the whole remainder). Without one:
35 * the plain task ratio. Clamped below 100 until everything is done, and never below what
36 * was last shown.
37 */
38export function percent(m: Meter): number {
39  const total = m.steps.length
40  const doneNow = done(m.steps)
41  let raw: number
42  if (m.estimate) {
43    const e = m.estimate
44    const ceiling = e.phase && e.phases ? Math.max(e.pct, (100 * e.phase) / e.phases) : 100
45    const left = total - m.doneAtEstimate
46    const gained = Math.max(0, doneNow - m.doneAtEstimate)
47    raw = left > 0 ? e.pct + (ceiling - e.pct) * (gained / left) : e.pct
48  } else {
49    raw = total === 0 ? 0 : (100 * doneNow) / total
50  }
51  const allSteps = doneNow === total
52  // done: every step finished and the estimate agrees, or Claude reports the goal complete
53  const allDone = (total > 0 && allSteps && (!m.estimate || m.estimate.pct >= 95)) || (allSteps && (m.estimate?.pct ?? 0) >= 100)
54  const v = allDone ? 100 : Math.min(raw, 99)
55  return Math.round(Math.max(v, m.shown))
56}
57
58/** Turns without a report after which the band dims, and after which it hides. */
59export const STALE_DIM = 2
60export const STALE_HIDE = 4
61
62const sameGoal = (a?: string, b?: string) =>
63  !a || !b || a.trim().toLowerCase() === b.trim().toLowerCase()
64
65/**
66 * Folds a new estimate in. A different goal name starts over (fresh bar, steps and timer); the
67 * same goal keeps its steps, and a lower pct there is Claude saying the scope grew.
68 */
69export function withEstimate(m: Meter, e: Estimate, now: number = Date.now()): Meter {
70  if (m.estimate && !sameGoal(m.estimate.goal, e.goal)) {
71    return { steps: [], estimate: e, doneAtEstimate: 0, shown: 0, startedAt: now, quiet: 0 }
72  }
73  const keep = { ...m.estimate, ...e }
74  return { ...m, estimate: keep, doneAtEstimate: done(m.steps), shown: Math.min(m.shown, e.pct), startedAt: m.startedAt ?? now, quiet: 0 }
75}
76
77/** One main turn passed with no report and no task change. */
78export function quietTurn(m: Meter): Meter {
79  return { ...m, quiet: (m.quiet ?? 0) + 1 }
80}
81
82/** What the band shows: 'live', 'stale' (dimmed, still there) or 'hidden'. */
83export function freshness(m: Meter): 'live' | 'stale' | 'hidden' {
84  const q = m.quiet ?? 0
85  if (percent(m) >= 100) return q >= STALE_DIM ? 'hidden' : 'live'
86  return q >= STALE_HIDE ? 'hidden' : q >= STALE_DIM ? 'stale' : 'live'
87}
88
89// ---- reading the task tools (observe only) ----
90
91export function onTaskCreate(m: Meter, id: string, title: string, active?: string): Meter {
92  if (m.steps.some(s => s.id === id)) return m
93  return { ...m, steps: [...m.steps, { id, title, active, status: 'pending' }] }
94}
95
96export function onTaskUpdate(m: Meter, id: string, u: { status?: StepStatus | 'deleted'; subject?: string; activeForm?: string }): Meter {
97  const status = u.status
98  if (status === 'deleted') return { ...m, steps: m.steps.filter(s => s.id !== id) }
99  return {
100    ...m,
101    steps: m.steps.map(s => s.id !== id ? s : {
102      ...s,
103      ...(status ? { status } : {}),
104      ...(u.subject ? { title: u.subject } : {}),
105      ...(u.activeForm ? { active: u.activeForm } : {}),
106    }),
107  }
108}
109
110export function onTodoWrite(m: Meter, todos: readonly { content: string; status: StepStatus; activeForm: string }[]): Meter {
111  return { ...m, steps: todos.map((t, i) => ({ id: `todo-${i}`, title: t.content, active: t.activeForm, status: t.status })) }
112}
113
114// ---- drawing helpers ----
115
116const FULL = '━'
117/** Filled cells as runs of one colour each (a gradient), then a half-cell tip and the track. */
118export function bar(pct: number, width: number): { runs: { text: string; color: string }[]; tip: string; rest: string } {
119  const cells = Math.max(4, width)
120  const exact = (pct / 100) * cells
121  const n = Math.floor(exact)
122  const tip = n < cells && exact - n >= 0.5 ? '╸' : ''
123  const runs: { text: string; color: string }[] = []
124  const SEG = Math.max(1, Math.ceil(cells / 16))
125  for (let i = 0; i < n; i += SEG) {
126    const len = Math.min(SEG, n - i)
127    runs.push({ text: FULL.repeat(len), color: gradient((i + len / 2) / cells) })
128  }
129  return { runs, tip, rest: FULL.repeat(Math.max(0, cells - n - (tip ? 1 : 0))) }
130}
131
132/** Indigo -> cyan -> green across the bar, as truecolor hex. */
133const STOPS: readonly [number, number, number][] = [[124, 92, 255], [56, 189, 248], [52, 211, 153]]
134export function gradient(t: number): string {
135  const x = Math.max(0, Math.min(1, t)) * (STOPS.length - 1)
136  const i = Math.min(STOPS.length - 2, Math.floor(x))
137  const f = x - i
138  const a = STOPS[i]!, b = STOPS[i + 1]!
139  const c = a.map((v, k) => Math.round(v + (b[k]! - v) * f))
140  return '#' + c.map(v => v.toString(16).padStart(2, '0')).join('')
141}
142
143export function dots(steps: readonly Step[], max = 12): string {
144  const icon = (s: Step) => s.status === 'completed' ? '●' : s.status === 'in_progress' ? '◐' : '○'
145  if (steps.length <= max) return steps.map(icon).join(' ')
146  const d = done(steps)
147  return `${'●'.repeat(Math.min(d, max - 3))}… ${d}/${steps.length}`
148}
149
150export function elapsed(ms: number): string {
151  const m = Math.floor(ms / 60000)
152  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
153}
154
hooks/metadata.ts 24 lines
1/** Reporting tags inside Markdown code are examples. Preserve them verbatim. */
2export function codeSpans(text: string): readonly (readonly [number, number])[] {
3  const spans: [number, number][] = []
4  const re = /(`+|~{3,})/g
5  let m: RegExpExecArray | null
6  while ((m = re.exec(text))) {
7    const marker = m[0]
8    if (marker[0] === '~' && text.slice(text.lastIndexOf('\n', m.index) + 1, m.index).trim()) continue
9    const end = text.indexOf(marker, re.lastIndex)
10    // An unfinished code span/fence is still code while a reply streams.
11    const stop = end < 0 ? text.length : end + marker.length
12    spans.push([m.index, stop]); re.lastIndex = stop
13  }
14  return spans
15}
16export function outsideCode(text: string, tag: RegExp): RegExpMatchArray[] {
17  const spans = codeSpans(text)
18  return [...text.matchAll(tag)].filter(m => !spans.some(([a, b]) => m.index! >= a && m.index! < b))
19}
20export function stripOutsideCode(text: string, tag: RegExp): string {
21  for (const m of outsideCode(text, tag).reverse()) text = text.slice(0, m.index!) + text.slice(m.index! + m[0].length)
22  return text.replace(/\n{3,}/g, '\n\n')
23}
24
types/index.d.ts 22 lines
1export type StepStatus = 'pending' | 'in_progress' | 'completed'
2export type Step = { id: string; title: string; active?: string; status: StepStatus }
3/** Claude's per-turn estimate for the whole goal. */
4export type Estimate = { goal?: string; phase?: number; phases?: number; phaseName?: string; pct: number }
5export type Meter = {
6  steps: Step[]
7  estimate?: Estimate
8  /** Steps done when the last estimate arrived: the bar advances from there. */
9  doneAtEstimate: number
10  /** What the bar last showed: it never shows less unless Claude lowers its estimate. */
11  shown: number
12  startedAt?: number
13  /** Main turns since the last progress report or task change; drives staleness. */
14  quiet?: number
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'goal-meter': { meter: Meter }
20  }
21}
22