Side pane, refreshed after each turn: project objective, top next steps, open risks and estimated completion

A Claude Code mod: a side pane that keeps track of where your project stands. After each turn, a side request to the model (a fork of the conversation, with no tools) reassesses the project, and the pane shows:
▶ in progress, ○ next, ⏸ blocked? a question for you, … an unknown, ⚠ an unmitigated riskNew and changed items are marked ◆ for two minutes after an assessment.
It follows how you work:
◇ planning. When you approve a plan, the next steps follow it, and the plan is remembered for the project.At the prompt of a Claude Code terminal session:
/plugin install project-compass --marketplace jasonwblock/project-compass
Answer y to add the marketplace, then pick the user scope.
/compass./compass refresh reassesses now; /compass reset clears the saved assessment and plan for the project.Set in /config, or under pluginConfigs in your settings:
| Setting | Values | Default |
|---|---|---|
Refresh (refresh) | turn: after every turn · interval: every few turns · edits: after a turn that edited files · manual: only on /compass refresh | turn |
Refresh interval (refreshInterval) | turns between assessments when Refresh is interval | 3 |
Account line (account) | full: email and plan · plan: the plan alone · off: no line | full |
Each assessment is one model call. It mostly reads from the prompt cache, and the Stats section shows what it costs; interval, edits or manual cut that down on long sessions.
The account line comes from claude auth status, run locally once per session; with off it is never run.
claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.tsx 470 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Change, Plan, RiskKind, Snapshot, Step, StepStatus } from '../types'
5import { buildPrompt, parseSnapshot, upgradeSnapshot } from './parse'
6import { addUsage, dollars, priceOf } from './pricing'
7import { contextTone, parseAccount } from './session'
8import { EDIT_TOOLS, readSettings, refreshLabel, shouldRefresh } from './settings'
9import { fromTodos, stepsFromTasks, taskCreated, taskUpdated, workContext } from './work'
10
11const PANE = 'project-compass'
12const TITLE = 'Project compass'
13/** How long a change since the assessment before stays marked. */
14const FRESH_MS = 2 * 60_000
15/** How often the pane redraws, so "updated 2m ago" and the change marks age. */
16const TICK_MS = 15_000
17
18const snapshot = atom({ plugin: 'project-compass', key: 'snapshot' } as const, null)
19const isUpdating = atom({ plugin: 'project-compass', key: 'isUpdating' } as const, false)
20const error = atom({ plugin: 'project-compass', key: 'error' } as const, null)
21const tasks = atom({ plugin: 'project-compass', key: 'tasks' } as const, [])
22const plan = atom({ plugin: 'project-compass', key: 'plan' } as const, null)
23const isPlanning = atom({ plugin: 'project-compass', key: 'isPlanning' } as const, false)
24const account = atom({ plugin: 'project-compass', key: 'account' } as const, null)
25const EMPTY_USAGE = { runs: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, unpriced: 0 }
26const projectUsage = atom({ plugin: 'project-compass', key: 'projectUsage' } as const, EMPTY_USAGE)
27const usage = atom({ plugin: 'project-compass', key: 'usage' } as const, {
28 runs: 0,
29 input: 0,
30 output: 0,
31 cacheRead: 0,
32 cacheWrite: 0,
33 cost: 0,
34 unpriced: 0,
35})
36
37const STEP_ICONS: Record<StepStatus, { icon: string; color: string }> = {
38 active: { icon: '▶', color: 'claude' },
39 next: { icon: '○', color: 'subtle' },
40 blocked: { icon: '⏸', color: 'warning' },
41}
42
43const RISK_ICONS: Record<RiskKind, { icon: string; color: string }> = {
44 question: { icon: '?', color: 'suggestion' },
45 unknown: { icon: '…', color: 'permission' },
46 risk: { icon: '⚠', color: 'warning' },
47}
48
49const storeKey = async ($: EngineInterface) => `snapshot:${await $.session.root()}`
50const planKey = async ($: EngineInterface) => `plan:${await $.session.root()}`
51
52/** A tool call that ran: neither refused nor answered with an error. */
53const ran = (r: { deny?: string; isError?: boolean }) => !r.deny && !r.isError
54
55const bar = (percent: number, width: number) => {
56 const filled = Math.round((percent / 100) * width)
57
58 return '█'.repeat(filled) + '░'.repeat(width - filled)
59}
60
61const count = (n: number) =>
62 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n)
63
64/** Whole thousands or millions, no decimals: the context line's rough figure. */
65const roughCount = (n: number) =>
66 n >= 1_000_000 ? `${Math.round(n / 1_000_000)}M` : n >= 1_000 ? `${Math.round(n / 1_000)}k` : String(n)
67
68const ago = (then: number, now: number) => {
69 if (then <= 0) return 'updated earlier'
70 const seconds = Math.max(0, Math.round((now - then) / 1000))
71 if (seconds < 60) return 'updated just now'
72 const minutes = Math.floor(seconds / 60)
73 if (minutes < 60) return `updated ${minutes}m ago`
74 const hours = Math.floor(minutes / 60)
75 if (hours < 24) return `updated ${hours}h ago`
76
77 return `updated ${Math.floor(hours / 24)}d ago`
78}
79
80const costText = (cost: number, unpriced: number, runs: number) =>
81 unpriced === runs ? 'API cost n/a' : `≈${dollars(cost)} API${unpriced > 0 ? ` (${unpriced} unpriced)` : ''}`
82
83/** Who the session is signed in as: the CLI's own answer, read once a session. */
84async function loadAccount($: EngineInterface) {
85 try {
86 const run = await $.process.run(['claude', 'auth', 'status', '--json'], { timeoutMs: 20_000 })
87 const found = run.exitCode === 0 ? parseAccount(run.stdout) : null
88 await update($, account, () => found)
89 } catch {
90 // No CLI on the path, or it timed out: the header shows no account line.
91 }
92}
93
94// Only the newest refresh may write; an older one that lands late is dropped.
95let generation = 0
96let ticker: Timer | undefined
97// What happened since the last assessment, for the refresh setting.
98let turnsSinceRefresh = 0
99let isEditedSinceRefresh = false
100
101async function refresh($: EngineInterface) {
102 const mine = ++generation
103 turnsSinceRefresh = 0
104 isEditedSinceRefresh = false
105 await update($, isUpdating, () => true)
106 try {
107 const previous = await read($, snapshot)
108 // The fork runs on the main thread's model: price it at that model's rates.
109 const model = await $.session.model()
110 const work = workContext(await read($, isPlanning), await read($, plan), await read($, tasks))
111 const reply = await $.model.fork({ prompt: buildPrompt(previous, work) })
112 if ('usage' in reply && reply.usage) {
113 const spent = reply.usage
114 const cost = priceOf(model, spent)
115 await update($, usage, sum => addUsage(sum, spent, cost))
116 }
117 if (mine !== generation) return
118 if (!reply.isAnswered) {
119 if (reply.reason !== 'nothing-to-fork' && reply.reason !== 'aborted') {
120 await update($, error, () => `Update failed: ${reply.reason}`)
121 }
122 return
123 }
124 const next = parseSnapshot(reply.text, await $.session.turns(), await $.clock.now(), previous)
125 if (next === null) {
126 await update($, error, () => 'Update failed: the reply held no readable assessment')
127 return
128 }
129 await update($, snapshot, () => next)
130 await update($, error, () => null)
131 await $.store.set(await storeKey($), next)
132 } catch (err) {
133 if (mine === generation) await update($, error, () => `Update failed: ${String(err)}`)
134 } finally {
135 if (mine === generation) await update($, isUpdating, () => false)
136 }
137}
138
139export const register: Register = (on, options) => {
140 const settings = readSettings(options)
141
142 on('session.start', async ($, e, next) => {
143 const started = await next(e)
144 await $.command.register({
145 name: 'compass',
146 description: 'Open the project compass pane',
147 argumentHint: '[refresh | reset]',
148 })
149
150 // What this session holds, or else what the last session in this project saved,
151 // read through the upgrade so a snapshot an earlier version wrote still draws.
152 const held = (await read($, snapshot)) ?? (await $.store.get(await storeKey($))) ?? null
153 const current = upgradeSnapshot(held)
154 await update($, snapshot, () => current)
155 if ((await read($, plan)) === null) {
156 const saved = (await $.store.get(await planKey($))) as Plan | undefined
157 if (saved) await update($, plan, () => saved)
158 }
159 const isOldShape = held !== null && typeof (held as { title?: unknown }).title !== 'string'
160 // Nothing yet, or an assessment with no title: assess now, not after the next turn.
161 const isAutomatic = settings.refresh !== 'manual'
162 if (isAutomatic && (current === null || isOldShape) && (await $.session.turns()) > 0) void refresh($)
163
164 if (settings.account !== 'off' && (await read($, account)) === null) void loadAccount($)
165
166 ticker?.cancel()
167 ticker = $.clock.every(TICK_MS, () => $.ui.invalidate('ui.render'))
168 void $.ui.open({ id: PANE, title: TITLE })
169
170 return started
171 })
172
173 on('turn.complete', async ($, e, next) => {
174 const done = await next(e)
175 // The project's own spend: every turn, the main loop's and its subagents'.
176 const used = e.usage
177 if (used) await update($, projectUsage, sum => addUsage(sum, used, priceOf(used.model, used)))
178 if (e.agentId === undefined && e.reason === 'answer') {
179 turnsSinceRefresh += 1
180 if (shouldRefresh(settings, { turns: turnsSinceRefresh, isEdited: isEditedSinceRefresh })) void refresh($)
181 }
182
183 return done
184 })
185
186 // The hooks below only watch: if one fails, what is beneath stands (their `.catch`).
187 // `next(e)` after the hook already called it replays that answer, so nothing runs twice.
188
189 // A file edit by any loop, for the `edits` refresh setting.
190 on('tool.call', async ($, e, next) => {
191 const r = await next(e)
192 if (EDIT_TOOLS.has(e.tool) && ran(r)) isEditedSinceRefresh = true
193 return r
194 }).catch(($, e, next) => next(e))
195
196 // Plan mode as the settings hooks see it: at each prompt (the mode the turn runs in) and as each turn stops.
197 on('classic.UserPromptSubmit', async ($, e, next) => {
198 if (e.agent_id === undefined && e.permission_mode) await update($, isPlanning, () => e.permission_mode === 'plan')
199 return next(e)
200 }).catch(($, e, next) => next(e))
201 on('classic.Stop', async ($, e, next) => {
202 if (e.agent_id === undefined && e.permission_mode) await update($, isPlanning, () => e.permission_mode === 'plan')
203 return next(e)
204 }).catch(($, e, next) => next(e))
205
206 on('tool.call', { tool: 'EnterPlanMode' }, async ($, e, next) => {
207 const r = await next(e)
208 if (e.agentId === undefined && ran(r)) await update($, isPlanning, () => true)
209 return r
210 }).catch(($, e, next) => next(e))
211
212 // An approved plan: what the next steps come from until it is replaced or reset.
213 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
214 const r = await next(e)
215 if (e.agentId !== undefined || !ran(r)) return r
216 await update($, isPlanning, () => false)
217 const text = (r.result as { plan?: unknown } | undefined)?.plan
218 if (typeof text === 'string' && text.trim() !== '') {
219 const approved: Plan = { text, approvedAt: await $.clock.now() }
220 await update($, plan, () => approved)
221 await $.store.set(await planKey($), approved)
222 }
223 return r
224 }).catch(($, e, next) => next(e))
225
226 // The main loop's task list, in either form the session uses.
227 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
228 const r = await next(e)
229 if (e.agentId === undefined && ran(r)) {
230 const now = await $.clock.now()
231 await update($, tasks, list => fromTodos(e.todos, list, now))
232 }
233 return r
234 }).catch(($, e, next) => next(e))
235 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
236 const r = await next(e)
237 const created = (r.result as { task?: { id?: unknown } } | undefined)?.task
238 if (e.agentId === undefined && ran(r) && typeof created?.id === 'string') {
239 const id = created.id
240 const now = await $.clock.now()
241 await update($, tasks, list => taskCreated(list, { id, subject: e.subject, activeForm: e.activeForm }, now))
242 }
243 return r
244 }).catch(($, e, next) => next(e))
245 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
246 const r = await next(e)
247 if (e.agentId === undefined && ran(r)) {
248 const now = await $.clock.now()
249 await update($, tasks, list => taskUpdated(list, e, now))
250 }
251 return r
252 }).catch(($, e, next) => next(e))
253
254 on('command.run', { command: 'compass' }, async ($, e) => {
255 await $.ui.open({ id: PANE, title: TITLE })
256 const arg = e.args.trim()
257 if (arg === 'refresh') {
258 void refresh($)
259 return { text: 'Project compass: refreshing.' }
260 }
261 if (arg === 'reset') {
262 generation += 1
263 await update($, snapshot, () => null)
264 await update($, error, () => null)
265 await update($, isUpdating, () => false)
266 await update($, plan, () => null)
267 await $.store.delete(await storeKey($))
268 await $.store.delete(await planKey($))
269 return {
270 text:
271 settings.refresh === 'manual'
272 ? 'Project compass: cleared. Run /compass refresh to reassess.'
273 : 'Project compass: cleared. It reassesses after the next turn.',
274 }
275 }
276
277 return { text: 'Project compass pane opened.' }
278 })
279
280 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
281 const { Box, Text } = $.ui.resolve(e)
282 const shot = await read($, snapshot)
283 const busy = await read($, isUpdating)
284 const failed = await read($, error)
285 const spent = await read($, usage)
286 const projectSpent = await read($, projectUsage)
287 const taskList = await read($, tasks)
288 const approvedPlan = await read($, plan)
289 const planning = await read($, isPlanning)
290 const signedIn = settings.account === 'off' ? null : await read($, account)
291 const now = await $.clock.now()
292 // The context window as the status line has it; unknown before the first response.
293 let context: { remaining: number; left: number; window: number } | null = null
294 try {
295 const { context: window } = await $.session.usage()
296 if (window.percent !== undefined) {
297 context = {
298 remaining: Math.max(0, 100 - window.percent),
299 left: Math.max(0, window.window - (window.tokens ?? 0)),
300 window: window.window,
301 }
302 }
303 } catch {
304 // No usage to read (a host without it): the line says so.
305 }
306 // One column of margin each side, inside the pane.
307 const columns = Math.max(20, e.props.bodyColumns - 2)
308 const isFresh = shot !== null && now - shot.updatedAt < FRESH_MS
309
310 const divider = <Text dimColor>{'─'.repeat(columns)}</Text>
311 const heading = (text: string) => <Text bold>{text.toUpperCase()}</Text>
312 const badge = (change: Change | undefined) => (change ? <Text color="success">{` ◆ ${change}`}</Text> : null)
313 const row = (icon: { icon: string; color: string }, text: string, change: Change | undefined, isBold = false) => (
314 <Box>
315 <Box width={3} flexShrink={0}>
316 <Text color={icon.color}>{icon.icon}</Text>
317 </Box>
318 <Box flexShrink={1}>
319 <Text bold={isBold}>{text}</Text>
320 </Box>
321 <Box flexShrink={0}>{badge(change)}</Box>
322 </Box>
323 )
324
325 const statLine = (label: string, sum: typeof spent, unit: string) => (
326 <Box>
327 <Box width={9} flexShrink={0}>
328 <Text bold>{label}</Text>
329 </Box>
330 <Box flexShrink={1}>
331 <Text dimColor wrap="wrap">
332 {sum.runs === 0
333 ? 'none yet'
334 : `↑${count(sum.input)} ↓${count(sum.output)} cache ↑${count(sum.cacheRead)} ↓${count(sum.cacheWrite)} ${sum.runs} ${unit}${sum.runs === 1 ? '' : 's'} ${costText(sum.cost ?? 0, sum.unpriced ?? 0, sum.runs)}`}
335 </Text>
336 </Box>
337 </Box>
338 )
339 const CONTEXT_LABEL = 'Context Remaining'
340 const contextLeft = context ? ` ${roughCount(context.left)} of ${roughCount(context.window)}` : ''
341 const contextWidth = Math.max(6, Math.min(20, columns - CONTEXT_LABEL.length - 2 - 5 - contextLeft.length))
342 const contextLine = (
343 <Box>
344 <Box width={CONTEXT_LABEL.length + 2} flexShrink={0}>
345 <Text bold>{CONTEXT_LABEL}</Text>
346 </Box>
347 {context === null ? (
348 <Text dimColor>not known yet</Text>
349 ) : (
350 <Box flexShrink={1}>
351 <Text color={contextTone(context.remaining)}>{bar(context.remaining, contextWidth)}</Text>
352 <Text bold>{` ${context.remaining}%`}</Text>
353 <Text dimColor>{contextLeft}</Text>
354 </Box>
355 )}
356 </Box>
357 )
358 const accountText =
359 signedIn === null
360 ? null
361 : settings.account === 'plan'
362 ? signedIn.plan
363 ? `${signedIn.plan} plan`
364 : null
365 : signedIn.plan
366 ? `${signedIn.email} · ${signedIn.plan}`
367 : signedIn.email
368 const accountLine = accountText ? (
369 <Text dimColor wrap="truncate-end">
370 {accountText}
371 </Text>
372 ) : null
373
374 const stats = (
375 <Box flexDirection="column">
376 {divider}
377 {heading('Stats')}
378 {contextLine}
379 {statLine('Project', projectSpent, 'turn')}
380 {statLine('Compass', spent, 'run')}
381 </Box>
382 )
383
384 const footer = busy ? (
385 <Text color="suggestion">↻ updating…</Text>
386 ) : failed ? (
387 <Text color="error">{failed}</Text>
388 ) : shot ? (
389 <Text dimColor>{`${ago(shot.updatedAt, now)} · turn ${shot.turn} · ${refreshLabel(settings)}`}</Text>
390 ) : null
391
392 if (shot === null) {
393 return (
394 <Box flexDirection="column" paddingX={1}>
395 <Text bold>{TITLE}</Text>
396 {accountLine}
397 <Text dimColor>
398 {settings.refresh === 'manual'
399 ? 'No assessment yet. Run /compass refresh.'
400 : 'No assessment yet. It appears after the next turn ends.'}
401 </Text>
402 {footer}
403 {stats}
404 </Box>
405 )
406 }
407
408 const tone = shot.completion >= 75 ? 'success' : shot.completion >= 35 ? 'claude' : 'warning'
409 const delta = isFresh && shot.delta ? shot.delta : 0
410 const percent = `${shot.completion}%`
411 const deltaText = delta > 0 ? ` ▲${delta}` : delta < 0 ? ` ▼${-delta}` : ''
412 const barWidth = Math.max(6, Math.min(20, columns - shot.title.length - percent.length - deltaText.length - 3))
413
414 // The task list, while it has open items, is the authority on what runs and what is next.
415 const taskSteps = stepsFromTasks(taskList, now, FRESH_MS)
416 const steps: Step[] = taskSteps ?? shot.steps
417 const source = taskSteps ? 'from task list' : approvedPlan ? 'from approved plan' : null
418
419 const body = (
420 <Box flexDirection="column">
421 {divider}
422
423 <Box>
424 {heading('Objective')}
425 {isFresh && shot.isObjectiveChanged && <Text color="success"> ◆ changed</Text>}
426 </Box>
427 <Text>{shot.objective}</Text>
428 {divider}
429
430 <Box>
431 {heading('Next steps')}
432 {planning && <Text color="planMode"> ◇ planning</Text>}
433 {source && <Text dimColor>{` · ${source}`}</Text>}
434 </Box>
435 {steps.length === 0 && <Text dimColor>None identified.</Text>}
436 {steps.map(step => row(STEP_ICONS[step.status], step.text, isFresh || taskSteps ? step.change : undefined, step.status === 'active'))}
437 {divider}
438
439 {heading('Questions & risks')}
440 {shot.risks.length === 0 && <Text dimColor>None open.</Text>}
441 {shot.risks.map(risk => row(RISK_ICONS[risk.kind], risk.text, isFresh ? risk.change : undefined))}
442 {divider}
443
444 {footer}
445 {stats}
446 </Box>
447 )
448
449 return (
450 <Box flexDirection="column" paddingX={1}>
451 <Box justifyContent="space-between">
452 <Box flexDirection="column" flexShrink={1}>
453 <Text bold wrap="truncate-end">
454 {shot.title}
455 </Text>
456 {accountLine}
457 </Box>
458 <Box flexShrink={0}>
459 <Text color={tone}>{bar(shot.completion, barWidth)}</Text>
460 <Text bold>{` ${percent}`}</Text>
461 {deltaText && <Text color={delta > 0 ? 'success' : 'error'}>{deltaText}</Text>}
462 </Box>
463 </Box>
464 {body}
465 </Box>
466 )
467 })
468}
469
470hooks/parse.ts 156 lines1import type { Risk, RiskKind, Snapshot, Step, StepStatus } from '../types'
2
3const STATUSES: readonly StepStatus[] = ['active', 'next', 'blocked']
4const KINDS: readonly RiskKind[] = ['question', 'unknown', 'risk']
5
6/** `work`: what workContext says of planning, the approved plan and the task list. */
7export const buildPrompt = (previous: Snapshot | null, work = ''): string => {
8 const prior = previous
9 ? `Your previous assessment, to keep stable (same wording for items that still hold) unless the conversation since then changed it:\n${JSON.stringify(
10 {
11 title: previous.title,
12 objective: previous.objective,
13 steps: previous.steps.map(({ text, status }) => ({ text, status })),
14 risks: previous.risks.map(({ text, kind }) => ({ text, kind })),
15 completion: previous.completion,
16 },
17 )}\n\n`
18 : ''
19
20 return `[Side request from the project-compass pane. This is not the user speaking to you, and it is not part of the task. Do not use tools. Do not continue the work.]
21
22From the conversation so far, assess the project the user is working on.
23
24${work}${prior}Reply with ONE JSON object and nothing else, no prose and no code fence:
25{
26 "title": "the project's name, 2 to 5 words",
27 "objective": "the project's overall objective, one sentence",
28 "steps": [{ "text": "short imperative step", "status": "active" | "next" | "blocked" }],
29 "risks": [{ "text": "one short line", "kind": "question" | "unknown" | "risk" }],
30 "completion": 0-100
31}
32
33Rules:
34- "steps": at most 3, most important first. Mark "active" the step being worked on right now (at most one), "blocked" one that waits on the user or an unknown.
35- "risks": at most 3, the ones that matter most; leave out anything already resolved. "question": a decision or answer needed from the user. "unknown": something not yet known or verified. "risk": something that could go wrong and is not mitigated.
36- "completion": your honest estimate of how much of the whole project (not just this session's task) is done, as an integer.
37- Each text under 80 characters.`
38}
39
40const clean = (value: unknown, max = 160): string | null => {
41 if (typeof value !== 'string') return null
42 const text = value.replace(/\s+/g, ' ').trim()
43
44 return text.length === 0 ? null : text.slice(0, max)
45}
46
47const sameText = (a: string, b: string) =>
48 a.toLowerCase().replace(/[^a-z0-9]+/g, '') === b.toLowerCase().replace(/[^a-z0-9]+/g, '')
49
50/**
51 * Reads the fork's reply into a snapshot, or null when it holds no usable JSON
52 * object. Items are marked against `previous`: `new` when it held no such text,
53 * `changed` when a step's status moved.
54 */
55export const parseSnapshot = (
56 reply: string,
57 turn: number,
58 updatedAt: number,
59 previous: Snapshot | null = null,
60): Snapshot | null => {
61 const start = reply.indexOf('{')
62 const end = reply.lastIndexOf('}')
63 if (start < 0 || end <= start) return null
64
65 let raw: Record<string, unknown>
66 try {
67 raw = JSON.parse(reply.slice(start, end + 1))
68 } catch {
69 return null
70 }
71 if (raw === null || typeof raw !== 'object') return null
72
73 const objective = clean(raw.objective, 240)
74 if (objective === null) return null
75 const title = clean(raw.title, 48) ?? 'Project'
76
77 const steps: Step[] = (Array.isArray(raw.steps) ? raw.steps : [])
78 .map((one: unknown): Step | null => {
79 const item = (one ?? {}) as Record<string, unknown>
80 const text = clean(typeof one === 'string' ? one : item.text)
81 if (text === null) return null
82 const status = STATUSES.includes(item.status as StepStatus) ? (item.status as StepStatus) : 'next'
83
84 return { text, status }
85 })
86 .filter((one): one is Step => one !== null)
87 .slice(0, 3)
88
89 // At most one step reads as in progress.
90 let hasActive = false
91 for (const step of steps) {
92 if (step.status !== 'active') continue
93 if (hasActive) step.status = 'next'
94 hasActive = true
95 }
96
97 const risks: Risk[] = (Array.isArray(raw.risks) ? raw.risks : [])
98 .map((one: unknown): Risk | null => {
99 const item = (one ?? {}) as Record<string, unknown>
100 const text = clean(typeof one === 'string' ? one : item.text)
101 if (text === null) return null
102 const kind = KINDS.includes(item.kind as RiskKind) ? (item.kind as RiskKind) : 'risk'
103
104 return { text, kind }
105 })
106 .filter((one): one is Risk => one !== null)
107 .slice(0, 3)
108
109 const number = Number(raw.completion)
110 const completion = Number.isFinite(number) ? Math.round(Math.min(100, Math.max(0, number))) : 0
111
112 if (previous !== null) {
113 for (const step of steps) {
114 const before = previous.steps.find(one => sameText(one.text, step.text))
115 if (!before) step.change = 'new'
116 else if (before.status !== step.status) step.change = 'changed'
117 }
118 for (const risk of risks) {
119 if (!previous.risks.some(one => sameText(one.text, risk.text))) risk.change = 'new'
120 }
121 }
122
123 return {
124 title,
125 objective,
126 steps,
127 risks,
128 completion,
129 turn,
130 updatedAt,
131 delta: previous === null ? null : completion - previous.completion,
132 isObjectiveChanged: previous !== null && !sameText(previous.objective, objective),
133 }
134}
135
136/** Reads a snapshot an earlier version of the mod saved, or null when it is not one. */
137export const upgradeSnapshot = (value: unknown): Snapshot | null => {
138 if (value === null || typeof value !== 'object') return null
139 const old = value as Partial<Snapshot> & { risks?: unknown[] }
140 if (typeof old.objective !== 'string' || !Array.isArray(old.steps)) return null
141
142 return {
143 title: typeof old.title === 'string' ? old.title : 'Project',
144 objective: old.objective,
145 steps: old.steps.map(({ text, status }) => ({ text, status })),
146 risks: (old.risks ?? []).map(one =>
147 typeof one === 'string' ? { text: one, kind: 'risk' as const } : { text: (one as Risk).text, kind: (one as Risk).kind },
148 ),
149 completion: typeof old.completion === 'number' ? old.completion : 0,
150 turn: typeof old.turn === 'number' ? old.turn : 0,
151 updatedAt: typeof old.updatedAt === 'number' ? old.updatedAt : 0,
152 delta: null,
153 isObjectiveChanged: false,
154 }
155}
156hooks/pricing.ts 58 lines1import type { Usage } from '../types'
2
3/** US dollars per million tokens. */
4type Rates = { input: number; output: number; cacheRead: number }
5
6/**
7 * First-party API list prices (Claude API reference, cached 2026-09-25), most
8 * specific pattern first. A 5-minute cache write is 1.25x the input price.
9 */
10const PRICES: ReadonlyArray<[RegExp, Rates]> = [
11 [/(fable|mythos)\W*5\W*1/, { input: 10, output: 50, cacheRead: 0.25 }],
12 [/fable|mythos/, { input: 10, output: 50, cacheRead: 1 }],
13 [/opus\W*5\W*5/, { input: 4, output: 20, cacheRead: 0.2 }],
14 [/opus\W*(5|4)/, { input: 5, output: 25, cacheRead: 0.5 }],
15 [/opus/, { input: 4, output: 20, cacheRead: 0.2 }],
16 [/sonnet\W*4/, { input: 3, output: 15, cacheRead: 0.3 }],
17 [/sonnet/, { input: 2, output: 10, cacheRead: 0.2 }],
18 [/haiku/, { input: 1, output: 5, cacheRead: 0.1 }],
19]
20
21const CACHE_WRITE = 1.25
22
23export type TokenCounts = {
24 input_tokens: number
25 output_tokens: number
26 cache_read_input_tokens: number
27 cache_creation_input_tokens: number
28}
29
30/** What the tokens would cost at API list price on `model`, or null for a model with no known price. */
31export const priceOf = (model: string, used: TokenCounts): number | null => {
32 const name = model.toLowerCase()
33 const rates = PRICES.find(([pattern]) => pattern.test(name))?.[1]
34 if (!rates) return null
35
36 return (
37 (used.input_tokens * rates.input +
38 used.output_tokens * rates.output +
39 used.cache_read_input_tokens * rates.cacheRead +
40 used.cache_creation_input_tokens * rates.input * CACHE_WRITE) /
41 1_000_000
42 )
43}
44
45/** Adds one call's tokens and cost to a sum; a sum an earlier version kept may lack `cost` and `unpriced`. */
46export const addUsage = (sum: Usage, used: TokenCounts, cost: number | null): Usage => ({
47 runs: sum.runs + 1,
48 input: sum.input + used.input_tokens,
49 output: sum.output + used.output_tokens,
50 cacheRead: sum.cacheRead + used.cache_read_input_tokens,
51 cacheWrite: sum.cacheWrite + used.cache_creation_input_tokens,
52 cost: (sum.cost ?? 0) + (cost ?? 0),
53 unpriced: (sum.unpriced ?? 0) + (cost === null ? 1 : 0),
54})
55
56export const dollars = (cost: number) =>
57 cost >= 1 ? `$${cost.toFixed(2)}` : cost >= 0.01 ? `$${cost.toFixed(3)}` : `$${cost.toFixed(4)}`
58hooks/session.ts 21 lines1import type { Account } from '../types'
2
3const PLANS: Record<string, string> = { max: 'Max', pro: 'Pro', team: 'Team', enterprise: 'Enterprise', free: 'Free' }
4
5/** Reads `claude auth status --json` into the account the header shows, or null when no one is signed in. */
6export const parseAccount = (stdout: string): Account | null => {
7 let raw: Record<string, unknown>
8 try {
9 raw = JSON.parse(stdout)
10 } catch {
11 return null
12 }
13 if (raw === null || typeof raw !== 'object' || raw.loggedIn !== true || typeof raw.email !== 'string') return null
14 const tier = typeof raw.subscriptionType === 'string' ? raw.subscriptionType : undefined
15
16 return { email: raw.email, plan: tier ? (PLANS[tier] ?? tier) : undefined }
17}
18
19/** The context bar's color: green with room, yellow under 60% left, red under 30%. */
20export const contextTone = (remaining: number) => (remaining < 30 ? 'error' : remaining < 60 ? 'warning' : 'success')
21hooks/settings.ts 57 lines1import type { PluginOptions } from 'claude-code'
2
3/** turn: after every turn. interval: every N turns. edits: after a turn that edited files. manual: on /compass refresh alone. */
4export type RefreshMode = 'turn' | 'interval' | 'edits' | 'manual'
5
6/** full: email and plan. plan: the plan alone. off: no line, and the CLI is never asked. */
7export type AccountMode = 'full' | 'plan' | 'off'
8
9export type Settings = { refresh: RefreshMode; interval: number; account: AccountMode }
10
11const REFRESH: readonly RefreshMode[] = ['turn', 'interval', 'edits', 'manual']
12const ACCOUNT: readonly AccountMode[] = ['full', 'plan', 'off']
13
14/** The manifest's userConfig values, with anything missing or out of range at its default. */
15export const readSettings = (options: PluginOptions): Settings => {
16 const refresh = REFRESH.includes(options.refresh as RefreshMode) ? (options.refresh as RefreshMode) : 'turn'
17 const account = ACCOUNT.includes(options.account as AccountMode) ? (options.account as AccountMode) : 'full'
18 const asked = Number(options.refreshInterval)
19 const interval = Number.isFinite(asked) && asked >= 1 ? Math.round(asked) : 3
20
21 return { refresh, interval, account }
22}
23
24/** What has happened since the last assessment. */
25export type SinceRefresh = { turns: number; isEdited: boolean }
26
27/** Whether a finished turn should reassess, under the chosen mode. */
28export const shouldRefresh = (settings: Settings, since: SinceRefresh): boolean => {
29 switch (settings.refresh) {
30 case 'turn':
31 return true
32 case 'interval':
33 return since.turns >= settings.interval
34 case 'edits':
35 return since.isEdited
36 case 'manual':
37 return false
38 }
39}
40
41/** The footer's few words on when the next assessment comes. */
42export const refreshLabel = (settings: Settings): string => {
43 switch (settings.refresh) {
44 case 'turn':
45 return 'refreshes every turn'
46 case 'interval':
47 return `refreshes every ${settings.interval} turns`
48 case 'edits':
49 return 'refreshes after edits'
50 case 'manual':
51 return 'refresh with /compass refresh'
52 }
53}
54
55/** The tools whose success counts as an edit for the `edits` mode. */
56export const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
57hooks/work.ts 106 lines1import type { Plan, Step, Task, TaskStatus } from '../types'
2
3export type Todo = { content: string; status: TaskStatus; activeForm?: string }
4
5/** A TodoWrite list replaces the whole list: items keep their times when their text matches one before. */
6export const fromTodos = (todos: readonly Todo[], previous: readonly Task[], now: number): Task[] =>
7 todos.map((todo, index) => {
8 const before = previous.find(one => one.subject === todo.content)
9
10 return {
11 id: before?.id ?? `todo-${index}-${now}`,
12 subject: todo.content,
13 activeForm: todo.activeForm,
14 status: todo.status,
15 blockedBy: [],
16 createdAt: before?.createdAt ?? now,
17 changedAt: before && before.status === todo.status ? before.changedAt : now,
18 }
19 })
20
21export const taskCreated = (
22 tasks: readonly Task[],
23 task: { id: string; subject: string; activeForm?: string },
24 now: number,
25): Task[] => [
26 ...tasks.filter(one => one.id !== task.id),
27 { id: task.id, subject: task.subject, activeForm: task.activeForm, status: 'pending', blockedBy: [], createdAt: now, changedAt: now },
28]
29
30export type TaskChange = {
31 taskId: string
32 subject?: string
33 activeForm?: string
34 status?: TaskStatus | 'deleted'
35 addBlockedBy?: string[]
36}
37
38export const taskUpdated = (tasks: readonly Task[], change: TaskChange, now: number): Task[] => {
39 const moved = change.status
40 if (moved === 'deleted') return tasks.filter(one => one.id !== change.taskId)
41
42 return tasks.map(one => {
43 if (one.id !== change.taskId) return one
44 const status = moved ?? one.status
45
46 return {
47 ...one,
48 subject: change.subject ?? one.subject,
49 activeForm: change.activeForm ?? one.activeForm,
50 status,
51 blockedBy: [...one.blockedBy, ...(change.addBlockedBy ?? [])],
52 changedAt: status === one.status ? one.changedAt : now,
53 }
54 })
55}
56
57/**
58 * The pane's next steps from the task list: what runs first, then what can
59 * start, then what waits on another task; null when nothing is left open.
60 */
61export const stepsFromTasks = (tasks: readonly Task[], now: number, freshMs: number): Step[] | null => {
62 const isOpen = (id: string) => tasks.some(one => one.id === id && one.status !== 'completed')
63 const open = tasks.filter(one => one.status !== 'completed')
64 if (open.length === 0) return null
65
66 const rank = (task: Task) =>
67 task.status === 'in_progress' ? 0 : task.blockedBy.some(isOpen) ? 2 : 1
68 const ordered = [...open].sort((a, b) => rank(a) - rank(b))
69
70 return ordered.slice(0, 3).map(task => {
71 const isRunning = task.status === 'in_progress'
72 const step: Step = {
73 text: isRunning ? (task.activeForm ?? task.subject) : task.subject,
74 status: isRunning ? 'active' : task.blockedBy.some(isOpen) ? 'blocked' : 'next',
75 }
76 if (now - task.createdAt < freshMs) step.change = 'new'
77 else if (now - task.changedAt < freshMs) step.change = 'changed'
78
79 return step
80 })
81}
82
83const PLAN_LIMIT = 8_000
84
85/** What the fork is told about planning, the approved plan and the task list. */
86export const workContext = (isPlanning: boolean, plan: Plan | null, tasks: readonly Task[]): string => {
87 const parts: string[] = []
88 if (isPlanning) {
89 parts.push(
90 'The session is in plan mode: the user and the assistant are designing a plan, and nothing is being implemented yet. "steps" are what the plan proposes; mark one "active" only when planning itself is the work under way.',
91 )
92 }
93 if (plan) {
94 const text = plan.text.length > PLAN_LIMIT ? `${plan.text.slice(0, PLAN_LIMIT)}\n[…plan cut]` : plan.text
95 parts.push(
96 `The user approved this plan. Take "steps" from it, in its order, leaving out what is already done:\n<plan>\n${text}\n</plan>`,
97 )
98 }
99 if (tasks.length > 0) {
100 const lines = tasks.map(task => `- [${task.status}] ${task.subject}`).join('\n')
101 parts.push(`The assistant's task list right now, the authority on what is done and what is under way:\n${lines}`)
102 }
103
104 return parts.length === 0 ? '' : `${parts.join('\n\n')}\n\n`
105}
106types/index.d.ts 76 lines1export type StepStatus = 'active' | 'next' | 'blocked'
2
3export type RiskKind = 'question' | 'unknown' | 'risk'
4
5/** `change` says how an item differs from the assessment before it, when it does. */
6export type Change = 'new' | 'changed'
7
8export type Step = { text: string; status: StepStatus; change?: Change }
9
10export type Risk = { text: string; kind: RiskKind; change?: Change }
11
12export type Snapshot = {
13 title: string
14 objective: string
15 steps: Step[]
16 risks: Risk[]
17 completion: number
18 turn: number
19 /** When the assessment landed, in ms since the epoch. */
20 updatedAt: number
21 /** Completion's move since the assessment before, null for the first. */
22 delta: number | null
23 isObjectiveChanged: boolean
24}
25
26/** Tokens and their API list-price cost, summed over runs (the mod's forks) or turns (the project's). */
27export type Usage = {
28 runs: number
29 input: number
30 output: number
31 cacheRead: number
32 cacheWrite: number
33 /** API list-price equivalent of the priced runs, in US dollars. */
34 cost: number
35 /** Runs on a model with no known price, left out of `cost`. */
36 unpriced: number
37}
38
39export type TaskStatus = 'pending' | 'in_progress' | 'completed'
40
41/** One item of the main loop's task list (TodoWrite, or TaskCreate and TaskUpdate). */
42export type Task = {
43 id: string
44 subject: string
45 /** The present-tense label shown while it runs ("Writing tests"). */
46 activeForm?: string
47 status: TaskStatus
48 /** Ids of tasks that must finish first. */
49 blockedBy: string[]
50 createdAt: number
51 /** When its status last moved. */
52 changedAt: number
53}
54
55/** Who the session is signed in as, from `claude auth status`. */
56export type Account = { email: string; plan?: string }
57
58/** The plan the person approved when the session left plan mode. */
59export type Plan = { text: string; approvedAt: number }
60
61declare module 'claude-code' {
62 interface PluginState {
63 'project-compass': {
64 snapshot: Snapshot | null
65 isUpdating: boolean
66 error: string | null
67 usage: Usage
68 projectUsage: Usage
69 tasks: Task[]
70 plan: Plan | null
71 isPlanning: boolean
72 account: Account | null
73 }
74 }
75}
76