What Claude is doing now and what needs you, as cards of selectable text, with a note box on every item; plus the steps only you can take

A Claude Code mod that shows what Claude is doing, what comes next, and what needs you, in plain words, in a pane beside the conversation. Claude's technical steps (commands, file reads, edits) are hidden from the conversation unless you ask to see them.
The mod's plugin name is overview.
Claude hears about it: at its next step while it works, or in a turn of its own about 30 seconds after you stop, while it is idle.
Everything in the pane is plain text, so any of it can be selected and copied.
Claude drives the pane through three tools the mod adds: mcp__overview__goals, mcp__overview__your_steps and mcp__overview__project_map. The mod tells Claude how to use them in its system prompt; you do not call them yourself.
In a terminal Claude Code session (the desktop app's Code tab does not offer this command), type:
/plugin install overview --marketplace Fox1304/FT_Claude_Overwatch
Answer y to add the marketplace, then pick the user scope so it loads in every project. The mod is active at once in that session, and in every session started afterwards, including the desktop app.
Or from a shell, with the same result:
claude plugin marketplace add Fox1304/FT_Claude_Overwatch
claude plugin install overview@claude-overwatch --scope user
To remove it: /plugin uninstall overview, or claude plugin uninstall overview@claude-overwatch.
git clone https://github.com/Fox1304/FT_Claude_Overwatch.git
claude --plugin-dir ./FT_Claude_Overwatch
To load it in every session without installing it, add the clone's absolute path to env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json.
/overview opens it again after you close it./overview steps shows or hides Claude's technical steps in the conversation (also the « Show technical steps » link at the bottom of the pane). Your choice is remembered.Set them in /config (or under pluginConfigs in ~/.claude/settings.json):
| Option | Default | What it does |
|---|---|---|
pickupAfterSeconds | 30 | How long after your last tick an idle Claude starts a turn to pick it up. 0: only with your next message. |
phoneAlerts | true | Phone alerts for new or reopened steps, and daily reminders. |
Everything stays on your machine, in Claude Code's own plugin store (~/.claude/plugins/store/): your steps per repository, the project map, the recent-changes feed and the session cards that let your sessions see each other. Nothing is sent anywhere except to Claude in your own sessions, and to your phone through Claude Code's notifications when you turn them on.
claude plugin validate .
claude plugin test .
claude plugin test runs the tests/*.test.tsx files against Claude Code's own engine (it needs mods enabled). Claude Code lays the API typings in .claude-plugin/types/ the first time it loads the mod; tsc -p . type-checks it after that.
| Path | What it holds |
|---|---|
hooks/register.tsx | the hooks: tools, prompt guide, pane, hidden rows, delivery to Claude |
hooks/goals.ts, your-steps.ts, map.ts, notes.ts, activity.ts | pure logic for goals, steps, the map, notes and the per-goal history |
types/index.d.ts | the shapes of the mod's state |
tests/ | engine tests, with world.ts standing in for the store, the clock and Claude |
MIT, see LICENSE.
hooks/register.tsx 1589 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import type { Board, Change, Goal, Helper, ItemKind, ItemNote, Milestone, OtherProject, Part, SessionCard, YourStep } from '../types'
5import { noteLine, noteMessage, noteText, putNote, setNoteStatus, settleNotes } from './notes'
6import {
7 CHANGES_PREFIX,
8 EMPTY_MAP,
9 MAP_DESCRIPTION,
10 MAP_GUIDE,
11 MAP_PREFIX,
12 MAP_SCHEMA,
13 MAP_TOOL,
14 addChanges,
15 applyMap,
16 describeMap,
17 diffMaps,
18 parseMap,
19 readChanges,
20 readMap,
21} from './map'
22import {
23 BEFORE_PLAN,
24 HEARTBEAT_MS,
25 SESSION_FORGET_MS,
26 SESSION_PREFIX,
27 SESSION_STALE_MS,
28 cardFor,
29 cardLine,
30 countOf,
31 describeAction,
32 forgetGone,
33 record,
34} from './activity'
35import {
36 EMPTY,
37 GOALS_DESCRIPTION,
38 GOALS_GUIDE,
39 GOALS_SCHEMA,
40 GOALS_TOOL,
41 HIDDEN_NOTE,
42 archive,
43 isBlocked,
44 merge,
45 parseGoals,
46 stepsFor,
47 technicalTitles,
48} from './goals'
49import {
50 STORE_PREFIX,
51 YOUR_STEPS_DESCRIPTION,
52 YOUR_STEPS_GUIDE,
53 YOUR_STEPS_SCHEMA,
54 YOUR_STEPS_TOOL,
55 answerKind,
56 applyOps,
57 choose,
58 describeList,
59 dueReminders,
60 dayName,
61 dayOf,
62 dueText,
63 githubBase,
64 groupSteps,
65 isEmpty,
66 labelFor,
67 linksFor,
68 markHeard,
69 markReminded,
70 noteFor,
71 parseOps,
72 phoneLine,
73 prune,
74 readStored,
75 reminderLine,
76 reply,
77 sortSteps,
78 summarizeCall,
79 ticksFor,
80 toggle,
81 unheard,
82 waitedText,
83} from './your-steps'
84
85const PANE = 'overview'
86const TITLE = 'Overview'
87
88const STEPS_KEY = 'showSteps'
89/** How often ticks, other projects and helpers are looked at. */
90const SWEEP_MS = 10_000
91const PICKUP_DEFAULT_SECONDS = 30
92/** Tool calls without an Overview update before Claude is reminded. */
93const NUDGE_AFTER = 12
94
95const board = atom({ plugin: 'overview', key: 'board' } as const, EMPTY)
96const isWorking = atom({ plugin: 'overview', key: 'isWorking' } as const, false)
97const showSteps = atom({ plugin: 'overview', key: 'showSteps' } as const, false)
98const shownCalls = atom({ plugin: 'overview', key: 'shownCalls' } as const, [])
99const yourSteps = atom({ plugin: 'overview', key: 'yourSteps' } as const, [])
100const project = atom({ plugin: 'overview', key: 'project' } as const, '')
101const projectLabel = atom({ plugin: 'overview', key: 'projectLabel' } as const, '')
102const otherProjects = atom({ plugin: 'overview', key: 'otherProjects' } as const, [])
103const helpers = atom({ plugin: 'overview', key: 'helpers' } as const, [])
104const otherSessions = atom({ plugin: 'overview', key: 'otherSessions' } as const, [])
105const activity = atom({ plugin: 'overview', key: 'activity' } as const, {})
106const openGoals = atom({ plugin: 'overview', key: 'openGoals' } as const, [])
107const unfolded = atom({ plugin: 'overview', key: 'unfolded' } as const, [])
108const openedSteps = atom({ plugin: 'overview', key: 'openSteps' } as const, [])
109const projectMap = atom({ plugin: 'overview', key: 'projectMap' } as const, EMPTY_MAP)
110const changes = atom({ plugin: 'overview', key: 'changes' } as const, [])
111const editing = atom({ plugin: 'overview', key: 'editing' } as const, '')
112const editProblem = atom({ plugin: 'overview', key: 'editProblem' } as const, '')
113const notes = atom({ plugin: 'overview', key: 'notes' } as const, [])
114
115/** Tools whose rows are for the person (files, widgets, plans, findings): never hidden. */
116const FOR_THE_PERSON = new Set([
117 'AskUserQuestion',
118 'ExitPlanMode',
119 'ReportFindings',
120 'SendUserFile',
121 'mcp__ccd_session__spawn_task',
122])
123
124/** Prompts that are not the person asking for something new. */
125const NOT_THE_PERSON = new Set(['plugin', 'task-notification', 'auto-continuation'])
126
127const isForThePerson = (tool: string, input: unknown) => {
128 if (FOR_THE_PERSON.has(tool) || tool.endsWith('__show_widget')) return true
129 if (tool !== 'Artifact') return false
130 const action = (input as { action?: unknown } | null)?.action
131
132 return action === undefined || action === 'publish' || action === 'open'
133}
134
135const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
136
137/** Module variables: a reload drops timers, starts the count afresh and reads the options again. */
138let sweeping: Timer | undefined
139let pickupMs = PICKUP_DEFAULT_SECONDS * 1000
140let phoneAlerts = true
141let callsSinceGoals = 0
142let lastCard = ''
143let lastCardAt = 0
144
145/** Shows or hides the steps (flips them without a value) and remembers it across sessions. */
146const setSteps = async ($: EngineInterface, value?: boolean) => {
147 const isShown = await update($, showSteps, shown => value ?? !shown)
148 await $.store.set(STEPS_KEY, isShown)
149
150 return isShown
151}
152
153/** The store key of this project's steps: the repository's main root, so worktrees share them. */
154const projectKey = async ($: EngineInterface) => {
155 const known = await read($, project)
156 if (known !== '' && (await read($, projectLabel)) !== '') return known
157 const repo = await $.session.repo()
158 const root = repo?.root ?? (await $.session.root())
159 const key = `${STORE_PREFIX}${root}`
160 await update($, projectLabel, () => labelFor(root, repo?.remote ?? null))
161 await update($, project, () => key)
162
163 return key
164}
165
166/** Reads this project's steps back from the store (another session may have changed them). */
167const loadYourSteps = async ($: EngineInterface) => {
168 const { steps } = readStored(await $.store.get(await projectKey($)))
169 if (!same(await read($, yourSteps), steps)) await update($, yourSteps, () => steps)
170
171 return steps
172}
173
174/** Reads every other project's open steps. */
175const loadOtherProjects = async ($: EngineInterface) => {
176 const own = await projectKey($)
177 const keys = (await $.store.keys()).filter(key => key.startsWith(STORE_PREFIX) && key !== own)
178 const found: OtherProject[] = []
179 for (const key of keys) {
180 const { label, steps } = readStored(await $.store.get(key))
181 const open = steps.filter(step => step.status !== 'done')
182 if (open.length > 0) found.push({ key, label: label ?? labelFor(key.slice(STORE_PREFIX.length), null), steps: open })
183 }
184 if (!same(await read($, otherProjects), found)) await update($, otherProjects, () => found)
185}
186
187/** Applies a change to the freshest list of a project in the store, so parallel sessions keep each other's steps. */
188const changeStepsAt = async ($: EngineInterface, key: string, change: (list: YourStep[]) => YourStep[]) => {
189 const own = await projectKey($)
190 const now = await $.clock.now()
191 const stored = readStored(await $.store.get(key))
192 const steps = prune(change(stored.steps), now)
193 const label = key === own ? await read($, projectLabel) : stored.label
194 await $.store.set(key, { label, steps })
195 if (key === own) {
196 await update($, yourSteps, () => steps)
197 } else {
198 await loadOtherProjects($)
199 }
200
201 return steps
202}
203
204/** The project's root, out of its steps' store key. */
205const rootOf = (key: string) => key.slice(STORE_PREFIX.length)
206
207/** Reads this project's map and feed back from the store (another session may have changed them). */
208const loadMap = async ($: EngineInterface) => {
209 const root = rootOf(await projectKey($))
210 const map = readMap(await $.store.get(`${MAP_PREFIX}${root}`))
211 const feed = readChanges(await $.store.get(`${CHANGES_PREFIX}${root}`))
212 if (!same(await read($, projectMap), map)) await update($, projectMap, () => map)
213 if (!same(await read($, changes), feed)) await update($, changes, () => feed)
214
215 return map
216}
217
218/** Adds lines to a project's feed (this one, or the one whose step was ticked from here). */
219const recordChanges = async ($: EngineInterface, key: string, added: Change[]) => {
220 if (added.length === 0) return
221 const storeKey = `${CHANGES_PREFIX}${rootOf(key)}`
222 const feed = addChanges(readChanges(await $.store.get(storeKey)), added)
223 await $.store.set(storeKey, feed)
224 if (key === (await projectKey($))) await update($, changes, () => feed)
225}
226
227/** Picks an option of a decision, in this project or another one. */
228const chooseStep = async ($: EngineInterface, key: string, id: string, choice: string) => {
229 ensureSweep($)
230 const session = await $.session.id()
231 const from = await projectKey($)
232 const now = await $.clock.now()
233 const list = await changeStepsAt($, key, steps => choose(steps, id, choice, session, from, now))
234 const step = list.find(one => one.id === id)
235 if (step?.answer !== undefined) await recordChanges($, key, [{ at: now, text: `You chose: ${step.answer}`, tone: 'you' }])
236}
237
238/** Answers a step that asks for words, in this project or another one; an empty answer takes it back. */
239const answerStep = async ($: EngineInterface, key: string, id: string, words: string) => {
240 ensureSweep($)
241 const session = await $.session.id()
242 const from = await projectKey($)
243 const now = await $.clock.now()
244 const list = await changeStepsAt($, key, steps => reply(steps, id, words, session, from, now))
245 const step = list.find(one => one.id === id)
246 if (step?.answer !== undefined) await recordChanges($, key, [{ at: now, text: `You answered: ${step.title}`, tone: 'you' }])
247}
248
249/** Publishes what this session is doing, when it changed or as a heartbeat; never fails its caller. */
250const publishCard = async ($: EngineInterface) => {
251 try {
252 await writeCard($)
253 } catch (error) {
254 $.ui.log(`overview: session card not published: ${String(error)}`, { to: 'debug' })
255 }
256}
257
258const writeCard = async ($: EngineInterface) => {
259 const id = await $.session.id()
260 const now = await $.clock.now()
261 await projectKey($)
262 const card = cardFor(id, await read($, projectLabel), await read($, board), await read($, isWorking), await read($, yourSteps), now)
263 const { updatedAt: _at, ...words } = card
264 const said = JSON.stringify(words)
265 if (said === lastCard && now - lastCardAt < HEARTBEAT_MS) return
266 lastCard = said
267 lastCardAt = now
268 await $.store.set(`${SESSION_PREFIX}${id}`, card)
269}
270
271/** Reads the other open sessions' cards, and forgets the ones gone for an hour. */
272const loadOtherSessions = async ($: EngineInterface) => {
273 const own = `${SESSION_PREFIX}${await $.session.id()}`
274 const now = await $.clock.now()
275 const found: SessionCard[] = []
276 for (const key of (await $.store.keys()).filter(one => one.startsWith(SESSION_PREFIX) && one !== own)) {
277 const card = (await $.store.get(key)) as SessionCard | undefined
278 const age = now - (card?.updatedAt ?? 0)
279 if (age >= SESSION_FORGET_MS) await $.store.delete(key)
280 else if (card !== undefined && age < SESSION_STALE_MS) found.push(card)
281 }
282 found.sort((a, b) => a.project.localeCompare(b.project) || a.id.localeCompare(b.id))
283 if (!same(await read($, otherSessions), found)) await update($, otherSessions, () => found)
284}
285
286/** Reminds the person's phone of steps that have waited a day, or are due by tomorrow. */
287const remind = async ($: EngineInterface) => {
288 if (!phoneAlerts) return
289 const now = await $.clock.now()
290 const due = dueReminders(await read($, yourSteps), now)
291 if (due.length === 0) return
292 await changeYourSteps($, steps => markReminded(steps, due, now))
293 await notifyPhone($, reminderLine(await read($, projectLabel), sortSteps(due), now))
294}
295
296const toggleFold = async ($: EngineInterface, part: string) => {
297 await update($, unfolded, open => ((open ?? []).includes(part) ? (open ?? []).filter(one => one !== part) : [...(open ?? []), part]))
298}
299
300const toggleStepOpen = async ($: EngineInterface, id: string) => {
301 await update($, openedSteps, open => ((open ?? []).includes(id) ? (open ?? []).filter(one => one !== id) : [...(open ?? []), id]))
302}
303
304/** The repository's GitHub address, which links to its documents start from. */
305const repoBase = async ($: EngineInterface) => githubBase((await $.session.repo())?.remote ?? null)
306
307const toggleGoalOpen = async ($: EngineInterface, title: string) => {
308 await update($, openGoals, open => ((open ?? []).includes(title) ? (open ?? []).filter(one => one !== title) : [...(open ?? []), title]))
309}
310
311/** Notes what a tool call did, under the goal Claude is working on. */
312const recordAction = async ($: EngineInterface, tool: string, input: unknown, failed: boolean) => {
313 const label = describeAction(tool, input)
314 if (label === null) return
315 const active = (await read($, board)).goals.find(goal => goal.status === 'active')?.title ?? BEFORE_PLAN
316 await update($, activity, log => record(log ?? {}, active, label, failed))
317}
318
319const tickStep = async ($: EngineInterface, key: string, id: string) => {
320 ensureSweep($)
321 const session = await $.session.id()
322 const from = await projectKey($)
323 const now = await $.clock.now()
324 const list = await changeStepsAt($, key, steps => toggle(steps, id, session, from, now))
325 const step = list.find(one => one.id === id)
326 if (step?.status === 'checked') await recordChanges($, key, [{ at: now, text: `You ticked: ${step.title}`, tone: 'you' }])
327}
328
329/** Running subagents, for the pane. */
330const refreshHelpers = async ($: EngineInterface) => {
331 const running: Helper[] = (await $.agent.list())
332 .filter(agent => agent.status === 'running')
333 .map(agent => ({ id: agent.id, description: agent.description }))
334 if (!same(await read($, helpers), running)) await update($, helpers, () => running)
335}
336
337/** Tells the person's phone, through the app's own notifications (sent only when they are away). */
338const notifyPhone = async ($: EngineInterface, message: string) => {
339 if (!phoneAlerts || message === '') return
340 try {
341 await $.tool.call({ tool: 'PushNotification', message, status: 'proactive' })
342 } catch (error) {
343 $.ui.log(`overview: phone alert not sent: ${String(error)}`, { to: 'debug' })
344 }
345}
346
347/**
348 * Reads other sessions' changes back, and while Claude is idle tells it about the ticks this
349 * session picks up in a turn of its own, once the person has stopped ticking for `pickupMs`
350 * (never when it is 0). While Claude works, its next tool result carries them instead.
351 */
352const sweep = async ($: EngineInterface) => {
353 const list = await loadYourSteps($)
354 await loadOtherProjects($)
355 await refreshHelpers($)
356 await publishCard($)
357 await loadOtherSessions($)
358 await loadMap($)
359 await remind($)
360 await settle($, () => false)
361 if (!(await read($, isWorking))) {
362 const queued = (await read($, notes)).filter(note => note.status === 'queued')
363 if (queued.length > 0) await deliverNotes($, queued.map(note => note.item))
364 }
365 if (pickupMs <= 0 || unheard(list).length === 0 || (await read($, isWorking))) return
366 const waiting = ticksFor(list, await $.session.id(), await projectKey($))
367 const lastTick = Math.max(0, ...waiting.map(step => step.tickedAt ?? 0))
368 if (waiting.length === 0 || (await $.clock.now()) - lastTick < pickupMs) return
369 await changeYourSteps($, steps => markHeard(steps, waiting))
370 void $.prompt.submit({ text: noteFor(waiting, (await read($, board)).goals) })
371}
372
373const changeYourSteps = async ($: EngineInterface, change: (list: YourStep[]) => YourStep[]) =>
374 changeStepsAt($, await projectKey($), change)
375
376/** The ticks this session picks up that Claude has not heard, marked heard as they are handed over. */
377const takeTicks = async ($: EngineInterface) => {
378 const list = await read($, yourSteps)
379 if (unheard(list).length === 0) return []
380 const waiting = ticksFor(list, await $.session.id(), await projectKey($))
381 if (waiting.length > 0) await changeYourSteps($, steps => markHeard(steps, waiting))
382
383 return waiting
384}
385
386/** An item of the pane a note can be left on. */
387type ItemRef = { item: string; kind: ItemKind; title: string; stepId?: string; stepKey?: string }
388
389/** Opens the edit box of an item (`where` tells apart the places one item is drawn), or closes it when open. */
390const toggleEdit = async ($: EngineInterface, box: string) => {
391 await update($, editProblem, () => '')
392 await update($, editing, open => (open === box ? '' : box))
393}
394
395const closeEdit = async ($: EngineInterface) => {
396 await update($, editProblem, () => '')
397 await update($, editing, () => '')
398}
399
400/** Sends the notes on these items to Claude: a turn of their own when it is idle, its next step when it works. */
401const deliverNotes = async ($: EngineInterface, items: readonly string[]) => {
402 const waiting = (await read($, notes)).filter(note => items.includes(note.item) && note.status !== 'sent')
403 if (waiting.length === 0) return
404 const keys = waiting.map(note => note.item)
405 if (await read($, isWorking)) {
406 await update($, notes, list => setNoteStatus(list ?? [], keys, 'queued'))
407
408 return
409 }
410 await update($, notes, list => setNoteStatus(list ?? [], keys, 'sending'))
411 try {
412 await $.prompt.submit({ text: noteMessage(waiting) })
413 await update($, notes, list => setNoteStatus(list ?? [], keys, 'sent'))
414 } catch (error) {
415 await update($, notes, list => setNoteStatus(list ?? [], keys, 'error', String(error instanceof Error ? error.message : error).slice(0, 160)))
416 }
417}
418
419/** Takes the person's note on an item: empty, it asks for words; else it goes to Claude. */
420const sendNote = async ($: EngineInterface, box: string, ref: ItemRef, words: string) => {
421 const text = noteText(words)
422 if (text === undefined) {
423 await update($, editProblem, () => box)
424
425 return
426 }
427 const now = await $.clock.now()
428 await update($, notes, list => putNote(list ?? [], { ...ref, text, status: 'sending', at: now }))
429 await closeEdit($)
430 await recordChanges($, ref.stepKey ?? (await projectKey($)), [{ at: now, text: `Your note on ${ref.title}`, tone: 'you' }])
431 await deliverNotes($, [ref.item])
432}
433
434const retryNote = async ($: EngineInterface, item: string) => deliverNotes($, [item])
435
436const dropNote = async ($: EngineInterface, item: string) => {
437 await update($, notes, list => (list ?? []).filter(note => note.item !== item))
438}
439
440/** The queued notes Claude has not read, marked sent as they ride on its next tool result. */
441const takeNotes = async ($: EngineInterface) => {
442 const queued = (await read($, notes)).filter(note => note.status === 'queued')
443 if (queued.length > 0) await update($, notes, list => setNoteStatus(list ?? [], queued.map(note => note.item), 'sent'))
444
445 return queued
446}
447
448/** Drops delivered notes on the items Claude has just reworked. */
449const settle = async ($: EngineInterface, reworked: (note: ItemNote) => boolean) => {
450 const now = await $.clock.now()
451 const list = await read($, notes)
452 const kept = settleNotes(list, reworked, now)
453 if (kept.length !== list.length) await update($, notes, () => kept)
454}
455
456/** Starts the periodic look, once per load. */
457const ensureSweep = ($: EngineInterface) => {
458 sweeping ??= $.clock.every(SWEEP_MS, () => {
459 sweep($).catch(error => $.ui.log(`overview: ${String(error)}`, { to: 'debug' }))
460 })
461}
462
463/** What rides on Claude's tool result: the person's ticks, and a reminder when the pane has gone stale. */
464const contextFor = async ($: EngineInterface, tool: string) => {
465 const notes: string[] = []
466 const ticks = await takeTicks($)
467 if (ticks.length > 0) notes.push(noteFor(ticks, (await read($, board)).goals))
468 const queued = await takeNotes($)
469 if (queued.length > 0) notes.push(noteMessage(queued))
470 callsSinceGoals = tool === GOALS_TOOL ? 0 : callsSinceGoals + 1
471 if (callsSinceGoals >= NUDGE_AFTER) {
472 callsSinceGoals = 0
473 const hasGoals = (await read($, board)).goals.length > 0
474 notes.push(
475 hasGoals
476 ? `The Overview pane has not changed for a while. Update it with ${GOALS_TOOL}: mark reached goals done, set the active one and its progress.`
477 : `You have been working for a while and the person's Overview pane is empty. Send the top-level goals with ${GOALS_TOOL}, as plain-word results.`,
478 )
479 }
480
481 return notes
482}
483
484const bar = (done: number, total: number, width: number) => {
485 const filled = total === 0 ? 0 : Math.round((done / total) * width)
486
487 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
488}
489
490export const register: Register = (on, options) => {
491 const seconds = Number(options.pickupAfterSeconds ?? PICKUP_DEFAULT_SECONDS)
492 pickupMs = Number.isFinite(seconds) ? Math.max(0, seconds) * 1000 : PICKUP_DEFAULT_SECONDS * 1000
493 phoneAlerts = options.phoneAlerts !== false
494
495 on('session.start', async ($, e, next) => {
496 await $.tool.register({ name: 'goals', description: GOALS_DESCRIPTION, inputSchema: GOALS_SCHEMA })
497 await $.tool.register({ name: 'your_steps', description: YOUR_STEPS_DESCRIPTION, inputSchema: YOUR_STEPS_SCHEMA })
498 await $.tool.register({ name: 'project_map', description: MAP_DESCRIPTION, inputSchema: MAP_SCHEMA })
499 await $.command.register({
500 name: 'overview',
501 description: 'Show what Claude is doing, will do and has finished; "steps" shows or hides the technical steps',
502 argumentHint: '[steps [on|off]]',
503 immediate: true,
504 })
505 const stored = await $.store.get(STEPS_KEY)
506 await update($, showSteps, () => stored === true)
507 await loadYourSteps($)
508 await loadOtherProjects($)
509 await loadMap($)
510 ensureSweep($)
511 void $.ui.open({ id: PANE, title: TITLE })
512
513 return next(e)
514 })
515
516 on('command.run', { command: 'overview' }, async ($, e) => {
517 const [word, value] = e.args.trim().toLowerCase().split(/\s+/)
518 if (word === 'steps') {
519 const isShown = await setSteps($, value === 'on' ? true : value === 'off' ? false : undefined)
520
521 return {
522 text: isShown
523 ? 'Technical steps are shown in the conversation.'
524 : 'Technical steps are hidden from the conversation.',
525 }
526 }
527 await $.ui.open({ id: PANE, title: TITLE })
528
529 return { text: 'Overview pane opened. /overview steps shows or hides the technical steps.' }
530 })
531
532 on('prompt.compose', async ($, e, next) => {
533 const composed = await next(e)
534 if (!e.tools.includes(GOALS_TOOL)) return composed
535 const text =
536 ((await read($, showSteps)) ? GOALS_GUIDE : GOALS_GUIDE + HIDDEN_NOTE) +
537 (e.tools.includes(YOUR_STEPS_TOOL) ? YOUR_STEPS_GUIDE : '') +
538 (e.tools.includes(MAP_TOOL) ? MAP_GUIDE : '')
539
540 return {
541 sections: [...composed.sections, { id: 'overview:guide', text, scope: 'session' }],
542 }
543 })
544
545 on('tool.describe', { tool: 'mcp__overview__goals' }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
546 on('tool.describe', { tool: 'mcp__overview__your_steps' }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
547 on('tool.check', { tool: 'mcp__overview__goals' }, () => ({ decision: 'allow' }))
548 on('tool.check', { tool: 'mcp__overview__your_steps' }, () => ({ decision: 'allow' }))
549 on('tool.describe', { tool: 'mcp__overview__project_map' }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
550 on('tool.check', { tool: 'mcp__overview__project_map' }, () => ({ decision: 'allow' }))
551
552 on('tool.call', { tool: 'mcp__overview__project_map' }, async ($, e) => {
553 const input = parseMap(e as unknown as Record<string, unknown>)
554 if (input.milestones === undefined && input.parts === undefined) return { result: describeMap(await loadMap($)) }
555 const root = rootOf(await projectKey($))
556 const now = await $.clock.now()
557 const before = readMap(await $.store.get(`${MAP_PREFIX}${root}`))
558 const after = applyMap(before, input, now)
559 await $.store.set(`${MAP_PREFIX}${root}`, after)
560 await update($, projectMap, () => after)
561 await settle($, note => (note.kind === 'part' && input.parts !== undefined) || (note.kind === 'milestone' && input.milestones !== undefined))
562 await recordChanges($, await projectKey($), diffMaps(before, after, now))
563
564 return { result: `Map updated.\n\n${describeMap(after)}` }
565 })
566
567 on('tool.call', async ($, e, next) => {
568 ensureSweep($)
569 if (String(e.tool) === 'Artifact' && isForThePerson('Artifact', e)) {
570 await update($, shownCalls, ids => [...(ids ?? []), e.tool_use_id].slice(-100))
571 }
572 const ran = await next(e)
573 if (e.agentId !== undefined || ran.deny !== undefined) return ran
574 try {
575 await recordAction($, String(e.tool), e, ran.isError === true)
576 if (String(e.tool) === 'Agent') await refreshHelpers($)
577 const notes = await contextFor($, String(e.tool))
578
579 return notes.length === 0 ? ran : { ...ran, context: [...(ran.context ?? []), ...notes] }
580 } catch (error) {
581 $.ui.log(`overview: ${String(error)}`, { to: 'debug' })
582
583 return ran
584 }
585 })
586
587 on('tool.call', { tool: 'mcp__overview__goals' }, async ($, e) => {
588 if (e.agentId !== undefined) {
589 return { result: 'Only the main conversation keeps the overview; carry on with your task.' }
590 }
591 const args = e as unknown as Record<string, unknown>
592 const goals = parseGoals(args.goals)
593 if (goals.length === 0) {
594 return { deny: 'Give at least one goal, each with a title and a status.' }
595 }
596 const technical = technicalTitles(goals)
597 if (technical.length > 0) {
598 return {
599 deny: `These titles read as technical steps; rewrite them as results the person gets, in plain words, and send the list again: ${technical.map(title => `"${title}"`).join(', ')}`,
600 }
601 }
602 const summary = typeof args.summary === 'string' ? args.summary.trim() : ''
603 const now = await $.clock.now()
604 const merged = await update($, board, prev => merge(prev ?? EMPTY, goals, summary, now))
605 await settle($, note => note.kind === 'goal')
606 const firstActive = goals.find(goal => goal.status === 'active')?.title
607 await update($, activity, log => {
608 const { [BEFORE_PLAN]: early, ...rest } = log ?? {}
609 const moved = early !== undefined && firstActive !== undefined && rest[firstActive] === undefined
610 const kept = moved ? { ...rest, [firstActive]: early } : early === undefined ? rest : { ...rest, [BEFORE_PLAN]: early }
611
612 return forgetGone(kept, merged)
613 })
614 await publishCard($)
615 const done = goals.filter(goal => goal.status === 'done').length
616 const steps = await read($, yourSteps)
617 const unmatched = goals.filter(goal => goal.status === 'waiting' && stepsFor(goal, steps).length === 0)
618 const hint =
619 unmatched.length > 0
620 ? `\n\n${unmatched.map(goal => `"${goal.title}"`).join(', ')} waits on no known step of the person: add the step with ${YOUR_STEPS_TOOL} and name its id in "waitingFor".`
621 : ''
622
623 return { result: `Overview updated: ${done} of ${goals.length} done.${hint}` }
624 })
625
626 on('tool.call', { tool: 'mcp__overview__your_steps' }, async ($, e) => {
627 const ops = parseOps(e as unknown as Record<string, unknown>)
628 if (isEmpty(ops)) return { result: describeList(await loadYourSteps($)) }
629 const base = await repoBase($)
630 const dropped: string[] = []
631 for (const step of [...ops.add, ...ops.update]) {
632 if (step.sources === undefined) continue
633 const made = linksFor(step.sources, base)
634 step.links = made.links
635 dropped.push(...made.dropped)
636 }
637 const now = await $.clock.now()
638 let unknown: string[] = []
639 let added: YourStep[] = []
640 const before = await loadYourSteps($)
641 const touched = new Set([...ops.update.map(one => one.id), ...ops.remove, ...ops.confirm.map(one => one.id), ...ops.reopen.map(one => one.id)])
642 await settle($, note => note.kind === 'step' && note.stepId !== undefined && touched.has(note.stepId))
643 const list = await changeYourSteps($, steps => {
644 const applied = applyOps(steps, ops, now, () => crypto.randomUUID().slice(0, 6))
645 unknown = applied.unknown
646 added = applied.added
647
648 return applied.list
649 })
650 const reopened = ops.reopen.flatMap(one => {
651 const step = before.find(found => found.id === one.id)
652
653 return step === undefined ? [] : [{ title: step.title, note: one.note }]
654 })
655 await notifyPhone($, phoneLine(await read($, projectLabel), added, reopened))
656 const titleOf = (id: string) => before.find(step => step.id === id)?.title
657 await recordChanges($, await projectKey($), [
658 ...ops.confirm.flatMap(one => (titleOf(one.id) ? [{ at: now, text: `${titleOf(one.id)} → Done`, tone: 'done' as const }] : [])),
659 ...reopened.map(one => ({ at: now, text: `${one.title} → Not done yet`, tone: 'stuck' as const })),
660 ])
661 const warning =
662 (unknown.length > 0 ? `\n\nUnknown ids, nothing changed for them: ${unknown.join(', ')}.` : '') +
663 (dropped.length > 0
664 ? `\n\nSources left out (not an https address, or a repository file outside GitHub): ${dropped.join(', ')}.`
665 : '')
666
667 return { result: describeList(list) + warning }
668 })
669
670 on('prompt.submit', async ($, e, next) => {
671 ensureSweep($)
672 await update($, isWorking, () => true)
673 callsSinceGoals = 0
674 if (!NOT_THE_PERSON.has(e.origin.kind)) await update($, board, prev => archive(prev ?? EMPTY))
675 let ticks: string | undefined
676 if (!(e.origin.kind === 'plugin' && e.origin.name === 'overview')) {
677 try {
678 const waiting = unheard(await loadYourSteps($))
679 if (waiting.length > 0) {
680 await changeYourSteps($, steps => markHeard(steps, waiting))
681 ticks = noteFor(waiting, (await read($, board)).goals)
682 }
683 } catch (error) {
684 $.ui.log(`overview: ${String(error)}`, { to: 'debug' })
685 }
686 }
687 try {
688 return await next(ticks === undefined ? e : { ...e, context: [...(e.context ?? []), ticks] })
689 } catch (error) {
690 // The prompt never started a turn: Claude is not working after all.
691 await update($, isWorking, () => false)
692 throw error
693 }
694 })
695
696 on('turn.complete', async ($, e, next) => {
697 if (e.agentId === undefined) await update($, isWorking, () => false)
698 await refreshHelpers($)
699 await publishCard($)
700 if (e.agentId === undefined) {
701 const queued = (await read($, notes)).filter(note => note.status === 'queued')
702 if (queued.length > 0) await deliverNotes($, queued.map(note => note.item))
703 }
704
705 return next(e)
706 })
707
708 on('session.end', async ($, e, next) => {
709 await $.store.delete(`${SESSION_PREFIX}${e.sessionId}`)
710 lastCard = ''
711 if (e.reason === 'clear') {
712 await update($, board, () => EMPTY)
713 await update($, isWorking, () => false)
714 await update($, activity, () => ({}))
715 }
716
717 return next(e)
718 })
719
720 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
721 const { Box, Text } = $.ui.resolve(e)
722 if (e.props.tool === YOUR_STEPS_TOOL) {
723 return (
724 <Box>
725 <Text dimColor wrap="truncate-end">
726 {summarizeCall(e.props.input)}
727 </Text>
728 </Box>
729 )
730 }
731 if (e.props.tool !== GOALS_TOOL) {
732 const isShown = (await read($, showSteps)) || isForThePerson(e.props.tool, e.props.input)
733
734 return isShown ? next(e) : <Box display="none" />
735 }
736 const goals = parseGoals((e.props.input as Record<string, unknown> | null)?.goals)
737 const done = goals.filter(goal => goal.status === 'done').length
738 const active = goals.find(goal => goal.status === 'active')
739 const waits = goals.some(goal => goal.status === 'waiting')
740
741 return (
742 <Box>
743 <Text dimColor wrap="truncate-end">
744 Overview · {done} of {goals.length} done{active ? ` · ${active.title}` : ''}
745 {waits ? ' · waiting for you' : ''}
746 </Text>
747 </Box>
748 )
749 })
750
751 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
752 const { Box } = $.ui.resolve(e)
753 const { tool, tool_use_id: id } = e.props
754 const isShown =
755 tool !== GOALS_TOOL &&
756 tool !== YOUR_STEPS_TOOL &&
757 ((await read($, showSteps)) ||
758 (tool === 'Artifact' ? (await read($, shownCalls)).includes(id) : isForThePerson(tool, null)))
759
760 return isShown ? next(e) : <Box display="none" />
761 })
762
763 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'plugin' } } }, ($, e, next) => {
764 const { origin, text, isExpanded } = e.props
765 if (origin.kind !== 'plugin' || origin.name !== 'overview' || isExpanded) return next(e)
766 const { Box, Text } = $.ui.resolve(e)
767 const titles = (part: string) =>
768 [...part.matchAll(/^- \S+ · (.+?)(?: \(asked: .*?\))?(?: \(the goal .*\))?$/gm)].map(match => (match[1] ?? '').replace(/ → ".*"$/, ''))
769 const lines = text.split('\n\n').flatMap(part => {
770 const names = titles(part).join(', ')
771 if (part.startsWith('The person answered')) return [`✎ You answered: ${names} · Claude is on it`]
772 if (part.startsWith('The person decided')) {
773 const picks = [...part.matchAll(/→ "(.+)"/g)].map(match => match[1]).join(', ')
774
775 return [`◆ You chose: ${picks} · Claude is on it`]
776 }
777 if (part.startsWith('The person ticked')) return [`☑ You ticked: ${names} · Claude is checking`]
778 if (part.startsWith('The person unticked')) return [`☐ You took back: ${names} · Claude knows`]
779 if (part.startsWith('The person left')) return [noteLine(part)]
780
781 return []
782 })
783
784 return (
785 <Box flexDirection="column">
786 {lines.map((line, index) => (
787 <Text key={`said:${index}`} dimColor wrap="wrap">
788 {line}
789 </Text>
790 ))}
791 </Box>
792 )
793 })
794
795 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
796 const { Box } = $.ui.resolve(e)
797
798 return (await read($, showSteps)) ? next(e) : <Box display="none" />
799 })
800
801 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
802 (await read($, showSteps)) ? next(e) : next({ ...e, props: { ...e.props, hint: '' } }),
803 )
804
805 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
806 if (await read($, showSteps)) return next(e)
807 const active = (await read($, board)).goals.find(goal => goal.status === 'active')
808 if (active !== undefined) return next({ ...e, props: { ...e.props, message: active.title } })
809 if (e.surface === 'desktop') return next({ ...e, props: { ...e.props, message: 'Working' } })
810
811 return next(e)
812 })
813
814 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
815 const table = $.ui.resolve(e)
816 const { Box, Button, Link, Markdown, Text } = table
817 // The table answers for every element name; the phone app draws no text field yet.
818 const Input = e.surface !== 'mobile' && 'Input' in table ? table.Input : undefined
819 const map = await read($, projectMap)
820 const feed = await read($, changes)
821 const projectName = await read($, projectLabel)
822 const { summary, goals, earlier }: Board = await read($, board)
823 const working = await read($, isWorking)
824 const stepsShown = await read($, showSteps)
825 const mine = await read($, yourSteps)
826 const others = await read($, otherProjects)
827 const helping = await read($, helpers)
828 const sessions = await read($, otherSessions)
829 const log = await read($, activity)
830 const opened = await read($, openGoals)
831 const open = await read($, unfolded)
832 const expanded = await read($, openedSteps)
833 const editBox = await read($, editing)
834 const problem = await read($, editProblem)
835 const itemNotes = await read($, notes)
836 const own = await read($, project)
837 const clock = await $.clock.now()
838 /** Room inside a card: its border and padding take four cells. */
839 const inner = Math.max(12, e.props.bodyColumns - 4)
840 const projectTitle = projectName !== '' ? projectName.charAt(0).toUpperCase() + projectName.slice(1) : 'This project'
841
842 /**
843 * One meaning per colour and icon, everywhere in the pane: Claude's work, what needs the person,
844 * what is late, soon, new, later, done, the plan, notes to Claude, and what happens elsewhere.
845 */
846 const TONE = {
847 claude: { color: 'claude', icon: '●' },
848 call: { color: 'error', icon: '◆' },
849 overdue: { color: 'error', icon: '⚠' },
850 soon: { color: 'warning', icon: '⏱' },
851 waiting: { color: 'warning', icon: '◐' },
852 fresh: { color: 'suggestion', icon: '✦' },
853 later: { color: 'inactive', icon: '○' },
854 done: { color: 'success', icon: '✓' },
855 plan: { color: 'permission', icon: '◉' },
856 note: { color: 'suggestion', icon: '✎' },
857 elsewhere: { color: 'promptBorder', icon: '⋯' },
858 } as const
859 type Tone = keyof typeof TONE
860
861 const openSteps = mine.filter(step => step.status !== 'done')
862 const groups = groupSteps(openSteps, clock)
863 const confirmed = mine
864 .filter(step => step.status === 'done')
865 .sort((a, b) => (b.doneAt ?? 0) - (a.doneAt ?? 0))
866 .slice(0, 3)
867 const overdue = groups.find(group => group.id === 'overdue')?.steps.length ?? 0
868
869 const planned = goals.filter(goal => goal.status === 'planned')
870 const done = goals
871 .filter(goal => goal.status === 'done')
872 .sort((a, b) => (b.doneAt ?? 0) - (a.doneAt ?? 0))
873 const isAllDone = goals.length > 0 && done.length === goals.length
874 const isBlockedOnYou = goals.some(goal => isBlocked(goal, mine))
875 const activeGoal = goals.find(goal => goal.status === 'active')
876 const waitingGoal = goals.find(goal => isBlocked(goal, mine))
877 const percent = goals.length === 0 ? 0 : Math.round(((activeGoal?.progress !== undefined ? done.length + activeGoal.progress / 100 : done.length) / goals.length) * 100)
878
879 const state = isAllDone
880 ? { tone: 'done' as Tone, text: 'ALL DONE' }
881 : working
882 ? { tone: 'claude' as Tone, text: 'WORKING' }
883 : isBlockedOnYou
884 ? { tone: 'waiting' as Tone, text: 'WAITING FOR YOU' }
885 : helping.length > 0
886 ? { tone: 'elsewhere' as Tone, text: 'HELPERS WORKING' }
887 : goals.length > 0
888 ? { tone: 'later' as Tone, text: 'PAUSED · YOUR TURN' }
889 : { tone: 'later' as Tone, text: 'READY WHEN YOU ARE' }
890
891 /** "14:05" today, "Mon 14:05" another day. */
892 const clockText = (at: number) => {
893 const date = new Date(at)
894 const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
895
896 return dayOf(at) === dayOf(clock) ? time : `${dayName(dayOf(at)).split(' ')[0]} ${time}`
897 }
898
899 /** A section card: an icon and a bold coloured title, a dim note on the right, then the body. */
900 const card = (key: string, tone: Tone, title: string, note: string | undefined, body: RenderChildren[]) => (
901 <Box key={`card:${key}`} flexDirection="column" borderStyle="round" borderColor={TONE[tone].color} paddingX={1} marginTop={1}>
902 <Box key={`head:${key}`} justifyContent="space-between">
903 <Text bold color={TONE[tone].color} wrap="truncate-end">
904 {TONE[tone].icon} {title}
905 </Text>
906 {note !== undefined && <Text dimColor> {note}</Text>}
907 </Box>
908 {body}
909 </Box>
910 )
911
912 /** A small heading inside a card, in the colour of its meaning. */
913 const label = (key: string, text: string, tone?: Tone) => (
914 <Box key={`label:${key}`} marginTop={1}>
915 <Text bold color={tone ? TONE[tone].color : undefined} dimColor={tone === undefined || tone === 'later'}>
916 {tone ? `${TONE[tone].icon} ` : ''}
917 {text}
918 </Text>
919 </Box>
920 )
921
922 /** The fold switch of a part of the pane, with its count. */
923 const fold = (part: string, text: string) => (
924 <Box key={`fold:${part}`} marginTop={1}>
925 <Button key={`fold:${part}`} plain dimColor label={`${open.includes(part) ? '▾' : '▸'} ${text}`} onPress={() => toggleFold($, part)} />
926 </Box>
927 )
928
929 /**
930 * One item's line: an optional control (tick or icon) on the left, the title as plain text in the middle,
931 * so it can be selected, and the ✎ edit button on the right, apart from the text.
932 */
933 const itemLine = (where: string, ref: ItemRef, lead: RenderChildren, title: RenderChildren, trail?: RenderChildren) => (
934 <Box key={`line:${where}:${ref.item}`} flexDirection="row">
935 {lead}
936 <Box key={`title:${where}:${ref.item}`} flexGrow={1} flexShrink={1}>
937 {title}
938 </Box>
939 {trail}
940 <Box key={`edit-at:${where}:${ref.item}`} marginLeft={1}>
941 <Button
942 key={`edit:${where}:${ref.item}`}
943 plain
944 dimColor={editBox !== `${where}::${ref.item}`}
945 label="✎"
946 onPress={() => toggleEdit($, `${where}::${ref.item}`)}
947 />
948 </Box>
949 </Box>
950 )
951
952 /** The edit box of an item while open, and the state of the note left on it. */
953 const itemEdit = (where: string, ref: ItemRef) => {
954 const box = `${where}::${ref.item}`
955 const note = itemNotes.find(one => one.item === ref.item)
956 const isOpen = editBox === box
957 const status =
958 note === undefined
959 ? null
960 : note.status === 'sending'
961 ? <Text color={TONE.note.color} wrap="wrap">✎ Sending your note to Claude…</Text>
962 : note.status === 'queued'
963 ? <Text color={TONE.note.color} wrap="wrap">✎ Note queued · Claude reads it at its next step</Text>
964 : note.status === 'sent'
965 ? <Text color={TONE.note.color} wrap="wrap">✎ Note sent · Claude is reworking this</Text>
966 : (
967 <Box key={`note-error:${where}:${ref.item}`} flexDirection="column">
968 <Text color="error" wrap="wrap">
969 ⚠ Your note was not sent{note.error ? `: ${note.error}` : ''}
970 </Text>
971 <Box key={`note-actions:${where}:${ref.item}`} columnGap={2}>
972 <Button key={`note-retry:${where}:${ref.item}`} plain label="↻ Retry" onPress={() => retryNote($, ref.item)} />
973 <Button key={`note-drop:${where}:${ref.item}`} plain dimColor label="✕ Discard" onPress={() => dropNote($, ref.item)} />
974 </Box>
975 </Box>
976 )
977
978 return (
979 <Box key={`edit-box:${where}:${ref.item}`} flexDirection="column" paddingLeft={2}>
980 {note !== undefined && !isOpen && (
981 <Box key={`note:${where}:${ref.item}`} flexDirection="column">
982 {status}
983 <Text dimColor wrap="wrap">
984 « {note.text} »
985 </Text>
986 </Box>
987 )}
988 {isOpen && (
989 <Box key={`editor:${where}:${ref.item}`} flexDirection="column" borderStyle="round" borderColor={TONE.note.color} paddingX={1}>
990 <Box key={`editor-head:${where}:${ref.item}`} justifyContent="space-between">
991 <Text bold color={TONE.note.color}>
992 ✎ Tell Claude what to change
993 </Text>
994 <Button key={`edit-close:${where}:${ref.item}`} plain dimColor role="dismiss" label="✕" onPress={() => closeEdit($)} />
995 </Box>
996 <Text dimColor wrap="wrap">
997 No longer relevant, needs rework, wrong date… Claude reworks the item with your note.
998 </Text>
999 {Input !== undefined ? (
1000 <Input
1001 key={`note-input:${where}:${ref.item}`}
1002 placeholder="Your note for Claude…"
1003 value={note?.status === 'error' ? note.text : undefined}
1004 submitLabel="send to Claude"
1005 autoFocus
1006 onSubmit={words => sendNote($, box, ref, words)}
1007 />
1008 ) : (
1009 <Text color="warning" wrap="wrap">
1010 This app has no text box: write your note in the conversation.
1011 </Text>
1012 )}
1013 {problem === box && (
1014 <Text color="error" wrap="wrap">
1015 ⚠ Type what should change first, or close the box with ✕.
1016 </Text>
1017 )}
1018 </Box>
1019 )}
1020 </Box>
1021 )
1022 }
1023
1024 /** What a step's deadline says, in the colour of how pressing it is. */
1025 const deadline = (step: YourStep) => {
1026 const day = step.due ?? step.decideBy?.slice(0, 10)
1027 if (day === undefined || step.status !== 'todo') return null
1028 const { text, isPressing } = dueText(day, clock)
1029 const tone: Tone = text.startsWith('overdue') ? 'overdue' : isPressing ? 'soon' : 'later'
1030
1031 return (
1032 <Text key={`due:${step.id}`} color={TONE[tone].color} dimColor={tone === 'later'} wrap="wrap">
1033 {tone === 'later' ? '⏱' : TONE[tone].icon} {text}
1034 </Text>
1035 )
1036 }
1037
1038 /** The "More" switch under a step, and what it opens: the explanation and its links. */
1039 const stepMore = (step: YourStep) => {
1040 const links = step.links ?? []
1041 if (step.more === undefined && links.length === 0) return null
1042 const isOpen = expanded.includes(step.id)
1043
1044 return (
1045 <Box key={`more:${step.id}`} flexDirection="column">
1046 <Button
1047 key={`more:${step.id}`}
1048 plain
1049 dimColor={!isOpen}
1050 label={isOpen ? '▾ Less' : links.length > 0 && step.more === undefined ? '▸ Where to read more' : '▸ More'}
1051 onPress={() => toggleStepOpen($, step.id)}
1052 />
1053 {isOpen && (
1054 <Box key={`more-card:${step.id}`} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
1055 {step.more !== undefined && <Markdown key={`more-text:${step.id}`} text={step.more} />}
1056 {links.length > 0 && (
1057 <Box key={`more-links:${step.id}`} flexDirection="column" marginTop={step.more !== undefined ? 1 : 0}>
1058 {links.map((link, index) => (
1059 <Box key={`link:${step.id}:${index}`}>
1060 <Text color="suggestion">↗ </Text>
1061 <Link href={link.url} label={link.label} />
1062 </Box>
1063 ))}
1064 </Box>
1065 )}
1066 </Box>
1067 )}
1068 </Box>
1069 )
1070 }
1071
1072 /** The lines under a step: when, where, what Claude said, where it stands. */
1073 const stepAbout = (step: YourStep, pickedUpBy: string, isBrief: boolean) => {
1074 const waited = step.due === undefined && step.status === 'todo' ? waitedText(step, clock) : undefined
1075 const told = step.answer !== undefined ? 'Claude knows · acting on it' : 'Claude knows · confirming'
1076 const isHeard = step.heard === 'checked' && (step.answer ?? '') === (step.heardAnswer ?? '')
1077
1078 return (
1079 <Box key={`about:${step.id}`} flexDirection="column" paddingLeft={2}>
1080 {deadline(step)}
1081 {step.choices !== undefined && step.defaultChoice !== undefined && step.status === 'todo' && (
1082 <Text dimColor wrap="wrap">
1083 No answer keeps: {step.defaultChoice}
1084 </Text>
1085 )}
1086 {waited !== undefined && (
1087 <Text dimColor wrap="wrap">
1088 {waited}
1089 </Text>
1090 )}
1091 {!isBrief && step.detail !== undefined && step.status === 'todo' && (
1092 <Text dimColor wrap="wrap">
1093 {step.detail}
1094 </Text>
1095 )}
1096 {step.note !== undefined && step.status === 'todo' && (
1097 <Text color="warning" wrap="wrap">
1098 ↺ Claude: {step.note}
1099 </Text>
1100 )}
1101 {stepMore(step)}
1102 {step.status === 'checked' && (
1103 <Text color={isHeard ? TONE.done.color : TONE.note.color} wrap="wrap">
1104 {isHeard ? `✓ ${told}` : `${step.answer === undefined ? 'Ticked' : step.asks !== undefined ? 'Answered' : 'Picked'} · ${pickedUpBy}`}
1105 </Text>
1106 )}
1107 </Box>
1108 )
1109 }
1110
1111 /** One step: its control, its title as text, its edit button, then how to answer it and what stands under it. */
1112 const stepItem = (step: YourStep, at: string, where: string, hotkey: string | undefined, pickedUpBy: string, isBrief: boolean) => {
1113 const ref: ItemRef = { item: `step:${step.id}`, kind: 'step', title: step.title, stepId: step.id, stepKey: at }
1114 const kind = answerKind(step)
1115 const isChecked = step.status === 'checked'
1116 const lead =
1117 kind === 'tick' ? (
1118 <Box key={`tick-at:${where}:${step.id}`} marginRight={1}>
1119 <Button key={`${where === 'other' ? 'other' : 'tick'}:${step.id}`} plain label={isChecked ? '☑' : '☐'} hotkey={hotkey} onPress={() => tickStep($, at, step.id)} />
1120 </Box>
1121 ) : (
1122 <Text key={`icon:${where}:${step.id}`} color={isChecked ? TONE.done.color : kind === 'pick' ? TONE.call.color : TONE.note.color}>
1123 {isChecked ? '☑ ' : kind === 'pick' ? '◆ ' : '⌨ '}
1124 </Text>
1125 )
1126 const title = (
1127 <Text bold={!isBrief} dimColor={isChecked} wrap="wrap">
1128 {step.title}
1129 </Text>
1130 )
1131
1132 return (
1133 <Box key={`row:${where}:${step.id}`} flexDirection="column" marginTop={isBrief ? 0 : 1}>
1134 {itemLine(where, ref, lead, title)}
1135 {kind === 'pick' && (
1136 <Box key={`choices:${step.id}`} flexDirection="row" flexWrap="wrap" columnGap={2} paddingLeft={2}>
1137 {(step.choices ?? []).map((choice, index) => (
1138 <Button
1139 key={`${where === 'other' ? 'other-pick' : 'pick'}:${step.id}:${index}`}
1140 plain
1141 label={`${step.answer === choice ? '●' : '○'} ${choice}`}
1142 onPress={() => chooseStep($, at, step.id, choice)}
1143 />
1144 ))}
1145 </Box>
1146 )}
1147 {kind === 'type' && (
1148 <Box key={`answer-box:${step.id}`} flexDirection="column" paddingLeft={2}>
1149 {step.answer !== undefined && (
1150 <Text wrap="wrap">
1151 <Text dimColor>Your answer: </Text>« {step.answer} »
1152 </Text>
1153 )}
1154 {Input !== undefined ? (
1155 <Input
1156 key={`${where === 'other' ? 'other-answer' : 'answer'}:${step.id}`}
1157 placeholder={step.answer !== undefined ? 'Type to change your answer' : `${step.asks}…`}
1158 submitLabel="send to Claude"
1159 onSubmit={words => answerStep($, at, step.id, words)}
1160 />
1161 ) : (
1162 step.answer === undefined && (
1163 <Text dimColor wrap="wrap">
1164 Answer in the conversation: {step.asks}
1165 </Text>
1166 )
1167 )}
1168 </Box>
1169 )}
1170 {itemEdit(where, ref)}
1171 {stepAbout(step, pickedUpBy, isBrief)}
1172 </Box>
1173 )
1174 }
1175
1176 /** A step that needs the person soon, in its own card framed in the colour of its urgency. */
1177 const stepCard = (step: YourStep, tone: Tone, hotkey: string | undefined) => (
1178 <Box key={`step-card:${step.id}`} flexDirection="column" borderStyle="round" borderColor={TONE[tone].color} borderDimColor paddingX={1} marginTop={1}>
1179 {stepItem(step, own, 'move', hotkey, 'Claude will pick it up', false)}
1180 </Box>
1181 )
1182
1183 /** The "what was done" switch and list under a goal. */
1184 const goalActions = (goal: Goal, where: string) => {
1185 const actions = log[goal.title]
1186 const count = countOf(actions)
1187 if (count === 0) return null
1188 const isOpen = opened.includes(goal.title)
1189
1190 return (
1191 <Box key={`acts:${where}:${goal.title}`} flexDirection="column" paddingLeft={2}>
1192 <Button
1193 key={`acts:${where}:${goal.title}`}
1194 plain
1195 dimColor
1196 label={`${isOpen ? '▾' : '▸'} ${count === 1 ? '1 thing done' : `${count} things done`}`}
1197 onPress={() => toggleGoalOpen($, goal.title)}
1198 />
1199 {isOpen &&
1200 (actions ?? []).map((action, index) => (hooks/notes.ts 58 lines1import type { ItemKind, ItemNote } from '../types'
2
3/** How long a delivered note stays on its item when Claude has not reworked it. */
4export const NOTE_KEPT_MS = 2 * 60 * 60 * 1000
5const NOTE_LIMIT = 2000
6
7const KIND_WORD: Record<ItemKind, string> = { goal: 'goal', step: 'step', part: 'part of the project map', milestone: 'milestone' }
8
9const TOOL_FOR: Record<ItemKind, string> = {
10 goal: 'mcp__overview__goals (send the whole list again)',
11 step: 'mcp__overview__your_steps ("update", "remove" or "add")',
12 part: 'mcp__overview__project_map ("parts")',
13 milestone: 'mcp__overview__project_map ("milestones")',
14}
15
16/** What Claude reads for the notes the person left on items of the pane. */
17export const noteMessage = (notes: readonly ItemNote[]) => {
18 const lines = notes.map(
19 note => `- the ${KIND_WORD[note.kind]} « ${note.title} »${note.stepId ? ` (step ${note.stepId})` : ''}:\n « ${note.text} »`,
20 )
21 const tools = [...new Set(notes.map(note => TOOL_FOR[note.kind]))].join(', ')
22
23 return [
24 `The person left ${notes.length === 1 ? 'a note' : 'notes'} on ${notes.length === 1 ? 'an item' : 'items'} of the Overview pane:`,
25 ...lines,
26 `Take ${notes.length === 1 ? 'it' : 'them'} into account now: rework, reword, reorder or remove the item as the note asks, with ${tools}. Then say in one line what you changed.`,
27 ].join('\n')
28}
29
30/** The trimmed note text, or undefined when there is nothing to send. */
31export const noteText = (words: string) => {
32 const text = words.trim()
33
34 return text === '' ? undefined : text.slice(0, NOTE_LIMIT)
35}
36
37/** Replaces any earlier note on the same item. */
38export const putNote = (list: readonly ItemNote[], note: ItemNote) => [...list.filter(one => one.item !== note.item), note]
39
40export const setNoteStatus = (list: readonly ItemNote[], items: readonly string[], status: ItemNote['status'], error?: string) =>
41 list.map(note => {
42 if (!items.includes(note.item)) return note
43 const { error: _error, ...rest } = note
44
45 return error === undefined ? { ...rest, status } : { ...rest, status, error }
46 })
47
48/** Drops the delivered notes on items Claude has just reworked, and delivered notes past their time. */
49export const settleNotes = (list: readonly ItemNote[], reworked: (note: ItemNote) => boolean, now: number) =>
50 list.filter(note => !(note.status === 'sent' && (reworked(note) || now - note.at > NOTE_KEPT_MS)))
51
52/** The conversation line for the pane's note message. */
53export const noteLine = (message: string) => {
54 const titles = [...message.matchAll(/^- the .+? « (.+) »(?: \(step \S+\))?:$/gm)].map(match => match[1])
55
56 return `✎ Your note on « ${titles.join(' », « ')} » · Claude is on it`
57}
58hooks/map.ts 170 lines1import type { Change, Milestone, Part, ProjectMap } from '../types'
2
3export const MAP_TOOL = 'mcp__overview__project_map'
4export const MAP_PREFIX = 'map:'
5export const CHANGES_PREFIX = 'changes:'
6export const EMPTY_MAP: ProjectMap = { milestones: [], parts: [], updatedAt: 0 }
7
8const MILESTONE_STATUSES: readonly Milestone['status'][] = ['done', 'active', 'next', 'later']
9const PART_STATUSES: readonly Part['status'][] = ['done', 'active', 'stuck', 'waiting', 'todo']
10const CHANGES_KEPT = 40
11const DATE = /^\d{4}-\d{2}-\d{2}$/
12
13export const MAP_DESCRIPTION = [
14 "Keeps the project's map in the Overview pane, shared by every session of this project: its milestones (a timeline) and its parts (a grid of cards).",
15 'Send "milestones" and/or "parts"; each list you send replaces the stored one, the other stays. Call it with nothing to read the map.',
16 'Titles and notes in plain words a non-engineer understands, no file names or code.',
17].join(' ')
18
19export const MAP_SCHEMA = {
20 type: 'object',
21 properties: {
22 milestones: {
23 type: 'array',
24 description: 'The stages, in order, 3 to 7: exactly one "active" while work goes on.',
25 items: {
26 type: 'object',
27 properties: {
28 title: { type: 'string', description: 'Short: "Public beta", "Launch".' },
29 status: { type: 'string', enum: MILESTONE_STATUSES },
30 date: { type: 'string', description: 'YYYY-MM-DD: when reached (done) or planned.' },
31 },
32 required: ['title', 'status'],
33 },
34 },
35 parts: {
36 type: 'array',
37 description: 'The parts of the project, 4 to 12.',
38 items: {
39 type: 'object',
40 properties: {
41 title: { type: 'string', description: 'Short: "iOS app", "Mail", "Store pages".' },
42 status: { type: 'string', enum: PART_STATUSES },
43 progress: { type: 'number', minimum: 0, maximum: 100 },
44 note: { type: 'string', description: 'One short line: what is left, what it waits on, when it shipped.' },
45 milestone: { type: 'string', description: 'The milestone it is needed for, by its title.' },
46 },
47 required: ['title', 'status'],
48 },
49 },
50 },
51}
52
53export const MAP_GUIDE = `
54## Project map
55
56For a project with work that spans days, keep its map with the ${MAP_TOOL} tool: the milestones (3 to 7, in order, one "active") and the parts (4 to 12 cards: status "done", "active" with "progress", "stuck", "waiting" on the person, or "todo"; a one-line "note"; the "milestone" it is needed for). Read it first when you start on the project (call it with nothing), and update it when a part or a milestone moves; the pane shows what changed. Plain words only.`
57
58const text = (value: unknown, limit = 120) =>
59 typeof value === 'string' && value.trim() !== '' ? value.trim().slice(0, limit) : undefined
60
61export type MapInput = { milestones?: Milestone[]; parts?: Part[] }
62
63/** Reads the model's map loosely: rows without a title go, unknown statuses fall back. */
64export const parseMap = (raw: Record<string, unknown>): MapInput => {
65 const input: MapInput = {}
66 if (Array.isArray(raw.milestones)) {
67 input.milestones = raw.milestones.slice(0, 9).flatMap(item => {
68 const row = (item ?? {}) as Record<string, unknown>
69 const title = text(row.title, 40)
70 if (title === undefined) return []
71 const status = MILESTONE_STATUSES.includes(row.status as Milestone['status']) ? (row.status as Milestone['status']) : 'later'
72 const milestone: Milestone = { title, status }
73 const date = text(row.date)
74 if (date !== undefined && DATE.test(date)) milestone.date = date
75
76 return [milestone]
77 })
78 }
79 if (Array.isArray(raw.parts)) {
80 input.parts = raw.parts.slice(0, 16).flatMap(item => {
81 const row = (item ?? {}) as Record<string, unknown>
82 const title = text(row.title, 40)
83 if (title === undefined) return []
84 const status = PART_STATUSES.includes(row.status as Part['status']) ? (row.status as Part['status']) : 'todo'
85 const part: Part = { title, status }
86 if (typeof row.progress === 'number' && Number.isFinite(row.progress) && status === 'active') {
87 part.progress = Math.max(0, Math.min(100, Math.round(row.progress)))
88 }
89 const note = text(row.note, 90)
90 if (note !== undefined) part.note = note
91 const milestone = text(row.milestone, 40)
92 if (milestone !== undefined) part.milestone = milestone
93
94 return [part]
95 })
96 }
97
98 return input
99}
100
101export const readMap = (stored: unknown): ProjectMap => {
102 if (typeof stored !== 'object' || stored === null) return EMPTY_MAP
103 const { milestones, parts, updatedAt } = stored as Partial<ProjectMap>
104
105 return {
106 milestones: Array.isArray(milestones) ? milestones : [],
107 parts: Array.isArray(parts) ? parts : [],
108 updatedAt: typeof updatedAt === 'number' ? updatedAt : 0,
109 }
110}
111
112export const readChanges = (stored: unknown): Change[] => (Array.isArray(stored) ? (stored as Change[]) : [])
113
114const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
115
116const partWord = (part: Part) =>
117 part.status === 'active'
118 ? part.progress !== undefined
119 ? `${part.progress}%`
120 : 'In progress'
121 : { done: 'Done', stuck: 'Stuck', waiting: 'Waiting on you', todo: 'Not started' }[part.status]
122
123/** What moved between two maps, as feed lines: parts and milestones whose status (or progress) changed. */
124export const diffMaps = (before: ProjectMap, after: ProjectMap, now: number): Change[] => {
125 const changes: Change[] = []
126 for (const part of after.parts) {
127 const was = before.parts.find(one => same(one.title, part.title))
128 if (was === undefined) {
129 if (before.parts.length > 0) changes.push({ at: now, text: `${part.title} → ${partWord(part)}`, tone: 'info' })
130 continue
131 }
132 if (was.status === part.status && was.progress === part.progress) continue
133 const tone: Change['tone'] =
134 part.status === 'done' ? 'done' : part.status === 'stuck' ? 'stuck' : part.status === 'waiting' ? 'you' : 'progress'
135 const from = was.status === 'active' && part.status === 'active' && was.progress !== undefined ? `${was.progress}% → ` : '→ '
136 changes.push({ at: now, text: `${part.title} ${from}${partWord(part)}`, tone })
137 }
138 for (const milestone of after.milestones) {
139 const was = before.milestones.find(one => same(one.title, milestone.title))
140 if (was === undefined || was.status === milestone.status) continue
141 if (milestone.status === 'done') changes.push({ at: now, text: `${milestone.title} reached`, tone: 'done' })
142 else if (milestone.status === 'active') changes.push({ at: now, text: `Now on ${milestone.title}`, tone: 'progress' })
143 }
144
145 return changes
146}
147
148/** Adds lines to the feed, newest first, keeping it bounded. */
149export const addChanges = (feed: readonly Change[], added: readonly Change[]) =>
150 [...[...added].reverse(), ...feed].slice(0, CHANGES_KEPT)
151
152/** Applies a map input: each list sent replaces the stored one. */
153export const applyMap = (map: ProjectMap, input: MapInput, now: number): ProjectMap => ({
154 milestones: input.milestones ?? map.milestones,
155 parts: input.parts ?? map.parts,
156 updatedAt: now,
157})
158
159/** The map as the tool answers it to Claude. */
160export const describeMap = (map: ProjectMap) => {
161 if (map.milestones.length === 0 && map.parts.length === 0) return 'No map for this project yet.'
162 const milestones = map.milestones.map(one => `- ${one.title} · ${one.status}${one.date ? ` · ${one.date}` : ''}`)
163 const parts = map.parts.map(
164 one =>
165 `- ${one.title} · ${partWord(one)}${one.note ? ` · ${one.note}` : ''}${one.milestone ? ` · for ${one.milestone}` : ''}`,
166 )
167
168 return ['Milestones:', ...milestones, '', 'Parts:', ...parts].join('\n')
169}
170hooks/activity.ts 138 lines1import type { Action, Board, SessionCard, YourStep } from '../types'
2import { isBlocked } from './goals'
3
4export const SESSION_PREFIX = 'session:'
5/** A session that has not published for this long is gone (it crashed or was closed hard). */
6export const SESSION_STALE_MS = 90_000
7/** Gone sessions' cards are deleted after this long. */
8export const SESSION_FORGET_MS = 60 * 60 * 1000
9/** A session republishes an unchanged card this often, as a heartbeat. */
10export const HEARTBEAT_MS = 30_000
11/** The bucket for actions taken before Claude set any active goal. */
12export const BEFORE_PLAN = 'Getting started'
13
14const ACTIONS_PER_GOAL = 30
15const GOALS_WITH_ACTIVITY = 40
16
17const base = (path: unknown) => (typeof path === 'string' ? (path.split('/').pop() ?? path) : 'a file')
18
19const host = (url: unknown) => {
20 if (typeof url !== 'string') return 'a page'
21 const match = /^https?:\/\/([^/]+)/.exec(url)
22
23 return match?.[1] ?? url
24}
25
26const humanize = (name: string) => name.replace(/[_-]+/g, ' ').trim()
27
28/**
29 * One line for what a tool call did, as a reader of the pane wants it; null for calls that are
30 * bookkeeping (loading a tool, the overview's own tools).
31 */
32export const describeAction = (tool: string, input: unknown): string | null => {
33 const args = (input ?? {}) as Record<string, unknown>
34 const text = (value: unknown) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined)
35 if (tool.startsWith('mcp__overview__') || tool === 'ToolSearch' || tool === 'TodoWrite') return null
36 switch (tool) {
37 case 'Bash':
38 case 'PowerShell':
39 return text(args.description) ?? 'Ran a command'
40 case 'Read':
41 return `Read ${base(args.file_path)}`
42 case 'Write':
43 return `Wrote ${base(args.file_path)}`
44 case 'Edit':
45 case 'MultiEdit':
46 case 'NotebookEdit':
47 return `Changed ${base(args.file_path ?? args.notebook_path)}`
48 case 'Grep':
49 case 'Glob':
50 return 'Searched the code'
51 case 'WebFetch':
52 return `Read ${host(args.url)}`
53 case 'WebSearch':
54 return `Searched the web${text(args.query) ? `: ${text(args.query)}` : ''}`
55 case 'Agent':
56 case 'Task':
57 return `Asked a helper${text(args.description) ? `: ${text(args.description)}` : ''}`
58 case 'Skill':
59 return `Used the ${text(args.skill) ?? ''} skill`.replace(' ', ' ')
60 case 'Artifact':
61 return 'Worked on a published page'
62 case 'AskUserQuestion':
63 return 'Asked you a question'
64 case 'SendUserFile':
65 return 'Sent you a file'
66 }
67 if (tool.startsWith('mcp__Claude_Browser__') || tool.startsWith('mcp__claude-in-chrome__')) return 'Used the browser'
68 if (tool.startsWith('mcp__computer-use__')) return 'Used the computer'
69 if (tool.startsWith('mcp__Claude_Code_iOS_Simulator__')) return 'Used the iPhone simulator'
70 const mcp = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(tool)
71 if (mcp) return `Used ${humanize(mcp[2] ?? tool)}`
72
73 return humanize(tool)
74}
75
76/** Adds one action to a goal's list, counting a repeat of the last line instead of adding it. */
77export const record = (
78 activity: Record<string, Action[]>,
79 goal: string,
80 label: string,
81 failed: boolean,
82): Record<string, Action[]> => {
83 const list = activity[goal] ?? []
84 const last = list[list.length - 1]
85 const next =
86 last !== undefined && last.label === label && Boolean(last.failed) === failed
87 ? [...list.slice(0, -1), { ...last, count: last.count + 1 }]
88 : [...list, failed ? { label, count: 1, failed } : { label, count: 1 }].slice(-ACTIONS_PER_GOAL)
89 const kept = Object.entries({ ...activity, [goal]: next }).slice(-GOALS_WITH_ACTIVITY)
90
91 return Object.fromEntries(kept)
92}
93
94/** Keeps only the goals the pane can still show. */
95export const forgetGone = (activity: Record<string, Action[]>, board: Board) => {
96 const titles = new Set([BEFORE_PLAN, ...board.goals.map(goal => goal.title), ...board.earlier.map(goal => goal.title)])
97
98 return Object.fromEntries(Object.entries(activity).filter(([title]) => titles.has(title)))
99}
100
101export const countOf = (actions: readonly Action[] | undefined) =>
102 (actions ?? []).reduce((sum, action) => sum + action.count, 0)
103
104/** What this session publishes about itself for the other sessions' panes. */
105export const cardFor = (
106 id: string,
107 project: string,
108 board: Board,
109 working: boolean,
110 steps: readonly YourStep[],
111 now: number,
112): SessionCard => {
113 const { summary, goals } = board
114 const done = goals.filter(goal => goal.status === 'done').length
115 const active = goals.find(goal => goal.status === 'active')?.title
116 const state: SessionCard['state'] =
117 goals.length > 0 && done === goals.length
118 ? 'done'
119 : working
120 ? 'working'
121 : goals.some(goal => isBlocked(goal, steps))
122 ? 'waiting'
123 : goals.length > 0
124 ? 'paused'
125 : 'idle'
126
127 return { id, project, summary, ...(active ? { active } : {}), done, total: goals.length, state, updatedAt: now }
128}
129
130/** The card's words in another session's pane. */
131export const cardLine = (card: SessionCard) => {
132 const what = card.summary || card.active || (card.state === 'idle' ? 'No task right now' : 'A task')
133 const progress = card.total > 0 ? ` · ${card.done} of ${card.total} done` : ''
134 const state = { working: 'working', waiting: 'waiting for you', paused: 'paused', done: 'all done', idle: 'idle' }[card.state]
135
136 return `${what}${progress} · ${state}`
137}
138hooks/goals.ts 141 lines1import type { Board, Goal, GoalStatus, YourStep } from '../types'
2
3export const GOALS_TOOL = 'mcp__overview__goals'
4export const STATUSES: readonly GoalStatus[] = ['planned', 'active', 'waiting', 'done']
5const EARLIER_KEPT = 30
6
7export const EMPTY: Board = { summary: '', goals: [], earlier: [] }
8
9export const GOALS_DESCRIPTION = [
10 'Updates the Overview pane: the person sees only these goals, never your steps.',
11 'Send the whole list each time; it replaces the previous one.',
12 'Each title is a result the person gets, in plain words, without file names, commands, tools or code.',
13].join(' ')
14
15export const GOALS_SCHEMA = {
16 type: 'object',
17 properties: {
18 summary: { type: 'string', description: 'One short line: the overall aim, as a result.' },
19 goals: {
20 type: 'array',
21 items: {
22 type: 'object',
23 properties: {
24 title: { type: 'string', description: 'The result, in plain words.' },
25 status: { type: 'string', enum: STATUSES },
26 progress: { type: 'number', minimum: 0, maximum: 100 },
27 waitingFor: {
28 type: 'array',
29 items: { type: 'string' },
30 description: 'For a "waiting" goal: the ids of the person\'s steps it waits on.',
31 },
32 },
33 required: ['title', 'status'],
34 },
35 },
36 },
37 required: ['goals'],
38}
39
40export const GOALS_GUIDE = `# Overview pane
41
42The person follows your work in an Overview pane that shows outcomes only. Keep it current with the ${GOALS_TOOL} tool.
43
44- When a request needs more than a couple of steps, call it before starting, with the top-level goals (usually 2 to 6).
45- Write each goal as the result the person will get, in plain words a non-engineer understands, in the language they write in. No file names, commands, tool names, code identifiers, test names or technical steps. Good: "Parents can pay in euros". Bad: "Add EUR price to PaywallStore.swift and run xcodebuild". Titles that read as technical steps are sent back.
46- Exactly one goal is "active" while you work; the rest are "planned", "waiting" or "done". Mark a goal "done" as soon as its result is reached, and set "progress" (0 to 100) on the active goal when you can estimate it.
47- A goal that cannot go on until the person does something is "waiting", with "waitingFor" naming the ids of their steps (add those first with the steps tool). Go on with other goals meanwhile, or tell the person you are waiting.
48- Send the whole list every time, finished goals included; it replaces the previous list. A new request starts a new list.
49- "summary" is one short line naming the overall aim.
50- The person can leave a note on any goal, step or map item from the pane (its ✎ button). It reaches you as « The person left a note… »: rework, reword, reorder or remove that item as asked, then say in one line what you changed.
51- Before your final answer, mark every reached goal "done". Skip the tool for quick answers and questions.`
52
53export const HIDDEN_NOTE = `
54- The person does not see your tool calls, only this pane and your messages. When you write to them mid-task, say what changed for them, not which files or commands you used.`
55
56/** The engine's own names that look like code but are plain words. */
57const BRANDS = /^(macOS|iPadOS|watchOS|tvOS|visionOS)$/
58
59const TECHNICAL: readonly RegExp[] = [
60 /`/,
61 /\b[\w-]+\.(swift|kts?|tsx?|jsx?|mjs|json|md|ya?ml|sh|py|sql|xml|gradle|plist|html|css|toml|lock|env|xcodeproj|pbxproj)\b/i,
62 /(^|\s)(~|\.{1,2})?\/[\w.-]+\//,
63 /\b(xcodebuild|gradlew|npm|npx|pnpm|yarn|git|supabase|fastlane|deno|curl|grep|sed|jq|tsc|swiftlint|simctl|adb)\b/i,
64 /(^|\s)--?[a-z][\w-]*/,
65 /\w\(\)/,
66 /\b[a-z0-9]+_[a-z0-9_]+\b/,
67]
68
69const isCamel = (word: string) => /^[a-z]{2,}[A-Z][A-Za-z]*$/.test(word) && !BRANDS.test(word)
70
71/** The goal titles that read as technical steps rather than results. */
72export const technicalTitles = (goals: readonly Goal[]) =>
73 goals
74 .map(goal => goal.title)
75 .filter(title => TECHNICAL.some(pattern => pattern.test(title)) || title.split(/[^\w]+/).some(isCamel))
76
77const sameTitle = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
78
79/** Reads the model's goals loosely: unknown statuses become planned, bad rows go. */
80export const parseGoals = (raw: unknown): Goal[] =>
81 (Array.isArray(raw) ? raw : []).flatMap(item => {
82 if (typeof item !== 'object' || item === null) return []
83 const { title, status, progress, waitingFor } = item as Record<string, unknown>
84 if (typeof title !== 'string' || title.trim() === '') return []
85 const goal: Goal = {
86 title: title.trim(),
87 status: STATUSES.includes(status as GoalStatus) ? (status as GoalStatus) : 'planned',
88 }
89 if (typeof progress === 'number' && Number.isFinite(progress) && goal.status === 'active') {
90 goal.progress = Math.max(0, Math.min(100, Math.round(progress)))
91 }
92 if (goal.status === 'waiting' && Array.isArray(waitingFor)) {
93 const named = waitingFor.filter((one): one is string => typeof one === 'string' && one.trim() !== '')
94 if (named.length > 0) goal.waitingFor = named.map(one => one.trim())
95 }
96
97 return [goal]
98 })
99
100/** The new list wins; finished goals it dropped move to `earlier`. */
101export const merge = (prev: Board, goals: Goal[], summary: string, now: number): Board => {
102 const stamped = goals.map(goal => {
103 if (goal.status !== 'done') return goal
104 const before = prev.goals.find(one => sameTitle(one.title, goal.title))
105
106 return { ...goal, doneAt: before?.doneAt ?? now }
107 })
108 const kept = (one: Goal) => !stamped.some(goal => sameTitle(goal.title, one.title))
109 const leftDone = prev.goals
110 .filter(one => one.status === 'done' && kept(one))
111 .sort((a, b) => (b.doneAt ?? 0) - (a.doneAt ?? 0))
112 const earlier = [...leftDone, ...prev.earlier.filter(kept)].slice(0, EARLIER_KEPT)
113
114 return { summary, goals: stamped, earlier }
115}
116
117/** A finished request's goals move to the done list when the person asks for something new. */
118export const archive = (prev: Board): Board => {
119 const isFinished = prev.goals.length > 0 && prev.goals.every(goal => goal.status === 'done')
120 if (!isFinished) return prev
121 const done = [...prev.goals].sort((a, b) => (b.doneAt ?? 0) - (a.doneAt ?? 0))
122
123 return { summary: '', goals: [], earlier: [...done, ...prev.earlier].slice(0, EARLIER_KEPT) }
124}
125
126/** The person's steps a waiting goal names, by id or by title. */
127export const stepsFor = (goal: Goal, steps: readonly YourStep[]) =>
128 (goal.waitingFor ?? []).flatMap(name => {
129 const found = steps.find(step => step.id === name || sameTitle(step.title, name))
130
131 return found === undefined ? [] : [found]
132 })
133
134/** Whether a waiting goal still waits on the person: some of its steps are not ticked yet. */
135export const isBlocked = (goal: Goal, steps: readonly YourStep[]) => {
136 if (goal.status !== 'waiting') return false
137 const named = stepsFor(goal, steps)
138
139 return named.length === 0 || named.some(step => step.status === 'todo')
140}
141hooks/your-steps.ts 623 lines1import type { Goal, StepLink, YourStep, YourStepStatus } from '../types'
2
3export const YOUR_STEPS_TOOL = 'mcp__overview__your_steps'
4export const STORE_PREFIX = 'yourSteps:'
5
6/** Confirmed steps stay this long, then leave the list. */
7const DONE_KEPT_MS = 7 * 24 * 60 * 60 * 1000
8const MAX_STEPS = 60
9const DAY_MS = 24 * 60 * 60 * 1000
10const DATE = /^\d{4}-\d{2}-\d{2}$/
11
12export const YOUR_STEPS_DESCRIPTION = [
13 "Keeps the list of steps only the person can take (a sign-in, a payment, a decision, a check on their phone) in the Overview pane, per project and across sessions.",
14 'The person ticks a step when it is done. "add" new steps, "confirm" ticked ones you have checked (saying how), "reopen" one that is not done yet with a note, "remove" one no longer needed.',
15 'Every call answers the current list with ids; call it with nothing to read the list.',
16].join(' ')
17
18const MORE_DESCRIPTION =
19 'What it is about, opened from the pane: markdown, 50 to 140 words. What it is and why it matters now, "**How**" with 2 to 6 numbered actions (where, which menu, which values), for a decision what each option leads to, and "**Done when**" with how you will confirm it.'
20
21const ASKS_DESCRIPTION =
22 'When you need words or numbers from the person (an address, a name, links): what to type, in a few words ("Your SIREN, postal address and phone"). The pane shows an answer box and you receive the reply. Not with "choices".'
23
24const DEFAULT_DESCRIPTION =
25 'For a decision: the option that stands if the person does not answer, one of "choices" (usually the one that keeps things as they are). Shown on the card, never applied by itself.'
26
27const DECIDE_BY_DESCRIPTION = 'For a decision: by when an answer is wanted, YYYY-MM-DD or YYYY-MM-DDTHH:MM.'
28
29const SOURCES_SCHEMA = {
30 type: 'array',
31 description:
32 'Where to read more, most useful first: a web page ({ label, url }), or a section of a file in this repository ({ label, path, heading } with the heading copied exactly as written after its #s, or { label, path, line }); repository files link to its page on GitHub, on "ref" (default main).',
33 items: {
34 type: 'object',
35 properties: {
36 label: { type: 'string', description: 'A short name: "HANDOFF › Mail setup".' },
37 url: { type: 'string' },
38 path: { type: 'string', description: 'Relative to the repository root: "docs/HANDOFF.md".' },
39 heading: { type: 'string' },
40 line: { type: 'number' },
41 ref: { type: 'string' },
42 },
43 required: ['label'],
44 },
45}
46
47export const YOUR_STEPS_SCHEMA = {
48 type: 'object',
49 properties: {
50 add: {
51 type: 'array',
52 description: 'New steps for the person.',
53 items: {
54 type: 'object',
55 properties: {
56 title: { type: 'string', description: 'The action, in plain words: "Approve the new Apple agreement".' },
57 detail: { type: 'string', description: 'Where or how, when useful: a link or the exact place.' },
58 choices: {
59 type: 'array',
60 items: { type: 'string' },
61 description: 'For a decision: 2 to 4 short options, drawn as buttons; the person picks one.',
62 },
63 due: { type: 'string', description: 'The day it is due by, YYYY-MM-DD, when there is a real deadline.' },
64 asks: { type: 'string', description: ASKS_DESCRIPTION },
65 defaultChoice: { type: 'string', description: DEFAULT_DESCRIPTION },
66 decideBy: { type: 'string', description: DECIDE_BY_DESCRIPTION },
67 more: { type: 'string', description: MORE_DESCRIPTION },
68 sources: SOURCES_SCHEMA,
69 },
70 required: ['title'],
71 },
72 },
73 update: {
74 type: 'array',
75 description: 'Changes to existing steps: only the fields given change ("due": "none" removes the date).',
76 items: {
77 type: 'object',
78 properties: {
79 id: { type: 'string' },
80 title: { type: 'string' },
81 detail: { type: 'string' },
82 due: { type: 'string' },
83 choices: { type: 'array', items: { type: 'string' } },
84 asks: { type: 'string', description: ASKS_DESCRIPTION },
85 defaultChoice: { type: 'string', description: DEFAULT_DESCRIPTION },
86 decideBy: { type: 'string', description: DECIDE_BY_DESCRIPTION },
87 more: { type: 'string', description: MORE_DESCRIPTION },
88 sources: SOURCES_SCHEMA,
89 },
90 required: ['id'],
91 },
92 },
93 confirm: {
94 type: 'array',
95 description: 'Steps you have checked are done.',
96 items: {
97 type: 'object',
98 properties: {
99 id: { type: 'string' },
100 checked: {
101 type: 'string',
102 description: 'How you checked it, in a few plain words: "Seen in TestFlight, build 42". "Your word" when you could not check.',
103 },
104 },
105 required: ['id', 'checked'],
106 },
107 },
108 reopen: {
109 type: 'array',
110 items: {
111 type: 'object',
112 properties: {
113 id: { type: 'string' },
114 note: { type: 'string', description: 'What is still missing, in plain words.' },
115 },
116 required: ['id', 'note'],
117 },
118 },
119 remove: { type: 'array', items: { type: 'string' }, description: 'Ids of steps no longer needed.' },
120 },
121}
122
123export const YOUR_STEPS_GUIDE = `
124## Steps only the person can take
125
126Do every step you can yourself. When one truly needs the person (a sign-in or 2FA, a password or payment, a decision only they can make, a check on their phone), add it with the ${YOUR_STEPS_TOOL} tool ("add") rather than only writing it in a message: an action in plain words, with "detail" saying where when useful, and "due" (YYYY-MM-DD) when there is a real deadline. Their phone is told, and reminded once a day while a step waits. The list stays in the Overview pane for this project, across sessions, and the person ticks a step once done.
127- Each step is answered one way, and its "more" must say that way: the person ticks it when it is done (the default), picks one of its "choices", or types into its answer box ("asks"). One decision per step: never bundle several choices or questions into one step; add one step for each. Never ask the person to "send Claude" something without "asks".
128- A decision you can keep working around is a step with "choices" (2 to 4 short options): the person picks one in the pane, now or later, even from their phone. Go on with what does not depend on it. When you cannot go on at all without the answer, ask in the conversation instead.
129- A decision may name "defaultChoice" (what stands without an answer, usually what keeps things as they are) and "decideBy": the pane shows « no answer by … keeps: … ». It is never applied by itself: without an answer, nothing changes.
130- When you are told the person answered a step with "asks", use the answer, then "confirm" the step with "checked" saying what you did with it.
131- When you are told the person picked an option, act on it, then "confirm" the step with "checked" saying what you did.
132- When you are told the person ticked a step, or they say in a message that one is done, check it yourself when you can (the build now shows in TestFlight, the setting is on), then "confirm" it with "checked" saying how in a few plain words. If it is not done, "reopen" it with a short note saying what is missing. If you cannot check it, confirm it with "checked": "Your word".
133- Give each step "more" (what it is, why now, how, and how you will confirm it) and "sources" (the document sections or pages with the details), so the person can open it in the pane. Keep them current with "update" when things change.
134- The pane's « Your move » shows decisions, what is overdue or due within a week, and steps added in the last day; everything else folds under « Later ». Give "due" to any step that should happen soon, so it stays in front.
135- "remove" a step that is no longer needed. Call the tool with nothing to read the list.`
136
137/** A source as Claude gives it, before it becomes a link. */
138export type Source = { label: string; url?: string; path?: string; heading?: string; line?: number; ref?: string }
139
140type Decision = { defaultChoice?: string; decideBy?: string }
141
142export type NewStep = Decision & { title: string; detail?: string; choices?: string[]; asks?: string; due?: string; more?: string; sources?: Source[]; links?: StepLink[] }
143
144export type StepChange = Decision & { id: string; title?: string; detail?: string; choices?: string[]; asks?: string; due?: string | null; more?: string; sources?: Source[]; links?: StepLink[] }
145
146const DECIDE_BY = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/
147
148/** A decision's default and deadline, kept only when the default is one of its choices. */
149const parseDecision = (row: Record<string, unknown>, choices: readonly string[] | undefined): Decision => {
150 const decision: Decision = {}
151 const fallback = text(row.defaultChoice)
152 if (fallback !== undefined && (choices === undefined || choices.includes(fallback))) decision.defaultChoice = fallback
153 const by = text(row.decideBy)
154 if (by !== undefined && DECIDE_BY.test(by)) decision.decideBy = by
155
156 return decision
157}
158
159export type YourStepsOps = {
160 add: NewStep[]
161 update: StepChange[]
162 confirm: { id: string; checked?: string }[]
163 reopen: { id: string; note: string }[]
164 remove: string[]
165}
166
167const text = (value: unknown) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined)
168
169const MORE_LIMIT = 4000
170
171const parseSources = (value: unknown): Source[] | undefined => {
172 if (!Array.isArray(value)) return undefined
173
174 return value.flatMap(item => {
175 const row = (item ?? {}) as Record<string, unknown>
176 const label = text(row.label)
177 if (label === undefined) return []
178 const source: Source = { label }
179 for (const key of ['url', 'path', 'heading', 'ref'] as const) {
180 const value = text(row[key])
181 if (value !== undefined) source[key] = value
182 }
183 if (typeof row.line === 'number' && Number.isInteger(row.line) && row.line > 0) source.line = row.line
184
185 return source.url !== undefined || source.path !== undefined ? [source] : []
186 })
187}
188
189const parseChoices = (value: unknown) => {
190 const choices = (Array.isArray(value) ? value : []).flatMap(one => (text(one) === undefined ? [] : [text(one) as string]))
191
192 return choices.length >= 2 ? [...new Set(choices)].slice(0, 4) : undefined
193}
194
195const rows = (value: unknown) =>
196 (Array.isArray(value) ? value : []).map(item => (typeof item === 'string' ? { id: item } : ((item ?? {}) as Record<string, unknown>)))
197
198/** Reads the model's operations loosely: rows without the needed text are dropped, bare ids accepted. */
199export const parseOps = (raw: Record<string, unknown>): YourStepsOps => ({
200 add: rows(raw.add).flatMap(row => {
201 const title = text(row.title)
202 if (title === undefined) return []
203 const step: NewStep = { title }
204 const detail = text(row.detail)
205 if (detail !== undefined) step.detail = detail
206 const choices = parseChoices(row.choices)
207 if (choices !== undefined) step.choices = choices
208 const due = text(row.due)
209 if (due !== undefined && DATE.test(due)) step.due = due
210 const more = text(row.more)
211 if (more !== undefined) step.more = more.slice(0, MORE_LIMIT)
212 const sources = parseSources(row.sources)
213 if (sources !== undefined && sources.length > 0) step.sources = sources
214 if (step.choices !== undefined) Object.assign(step, parseDecision(row, step.choices))
215 const asks = text(row.asks)
216 if (asks !== undefined && step.choices === undefined) step.asks = asks.slice(0, 140)
217
218 return [step]
219 }),
220 update: rows(raw.update).flatMap(row => {
221 const id = text(row.id)
222 if (id === undefined) return []
223 const change: StepChange = { id }
224 const title = text(row.title)
225 if (title !== undefined) change.title = title
226 const detail = text(row.detail)
227 if (detail !== undefined) change.detail = detail
228 const due = text(row.due)
229 if (due === 'none') change.due = null
230 else if (due !== undefined && DATE.test(due)) change.due = due
231 const choices = parseChoices(row.choices)
232 if (choices !== undefined) change.choices = choices
233 const more = text(row.more)
234 if (more !== undefined) change.more = more.slice(0, MORE_LIMIT)
235 const sources = parseSources(row.sources)
236 if (sources !== undefined) change.sources = sources
237 Object.assign(change, parseDecision(row, change.choices))
238 const asks = text(row.asks)
239 if (asks !== undefined) change.asks = asks.slice(0, 140)
240
241 return [change]
242 }),
243 confirm: rows(raw.confirm).flatMap(row => {
244 const id = text(row.id)
245 if (id === undefined) return []
246 const checked = text(row.checked)
247
248 return [checked === undefined ? { id } : { id, checked }]
249 }),
250 reopen: rows(raw.reopen).flatMap(row => {
251 const id = text(row.id)
252 const note = text(row.note)
253
254 return id === undefined || note === undefined ? [] : [{ id, note }]
255 }),
256 remove: rows(raw.remove).flatMap(row => (text(row.id) === undefined ? [] : [text(row.id) as string])),
257})
258
259export const isEmpty = (ops: YourStepsOps) =>
260 ops.add.length + ops.update.length + ops.confirm.length + ops.reopen.length + ops.remove.length === 0
261
262/** GitHub's anchor for a heading: lower case, punctuation dropped, spaces as hyphens. */
263export const slugify = (heading: string) =>
264 heading
265 .trim()
266 .toLowerCase()
267 .replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
268 .replace(/ /g, '-')
269
270/** The repository's web address on GitHub, from its remote; undefined for any other host. */
271export const githubBase = (remote: string | null) => {
272 const match = /github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(remote ?? '')
273
274 return match ? `https://github.com/${match[1]}/${match[2]}` : undefined
275}
276
277const encodePath = (path: string) => path.replace(/^\.?\//, '').split('/').map(encodeURIComponent).join('/')
278
279/** Turns sources into https links; a repository file needs the repository on GitHub. */
280export const linksFor = (sources: readonly Source[], base: string | undefined): { links: StepLink[]; dropped: string[] } => {
281 const links: StepLink[] = []
282 const dropped: string[] = []
283 for (const source of sources) {
284 if (source.url !== undefined) {
285 if (/^https:\/\/[^\s@]+$/.test(source.url)) links.push({ label: source.label, url: source.url })
286 else dropped.push(source.label)
287 continue
288 }
289 if (base === undefined || source.path === undefined) {
290 dropped.push(source.label)
291 continue
292 }
293 const anchor = source.heading !== undefined ? `#${encodeURIComponent(slugify(source.heading))}` : source.line !== undefined ? `#L${source.line}` : ''
294 links.push({ label: source.label, url: `${base}/blob/${encodeURIComponent(source.ref ?? 'main')}/${encodePath(source.path)}${anchor}` })
295 }
296
297 return { links, dropped }
298}
299
300/** Applies Claude's operations; ids it does not know are reported, never fatal. */
301export const applyOps = (
302 list: YourStep[],
303 ops: YourStepsOps,
304 now: number,
305 newId: () => string,
306): { list: YourStep[]; unknown: string[]; added: YourStep[] } => {
307 const known = new Set(list.map(step => step.id))
308 const unknown = [...ops.update.map(one => one.id), ...ops.confirm.map(one => one.id), ...ops.reopen.map(one => one.id), ...ops.remove].filter(
309 id => !known.has(id),
310 )
311 const removed = new Set(ops.remove)
312 const confirmed = new Map(ops.confirm.map(one => [one.id, one.checked]))
313 const reopened = new Map(ops.reopen.map(one => [one.id, one.note]))
314
315 const changes = new Map(ops.update.map(one => [one.id, one]))
316 const changed = list.map(step => {
317 const change = changes.get(step.id)
318 if (change === undefined) return step
319 const { id: _id, sources: _sources, due, ...fields } = change
320 const next: YourStep = { ...step, ...fields }
321 if (due === null) delete next.due
322 else if (due !== undefined) next.due = due
323
324 return next
325 })
326
327 const kept = changed.flatMap(step => {
328 if (removed.has(step.id)) return []
329 if (confirmed.has(step.id)) {
330 const { note: _note, ...rest } = step
331 const checked = confirmed.get(step.id)
332
333 return [{ ...rest, ...(checked ? { checked } : {}), status: 'done' as const, heard: 'done' as const, doneAt: now }]
334 }
335 const note = reopened.get(step.id)
336 if (note !== undefined) {
337 const { doneAt: _doneAt, checked: _checked, answer: _answer, heardAnswer: _heard, ...rest } = step
338
339 return [{ ...rest, note, status: 'todo' as const, heard: 'todo' as const }]
340 }
341
342 return [step]
343 })
344 const added: YourStep[] = ops.add
345 .filter(one => !kept.some(step => step.status !== 'done' && step.title.toLowerCase() === one.title.toLowerCase()))
346 .map(({ sources: _sources, ...one }) => ({ ...one, id: newId(), status: 'todo' as const, heard: 'todo' as const, createdAt: now }))
347
348 return { list: [...kept, ...added], unknown, added }
349}
350
351/** Drops confirmed steps once they are a week old, and keeps the list bounded. */
352export const prune = (list: YourStep[], now: number) =>
353 list.filter(step => step.status !== 'done' || now - (step.doneAt ?? now) < DONE_KEPT_MS).slice(-MAX_STEPS)
354
355/** Ticks a step, or unticks it; a confirmed step stays confirmed. */
356export const toggle = (list: YourStep[], id: string, session: string, from: string, now: number) =>
357 list.map(step => {
358 if (step.id !== id || step.status === 'done') return step
359 const status: YourStepStatus = step.status === 'checked' ? 'todo' : 'checked'
360
361 return { ...step, status, tickedIn: session, tickedFrom: from, tickedAt: now }
362 })
363
364/** Picks an option of a decision; picking the chosen one again takes the answer back. */
365export const choose = (list: YourStep[], id: string, choice: string, session: string, from: string, now: number) =>
366 list.map(step => {
367 if (step.id !== id || step.status === 'done' || !(step.choices ?? []).includes(choice)) return step
368 const tick = { tickedIn: session, tickedFrom: from, tickedAt: now }
369 if (step.answer === choice) {
370 const { answer: _answer, ...rest } = step
371
372 return { ...rest, ...tick, status: 'todo' as const }
373 }
374
375 return { ...step, ...tick, answer: choice, status: 'checked' as const }
376 })
377
378/** Answers a step that asks for words; an empty answer takes it back. */
379export const reply = (list: YourStep[], id: string, words: string, session: string, from: string, now: number) =>
380 list.map(step => {
381 if (step.id !== id || step.status === 'done' || step.asks === undefined) return step
382 const tick = { tickedIn: session, tickedFrom: from, tickedAt: now }
383 const answer = words.trim().slice(0, 2000)
384 if (answer === '') {
385 const { answer: _answer, ...rest } = step
386
387 return { ...rest, ...tick, status: 'todo' as const }
388 }
389
390 return { ...step, ...tick, answer, status: 'checked' as const }
391 })
392
393/** How the person answers a step: tick it, pick an option, or type. */
394export const answerKind = (step: YourStep) => (step.choices !== undefined ? 'pick' : step.asks !== undefined ? 'type' : 'tick')
395
396/** Steps whose status (or picked option) Claude has not heard yet. */
397export const unheard = (list: readonly YourStep[]) =>
398 list.filter(
399 step =>
400 step.status !== 'done' &&
401 (step.status !== step.heard || (step.status === 'checked' && (step.answer ?? '') !== (step.heardAnswer ?? ''))),
402 )
403
404/** The unheard ticks a session of project `key` picks up: its own pane's, and any other project's pane's. */
405export const ticksFor = (list: readonly YourStep[], session: string, key: string) =>
406 unheard(list).filter(step => step.tickedIn === session || (step.tickedFrom !== undefined && step.tickedFrom !== key))
407
408/** Records that Claude was told these steps' statuses; a step ticked again since stays unheard. */
409export const markHeard = (list: YourStep[], told: readonly YourStep[]) =>
410 list.map(step => {
411 const said = told.find(one => one.id === step.id)
412
413 if (said === undefined || said.status !== step.status || said.answer !== step.answer) return step
414
415 return step.answer === undefined ? { ...step, heard: step.status } : { ...step, heard: step.status, heardAnswer: step.answer }
416 })
417
418/** What Claude reads when the person has ticked or unticked steps. */
419export const noteFor = (steps: readonly YourStep[], goals: readonly Goal[] = []) => {
420 const ticked = steps.filter(step => step.status === 'checked' && step.answer === undefined)
421 const answered = steps.filter(step => step.status === 'checked' && step.answer !== undefined && step.asks !== undefined)
422 const decided = steps.filter(step => step.status === 'checked' && step.answer !== undefined && step.asks === undefined)
423 const unticked = steps.filter(step => step.status === 'todo')
424 const unblocks = (step: YourStep) => {
425 const names = goals
426 .filter(goal => goal.status === 'waiting' && (goal.waitingFor ?? []).some(one => one === step.id || one.toLowerCase() === step.title.toLowerCase()))
427 .map(goal => `"${goal.title}"`)
428
429 return names.length > 0 ? ` (the goal ${names.join(', ')} waits on it)` : ''
430 }
431 const line = (step: YourStep) => `- ${step.id} · ${step.title}${unblocks(step)}`
432 const pick = (step: YourStep) => `- ${step.id} · ${step.title} → "${step.answer}"${unblocks(step)}`
433 const words = (step: YourStep) => `- ${step.id} · ${step.title} (asked: ${step.asks})${unblocks(step)}\n « ${step.answer} »`
434 const parts = [
435 answered.length > 0 &&
436 `The person answered in the Overview pane:\n${answered.map(words).join('\n')}\nUse ${answered.length === 1 ? 'the answer' : 'the answers'}, then confirm the step with ${YOUR_STEPS_TOOL}, "checked" saying what you did with it.`,
437 decided.length > 0 &&
438 `The person decided in the Overview pane:\n${decided.map(pick).join('\n')}\nAct on ${decided.length === 1 ? 'it' : 'them'}, then confirm the step with ${YOUR_STEPS_TOOL}, "checked" saying what you did.`,
439 ticked.length > 0 &&
440 `The person ticked ${ticked.length === 1 ? 'this step' : 'these steps'} as done in the Overview pane:\n${ticked.map(line).join('\n')}\nCheck each one yourself when you can, then confirm it with ${YOUR_STEPS_TOOL} (saying how you checked), or reopen it with a short note saying what is missing. Then carry on with any goal that was waiting on it.`,
441 unticked.length > 0 &&
442 `The person unticked ${unticked.length === 1 ? 'this step' : 'these steps'} (or took back their pick or answer); not done after all:\n${unticked.map(line).join('\n')}`,
443 ].filter(Boolean)
444
445 return parts.join('\n\n')
446}
447
448/** The list as the tool answers it to Claude. */
449export const describeList = (list: readonly YourStep[]) => {
450 const open = list.filter(step => step.status !== 'done')
451 if (open.length === 0) return 'No open steps for the person.'
452 const word = (step: YourStep) =>
453 step.status === 'checked'
454 ? step.answer !== undefined
455 ? step.asks !== undefined
456 ? `answered « ${step.answer} », not confirmed yet`
457 : `decided "${step.answer}", not confirmed yet`
458 : 'ticked by the person, not confirmed yet'
459 : step.note
460 ? `to do (reopened: ${step.note})`
461 : step.choices
462 ? `decision waiting (${step.choices.join(' / ')})`
463 : step.asks
464 ? `answer waiting (asks: ${step.asks})`
465 : 'to do'
466 const due = (step: YourStep) => (step.due ? ` · due ${step.due}` : '')
467
468 return ['Open steps for the person:', ...open.map(step => `- ${step.id} · ${word(step)}${due(step)} · ${step.title}`)].join('\n')
469}
470
471/** The quiet transcript line for one call of the tool. */
472export const summarizeCall = (input: unknown) => {
473 const ops = parseOps((input ?? {}) as Record<string, unknown>)
474 const parts = [
475 ops.add.length > 0 && `${ops.add.length} added`,
476 ops.update.length > 0 && `${ops.update.length} updated`,
477 ops.confirm.length > 0 && `${ops.confirm.length} confirmed`,
478 ops.reopen.length > 0 && `${ops.reopen.length} reopened`,
479 ops.remove.length > 0 && `${ops.remove.length} removed`,
480 ].filter(Boolean)
481
482 return `Your steps · ${parts.length > 0 ? parts.join(', ') : 'checked the list'}`
483}
484
485/** A project's steps as kept in the store: `{ label, steps }`, or a bare list from before labels. */
486export const readStored = (stored: unknown): { label?: string; steps: YourStep[] } => {
487 if (Array.isArray(stored)) return { steps: stored as YourStep[] }
488 if (typeof stored !== 'object' || stored === null) return { steps: [] }
489 const { label, steps } = stored as { label?: unknown; steps?: unknown }
490
491 return { label: typeof label === 'string' ? label : undefined, steps: Array.isArray(steps) ? (steps as YourStep[]) : [] }
492}
493
494/** A project's short name: its git remote's repository name, else its folder's name. */
495export const labelFor = (root: string, remote: string | null) => {
496 const fromRemote = remote?.replace(/\.git$/, '').split(/[/:]/).pop()
497 const fromFolder = root.split('/').filter(Boolean).pop()
498
499 return fromRemote || fromFolder || root
500}
501
502/** The phone line when Claude adds or reopens steps, under 200 characters. */
503export const phoneLine = (label: string, added: readonly YourStep[], reopened: readonly { note: string; title: string }[]) => {
504 const asks = (step: YourStep | undefined) => (step?.choices ? 'needs a decision' : step?.asks ? 'needs an answer' : 'needs you')
505 const parts = [
506 added.length === 1 && `${label} ${asks(added[0])}: ${added[0]?.title}`,
507 added.length > 1 && `${label} needs you for ${added.length} steps: ${added.map(step => step.title).join(' · ')}`,
508 reopened.length > 0 && `Not done yet: ${reopened.map(one => `${one.title} (${one.note})`).join(' · ')}`,
509 ].filter(Boolean)
510 const line = parts.join(' — ')
511
512 return line.length > 195 ? `${line.slice(0, 194)}…` : line
513}
514
515const pad = (n: number) => String(n).padStart(2, '0')
516
517/** The local day of a clock time, `YYYY-MM-DD`. */
518export const dayOf = (ms: number) => {
519 const date = new Date(ms)
520
521 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
522}
523
524const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
525const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
526
527/** "Mon 2 Nov" for `2026-11-02`. */
528export const dayName = (day: string) => {
529 const [year, month, date] = day.split('-').map(Number)
530 const at = new Date(year ?? 1970, (month ?? 1) - 1, date ?? 1)
531
532 return `${WEEKDAYS[at.getDay()]} ${at.getDate()} ${MONTHS[at.getMonth()]}`
533}
534
535const daysBetween = (from: string, to: string) => {
536 const at = (day: string) => {
537 const [year, month, date] = day.split('-').map(Number)
538
539 return Date.UTC(year ?? 1970, (month ?? 1) - 1, date ?? 1)
540 }
541
542 return Math.round((at(to) - at(from)) / DAY_MS)
543}
544
545/** The deadline as the pane says it, and whether it is pressing (today, tomorrow or past). */
546export const dueText = (due: string, now: number) => {
547 const left = daysBetween(dayOf(now), due)
548 if (left < 0) return { text: `overdue since ${dayName(due)}`, isPressing: true }
549 if (left === 0) return { text: 'due today', isPressing: true }
550 if (left === 1) return { text: 'due tomorrow', isPressing: true }
551
552 return { text: `by ${dayName(due)}`, isPressing: false }
553}
554
555/** How long a step has waited, once it is a day or more. */
556export const waitedText = (step: YourStep, now: number) => {
557 const days = Math.floor((now - step.createdAt) / DAY_MS)
558
559 return days >= 1 ? `waiting ${days === 1 ? '1 day' : `${days} days`}` : undefined
560}
561
562/** Open steps with a deadline first, soonest first, then the rest as they came. */
563export const sortSteps = (steps: readonly YourStep[]) =>
564 [...steps].sort((a, b) => {
565 if (a.due !== undefined && b.due !== undefined) return a.due.localeCompare(b.due)
566 if (a.due !== undefined) return -1
567 if (b.due !== undefined) return 1
568
569 return a.createdAt - b.createdAt
570 })
571
572/**
573 * Steps the person's phone should be reminded of now: still to do, and either waiting a day or due
574 * by tomorrow, with no reminder in the last day. Only between 9:00 and 21:00 local time.
575 */
576export const dueReminders = (list: readonly YourStep[], now: number) => {
577 const hour = new Date(now).getHours()
578 if (hour < 9 || hour >= 21) return []
579
580 return list.filter(step => {
581 if (step.status !== 'todo') return false
582 if (step.remindedAt !== undefined && now - step.remindedAt < DAY_MS) return false
583 const isOld = now - step.createdAt >= DAY_MS
584 const isNear = step.due !== undefined && daysBetween(dayOf(now), step.due) <= 1
585
586 return isOld || isNear
587 })
588}
589
590/** One phone line for the steps to be reminded of, under 200 characters. */
591export const reminderLine = (label: string, steps: readonly YourStep[], now: number) => {
592 const tag = (step: YourStep) => (step.due ? ` (${dueText(step.due, now).text})` : '')
593 const line =
594 steps.length === 1
595 ? `${label}: still waiting for you — ${steps[0]?.title}${steps[0] ? tag(steps[0]) : ''}`
596 : `${label}: ${steps.length} steps waiting for you — ${steps.map(step => `${step.title}${tag(step)}`).join(' · ')}`
597
598 return line.length > 195 ? `${line.slice(0, 194)}…` : line
599}
600
601export const markReminded = (list: YourStep[], reminded: readonly YourStep[], now: number) =>
602 list.map(step => (reminded.some(one => one.id === step.id) ? { ...step, remindedAt: now } : step))
603
604export type StepGroup = { id: 'overdue' | 'soon' | 'later' | 'undated'; label: string; steps: YourStep[] }
605
606/** Open steps by urgency: overdue, due within a week, later, and with no date; empty groups left out. */
607export const groupSteps = (steps: readonly YourStep[], now: number): StepGroup[] => {
608 const today = dayOf(now)
609 const groups: StepGroup[] = [
610 { id: 'overdue', label: 'Overdue', steps: [] },
611 { id: 'soon', label: 'This week', steps: [] },
612 { id: 'later', label: 'Later', steps: [] },
613 { id: 'undated', label: 'No date', steps: [] },
614 ]
615 for (const step of sortSteps(steps)) {
616 const left = step.due === undefined ? undefined : daysBetween(today, step.due)
617 const id = left === undefined ? 'undated' : left < 0 ? 'overdue' : left <= 7 ? 'soon' : 'later'
618 groups.find(group => group.id === id)?.steps.push(step)
619 }
620
621 return groups.filter(group => group.steps.length > 0)
622}
623types/index.d.ts 178 lines1export type GoalStatus = 'planned' | 'active' | 'waiting' | 'done'
2
3export type Goal = {
4 title: string
5 status: GoalStatus
6 /** 0–100, the active goal's estimate when Claude gives one. */
7 progress?: number
8 /** For a waiting goal: the ids or titles of the person's steps it waits on. */
9 waitingFor?: string[]
10 /** Clock time it was first seen done; orders the Done list. */
11 doneAt?: number
12}
13
14export type Board = {
15 /** One line: the overall aim of the current request. */
16 summary: string
17 /** The current request's goals, in Claude's order. */
18 goals: Goal[]
19 /** Goals finished for earlier requests in this session, newest first. */
20 earlier: Goal[]
21}
22
23/** `todo` until the person ticks it, `checked` once ticked, `done` once Claude confirms. */
24export type YourStepStatus = 'todo' | 'checked' | 'done'
25
26/** A step only the person can take (a sign-in, a payment, a decision), kept per project. */
27export type YourStep = {
28 id: string
29 title: string
30 /** Where or how, when Claude gave it: a link, the exact place. */
31 detail?: string
32 /** What Claude said was still missing when it reopened the step. */
33 note?: string
34 /** What it is about, in more depth (markdown): shown when the person opens the step. */
35 more?: string
36 /** Where to read more: web pages, or a document's section on the repository's site. */
37 links?: StepLink[]
38 /** How Claude checked it when it confirmed it ("Seen in TestFlight, build 42"). */
39 checked?: string
40 /** For a decision: the options, drawn as buttons; the person's pick is `answer`. */
41 choices?: string[]
42 /** When Claude needs words or numbers from the person: what to type. The pane shows an answer box; the reply is `answer`. */
43 asks?: string
44 /** For a decision: what stays if the person does not answer (shown, never applied). */
45 defaultChoice?: string
46 /** For a decision: by when an answer is wanted, `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM`. */
47 decideBy?: string
48 answer?: string
49 /** The answer Claude last heard. */
50 heardAnswer?: string
51 /** The day it is due by, `YYYY-MM-DD`. */
52 due?: string
53 /** When the person's phone was last reminded of it. */
54 remindedAt?: number
55 status: YourStepStatus
56 /** The status Claude last heard; a step whose status differs is waiting to be told. */
57 heard: YourStepStatus
58 /** The session whose pane ticked it last. */
59 tickedIn?: string
60 /** The project (store key) whose pane ticked it last; another project's tick is picked up by any session here. */
61 tickedFrom?: string
62 tickedAt?: number
63 doneAt?: number
64 createdAt: number
65}
66
67/** A stage of the project, on the map's timeline. */
68export type Milestone = {
69 title: string
70 status: 'done' | 'active' | 'next' | 'later'
71 /** `YYYY-MM-DD`: when it was reached (done) or is planned. */
72 date?: string
73}
74
75/** A part of the project, one card of the map's grid. */
76export type Part = {
77 title: string
78 status: 'done' | 'active' | 'stuck' | 'waiting' | 'todo'
79 /** 0–100, for an active part. */
80 progress?: number
81 /** One short line: what is left, what it waits on, when it shipped. */
82 note?: string
83 /** The milestone it is needed for, by title. */
84 milestone?: string
85}
86
87/** The project's map, kept by Claude, shared by every session of the project. */
88export type ProjectMap = { milestones: Milestone[]; parts: Part[]; updatedAt: number }
89
90/** One line of the "changed recently" feed. */
91export type Change = { at: number; text: string; tone: 'done' | 'stuck' | 'progress' | 'you' | 'info' }
92
93/** What an item of the pane is, for a note the person leaves on it. */
94export type ItemKind = 'goal' | 'step' | 'part' | 'milestone'
95
96/** A note the person left on one item of the pane, on its way to Claude. */
97export type ItemNote = {
98 /** The item's address: `goal:<title>`, `step:<id>`, `part:<title>`, `milestone:<title>`. */
99 item: string
100 kind: ItemKind
101 title: string
102 /** For a step: its id, and the store key of the project it belongs to. */
103 stepId?: string
104 stepKey?: string
105 text: string
106 /** `sending` while it goes out, `queued` until Claude's next step, `sent` once delivered, `error` when it failed. */
107 status: 'sending' | 'queued' | 'sent' | 'error'
108 error?: string
109 at: number
110}
111
112/** A link under a step, always an https address. */
113export type StepLink = { label: string; url: string }
114
115/** Another project's open steps, shown and tickable from any project's pane. */
116export type OtherProject = { key: string; label: string; steps: YourStep[] }
117
118/** A subagent working in the background. */
119export type Helper = { id: string; description: string }
120
121/** What a session is doing, as it publishes it for the other sessions' panes. */
122export type SessionCard = {
123 id: string
124 project: string
125 summary: string
126 active?: string
127 done: number
128 total: number
129 state: 'working' | 'waiting' | 'paused' | 'done' | 'idle'
130 updatedAt: number
131}
132
133/** One line of what was done for a goal; repeats of the same line are counted. */
134export type Action = { label: string; count: number; failed?: boolean }
135
136declare module 'claude-code' {
137 interface PluginState {
138 overview: {
139 board: Board
140 isWorking: boolean
141 /** Whether the conversation shows Claude's technical steps; kept in the store too. */
142 showSteps: boolean
143 /** Calls whose rows are for the person (an artifact published), shown even with steps hidden. */
144 shownCalls: string[]
145 /** This project's steps for the person, as last read from the store. */
146 yourSteps: YourStep[]
147 /** The store key of this project's steps, found once per session. */
148 project: string
149 /** This project's short name ("myapp"), shown in other projects' panes. */
150 projectLabel: string
151 /** Other projects with open steps. */
152 otherProjects: OtherProject[]
153 /** Subagents running in the background. */
154 helpers: Helper[]
155 /** The other sessions open now, as they last published themselves. */
156 otherSessions: SessionCard[]
157 /** What was done for each goal, by goal title. */
158 activity: Record<string, Action[]>
159 /** The goals whose "what was done" list is open in the pane. */
160 openGoals: string[]
161 /** The folded parts of the pane the person opened ("later", "earlier"). */
162 unfolded: string[]
163 /** The steps whose "more" the person opened. */
164 openSteps: string[]
165 /** This project's map, as last read from the store. */
166 projectMap: ProjectMap
167 /** This project's recent changes, newest first. */
168 changes: Change[]
169 /** The edit box open in the pane, as `<where>::<item>`, or empty. */
170 editing: string
171 /** The edit box whose last send was empty, as `<where>::<item>`, or empty. */
172 editProblem: string
173 /** Notes the person left on items, until Claude has reworked them. */
174 notes: ItemNote[]
175 }
176 }
177}
178