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

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.
claude plugin install goal-meter --marketplace sruthik27/claude-foundry
Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.
Ask Claude to complete a task with tracked steps.
MIT licensed. Maintained by Sruthik Issac.
hooks/register.tsx 174 lines1import { 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}
174hooks/meter.ts 154 lines1import 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}
154hooks/metadata.ts 24 lines1/** 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}
24types/index.d.ts 22 lines1export 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