SLOPSHOPPER

overview

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

newpanespinnerrowsguardcommand
v1.0.0MITupdated 2026-10-15Fox1304/FT_Claude_Overwatch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · overview
│ ┃ Overview ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ ○ READY WHEN YOU ARE App │ ⎿ Read 6 lines │ ┃ │ │ ⏺ Bash(bun test) │ ┃ │ Goals appear here when Claude starts a │ ⎿ 3 pass, 1 fail │ ┃ │ task. │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ s: Show technical steps ✻ Worked for 42s · done 4:20 PM │ │ › /overview │ ⎿ overview: Overview pane opened. /overview steps shows or hides t │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Overview
╭──────────────────────────────────────────────────────────╮ │ ○ READY WHEN YOU ARE App │ │ │ │ Goals appear here when Claude starts a task. │ ╰──────────────────────────────────────────────────────────╯ s: Show technical steps
README

Claude Overwatch

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.

What it does

  • Now: a card with Claude's current goal, a progress bar, the next goals, what changed in the last minutes, and the project plan as one line.
  • Your move: the steps only you can take (a sign-in, a payment, a decision, a check on your phone). Each step is answered in one of three ways:
  • ☐ tick it when it is done,
  • ◆ pick one of its options (decisions),
  • ⌨ type an answer in its box and press Enter.

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.

  • ✎ Notes on any item: every goal, step, map part and milestone has an ✎ button. It opens a box where you tell Claude what to change ("no longer relevant", "split it in two", "wrong date"); Claude reworks the item with your note.
  • Project map (folded): milestones and one card per part of the project, kept by Claude across sessions.
  • Elsewhere: helpers working in the background, your other open sessions, and open steps of your other projects.
  • Deadlines and reminders: steps can carry a due date ("due tomorrow", "overdue since Mon 5 Oct"). With phone alerts on, new steps and once-a-day reminders reach your phone through Claude Code's own notifications (only when you are away from the session, between 9:00 and 21:00).
  • Quiet conversation: tool calls are hidden; each goal update shows as one dim line. Rows meant for you (files sent to you, questions, plans to approve) stay visible.

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.

Requirements

  • Claude Code 2.1.288 or later (terminal, or the desktop app's Code tab).
  • Mods (plugin hooks modules) enabled for your account. They are an early-access feature that Anthropic switches on per account. When the switch is off, Claude Code still installs the mod but does not load it.
  • No other dependency: no npm install, no build step. The engine runs the TypeScript sources directly.

Install

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.

Run it from a clone instead

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.

Usage

  • The pane opens by itself at the start of a session. /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.
  • Press a step's ☐ (or its number key while the pane has focus) to tick it; press it again to take the tick back before Claude has heard it.
  • Press ✎ on any item, type your note, press Enter. ✕ closes the box. A note that could not be sent shows ↻ Retry and ✕ Discard.
  • Folded parts (« Later », « All goals », « Project map », « Confirmed recently ») open with a click.

Options

Set them in /config (or under pluginConfigs in ~/.claude/settings.json):

OptionDefaultWhat it does
pickupAfterSeconds30How long after your last tick an idle Claude starts a turn to pick it up. 0: only with your next message.
phoneAlertstruePhone alerts for new or reopened steps, and daily reminders.

Your data

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.

Development

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.

PathWhat it holds
hooks/register.tsxthe hooks: tools, prompt guide, pane, hidden rows, delivery to Claude
hooks/goals.ts, your-steps.ts, map.ts, notes.ts, activity.tspure logic for goals, steps, the map, notes and the per-goal history
types/index.d.tsthe shapes of the mod's state
tests/engine tests, with world.ts standing in for the store, the clock and Claude

Licence

MIT, see LICENSE.

Source 7 files
hooks/register.tsx 1589 lines
1import { 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 lines
1import 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}
58
hooks/map.ts 170 lines
1import 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}
170
hooks/activity.ts 138 lines
1import 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}
138
hooks/goals.ts 141 lines
1import 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}
141
hooks/your-steps.ts 623 lines
1import 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}
623
types/index.d.ts 178 lines
1export 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