SLOPSHOPPER

project-compass

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

newpaneguardcommandmodelprocess
v0.2.1MITupdated 2026-10-07jasonwblock/project-compass
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-compass
│ ┃ Project compass ✕ › fix the failing auth test and add an audit log call │ ┃ Project compass │ ┃ No assessment yet. It appears after the ⏺ Read(src/auth.ts) │ ┃ next turn ends. ⎿ Read 6 lines │ ┃ ↻ updating… ⏺ Update(src/auth.ts) │ ┃ ────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ──────────── ⏺ Bash(bun test) │ ┃ STATS ⎿ 3 pass, 1 fail │ ┃ Context Remaining ████████░░░░░░░░ 51% 10 │ ┃ Project ↑2.1k ↓1.5k cache ↑91.0k ↓4.3k 1 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ≈$0.078 API │ ┃ Compass ↑12 ↓1 cache ↑0 ↓0 1 run ≈$0.00 ✻ Worked for 42s · done 4:20 PM │ │ › /compass │ ⎿ project-compass: Project compass pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Project compass
Project compass No assessment yet. It appears after the next turn ends. ↻ updating… ────────────────────────────────────────────────────── STATS Context Remaining ████████░░░░░░░░ 51% 103k of 200k Project ↑2.1k ↓1.5k cache ↑91.0k ↓4.3k 1 turn ≈$0.078 A Compass ↑12 ↓1 cache ↑0 ↓0 1 run ≈$0.0001 API
README

Project Compass

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:

  • Header: the project's name, the account you're signed in with, and an estimated completion bar with how far it moved since the last assessment
  • Objective: the project's goal, in one sentence
  • Next steps (up to 3): ▶ in progress, ○ next, ⏸ blocked
  • Questions & risks (up to 3): ? a question for you, … an unknown, ⚠ an unmitigated risk
  • Stats:
  • Context Remaining: how much of the context window is free (green, yellow under 60%, red under 30%)
  • Project: tokens and approximate API list-price cost of the session's own turns
  • Compass: the same for the compass's assessments

New and changed items are marked ◆ for two minutes after an assessment.

It follows how you work:

  • Task list: when Claude keeps a task list, the next steps come straight from it (the in-progress task, then what can start, then what's blocked).
  • Plan mode: while you plan, the pane shows ◇ planning. When you approve a plan, the next steps follow it, and the plan is remembered for the project.

Install

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.

Use

  • The pane opens by itself on a terminal at least 144 columns wide; otherwise type /compass.
  • /compass refresh reassesses now; /compass reset clears the saved assessment and plan for the project.

Settings

Set in /config, or under pluginConfigs in your settings:

SettingValuesDefault
Refresh (refresh)turn: after every turn · interval: every few turns · edits: after a turn that edited files · manual: only on /compass refreshturn
Refresh interval (refreshInterval)turns between assessments when Refresh is interval3
Account line (account)full: email and plan · plan: the plan alone · off: no linefull

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.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 7 files
hooks/register.tsx 470 lines
1import { 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
470
hooks/parse.ts 156 lines
1import 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}
156
hooks/pricing.ts 58 lines
1import 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)}`
58
hooks/session.ts 21 lines
1import 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')
21
hooks/settings.ts 57 lines
1import 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'])
57
hooks/work.ts 106 lines
1import 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}
106
types/index.d.ts 76 lines
1export 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