SLOPSHOPPER

Waypoints

A route for /goal and any multi-step work, built from Claude's own task plan: the plan drawn as a line of nodes and legs above the prompt, an ETA at the goal's…

panebandspinnerguardcommandtoast
v2.2.1MITupdated 2026-10-25afterever/claude-mods/waypoints
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · waypoints
│ ┃ Waypoints ✕ › fix the failing auth test and add an audit log call │ ┃ No goal in this chat. Type /goal <what done │ ┃ looks like> to start one. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Other chats (0) ⏺ Update(src/auth.ts) │ ┃ No other chat has a goal in the last hour. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ auto: on · /waypoints hide|show · clear ⎿ 3 pass, 1 fail │ ┃ (stop the goal) · look & pane: /config → │ ┃ Waypoints ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ waypoints v2.2.1 │ ✻ Worked for 42s · done 4:20 PM │ │ › /waypoints │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Waypoints
No goal in this chat. Type /goal <what done looks like> to start one. Other chats (0) No other chat has a goal in the last hour. auto: on · /waypoints hide|show · clear (stop the goal) · look & pane: /config → Waypoints waypoints v2.2.1
README

Waypoints

A progress bar for what Claude is working through: the task plan as a bar, with elapsed time and an ETA.

Waypoints is a fork of Nate Herk's goal-meter 1.0.0 (MIT, see LICENSE), imported with its history, so the original commit keeps its author. Everything that makes the meter work is Nate's: the task tool Claude plans with, the band and footer, the ETA at the goal's own pace, the goal check's "not met yet" reason, and the pane with every chat's goal. This fork renames it and builds the plan request into the mod, so nothing has to be added to your global CLAUDE.md.

What it shows

Claude plans the work as tasks sized S, M or L with the mod's own tool, mcp__waypoints__tasks, then marks each one started and done. Everything on screen comes from that list.

  • The band above the prompt: the plan drawn as a route, a line of nodes and legs, each leg as long as its task is big (S, M or L). Finished legs are solid and shaded, the task running now is a twinkling ✦ at the start of its leg, a parked task's leg stays dashed with a ⏸, and the last node is a flag: ⚐ on the way, ⚑ once the plan is done. A long plan in a narrow window drops the legs and draws one glyph per task. Under it: what is running and who runs it, the next task, and one notable row: the goal check's latest "not met yet" reason, paused, waiting on you, or ⏸ parked: <blocker>. In the header: tasks done, the percentage, the ETA at this goal's own pace and how many tasks are parked.
  • The footer: ◎ goal 69% · ~15m, whether or not the band shows. On Claude Desktop, where the band already draws the plan, it appears in the footer only while the band is hidden with /waypoints hide.
  • The pane: /waypoints opens this chat's whole task trail (every task, how long each took, who is running which, what a finished task turned out to be, and why the plan was revised) and your other chats' goals from the last hour (the newest 15; the heading counts them all), each with its own small route. It also opens by itself when a goal's first plan starts, without taking the keys from your prompt, unless you closed the pane during that goal.
  • Claude Desktop: the route is a drawn line instead of characters, with the same states and its own motion. Any surface that is not the terminal gets the drawn line.
◎ Goal: Add band to Settings type…               3 of 6 · 50% · ~2m   all tasks g
●━━━━━━━━●━━━━●━━━━━━━━✦┄┄┄┄┄┄┄┄○┄┄┄┄○┄┄┄┄┄┄┄┄⚐ +2
⠋ M  Tests: normalize, command, band on/off           ◆ reviewer · 1m
  next ○ S  README, version bump, marketplace.json

The ETA appears after two tasks are done. When a goal finishes the full band stays for 8 seconds, then collapses to one row; three minutes after the finish the band goes, and a pane that Waypoints opened closes with it, within about 15 seconds (a pane you opened yourself stays). The mod makes no model calls of its own.

Settings

In /config, under Waypoints:

SettingValuesDefault
Palettefollow, ember, claude, neon, monofollow
Animationfollow, off, subtle, fullfollow
Paneauto, offauto

follow takes Agent-Pulse's own palette or animation, so the two bands never disagree, and falls back to ember / subtle when Agent-Pulse is not installed. Pick a value to stop following. Colour and shape show at every animation level; off keeps everything still, subtle pulses the running task, full also breathes the node and sweeps the route when the plan finishes. Pane: off opens the pane only when you ask (/waypoints or g). A window narrower than 144 columns leaves an unasked pane waiting until it widens; g and /waypoints open it at any width.

When the last step is not Claude's to finish

Sometimes everything this chat can do is done and the last task waits on someone else: another team's reply, a release window, a person who is away. Claude parks that task with a note naming the blocker. A parked task stays on the route as a dashed leg with a ⏸, but leaves the header's numbers, so the plan reads 6 of 6 · 100% · 1 parked and finishes green; the summary row keeps 1 parked: <blocker>, so green is not read as "all of it is done". paused, waiting on you now shows only when an open task really waits on you.

Claude is also asked two things in the same words that ask it to plan: when a task turns out differently than the plan assumed, fix the later tasks and say what changed (the pane shows revised after #3: <why>); and never end a turn on an open task without saying why. It finishes the task, parks it, or, when it is unsure what the task takes, asks you with options and a recommendation.

When Claude plans

WorkWhat asks for a plan
A /goalThe mod adds a note to the command asking for a plan. If four tool calls pass with no plan, it reminds Claude once. Until there is a plan, Claude's Edit, Write, NotebookEdit and MultiEdit calls are refused, so the bar is full from the start (Bash and subagents are not held back, and nothing is refused if the mod's tool did not register).
Any other work of 3 or more stepsA standing line in the system prompt (new in 1.1.0). A plan made this way shows the same, named after the one-line goal Claude gives it (its first task if it gives none) and labelled Plan where a goal is labelled Goal.
A small, single-step requestNothing: the line tells Claude to skip it.

The standing line tells subagents never to call plan, because it replaces the main agent's pending tasks; they can start and done tasks. It is a request, not a rule: the tool does not refuse a subagent's plan. The line is also left out of bare, print and teammate sessions, and while /waypoints auto is off.

The standing line is the one thing the global CLAUDE.md bullet used to do. If you still have that bullet from goal-meter, delete it.

Needs function hooks, which are early access. Where an organization manages Claude Code, the engine passes over prompt.compose in plugins you install, so the standing line does not run there; a /goal still asks for a plan.

Use it

CommandWhat it does
/waypointsOpens the pane (so does any word it does not know). Where the surface draws none, it prints the plan as text.
/waypoints hide, showHides or shows the band, and says so in a toast. The footer stays. A new goal or plan shows it again.
/waypoints auto on, offTurns the standing line on or off. On by default; the choice is kept between sessions. The pane's last row shows which it is.
/waypoints clearMarks this chat's running goal as stopped; the band keeps showing stopped for three minutes. A finished goal is left as it is.
/waypoints pruneDeletes old files in ~/.claude/mods-data/waypoints/: other chats' goals that finished over an hour ago, and files untouched for a day. Never this chat's file, never a running goal's. It deletes them straight away, with no dialog: typing the command is the confirmation. Past 100 files the pane names the folder and reminds you.

For auto, anything but off turns it on. /goal clear (or stop, off, cancel, end, none, reset) stops the goal in the same way. If Claude Code will not give the mod the name /waypoints, it registers /waypoints-mod instead.

In the band, the all tasks button (or g) opens the pane. g is a button in the band, so it only works while the band shows; /waypoints always does.

What changed in 2.2.0

  • Parked tasks. The tool's new park action, with a required note naming the blocker, takes a task that waits on someone outside the chat out of the numbers, so the plan can finish green. The band, the summary row and the pane name it. See "When the last step is not Claude's to finish".
  • Plan revisions. A new plan or add takes a note saying what changed. The pane shows revised after #n, a finished task's note and the new tasks marked revised.
  • Two rules for Claude: revise the later steps when a step surprises, and never end a turn on an unexplained open task.
  • New look: the destination is a flag (⚐, then ⚑ when done) instead of a ◎ among the circles, and the running task is a twinkling ✦, in the pane too.

What changed in 2.1.0

The options are fewer and say what they do. /waypoints hide and show confirm in a toast. The pane's last row shows whether auto is on, what clear does and that the look and the pane's opening are set in /config → Waypoints. Other chats are listed for an hour instead of twelve. /waypoints prune deletes old data files, and the pane reminds you once the folder holds more than 100.

A /goal now always plans before Claude edits files; the strict option is gone.

What changed in 2.0.3

Fixed: on Claude Desktop the footer's ◎ plan 44% never showed, so /waypoints hide left no sign of a running plan. Desktop does not draw the labels a mod adds to the footer, only what it draws as a tree, so there the label is now a tree around the engine's footer. It shows only while the band is hidden, because the band already shows the plan; the terminal is unchanged.

What changed in 2.0.2

Fixed: a plan made without /goal was named after its first task, so a plan to review the README showed as "Plan: Review README intro". The plan action now takes an optional goal, one line naming what the whole plan achieves, and the standing line asks Claude for it. The title, the toast and the pane use it; without one the plan is still named after its first task.

What changed in 2.0.1

Fixed: on the terminal the route row was missing and the running node did not pulse, because the band took the terminal for Desktop. It now goes by the surface the engine names.

What changed in 2.0

Only how it looks and when the pane opens; what Claude is told and the tool it plans with are the same as in 1.1, and the data files are unchanged, so chats still on 1.1 appear in your pane.

  • The one-row bar is now the route; the stats row moved into the pane.
  • The pane shows every task as a trail with duration bars, instead of a window of six rows with +n more.
  • The pane opens when a plan starts (Pane setting) and closes with the band.
  • Fixed hex palettes copied from Agent-Pulse replace the theme keys 1.1 drew in (cyan, green), which a skin can remap, so the colours no longer depend on your theme unless you pick claude or mono.
  • The band stays three minutes after a finish instead of ten.

What changed from goal-meter

goal-meter 1.0.0Waypoints
Plugingoal-meterwaypoints
Toolmcp__goal-meter__tasksmcp__waypoints__tasks
Command/goals/waypoints
State folder~/.claude/mods-data/goal-meter/~/.claude/mods-data/waypoints/
Plans outside /goalOnly with a line in the global CLAUDE.mdA standing line in the system prompt, /waypoints auto off to remove it

Switching: uninstall goal-meter@nateherk-mods, install waypoints@afterever-mods, delete the goal-meter bullet from the global CLAUDE.md, and open a new chat. Goals saved by goal-meter are not carried over; they only last a day anyway.

Files

State is one JSON file per chat in ~/.claude/mods-data/waypoints/, written so the pane can list other chats; /waypoints prune deletes the old ones. When Recording Mode's flag is on, titles and notes are masked before they are drawn.

Source 12 files
hooks/register.mjs 675 lines
1// Waypoints: a progress bar for /goal, built from Claude's own task plan.
2// (A fork of Nate Herk's goal-meter 1.0.0.)
3// - /goal starts it. The mod asks Claude to plan the goal as sized tasks (S, M, L)
4//   with the mod's tool, mcp__waypoints__tasks, then mark each task started and
5//   done. Claude Code stopped shipping a task-list tool after 2.1.229, so the mod
6//   brings its own. A plan made outside a /goal shows the same way.
7// - The band above the prompt: the route (the plan as nodes and legs, weighted by
8//   size), the ETA at this goal's own pace, what's running and who runs it, the next
9//   task, and the goal check's latest "not met yet" reason. The drawing is in draw.mjs.
10// - The footer: "◎ goal 69% · ~15m" in every view, terminal and Desktop.
11// - /waypoints: a pane with this chat's whole task trail and every other chat's goal.
12//   It opens by itself when a plan starts (userConfig pane: auto|off).
13//   /waypoints hide|show (the band), /waypoints auto on|off, /waypoints clear.
14// - In a /goal, file edits wait until Claude has planned.
15// - The look (userConfig palette, animation) follows Agent-Pulse's by default.
16// - A standing line in the system prompt asks Claude to plan any multi-step work,
17//   not only a /goal; /waypoints auto off removes it.
18// - Each chat writes ~/.claude/mods-data/waypoints/<session>.json for /waypoints.
19//   The mod sends no model requests of its own.
20
21import { minutes, clip, bar, basename } from './fmt.mjs'
22import { makeMasker } from './privacy.mjs'
23import { newGoal, applyAction, progress, eta, parseCheck, isStopWord, normalizeTasks, planSubject, TOOL_SPEC, instruction, standing, nudge, strictDeny } from './plan.mjs'
24import { rolesOf, resolveLook } from './palette.mjs'
25import { drawBand, drawPane, statsLine, label, FULL_MS } from './draw.mjs'
26import { createAutoPane } from './pane.mjs'
27import { OTHERS_MS, shownPath, pickPrunable, deleteArgv } from './prune.mjs'
28
29const DIR = '/.claude/mods-data/waypoints'
30const PANE = 'waypoints'
31const RECENT_MS = 3 * 60000 // a finished goal stays on screen this long
32const REOPEN_MS = 5 * 60000 // a goal closed on its tasks reopens if Claude carries on this soon
33const NUDGE_AFTER = 4 // tool calls into a goal with no plan before the reminder
34const WRITERS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
35
36let G = null
37let sessionId = ''
38let home = ''
39let win = false // the host is Windows: prune deletes with cmd's del
40let cwd = ''
41let now = 0
42let toolName = 'mcp__waypoints__tasks'
43let commandName = 'waypoints'
44let settings = { auto: true }
45let toolFailed = false
46let hidden = false
47let nudged = false
48let callsWithoutPlan = 0
49let pendingGoal = null
50let paneUp = false // the pane is open (placed or waiting), from $.ui.panes() and ui.close
51const autoPane = createAutoPane() // when the pane opens and closes by itself
52let options = { palette: 'follow', animation: 'follow', pane: 'auto' }
53let look = { palette: 'ember', level: 'subtle' }
54let roles = rolesOf('ember')
55let others = []
56let fileCount = 0 // the data folder's .json files, this chat's included
57let transcriptPath = ''
58const agentNames = new Map()
59const runningAgents = new Set()
60let rec = { on: false, strict: false }
61let mask = (s) => s
62
63function goalFile(id) {
64  return `${home}${DIR}/${id}.json`
65}
66
67function isRecent(g) {
68  return g && g.status !== 'running' && g.endedAt && now - g.endedAt < RECENT_MS
69}
70
71async function save($) {
72  if (!G || !sessionId) return
73  G.updatedAt = now
74  try {
75    await $.fs.write(goalFile(sessionId), JSON.stringify({ ...G, label: basename(cwd) }))
76  } catch {
77    // the pane falls back to this chat alone
78  }
79}
80
81async function restore($) {
82  try {
83    const path = goalFile(sessionId)
84    if (!(await $.fs.exists(path))) return
85    const saved = JSON.parse(await $.fs.read(path))
86    if (saved && Array.isArray(saved.tasks) && now - (saved.updatedAt || 0) < 24 * 3600000) G = saved
87  } catch {
88    G = null
89  }
90}
91
92async function loadOthers($) {
93  const dir = home + DIR
94  const list = []
95  let count = 0
96  try {
97    for (const entry of await $.fs.list(dir)) {
98      if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
99      count += 1
100      if (entry.name === sessionId + '.json') continue
101      if (now - (entry.mtimeMs || 0) > OTHERS_MS) continue
102      try {
103        const g = JSON.parse(await $.fs.read(dir + '/' + entry.name))
104        if (!g || !Array.isArray(g.tasks)) continue
105        if (g.status !== 'running' && now - (g.endedAt || 0) > OTHERS_MS) continue
106        list.push(g)
107      } catch {
108        // a file another chat is writing right now; next refresh reads it
109      }
110    }
111  } catch {
112    // no folder yet
113  }
114  fileCount = count
115  others = list.sort((a, b) => (a.status === 'running' ? 0 : 1) - (b.status === 'running' ? 0 : 1) || b.updatedAt - a.updatedAt)
116}
117
118// /waypoints prune: deletes what pickPrunable picks with the host's own delete ($.fs has
119// none), no shell and no dialog. Says what happened in words for a toast; the count is the
120// files really gone.
121async function prune($) {
122  const dir = home + DIR
123  const items = []
124  try {
125    for (const entry of await $.fs.list(dir)) {
126      if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
127      let goal = null
128      try {
129        goal = JSON.parse(await $.fs.read(dir + '/' + entry.name))
130      } catch {
131        // unreadable: pickPrunable keeps it while it is young
132      }
133      items.push({ name: entry.name, mtimeMs: entry.mtimeMs, goal })
134    }
135  } catch {
136    // no folder yet
137  }
138  const paths = pickPrunable(items, { now, self: sessionId + '.json' }).map((name) => dir + '/' + name)
139  if (!paths.length) return 'Nothing to prune'
140  const argv = deleteArgv(win, paths)
141  if (!argv) return `Prune did not run: delete old files by hand in ${shownPath(dir)}`
142  let r
143  try {
144    r = await $.process.run(argv)
145  } catch (err) {
146    return `Prune did not run: ${clip(String(err && err.message ? err.message : err), 80)}`
147  }
148  let gone = 0
149  for (const p of paths) if (!(await $.fs.exists(p))) gone += 1
150  await loadOthers($)
151  $.ui.invalidate('ui.render')
152  if (!gone) return `Prune did not run: ${clip(r.stderr || 'exit ' + r.exitCode, 80)}`
153  return `Pruned ${gone} old file${gone === 1 ? '' : 's'}`
154}
155
156async function readRecording($) {
157  try {
158    const path = (home || '.') + '/.claude/mods-data/recording.json'
159    if (!(await $.fs.exists(path))) rec = { on: false, strict: false }
160    else {
161      const flag = JSON.parse(await $.fs.read(path))
162      rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
163    }
164  } catch {
165    rec = { on: false, strict: false }
166  }
167  mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
168}
169
170// The palette and animation to draw with: this mod's own setting, or 'follow' Agent-Pulse's row
171async function refreshLook($) {
172  let rows = []
173  try {
174    rows = await $.config.list()
175  } catch {
176    // no rows: follow falls back to ember / subtle
177  }
178  const next = resolveLook(options, rows)
179  if (next.palette === look.palette && next.level === look.level) return
180  look = next
181  roles = rolesOf(next.palette)
182  $.ui.invalidate('ui.render')
183}
184
185// Whether the pane is open: the engine's own record, which survives a reload
186async function refreshPanes($) {
187  try {
188    paneUp = (await $.ui.panes()).some((p) => p.id === PANE)
189  } catch {
190    // keep the last answer
191  }
192}
193
194// The first plan of a goal opens the pane, unasked: it never takes the keys from the
195// prompt, waits undrawn on a narrow window, and stays shut once the person closed it
196async function maybeAutoOpen($) {
197  await refreshPanes($)
198  if (!autoPane.canOpen(options.pane, G, paneUp)) return
199  try {
200    await loadOthers($)
201    // no `focus`: the pane never asks for the prompt's keys
202    await $.ui.open({ id: PANE, title: 'Waypoints', closeOnEscape: true })
203    paneUp = true
204    autoPane.opened()
205  } catch (err) {
206    // nobody asked, so no toast; the log keeps the reason
207    $.ui.log(`waypoints: the pane did not open (${err && err.message ? err.message : err})`)
208  }
209}
210
211// A pane the mod opened leaves with the band; one the person opened stays
212async function maybeAutoClose($) {
213  if (!autoPane.shouldClose(paneUp, !!G && (G.status === 'running' || isRecent(G)))) return
214  autoPane.takenOver()
215  try {
216    await $.ui.close({ id: PANE })
217  } catch {
218    // already gone
219  }
220  paneUp = false
221}
222
223async function registerCommand($) {
224  const spec = { name: 'waypoints', description: 'Waypoints: this chat\'s goal task list and every chat\'s goal (/waypoints hide|show|auto on|off|clear|prune)', argumentHint: '[hide|show|auto on|off|clear|prune]', immediate: true }
225  try {
226    await $.command.register(spec)
227    return 'waypoints'
228  } catch {
229    try {
230      await $.command.register({ ...spec, name: 'waypoints-mod' })
231      return 'waypoints-mod'
232    } catch {
233      return null
234    }
235  }
236}
237
238async function startGoal($, condition) {
239  G = newGoal({ sessionId, condition, now, cwd })
240  hidden = false
241  nudged = false
242  callsWithoutPlan = 0
243  pendingGoal = null
244  await save($)
245  $.ui.invalidate('ui.render')
246}
247
248async function stopGoal($, status) {
249  if (!G || G.status !== 'running') return
250  G.status = status
251  G.endedAt = now
252  G.active = false
253  await save($)
254  $.ui.invalidate('ui.render')
255}
256
257async function finishGoal($, how) {
258  if (!G || G.status !== 'running') return
259  G.status = 'met'
260  G.endedAt = now
261  G.active = false
262  G.finishedBy = how
263  await save($)
264  const took = minutes(G.endedAt - G.startedAt)
265  $.ui.toast(`${G.kind === 'plan' ? 'Plan' : 'Goal'} done in ${took}: ${clip(mask(G.title), 60)}`)
266  $.ui.invalidate('ui.render')
267  // the full band keeps FULL_MS, then it is one row: draw again just after
268  $.clock.after(FULL_MS + 200, async () => {
269    now = await $.clock.now()
270    $.ui.invalidate('ui.render')
271  })
272}
273
274async function serveTool($, e) {
275  now = await $.clock.now()
276  const action = String(e.action || 'show').toLowerCase()
277  const first = normalizeTasks(e.tasks)[0]
278  if (!G || (G.status !== 'running' && (action === 'plan' || action === 'add'))) {
279    if (!first) return { result: `Waypoints: no plan in this chat yet. Call action "plan" with the tasks first, each { "title": "...", "size": "S" | "M" | "L" }.` }
280    // a plan outside /goal: tracked the same way, named after its goal line, else its first task
281    G = newGoal({ sessionId, condition: planSubject(e), now, cwd, kind: 'plan' })
282    hidden = false
283  }
284  const by = e.by ? String(e.by) : e.agentId ? agentNames.get(e.agentId) || 'agent' : ''
285  const firstPlan = action === 'plan' && !G.planAt
286  const r = applyAction(G, e, { now, by })
287  if (r.ok) {
288    await save($)
289    $.ui.invalidate('ui.render')
290    if (firstPlan && G.planAt) await maybeAutoOpen($)
291  }
292  return { result: r.text }
293}
294
295// The goal check's verdict. Its row reaches session.append with no content (the
296// payload is stored beside it, seen live 2026-10-02), so read the record from
297// the end of the chat's log: a few lines, never the whole file (logs reach 200 MB).
298async function lastGoalStatus($, since) {
299  const path = transcriptPath
300  if (!path) return null
301  const windows = /^[A-Za-z]:/.test(path)
302  const argv = windows
303    ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', `Get-Content -LiteralPath '${path.replace(/'/g, "''")}' -Tail 12 -Encoding UTF8`]
304    : ['tail', '-n', '12', path]
305  let out = ''
306  try {
307    const r = await $.process.run(argv, { timeoutMs: 15000 })
308    out = r.stdout || ''
309  } catch {
310    return null
311  }
312  const lines = out.split(/\r?\n/).filter((l) => l.includes('"goal_status"')).reverse()
313  for (const line of lines) {
314    try {
315      const row = JSON.parse(line)
316      const a = row.attachment
317      if (!a || a.type !== 'goal_status') continue
318      // an older check's record is not this check's verdict
319      const ts = Date.parse(row.timestamp || '') || 0
320      return ts && since && ts < since - 3000 ? null : a
321    } catch {
322      // a line cut by the tail
323    }
324  }
325  return null
326}
327
328// The log is written a moment after the row reaches the hook: read it now, and
329// again after 1.5 and 4 seconds when the verdict isn't there yet
330async function onCheck($, message, at, attempt = 0) {
331  if (!G || G.kind !== 'goal' || G.status !== 'running') return
332  let verdict = await lastGoalStatus($, at)
333  if (!verdict) {
334    // a build that renders the verdict into the row itself
335    const blocks = Array.isArray(message.content) ? message.content : []
336    const c = parseCheck(blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n'))
337    verdict = c.met ? { met: true } : c.notMet ? { met: false, reason: c.reason } : null
338  }
339  if (!verdict && attempt < 2) {
340    $.clock.after(attempt ? 4000 : 1500, async () => {
341      now = await $.clock.now()
342      await onCheck($, message, at, attempt + 1).catch(() => {})
343    })
344    return
345  }
346  if (!verdict || verdict.sentinel) return // the row written when the goal is set
347  if (verdict.met) return finishGoal($, 'check')
348  G.check = { met: false, reason: clip(verdict.reason || 'no reason given', 300), at: now }
349  G.checks += 1
350  await save($)
351  $.ui.invalidate('ui.render')
352}
353
354export function register(on, opts) {
355  options = { palette: 'follow', animation: 'follow', pane: 'auto', ...(opts || {}) }
356  look = resolveLook(options, [])
357  roles = rolesOf(look.palette)
358
359  on('session.start', async ($, e, next) => {
360    now = await $.clock.now()
361    home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
362    win = (await $.env.get('OS')) === 'Windows_NT'
363    sessionId = await $.session.id()
364    cwd = await $.session.cwd()
365    // where Claude Code keeps this chat's log; a settings-hook event confirms it below
366    transcriptPath = `${home}/.claude/projects/${String(cwd).replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
367    const saved = await $.store.get('settings')
368    if (saved && typeof saved === 'object') settings = { ...settings, ...saved }
369    await readRecording($)
370    try {
371      const reg = await $.tool.register(TOOL_SPEC)
372      if (reg && reg.tool) toolName = reg.tool
373    } catch (err) {
374      toolFailed = true
375      $.ui.log(`waypoints: the task tool did not register (${err && err.message ? err.message : err})`)
376    }
377    commandName = (await registerCommand($)) || commandName
378    await restore($)
379    await refreshLook($)
380    await refreshPanes($)
381    $.clock.every(15000, async () => {
382      now = await $.clock.now()
383      // the fallback for a /config change of Agent-Pulse's that never reached config.set
384      await refreshLook($)
385      await refreshPanes($)
386      if (paneUp) await loadOthers($)
387      await maybeAutoClose($)
388      if (paneUp || (G && (G.status === 'running' || isRecent(G)))) $.ui.invalidate('ui.render')
389    })
390    $.clock.every(10000, () => readRecording($).catch(() => {}))
391    return next(e)
392  })
393
394  // The standing ask for multi-step work outside a /goal, so the global CLAUDE.md
395  // needs no line for it. A session section: it sits after the cache boundary and
396  // reads the same every request. Left out where no one watches the bar (-p, the
397  // bare prompt), for teammates (they must not replace the lead's plan), and when
398  // the tool did not register or /waypoints auto off.
399  on('prompt.compose', async ($, e, next) => {
400    const r = await next(e)
401    const traits = e.traits || []
402    if (!settings.auto || toolFailed || traits.some((t) => t === 'bare' || t === 'print' || t === 'teammate')) return r
403    return { sections: [...r.sections, { id: 'waypoints:plan', text: standing(toolName), scope: 'session' }] }
404  })
405
406  // /goal <condition> starts the meter and asks Claude to plan. The ask rides as
407  // the command's hidden note, so the cached prefix is untouched.
408  on('command.run', { command: 'goal' }, async ($, e, next) => {
409    const args = String(e.args || '').trim()
410    const r = await next(e)
411    now = await $.clock.now()
412    pendingGoal = null
413    if (!args) return r
414    if (isStopWord(args)) {
415      await stopGoal($, 'stopped')
416      return r
417    }
418    await startGoal($, args)
419    const notes = r && Array.isArray(r.context) ? r.context : []
420    return { ...(r || {}), context: [...notes, instruction(toolName)] }
421  })
422
423  // Fallback when /goal reaches the session without a command.run: remember it
424  // here and start at turn.start, asking for the plan with an appended note.
425  on('prompt.submit', async ($, e, next) => {
426    const m = String(e.text || '').match(/^\s*\/goal\s+([\s\S]+)$/)
427    if (m && !isStopWord(m[1])) pendingGoal = { args: m[1].trim(), at: await $.clock.now() }
428    return next(e)
429  })
430
431  on('turn.start', async ($, e, next) => {
432    now = await $.clock.now()
433    if (pendingGoal && (!G || G.startedAt < pendingGoal.at)) {
434      const args = pendingGoal.args
435      await startGoal($, args)
436      try {
437        await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: instruction(toolName) }] } })
438      } catch {
439        // the nudge after a few tool calls is the second chance
440      }
441    }
442    pendingGoal = null
443    // closed on its tasks, but the goal loop carries on without a new prompt: reopen
444    if (G && G.status === 'met' && G.finishedBy === 'tasks' && G.kind === 'goal' && !String(e.text || '').trim() && now - G.endedAt < REOPEN_MS) {
445      G.status = 'running'
446      G.endedAt = 0
447      G.finishedBy = ''
448    }
449    if (G && G.status === 'running') {
450      G.active = true
451      G.interrupted = false
452    }
453    return next(e)
454  })
455
456  // The settings hooks' events carry the log's real path
457  on('classic.Stop', async ($, e, next) => {
458    if (e && typeof e.transcript_path === 'string' && e.transcript_path) transcriptPath = e.transcript_path
459    return next(e)
460  })
461
462  on('tool.call', async ($, e, next) => {
463    if (e.tool === toolName) return serveTool($, e)
464    if (G && G.status === 'running' && G.kind === 'goal' && !G.planAt && !e.agentId) {
465      // always on, but never asks for a tool that did not register
466      if (!toolFailed && WRITERS.has(e.tool)) return { deny: strictDeny(toolName) }
467      if (e.tool !== 'ToolSearch') callsWithoutPlan += 1
468      if (callsWithoutPlan >= NUDGE_AFTER && !nudged) {
469        nudged = true
470        try {
471          await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: nudge(toolName) }] } })
472        } catch {
473          // the band says the plan is missing either way
474        }
475        $.ui.invalidate('ui.render')
476      }
477    }
478    return next(e)
479  })
480
481  on('agent.spawn', async ($, e, next) => {
482    const r = await next(e)
483    if (r && r.agentId) {
484      agentNames.set(r.agentId, clip(e.name || e.description || e.subagentType || 'agent', 32))
485      runningAgents.add(r.agentId)
486      if (G && G.status === 'running') $.ui.invalidate('ui.render')
487    }
488    return r
489  })
490
491  // The goal check writes its verdict as an attachment row
492  on('session.append', { door: 'attachment' }, async ($, e, next) => {
493    const r = await next(e)
494    if (!e.agentId && e.message && e.message.name === 'goal_status') {
495      now = await $.clock.now()
496      try {
497        await onCheck($, e.message, now)
498      } catch {
499        // the row is stored whatever the parser makes of it
500      }
501    }
502    return r
503  })
504
505  on('turn.complete', async ($, e, next) => {
506    const r = await next(e)
507    now = await $.clock.now()
508    if (e.agentId) {
509      runningAgents.delete(e.agentId)
510      if (G && G.status === 'running') $.ui.invalidate('ui.render')
511      return r
512    }
513    if (G && G.status === 'running') {
514      G.active = false
515      G.lastTurnEnd = now
516      if (e.isAborted) G.interrupted = true
517      const p = progress(G)
518      // the turn ended with every task done: finished (the goal check's own
519      // verdict, read from the log, usually closed it a moment earlier)
520      if (e.reason === 'answer' && !e.isAborted && p.n > 0 && p.doneN === p.n) await finishGoal($, 'tasks')
521      else await save($)
522      $.ui.invalidate('ui.render')
523    }
524    return r
525  })
526
527  on('command.run', { command: ['waypoints', 'waypoints-mod'] }, async ($, e) => {
528    now = await $.clock.now()
529    const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
530    if (key === 'auto') {
531      settings.auto = value !== 'off'
532      await $.store.set('settings', settings)
533      $.ui.toast(`Standing plan ask ${settings.auto ? 'on: Claude plans any multi-step work' : 'off: only a /goal asks for a plan'}`)
534      return {}
535    }
536    if (key === 'hide' || key === 'show') {
537      hidden = key === 'hide'
538      $.ui.invalidate('ui.render')
539      $.ui.toast(hidden ? `Waypoints band hidden · /${commandName} show brings it back` : 'Waypoints band shown')
540      return {}
541    }
542    if (key === 'clear') {
543      await stopGoal($, 'stopped')
544      return {}
545    }
546    if (key === 'prune') {
547      const msg = await prune($)
548      if (!(await $.session.surface())) return { text: msg }
549      $.ui.toast(msg)
550      return {}
551    }
552    const surface = await $.session.surface()
553    if (!surface) return { text: plainText() }
554    await openPane($)
555    return {}
556  })
557
558  // The pane closing: a close by the person is remembered for this goal, so the next plan
559  // does not reopen it; the engine dropping it (unload) is not the person's doing
560  on('ui.close', async ($, e, next) => {
561    if (e.id === PANE) {
562      paneUp = false
563      autoPane.closed(e.origin, G)
564    }
565    return next(e)
566  })
567
568  // Another plugin's /config change that Waypoints follows: the new value is in the rows
569  // once the change has gone through
570  // (literals, so the engine can read the matcher off the source; FOLLOW_KEYS names the same two)
571  on('config.set', { key: ['agent-pulse.palette', 'agent-pulse.animation'] }, async ($, e, next) => {
572    const r = await next(e)
573    if (!r || !r.deny) await refreshLook($)
574    return r
575  })
576
577  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
578    const below = await next(e)
579    if (e.props && e.props.hasSurvey) return below
580    if (!bandUp()) return below
581    const el = $.ui.resolve(e)
582    // a Client names its module as a literal here, where the engine reads it from
583    const pulse = el.Client ? (key, props, width) => el.Client({ key, module: './pulse.mjs', props, width, height: 1 }) : undefined
584    const view = {
585      g: G,
586      now,
587      mask,
588      roles,
589      level: look.level,
590      width: Math.max(40, (e.props && e.props.bodyColumns) || 100),
591      maxRows: e.props && e.props.maxRows,
592      nudged,
593      pulse,
594      svg: e.surface !== 'terminal',
595      open: () => openPane($),
596    }
597    const mine = drawBand(el, view)
598    return el.Box({ flexDirection: 'column', children: below ? [mine, below] : [mine] })
599  })
600
601  // The footer shows even when the band is hidden or collapsed. Desktop does not
602  // draw the labels in `modes` (this one never showed there), only a tree, so
603  // there the engine's footer is wrapped, as arsenal's launcher and ctx do. The
604  // band shows the plan on Desktop, so the label is drawn only while it is away
605  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
606    const label = footerLabel()
607    if (!label) return next(e)
608    if (e.surface !== 'terminal') {
609      if (bandUp()) return next(e)
610      const footer = await next(e)
611      const { Box, Text } = $.ui.resolve(e)
612      return Box({ key: 'waypoints-footer', children: [Text({ children: [label] }), Text({ children: [' '] }), footer] })
613    }
614    const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
615    return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
616  })
617
618  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
619    if (e.requestId !== PANE) return next(e)
620    const el = $.ui.resolve(e)
621    const width = Math.max(50, (e.props && e.props.bodyColumns) || 100)
622    const files = { count: fileCount, dir: shownPath(home + DIR) }
623    // the running task twinkles in the pane too: the same Client as the band's route
624    const pulse = el.Client ? (key, props, w) => el.Client({ key, module: './pulse.mjs', props, width: w, height: 1 }) : undefined
625    return drawPane(el, { g: G, now, mask, roles, level: look.level, width, others, sessionId, commandName, settings, files, pulse, svg: e.surface !== 'terminal' })
626  })
627}
628
629async function openPane($) {
630  now = await $.clock.now()
631  await loadOthers($)
632  autoPane.takenOver() // the person asked: it is theirs now
633  paneUp = true
634  await $.ui.open({ id: PANE, title: 'Waypoints', focus: true, closeOnEscape: true })
635}
636
637// ---------- words ----------
638
639// Whether the band draws: a goal that runs or ended lately, and not hidden
640function bandUp() {
641  return !!G && !hidden && (G.status === 'running' || isRecent(G))
642}
643
644function footerLabel() {
645  if (!G) return ''
646  const word = G.kind === 'plan' ? 'plan' : 'goal'
647  if (G.status === 'running') {
648    if (!G.planAt) return `◎ ${word} · planning`
649    const p = progress(G)
650    const t = eta(G, now)
651    return `◎ ${word} ${p.pct}%` + (t ? ` · ~${minutes(t.ms)}` : ` · ${p.doneN}/${p.n}`)
652  }
653  if (isRecent(G)) return G.status === 'met' ? `◎ ${word} done ✓` : `◎ ${word} stopped`
654  return ''
655}
656
657const ICON = { done: '✓', active: '▶', pending: '○', dropped: '×', parked: '⏸' }
658
659function taskTail(t) {
660  if (t.status === 'done') return t.doneAt > t.startedAt ? minutes(t.doneAt - t.startedAt) : ''
661  if (t.status === 'active') return (t.by ? t.by + ' · ' : '') + 'running ' + minutes(now - t.startedAt)
662  if (t.status === 'dropped') return 'dropped' + (t.note ? ': ' + t.note : '')
663  if (t.status === 'parked') return 'parked: ' + (t.note || '')
664  return ''
665}
666
667// /waypoints where no surface can draw a pane (a -p run)
668function plainText() {
669  if (!G) return 'No goal in this chat.'
670  const p = progress(G)
671  const lines = [`${label(G)}: ${mask(G.title)}`, `${statsLine(G, p, now, mask)}  ${bar(p.fraction, 30)}`]
672  for (const t of G.tasks.filter((x) => !x.replaced)) lines.push(`${ICON[t.status] || '○'} ${t.size}  ${mask(t.title)}  ${mask(taskTail(t))}`)
673  return lines.filter(Boolean).join('\n')
674}
675
hooks/fmt.mjs 70 lines
1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2
3export function tokens(n) {
4  const v = Number(n) || 0
5  if (v >= 1e6) return (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M'
6  if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e5 ? 0 : 1) + 'k'
7  return String(Math.round(v))
8}
9
10export function usd(n) {
11  const v = Number(n) || 0
12  if (v === 0) return '$0'
13  if (v < 0.01) return '<$0.01'
14  if (v < 10) return '$' + v.toFixed(2)
15  return '$' + v.toFixed(0)
16}
17
18export function duration(ms) {
19  const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
20  if (s < 60) return s + 's'
21  const m = Math.floor(s / 60)
22  if (m < 60) return m + 'm' + String(s % 60).padStart(2, '0') + 's'
23  const h = Math.floor(m / 60)
24  return h + 'h' + String(m % 60).padStart(2, '0') + 'm'
25}
26
27export function minutes(ms) {
28  const m = Math.round((Number(ms) || 0) / 60000)
29  if (m <= 60) return m + 'm'
30  return Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0') + 'm'
31}
32
33export function clock(ts) {
34  const d = new Date(ts)
35  let h = d.getHours()
36  const ampm = h >= 12 ? 'pm' : 'am'
37  h = h % 12 || 12
38  return h + ':' + String(d.getMinutes()).padStart(2, '0') + ampm
39}
40
41const SPARKS = '▁▂▃▄▅▆▇█'
42
43export function sparkline(values, width = 12) {
44  const vals = values.slice(-width)
45  if (vals.length === 0) return ''
46  const max = Math.max(...vals, 1)
47  return vals.map((v) => SPARKS[Math.min(7, Math.floor((v / max) * 7.999))]).join('')
48}
49
50export function bar(fraction, width = 20, full = '█', empty = '░') {
51  const f = Math.max(0, Math.min(1, Number(fraction) || 0))
52  const n = Math.round(f * width)
53  return full.repeat(n) + empty.repeat(width - n)
54}
55
56export function clip(text, max) {
57  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
58  return s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s
59}
60
61export function pad(text, width) {
62  const s = String(text ?? '')
63  return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length)
64}
65
66export function basename(path) {
67  const parts = String(path || '').split(/[\\/]+/).filter(Boolean)
68  return parts[parts.length - 1] || String(path || '')
69}
70
hooks/privacy.mjs 337 lines
1// Masks secrets, personal details, and business figures for screen recording.
2// Pure functions: no mods API calls here. The same file ships in each mod that
3// draws text, since a mod may import only its own files.
4
5const DOTS = '••••••••'
6const HIDE = '•••'
7const HIDDEN_OUTPUT = '••• hidden while recording'
8
9const SECRET_PATTERNS = [
10  /sk-ant-[A-Za-z0-9_-]{16,}/g,
11  /\bsk-(?:proj-|live-|test-|svcacct-)?[A-Za-z0-9_-]{20,}/g,
12  /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
13  /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
14  /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
15  /\bAIza[0-9A-Za-z_-]{30,}/g,
16  /\bAKIA[0-9A-Z]{16}\b/g,
17  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
18  /\b(?:hf|r8|pk_live|sk_live|rk_live|whsec|pat|key)_[A-Za-z0-9]{20,}/g,
19  /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/gi,
20]
21
22// NAME=value or "name": "value" where the name looks like a credential
23const ASSIGNMENT =
24  /\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|AUTH)[A-Za-z0-9_.-]*)(["']?\s*[=:]\s*["']?)([^\s"'`,;}{]{6,})/gi
25
26const EMAIL_SRC = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}'
27const EMAIL = new RegExp(`\\b${EMAIL_SRC}\\b`, 'g')
28
29// People: "Jane Doe <jane@x.com>", "displayName": "Jane Doe", and From:/Attendees: lines
30const NAME_WORD = "[A-Z][\\p{L}'’.-]*"
31const DISPLAY_NAME = new RegExp(`(["']?)(${NAME_WORD}(?:[ \\t]+${NAME_WORD}){0,3})\\1([ \\t]*<[ \\t]*${EMAIL_SRC}[ \\t]*>)`, 'gu')
32const PERSON_KEY =
33  /("(?:displayName|display_name|fullName|full_name|firstName|first_name|lastName|last_name|givenName|given_name|familyName|family_name|real_name|realName|senderName|sender_name|authorName|author_name|username|user_name)"\s*:\s*")([^"]{1,80})(")/g
34const PERSON_LINE = /^([ \t>*-]*(?:From|To|Cc|Bcc|Reply-To|Attendees?|Organizer|Invitees?|Guests?|Assignees?|Sender|Owner)[ \t]*:[ \t]*)(\S.*)$/gm
35
36// Personal details
37const PHONE = /(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\w.])/g
38const PHONE_INTL = /(?<![\w.])\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?![\w.])/g
39const SSN = /(?<![\w-])\d{3}-\d{2}-\d{4}(?![\w-])/g
40const EIN = /(?<![\w-])\d{2}-\d{7}(?![\w-])/g
41const CARD = /(?<![\w-])(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d{2,4}){3,4}(?![\w-])/g
42const IBAN = /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g
43const ACCOUNT = /\b((?:routing|account|acct|a\/c|aba|swift|bic|iban|passport|driver'?s license)(?:\s*(?:number|no\.?|num|#))?\s*[:#]?\s*)(\d[\d -]{3,}\d)/gi
44const CARD_ENDING = /\b((?:ending(?:\s+in)?|last\s+(?:4|four)(?:\s+digits)?)\s*[:#]?\s*)(\d{4})\b/gi
45const STREET = /\b\d{1,6}\s+(?:[NSEW]\.?\s+)?(?:[A-Z][\p{L}'.-]*\s+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Place|Pl|Parkway|Pkwy|Highway|Hwy|Circle|Cir|Terrace|Ter|Trail|Trl|Square|Sq)\b\.?(?:,?\s+(?:Apt|Apartment|Suite|Ste|Unit|#)\.?\s*[\w-]+)?/gu
46const PO_BOX = /\bP\.?\s?O\.?\s+Box\s+\d+/gi
47const STATE_ZIP = /(,\s*(?:A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY]))\s+\d{5}(?:-\d{4})?\b/g
48const BIRTH = /\b((?:DOB|D\.O\.B\.|date of birth|birth\s?date|birthday|born(?: on)?)\s*[:-]?\s*)([A-Za-z0-9 ,/.-]{4,20}\d)/gi
49const IPV4 = /(?<![\w.])((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})(?![\w.])/g
50
51// Money in any common currency
52const MONEY = /(?:US|CA|AU)?[$€£¥]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?/g
53const MONEY_WORD = /(?<![\w.])(?:(?:USD|EUR|GBP|CAD|AUD)\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?|\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion))?\s?(?:USD|EUR|GBP|CAD|AUD|dollars|bucks)\b)/gi
54
55// Figures near business words: "MRR is 84k", "40% margin", "profit share 20%"
56const FIN_WORD =
57  /\b(?:revenues?|mrr|arr|gmv|profits?|profit share|margins?|ebitda|income|earnings|payroll|salar(?:y|ies)|wages?|compensation|comp|bonus(?:es)?|equity|stakes?|ownership|valuation|runway|burn(?: rate)?|cash(?:flow)?|balances?|budgets?|spend(?:ing)?|pric(?:e|es|ing)|fees?|invoices?|payouts?|distributions?|dividends?|tax(?:es)?|sales|ltv|cac|aov|sponsor(?:s|ships?)?|cpm|rpm|retainers?|commissions?|royalt(?:y|ies)|refunds?|expenses?|debts?|loans?|funding|investments?|deals?|net|gross|paid|pays?|owed?|costs?)\b/gi
58const FIGURE = /(?<![\w.])\d[\d,]*(?:\.\d+)?(?:\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b|x\b))?/g
59const BIG_FIGURE = /(?<![\w.])(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b))/g
60
61// Single names that are also ordinary words, and names that stay visible (the channel's own)
62const COMMON_WORDS = new Set(
63  'Will Mark Grant Bill Rich Art Hope Faith Joy May June April August Summer Rose Page Chase Hunter Max Ray Dawn Sky Brook Lane Dean Gene Guy Frank Sterling Major Price Young King Long Little Wood Hill Stone Field Ford Hall Bell Rice Banks Case Cook Fox Gray Green Brown White Black Day Lee West North South Love Church Park Street Bishop Mason Miller Baker Carter Cole Wells Hart Moon Star Van Von De Del La Le Da Di St Mr Mrs Ms Dr Jr Sr Claude Code Team Support Admin Info Hello The And Ai Slack Gmail Google Calendar Meeting Sync Notes Update Review Weekly Daily Monday Tuesday Wednesday Thursday Friday Saturday Sunday'.split(' '),
64)
65const ROLE_LOCAL_PARTS = /^(?:info|hello|hi|team|support|help|admin|noreply|no-reply|donotreply|contact|sales|billing|accounts?|notifications?|news|newsletter|marketing|office|mail|security|privacy|legal|press|jobs|careers|hr|ops|dev|bot|alerts?|updates?|calendar|invites?)$/i
66
67// Files that stay closed while recording. Add your own folders and file names
68// in ~/.claude/mods-data/recording-mode/config.json ("privatePaths"); /rec config makes one.
69const PRIVATE_PATHS = [
70  /(^|[\\/\s"'`])\.env(\.[A-Za-z0-9_-]+)?(?=$|[\s"'`;|&)])/i,
71  /\.credentials\.json/i,
72  /[\\/]\.claude\.json\b/i,
73  /\.claude[\\/]projects[\\/][^\\/\s"'`]+[\\/]memory/i,
74  /(^|[\\/\s"'`])\.(?:ssh|aws|gnupg)(?=$|[\\/])/i,
75  /\bid_(?:rsa|ed25519|ecdsa)\b/i,
76  /(^|[\\/\s"'`])\.(?:netrc|npmrc|pypirc)\b/i,
77  /\.(?:pem|p12|pfx)\b/i,
78  /taxes?[-_ ]?20\d\d/i,
79  /[\\/_-](?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|cap[-_ ]?table|term[-_ ]?sheets?|offer[-_ ]?letters?|business[-_ ]?plans?)(?=$|[\\/._\s"'`;|&)-])/i,
80  /(^|[\s"'`])(?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|business[-_ ]?plans?)(?=[\\/.])/i,
81]
82
83// Tools whose results are business data: drawn as hidden while recording
84const BUSINESS_TOOL =
85  /(?:^|__|_)(?:clickup|slack|gmail|outlook|notion|asana|linear|jira|clay|qbo|quickbooks|xero|fireflies|hubspot|salesforce|stripe|calendar|gcal|google_drive|drive|sheets)|search_threads|get_thread|get_message|list_drafts|get_draft|read_file_content|download_file_content|search_files|list_recent_files|get_file_metadata|profit_loss|cash_flow|balance_sheet|payroll|session_transcripts|export_transcript/i
86const BUSINESS_COMMAND = /\bgws(?:\.cmd|\.exe)?\b|fireflies|quickbooks/i
87// Finance tools stay closed while recording
88const FINANCE_TOOL = /__(?:qbo_|quickbooks|profit_loss|cash_flow|balance_sheet|benchmarking_quickbooks|money_onboarding|company_info)/i
89
90function escapeRegExp(s) {
91  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
92}
93
94// Values from .env files: long enough to be secret, and not plain words or numbers.
95export function secretValuesFromEnv(text) {
96  const values = []
97  for (const line of String(text || '').split(/\r?\n/)) {
98    const m = line.match(/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)\s*$/)
99    if (!m) continue
100    let v = m[1].trim()
101    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
102    if (v.length < 8) continue
103    if (/^(true|false|null|none|yes|no)$/i.test(v)) continue
104    if (/^\d+(\.\d+)?$/.test(v)) continue
105    if (/your[-_ ]?(key|token|secret)|here$|^<.*>$|^xxx/i.test(v)) continue
106    values.push(v)
107  }
108  return [...new Set(values)].sort((a, b) => b.length - a.length)
109}
110
111// The forms of one person's name that get masked: the full name, its slug, and each part that isn't an ordinary word
112function nameForms(full, keep) {
113  const clean = String(full || '').replace(/\s+/g, ' ').trim()
114  if (!clean || clean.length > 60 || keep.has(clean)) return []
115  const forms = new Set()
116  const parts = clean.split(/[ -]/).filter((p) => /^[A-Z][\p{L}'’.]*$/u.test(p) && p.length >= 2)
117  if (parts.length >= 2) {
118    forms.add(clean)
119    forms.add(clean.toLowerCase())
120    forms.add(clean.toLowerCase().replace(/ /g, '-'))
121    forms.add(clean.toLowerCase().replace(/ /g, '_'))
122  }
123  for (const p of parts) if (!COMMON_WORDS.has(p) && !keep.has(p)) forms.add(p)
124  return [...forms]
125}
126
127function namesFromEmail(email) {
128  const local = email.split('@')[0]
129  if (ROLE_LOCAL_PARTS.test(local)) return null
130  const parts = local.split(/[._+-]/).filter((p) => /^[a-z]{2,}$/i.test(p))
131  if (parts.length < 2 || parts.length > 4) return null
132  return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ')
133}
134
135// Masks numbers in the same clause as a business word
136function maskFigures(text, strict) {
137  if (strict) text = text.replace(BIG_FIGURE, HIDE)
138  const ranges = []
139  for (const m of text.matchAll(FIN_WORD)) {
140    const s = m.index
141    const e = s + m[0].length
142    let a = Math.max(0, s - 30)
143    let b = Math.min(text.length, e + 45)
144    const before = text.slice(a, s)
145    const stop = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';'), before.search(/[.!?]\s[^.!?]*$/))
146    if (stop >= 0) a += stop + 1
147    const after = text.slice(e, b)
148    const end = after.search(/\n|;|[.!?](?:\s|$)/)
149    if (end >= 0) b = e + end
150    ranges.push([a, b])
151  }
152  if (!ranges.length) return text
153  return text.replace(FIGURE, (fig, offset) => {
154    if (/^(?:19|20)\d\d$/.test(fig)) return fig // a year
155    return ranges.some(([a, b]) => offset >= a && offset < b) ? HIDE : fig
156  })
157}
158
159function luhn(digits) {
160  let sum = 0
161  for (let i = 0; i < digits.length; i++) {
162    let d = Number(digits[digits.length - 1 - i])
163    if (i % 2 === 1) {
164      d *= 2
165      if (d > 9) d -= 9
166    }
167    sum += d
168  }
169  return sum % 10 === 0
170}
171
172function publicIp(ip) {
173  const [a, b] = ip.split('.').map(Number)
174  if (a === 10 || a === 127 || a === 0 || ip === '255.255.255.255') return false
175  if (a === 192 && b === 168) return false
176  if (a === 172 && b >= 16 && b <= 31) return false
177  if (a === 169 && b === 254) return false
178  return true
179}
180
181// Your own name and its parts stay visible
182function keepSet(keepNames) {
183  const keep = new Set()
184  for (const n of keepNames || []) {
185    const clean = String(n || '').replace(/\s+/g, ' ').trim()
186    if (!clean) continue
187    keep.add(clean)
188    keep.add(clean.toLowerCase())
189    keep.add(clean.toLowerCase().replace(/ /g, '-'))
190    for (const part of clean.split(/[ -]/)) if (part) keep.add(part)
191  }
192  return keep
193}
194
195// strict: also every large figure (1,250 / 18% / 40k) wherever it appears
196// names: people to mask; keepNames: names that stay visible (yours)
197export function makeMasker({ strict = false, values = [], names = [], keepNames = [] } = {}) {
198  const KEEP_NAMES = keepSet(keepNames)
199  const valueRe = values.length
200    ? new RegExp(values.map(escapeRegExp).join('|'), 'g')
201    : null
202  // Names: the roster passed in plus any learned from emails and contact fields. Memory only.
203  const people = new Set()
204  let nameRe = null
205  let dirty = false
206  const learn = (full) => {
207    if (people.size > 2000) return
208    for (const f of nameForms(full, KEEP_NAMES)) {
209      if (!people.has(f)) {
210        people.add(f)
211        dirty = true
212      }
213    }
214  }
215  for (const n of names) learn(n)
216  const nameRegex = () => {
217    if (dirty) {
218      const list = [...people].sort((a, b) => b.length - a.length).map(escapeRegExp)
219      nameRe = list.length ? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${list.join('|')})(?:['’]s)?(?![\\p{L}\\p{N}_])`, 'gu') : null
220      dirty = false
221    }
222    return nameRe
223  }
224
225  return function mask(text) {
226    if (typeof text !== 'string' || text.length === 0) return text
227    let out = text
228    if (valueRe) out = out.replace(valueRe, DOTS)
229    for (const re of SECRET_PATTERNS) out = out.replace(re, DOTS)
230    out = out.replace(ASSIGNMENT, (_, name, sep) => name + sep + DOTS)
231
232    // people, learned before the emails that name them are masked
233    for (const m of out.matchAll(EMAIL)) {
234      const n = namesFromEmail(m[0])
235      if (n) learn(n)
236    }
237    out = out.replace(DISPLAY_NAME, (_, q, name, addr) => {
238      learn(name)
239      return KEEP_NAMES.has(name) ? q + name + q + addr : HIDE + addr
240    })
241    out = out.replace(PERSON_KEY, (_, head, value, tail) => {
242      learn(value)
243      return head + (KEEP_NAMES.has(value) ? value : HIDE) + tail
244    })
245    out = out.replace(PERSON_LINE, (line, label, value) => (KEEP_NAMES.has(value.trim()) ? line : label + HIDE))
246    out = out.replace(EMAIL, '•••@•••')
247    const re = nameRegex()
248    if (re) out = out.replace(re, HIDE)
249
250    // personal details
251    out = out.replace(CARD, (m) => {
252      const digits = m.replace(/\D/g, '')
253      return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? '•••• •••• •••• ••••' : m
254    })
255    out = out.replace(SSN, '•••-••-••••')
256    out = out.replace(EIN, '••-•••••••')
257    out = out.replace(IBAN, HIDE)
258    out = out.replace(ACCOUNT, (_, head) => head + HIDE)
259    out = out.replace(CARD_ENDING, (_, head) => head + '••••')
260    out = out.replace(BIRTH, (_, head) => head + HIDE)
261    out = out.replace(STREET, HIDE)
262    out = out.replace(PO_BOX, HIDE)
263    out = out.replace(STATE_ZIP, (_, head) => head + ' •••••')
264    out = out.replace(PHONE, '•••-•••-••••')
265    out = out.replace(PHONE_INTL, '+•• •••')
266    out = out.replace(IPV4, (ip) => (publicIp(ip) ? '•••.•••.•••.•••' : ip))
267
268    // business figures
269    out = out.replace(MONEY, '$•••')
270    out = out.replace(MONEY_WORD, HIDE)
271    out = maskFigures(out, strict)
272    return out
273  }
274}
275
276// Masks every string inside plain data, keeping the shape.
277export function deepMask(value, mask, depth = 0) {
278  if (depth > 12) return value
279  if (typeof value === 'string') return mask(value)
280  if (Array.isArray(value)) return value.map((v) => deepMask(v, mask, depth + 1))
281  if (value && typeof value === 'object') {
282    const proto = Object.getPrototypeOf(value)
283    if (proto !== Object.prototype && proto !== null) return value
284    const out = {}
285    for (const [k, v] of Object.entries(value)) out[k] = deepMask(v, mask, depth + 1)
286    return out
287  }
288  return value
289}
290
291// Hides the readable text inside a business tool's result, keeping short tags (type: 'text') so the row still draws.
292export function hideText(s) {
293  if (typeof s !== 'string' || s.length === 0) return s
294  return s.length > 24 || /\s/.test(s) ? HIDDEN_OUTPUT : s
295}
296
297// extra: your own folder or file names from the config file, matched anywhere in a path, any case
298export function privatePathHit(text, extra = []) {
299  const s = String(text || '')
300  for (const re of PRIVATE_PATHS) {
301    const m = s.match(re)
302    if (m) return m[0].trim().replace(/^["'`\\/_-]/, '')
303  }
304  const flat = s.replace(/\\/g, '/').toLowerCase()
305  for (const item of extra) {
306    const needle = String(item || '').replace(/\\/g, '/').toLowerCase().trim()
307    if (needle && flat.includes(needle)) return String(item)
308  }
309  return null
310}
311
312// The strings in a tool call's arguments that could name a file or carry a command.
313export function toolCallTargets(e) {
314  const keys = ['file_path', 'path', 'pattern', 'glob', 'command', 'notebook_path', 'url']
315  const out = []
316  for (const k of keys) if (typeof e?.[k] === 'string') out.push(e[k])
317  return out
318}
319
320// Whether a tool's result is business data (mail, chat, tasks, files, calendar, finance)
321// extra: more tool or command names (any part of the name) whose results draw as hidden
322export function businessSource(tool, input, extra = []) {
323  const name = String(tool || '')
324  if (BUSINESS_TOOL.test(name)) return true
325  const cmd = input && typeof input.command === 'string' ? input.command : ''
326  const hay = (name + ' ' + cmd).toLowerCase()
327  if (extra.some((x) => x && hay.includes(String(x).toLowerCase()))) return true
328  const command = input && typeof input.command === 'string' ? input.command : ''
329  return command ? BUSINESS_COMMAND.test(command) : false
330}
331
332// extra: more tool names (any part of the name) that stay closed while recording
333export function financeTool(tool, extra = []) {
334  const name = String(tool || '')
335  return FINANCE_TOOL.test(name) || extra.some((x) => x && name.toLowerCase().includes(String(x).toLowerCase()))
336}
337
hooks/plan.mjs 299 lines
1// Waypoints' plan: the task list Claude keeps through the mod's own tool, and
2// what the bar, the ETA, and the goal check read from it. Pure functions: no mods
3// API calls here, so the harness tests can drive them directly.
4
5export const SIZES = { S: 1, M: 2, L: 3 }
6
7const STOP = /^(clear|stop|off|cancel|end|none|reset)$/i
8
9export function isStopWord(text) {
10  return STOP.test(String(text || '').trim())
11}
12
13export function titleOf(condition) {
14  const line = String(condition || '').split(/\r?\n/).map((l) => l.trim()).find(Boolean) || 'Goal'
15  return line.length > 160 ? line.slice(0, 159) + '…' : line
16}
17
18// What a plan made outside /goal is named after: the one line Claude gives as "goal",
19// else the first task (which names a step, not the whole plan)
20export function planSubject(input) {
21  const goal = String(input.goal || '').trim()
22  return goal ? titleOf(goal) : titleOf(normalizeTasks(input.tasks)[0]?.title)
23}
24
25export function newGoal({ sessionId, condition, now, cwd, kind = 'goal' }) {
26  return {
27    sessionId,
28    kind,
29    title: titleOf(condition),
30    condition: String(condition || '').slice(0, 2000),
31    cwd: cwd || '',
32    startedAt: now,
33    planAt: 0,
34    endedAt: 0,
35    status: 'running',
36    active: true,
37    lastTurnEnd: 0,
38    interrupted: false,
39    tasks: [],
40    nextId: 1,
41    firstPlan: 0,
42    check: null,
43    checks: 0,
44    updatedAt: now,
45  }
46}
47
48function sizeOf(v) {
49  const s = String(v || '').trim().toUpperCase().charAt(0)
50  return SIZES[s] ? s : 'M'
51}
52
53function clean(text, max) {
54  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
55  return s.length > max ? s.slice(0, max - 1) + '…' : s
56}
57
58export function weight(t) {
59  return SIZES[t.size] || 2
60}
61
62// Tasks still part of the plan: dropped and replaced ones don't count
63export function live(goal) {
64  return goal.tasks.filter((t) => t.status !== 'dropped' && !t.replaced)
65}
66
67// What the numbers count: a parked task is still drawn, but waits on someone outside
68// the chat, so it is left out of the total and the plan can finish without it
69export function progress(goal) {
70  const all = live(goal)
71  const parked = all.filter((t) => t.status === 'parked')
72  const tasks = all.filter((t) => t.status !== 'parked')
73  const total = tasks.reduce((a, t) => a + weight(t), 0)
74  const done = tasks.filter((t) => t.status === 'done')
75  const doneW = done.reduce((a, t) => a + weight(t), 0)
76  return {
77    total,
78    doneW,
79    doneN: done.length,
80    n: tasks.length,
81    parked,
82    active: tasks.filter((t) => t.status === 'active'),
83    pct: total ? Math.round((100 * doneW) / total) : 0,
84    fraction: total ? doneW / total : 0,
85  }
86}
87
88// The ETA at this goal's own pace: time since the plan per unit of finished
89// work, times the work left. Nothing until two tasks are done.
90export function eta(goal, now) {
91  const p = progress(goal)
92  if (goal.status !== 'running' || p.doneN < 2 || p.doneW <= 0 || p.doneW >= p.total) return null
93  const since = goal.planAt || goal.startedAt
94  const per = Math.max(0, now - since) / p.doneW
95  const ms = Math.round(per * (p.total - p.doneW))
96  return { ms, at: now + ms }
97}
98
99// Accepts [{ title, size }], plain strings, or the same as a JSON string
100export function normalizeTasks(raw) {
101  let list = raw
102  if (typeof list === 'string') {
103    try {
104      list = JSON.parse(list)
105    } catch {
106      list = list.split(/\r?\n/)
107    }
108  }
109  if (!Array.isArray(list)) return []
110  const out = []
111  for (const item of list) {
112    if (typeof item === 'string') {
113      const title = clean(item.replace(/^\s*[-*\d.)\]]+\s*/, ''), 140)
114      if (title) out.push({ title, size: 'M' })
115      continue
116    }
117    if (!item || typeof item !== 'object') continue
118    const title = clean(item.title ?? item.subject ?? item.name ?? item.task ?? item.description, 140)
119    if (title) out.push({ title, size: sizeOf(item.size) })
120  }
121  return out.slice(0, 60)
122}
123
124function idsOf(input) {
125  const raw = Array.isArray(input.ids) ? input.ids : input.id !== undefined ? [input.id] : []
126  return raw.map((v) => Number(String(v).replace(/^#/, ''))).filter((n) => Number.isInteger(n) && n > 0)
127}
128
129function addTasks(goal, list, now, origin, rev = false) {
130  for (const t of list) {
131    goal.tasks.push({ id: goal.nextId++, title: t.title, size: t.size, status: 'pending', by: '', addedAt: now, startedAt: 0, doneAt: 0, origin, note: '', ...(rev ? { rev: true } : {}) })
132  }
133}
134
135// The task finished last, which a revision of the plan follows from (0 when none is)
136function lastDoneId(goal) {
137  const done = goal.tasks.filter((t) => t.status === 'done').sort((a, b) => b.doneAt - a.doneAt || b.id - a.id)
138  return done.length ? done[0].id : 0
139}
140
141const WORD = { pending: 'todo', active: 'running', done: 'done', dropped: 'dropped', parked: 'parked' }
142
143export function listText(goal) {
144  const p = progress(goal)
145  const parked = p.parked.length ? ` ${p.parked.length} parked (not counted).` : ''
146  const lines = [`Plan: ${p.doneN} of ${p.n} tasks done, ${p.pct}% of the work.${parked}`]
147  for (const t of goal.tasks) {
148    if (t.replaced) continue
149    const who = t.status === 'active' && t.by ? ` by ${t.by}` : ''
150    const why = t.status === 'parked' && t.note ? `: ${t.note}` : ''
151    lines.push(`#${t.id} ${WORD[t.status] || t.status}  ${t.title} (${t.size})${who}${why}`)
152  }
153  return lines.join('\n')
154}
155
156// One call of the task tool. Returns { ok, text } and changes the goal in place.
157export function applyAction(goal, input, { now, by = '' } = {}) {
158  const action = String(input.action || 'show').toLowerCase()
159  const fail = (msg) => ({ ok: false, text: `Waypoints: ${msg}\n${listText(goal)}` })
160  if (action === 'plan' || action === 'add') {
161    const list = normalizeTasks(input.tasks)
162    if (!list.length) return fail(`"${action}" needs tasks: [{ "title": "...", "size": "S" | "M" | "L" }].`)
163    if (action === 'plan' && goal.planAt) {
164      // a new plan replaces the work not started yet; finished and running tasks stay
165      for (const t of goal.tasks) if (t.status === 'pending') t.replaced = true
166    }
167    const first = !goal.planAt
168    // a plan changed for a reason: what changed, and after which finished task
169    const note = first ? '' : clean(input.note, 140)
170    if (note) {
171      goal.revisions = [...(goal.revisions || []), { at: now, after: lastDoneId(goal), note }].slice(-20)
172    }
173    addTasks(goal, list, now, first ? 'plan' : 'later', !!note)
174    if (first) {
175      goal.planAt = now
176      goal.firstPlan = list.length
177    }
178  } else if (action === 'start' || action === 'done' || action === 'drop' || action === 'park') {
179    const ids = idsOf(input)
180    if (!ids.length) return fail(`"${action}" needs the task's id.`)
181    if (action === 'park' && !clean(input.note, 140)) return fail('"park" needs a "note" naming who or what the task waits on.')
182    const tasks = ids.map((id) => goal.tasks.find((t) => t.id === id && !t.replaced))
183    const missing = ids.filter((id, i) => !tasks[i])
184    if (missing.length) return fail(`no task #${missing.join(', #')}.`)
185    for (const t of tasks) {
186      if (action === 'start') {
187        // a parked task taken up again loses its blocker
188        if (t.status === 'parked') t.note = ''
189        t.status = 'active'
190        t.startedAt = t.startedAt || now
191        if (by) t.by = clean(by, 32)
192      } else if (action === 'park') {
193        t.status = 'parked'
194        t.note = clean(input.note, 140)
195      } else if (action === 'done') {
196        t.status = 'done'
197        t.startedAt = t.startedAt || now
198        t.doneAt = now
199        if (input.note) t.note = clean(input.note, 140)
200      } else {
201        t.status = 'dropped'
202        t.doneAt = now
203        t.note = clean(input.note || 'no longer needed', 140)
204      }
205    }
206  } else if (action !== 'show') {
207    return fail(`unknown action "${action}". Use plan, add, start, done, drop, park, or show.`)
208  }
209  goal.updatedAt = now
210  return { ok: true, text: listText(goal) }
211}
212
213// The goal check's verdict, from the text the engine records for it. The shape
214// is not documented, so this reads both a JSON-ish record and plain words.
215export function parseCheck(text) {
216  const t = String(text || '')
217  const notMet = /"met"\s*:\s*false|\bmet\s*[=:]\s*false|\bnot\s+(?:yet\s+)?(?:been\s+)?(?:met|achieved|satisfied|complete)|\bunmet\b|\bnot\s+satisfied\b/i.test(t)
218  const met = !notMet && /"met"\s*:\s*true|\bmet\s*[=:]\s*true|\b(?:condition|goal)\b[^.\n]{0,60}\b(?:is|was|has been)\s+(?:met|achieved|satisfied)\b/i.test(t)
219  const m = t.match(/"reason"\s*:\s*"((?:[^"\\]|\\.)*)"/) || t.match(/\breason\b[^:\n]{0,20}:\s*([\s\S]+)/i)
220  const reason = clean(m ? m[1].replace(/\\"/g, '"').replace(/\\n/g, ' ') : '', 300)
221  return { met, notMet, reason }
222}
223
224export const TOOL_SPEC = {
225  name: 'tasks',
226  description:
227    'Waypoints: the task plan the user watches as a progress bar while a /goal runs. ' +
228    'Call action "plan" first with every task needed to meet the goal, in order, each sized S, M, or L. ' +
229    'Call "start" with a task id when you begin it (set "by" to a subagent\'s short name when one does it) and "done" when it is finished. ' +
230    'Call "add" for work you discover or when the goal check says the goal is not met yet, and "drop" for a task no longer needed. ' +
231    'Call "park" with a "note" naming the blocker for a task only someone outside this session can move (another team, a release window, a person who is away): it leaves the bar\'s total, so the plan can finish without it. ' +
232    'When a task turns out differently than the plan assumed, fix the tasks still to do ("plan" again replaces the pending ones, or "add" / "drop") with a "note" saying what changed. ' +
233    'Keep it accurate: the bar and the ETA come only from this list.',
234  inputSchema: {
235    type: 'object',
236    properties: {
237      action: { type: 'string', enum: ['plan', 'add', 'start', 'done', 'drop', 'park', 'show'] },
238      goal: { type: 'string', description: 'For plan: one line naming what the whole plan achieves, shown as its title' },
239      tasks: {
240        type: 'array',
241        description: 'For plan and add: the tasks in order.',
242        items: {
243          type: 'object',
244          properties: {
245            title: { type: 'string', description: 'What the task delivers, in a few words' },
246            size: { type: 'string', enum: ['S', 'M', 'L'], description: 'S: a few minutes. M: a solid chunk. L: the big piece.' },
247          },
248          required: ['title'],
249        },
250      },
251      id: { type: 'number', description: 'For start, done, drop, and park: the task number' },
252      ids: { type: 'array', items: { type: 'number' }, description: 'Several task numbers at once' },
253      by: { type: 'string', description: 'For start: who does it, "main" or the subagent\'s short name' },
254      note: { type: 'string', description: 'One short line. For park (required): who or what the task waits on. For plan again or add: what changed. For done or drop: the outcome or why.' },
255    },
256    required: ['action'],
257  },
258}
259
260// The two rules both asks carry: keep the later steps true to what happened, and never
261// leave the bar on an open task nobody explained
262const REVISE = `When a task turns out differently than the plan assumed, check the tasks still to do and fix them ("plan" again, "add" or "drop") with a "note" saying what changed. `
263const OPEN_END =
264  `Never end your turn with a task still open and unexplained: finish it, "park" it with a "note" naming the blocker when it waits on someone outside this session, ` +
265  `or, when you are unsure what it takes, ask the user with concrete options and your recommendation. `
266
267export function instruction(tool) {
268  return (
269    `Waypoints is on for this goal: the user watches your progress as a bar built from your task plan. ` +
270    `Before you start the work, call ${tool} (load it with ToolSearch if it is deferred) with action "plan" and every task needed to meet the goal, in order, each sized S, M, or L. ` +
271    `Call "start" with a task's id when you begin it and "done" when it is finished. ` +
272    `If you find more work, or the goal check says the goal is not met yet, call "add" with the new tasks. ` +
273    REVISE +
274    OPEN_END +
275    `When a subagent does a task, call "start" with "by" set to its short name, and "done" when it reports back.`
276  )
277}
278
279// The standing ask, kept in the system prompt: plan any multi-step work, not only
280// a /goal. It replaces the line the mod used to need in the global CLAUDE.md.
281export function standing(tool) {
282  return (
283    `Waypoints is installed: the user watches a progress bar built from your task plan. ` +
284    `For multi-step work (3 or more steps), call ${tool} (load it with ToolSearch if it is deferred) with action "plan" before you start, with "goal" set to one line naming what the whole plan achieves and every task in order, each sized S, M, or L. ` +
285    `Then call "start" with a task's id when you begin it and "done" when it is finished, and "add" work you find along the way. ` +
286    REVISE +
287    OPEN_END +
288    `Skip it for small single-step requests. If you are a subagent, never call "plan": it replaces the main agent's pending tasks.`
289  )
290}
291
292export function nudge(tool) {
293  return `Reminder from Waypoints: this /goal has no task plan yet, so the user's progress bar is empty. Call ${tool} with action "plan" now (every task to meet the goal, in order, sized S, M, or L), then mark tasks as you go.`
294}
295
296export function strictDeny(tool) {
297  return `Waypoints: plan the goal before changing files. Call ${tool} with action "plan" first, then retry this.`
298}
299
hooks/palette.mjs 138 lines
1// Waypoints' colours. The four palettes below are COPIED from agent-pulse/hooks/palette.ts
2// (each mod is installed alone, so there is no import across mods): keep the values in
3// step with Agent-Pulse. palette.test.ts pins a few, so a drift is a deliberate edit.
4// Only the phases Waypoints paints with are copied: idle, tool, agent, approval.
5
6const CLAUDE_HEX = { idle: '#8a8a8a', tool: '#d97757', agent: '#2ec4b6', approval: '#ffc107' }
7const EMBER_HEX = { idle: '#857a74', tool: '#d97757', agent: '#8cc7b5', approval: '#ffcf5c' }
8const EMBER_GLOW = { idle: '#9a8f88', tool: '#f3a97c', agent: '#b6d99a', approval: '#ffb347' }
9const NEON_HEX = { idle: '#5b6785', tool: '#22e5f0', agent: '#4ade80', approval: '#ffd166' }
10const NEON_GLOW = { idle: '#6f7a99', tool: '#6ef0c8', agent: '#bef264', approval: '#ff9f6e' }
11const MONO_HEX = { idle: '#7a7a7a', tool: '#d0d0d0', agent: '#d0d0d0', approval: '#ffc107' }
12
13const OUTCOME_HEX = { ok: '#4eba65', error: '#ff6b80', denied: '#ffc107', interrupted: '#8a8a8a' }
14const OUTCOME_THEME = { ok: 'success', error: 'error', denied: 'warning', interrupted: 'inactive' }
15const EMBER_OUTCOME = { ok: '#8fd694', error: '#ff6b6b', denied: '#ffcf5c', interrupted: '#857a74' }
16const NEON_OUTCOME = { ok: '#4ade80', error: '#ff5c7a', denied: '#ffd166', interrupted: '#6f7a99' }
17
18const SHIMMER_STEPS = 12
19
20// phase: what Text takes (a theme key where one means the same); hex/glow: the same colour as hex
21const PALETTES = {
22  ember: {
23    phase: EMBER_HEX,
24    hex: EMBER_HEX,
25    glow: EMBER_GLOW,
26    outcome: EMBER_OUTCOME,
27    sparkle: ['#d97757', '#e8957a', '#f3a97c', '#f6c177', '#ffe3b0', '#f6c177', '#f3a97c', '#e8957a'],
28    heat: ['#f6d36b', '#ff9a4d', '#ff5c5c'],
29    agents: ['#8cc7b5', '#f2b8a0', '#c6a4ec', '#f6c177', '#ee9aa4', '#9fc7e8'],
30  },
31  claude: {
32    phase: { idle: 'inactive', tool: 'claude', agent: CLAUDE_HEX.agent, approval: 'warning' },
33    hex: CLAUDE_HEX,
34    glow: CLAUDE_HEX,
35    outcome: OUTCOME_THEME,
36    sparkle: ['#d97757', '#e48a66', '#f0a47f', '#fbc8a8', '#ffe6d5', '#fbc8a8', '#f0a47f', '#e48a66'],
37    heat: ['#e0af68', '#ff9e64', 'error'],
38    agents: ['#2ec4b6', '#f4a261', '#a78bfa', '#60a5fa', '#f472b6', '#84cc16'],
39  },
40  neon: {
41    phase: NEON_HEX,
42    hex: NEON_HEX,
43    glow: NEON_GLOW,
44    outcome: NEON_OUTCOME,
45    sparkle: ['#22e5f0', '#5eead4', '#7dd3fc', '#b39dff', '#ff8ad8', '#c4a1ff', '#7aa2ff', '#38bdf8'],
46    heat: ['#ffd166', '#ff9f6e', '#ff5c7a'],
47    agents: ['#5eead4', '#ff8ad8', '#bef264', '#ffd166', '#b39dff', '#7dd3fc'],
48  },
49  mono: {
50    phase: { idle: 'inactive', tool: 'text', agent: 'text', approval: 'warning' },
51    hex: MONO_HEX,
52    glow: MONO_HEX,
53    outcome: OUTCOME_THEME,
54    sparkle: ['#6e6e6e', '#8a8a8a', '#a8a8a8', '#c8c8c8', '#e8e8e8', '#c8c8c8', '#a8a8a8', '#8a8a8a'],
55    heat: ['warning', 'warning', 'error'],
56    agents: ['#d0d0d0', '#a8a8a8', '#e8e8e8', '#8a8a8a'],
57  },
58}
59
60export const PALETTE_NAMES = ['ember', 'claude', 'neon', 'mono']
61export const LEVELS = ['off', 'subtle', 'full']
62
63export function paletteOf(name) {
64  return PALETTES[name === 'claude' || name === 'neon' || name === 'mono' ? name : 'ember']
65}
66
67export function levelOf(value) {
68  return value === 'off' || value === 'full' ? value : 'subtle'
69}
70
71// Mixes two hex colours, t 0 being a and 1 being b
72export function mix(a, b, t) {
73  const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
74  const from = channels(a)
75  const to = channels(b)
76  const k = Math.min(1, Math.max(0, t))
77  return '#' + from.map((c, i) => Math.round(c + (to[i] - c) * k).toString(16).padStart(2, '0')).join('')
78}
79
80// A phase's colour t of the way from its core to its glow. A flat palette keeps its own
81// colour, which may be a theme key.
82export function shadeOf(palette, phase, t) {
83  const core = palette.hex[phase]
84  const glow = palette.glow[phase]
85  return core === glow ? palette.phase[phase] : mix(core, glow, t)
86}
87
88// The spinner's colours, one per frame: core to glow and back, or the one colour of a flat palette
89export function shimmerOf(palette, phase) {
90  if (palette.hex[phase] === palette.glow[phase]) return [palette.phase[phase]]
91  return Array.from({ length: SHIMMER_STEPS }, (_, i) => shadeOf(palette, phase, (1 - Math.cos((i / SHIMMER_STEPS) * 2 * Math.PI)) / 2))
92}
93
94// One colour per agent, the same each time its name comes up (and the same Agent-Pulse gives it)
95export function agentColorOf(palette, name) {
96  let hash = 0
97  for (const char of String(name)) hash = (hash * 31 + char.charCodeAt(0)) | 0
98  return palette.agents[Math.abs(hash) % palette.agents.length] || palette.hex.agent
99}
100
101// The roles Waypoints draws with, mapped onto the palette's phase colours so a palette
102// means the same in both mods. `done(t)` shades along the route, t 0..1 (position / width).
103export function rolesOf(name) {
104  const p = paletteOf(name)
105  return {
106    name: PALETTE_NAMES.includes(name) ? name : 'ember',
107    done: (t) => shadeOf(p, 'tool', t),
108    active: shimmerOf(p, 'tool'),
109    activeStill: p.phase.tool,
110    pending: p.phase.idle,
111    attention: p.phase.approval,
112    ok: p.outcome.ok,
113    sparkle: p.sparkle,
114    heat: p.heat[0],
115    agent: (by) => agentColorOf(p, by),
116    // the SVG always takes hex, never a theme key
117    hex: { done: p.hex.tool, glow: p.glow.tool, pending: p.hex.idle, ok: p.hex.tool, sparkle: p.sparkle },
118  }
119}
120
121// The row Waypoints follows in /config; the key is confirmed live (see DESIGN.md)
122export const FOLLOW_KEYS = { palette: 'agent-pulse.palette', animation: 'agent-pulse.animation' }
123
124// The look to draw with. options: the userConfig values ('follow' or explicit); rows:
125// $.config.list(). 'follow' takes Agent-Pulse's own value, or ember/subtle without it.
126export function resolveLook(options, rows) {
127  const o = options || {}
128  const list = Array.isArray(rows) ? rows : []
129  const fromRow = (key) => {
130    const row = list.find((r) => r && r.key === key)
131    return row ? row.value : undefined
132  }
133  const palette = o.palette && o.palette !== 'follow' ? o.palette : fromRow(FOLLOW_KEYS.palette)
134  const animation = o.animation && o.animation !== 'follow' ? o.animation : fromRow(FOLLOW_KEYS.animation)
135  const name = PALETTE_NAMES.includes(palette) ? palette : 'ember'
136  return { palette: name, level: levelOf(animation) }
137}
138
hooks/draw.mjs 410 lines
1// Waypoints' drawing: the band above the prompt (and, below, the pane). Pure: every
2// function takes the element table `el` and a `view` and returns a tree, so the harness
3// can draw a band without the engine.
4//
5// view = {
6//   g,        the goal                     now,    the clock
7//   mask,     Recording Mode's masker      roles,  rolesOf(palette)
8//   level,    'off' | 'subtle' | 'full'    width,  bodyColumns
9//   maxRows,  rows the band may take       nudged, Claude was reminded to plan
10//   pulse,    (key, props, width) → a terminal Client (built in register.mjs, which the
11//             engine reads the module path from); absent, nothing animates
12//   open,     () → open the pane
13//   svg,      the surface paints Svg (Desktop, mobile): the engine's `e.surface` says so, as
14//             the terminal's element table lists `Svg` too and cannot tell them apart
15// }
16// A surface that paints Svg gets the route as one Svg and a still spinner; the terminal
17// gets Text runs and the Client animations.
18
19import { minutes, clock, clip, basename } from './fmt.mjs'
20import { progress, eta, live, weight } from './plan.mjs'
21import { routeCells, miniRoute, runs, FLAG, FLAG_DONE, PARKED, RUNNING } from './route.mjs'
22import { routeSvg, routeAlt, svgWidth } from './svg.mjs'
23import { NOTE_AT } from './prune.mjs'
24import { VERSION } from './version.mjs'
25
26export const FULL_MS = 8000 // a finished band keeps all its rows this long
27export const SPIN = { frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], ms: 80 }
28
29// ---------- words ----------
30
31export function label(g) {
32  return g.kind === 'plan' ? 'Plan' : 'Goal'
33}
34
35export function isPaused(g) {
36  return g.status === 'running' && !g.active && g.lastTurnEnd > 0
37}
38
39// A task running past twice the plan's own pace for its size (only once the ETA exists)
40export function runsLong(g, t, now) {
41  if (!eta(g, now)) return false
42  const pace = (now - (g.planAt || g.startedAt)) / progress(g).doneW
43  return now - t.startedAt > 2 * pace * weight(t)
44}
45
46function byOf(t) {
47  return t.by && t.by !== 'main' ? t.by : ''
48}
49
50function lastDone(tasks) {
51  return tasks.filter((t) => t.status === 'done').sort((a, b) => b.doneAt - a.doneAt || b.id - a.id)[0]
52}
53
54// The parked tasks in a few words: one names its blocker (when `withNote`), more are counted
55function parkedWords(p, mask, withNote) {
56  const parked = p.parked || []
57  const n = parked.length
58  if (!n) return ''
59  const note = parked[0].note
60  return n === 1 && withNote && note ? `1 parked: ${mask(note)}` : `${n} parked`
61}
62
63// ---------- the route ----------
64
65function colorOf(roles, cell) {
66  if (cell.role === 'done') return roles.done(cell.t)
67  if (cell.role === 'sparkle') return roles.sparkle[0]
68  if (cell.role === 'active') return roles.activeStill
69  return roles.pending
70}
71
72function routeRow(el, v, cells, tail, tasks) {
73  const { Box, Text } = el
74  const finishing = v.g.status === 'met'
75  // Desktop draws the route as an Svg, which moves by CSS; only the terminal takes a Client
76  const svg = v.svg && el.Svg
77  const animate = v.pulse && !svg
78  const kids = []
79  if (svg) {
80    const p = progress(v.g)
81    const running = tasks.map((t, i) => (t.status === 'active' ? i + 1 : 0)).filter(Boolean)
82    const width = svgWidth(v.width)
83    const source = routeSvg(cells, { hex: v.roles.hex, level: v.level, width, finishId: finishing ? `${v.g.startedAt}-${v.g.endedAt}` : '' })
84    kids.push(el.Svg({ key: 'route', source, alt: routeAlt(p.doneN, p.n, running, p.parked.length), width, height: 14 }))
85  } else if (finishing && v.level === 'full' && animate) {
86    // one-shot, keyed by the finish: it sweeps the sparkle colours along the route once,
87    // then rests on the solid route
88    kids.push(
89      v.pulse(
90        `sweep-${v.g.startedAt}-${v.g.endedAt}`,
91        { kind: 'sweep', chars: cells.map((c) => c.ch).join(''), done: cells.map((c) => colorOf(v.roles, c)), colors: [...v.roles.sparkle] },
92        cells.length,
93      ),
94    )
95  } else {
96    let buf = []
97    const flush = () => {
98      for (const r of runs(buf, (c) => colorOf(v.roles, c))) kids.push(Text({ color: r.color, children: [r.text] }))
99      buf = []
100    }
101    for (const c of cells) {
102      if (c.role === 'active' && v.level !== 'off' && animate) {
103        flush()
104        kids.push(v.pulse(`node-${c.id}`, { kind: 'node', colors: [...v.roles.active], breathe: v.level === 'full' }, 1))
105      } else buf.push(c)
106    }
107    flush()
108  }
109  if (tail) kids.push(Text({ dimColor: true, children: [tail] }))
110  return Box({ flexDirection: 'row', children: kids })
111}
112
113// ---------- rows ----------
114
115function headerRow(el, v, mark, right) {
116  const { Box, Text, Button } = el
117  const { g, mask } = v
118  const open = Button({ key: 'goal-tasks', label: 'all tasks', hotkey: 'g', plain: true, onPress: () => v.open && v.open() })
119  return Box({
120    flexDirection: 'row',
121    justifyContent: 'space-between',
122    width: '100%',
123    columnGap: 2,
124    children: [
125      Box({
126        flexDirection: 'row',
127        flexShrink: 1,
128        children: [mark, Text({ bold: true, wrap: 'truncate-end', children: [`${label(g)}: ${mask(g.title)}`] })],
129      }),
130      Box({ flexDirection: 'row', columnGap: 2, flexShrink: 0, children: [...right, open] }),
131    ],
132  })
133}
134
135function spinner(el, v, key) {
136  if (v.level === 'off' || v.svg || !v.pulse) return el.Text({ color: v.roles.activeStill, children: [RUNNING] })
137  return v.pulse(key, { kind: 'spin', frames: SPIN.frames, ms: SPIN.ms, colors: [...v.roles.active] }, 1)
138}
139
140function nowRow(el, v, t, more) {
141  const { Box, Text } = el
142  const { g, now, mask, roles } = v
143  const by = byOf(t)
144  const long = runsLong(g, t, now)
145  const right = []
146  if (by) right.push(Text({ color: roles.agent(by), children: [`◆ ${clip(mask(by), 20)}`] }))
147  right.push(Text(long ? { color: roles.heat, children: [` · ${minutes(now - t.startedAt)}`] } : { dimColor: true, children: [` · ${minutes(now - t.startedAt)}`] }))
148  if (more) right.push(Text({ dimColor: true, children: [`  +${more} running`] }))
149  return Box({
150    flexDirection: 'row',
151    justifyContent: 'space-between',
152    width: '100%',
153    columnGap: 2,
154    children: [
155      Box({ flexDirection: 'row', flexShrink: 1, columnGap: 1, children: [spinner(el, v, `spin-${t.id}`), Text({ bold: true, wrap: 'truncate-end', children: [`${t.size}  ${mask(t.title)}`] })] }),
156      Box({ flexDirection: 'row', flexShrink: 0, children: right }),
157    ],
158  })
159}
160
161function doneRow(el, v, t) {
162  const took = t.doneAt > t.startedAt ? minutes(t.doneAt - t.startedAt) : ''
163  return el.Text({ dimColor: true, wrap: 'truncate-end', children: [`✓ ${t.size}  ${v.mask(t.title)}${took ? '  ' + took : ''}`] })
164}
165
166function nextRow(el, v, t) {
167  return el.Text({ dimColor: true, wrap: 'truncate-end', children: [`  next ○ ${t.size}  ${v.mask(t.title)}`] })
168}
169
170function notableRow(el, v) {
171  const { g, mask, roles } = v
172  if (g.check && g.check.met === false && g.check.reason) {
173    return el.Text({ color: roles.attention, wrap: 'truncate-end', children: [`Goal check: not met yet: ${mask(g.check.reason)}`] })
174  }
175  if (g.interrupted) return el.Text({ color: roles.attention, children: ['interrupted'] })
176  // parked tasks are left out of n, so only an open task of this chat's own waits on the person
177  const p = progress(g)
178  if (isPaused(g) && p.doneN < p.n) return el.Text({ color: roles.attention, children: ['⏸ paused, waiting on you'] })
179  // someone outside the chat has the ball: worth a glance, not the person's attention
180  if (p.parked.length) {
181    const more = p.parked.length > 1 ? `  +${p.parked.length - 1}` : ''
182    return el.Text({ dimColor: true, wrap: 'truncate-end', children: [`${PARKED} parked: ${mask(p.parked[0].note || '')}${more}`] })
183  }
184  return null
185}
186
187// ---------- the band ----------
188
189export function drawBand(el, v) {
190  const { Box, Text } = el
191  const { g, now, roles } = v
192  const p = progress(g)
193  const mark = Text({ color: roles.activeStill, children: ['◎ '] })
194
195  if (g.status === 'running' && !g.planAt) {
196    const wait = Text({ dimColor: true, wrap: 'truncate-end', children: [v.nudged ? 'No plan yet: reminded Claude to plan with Waypoints' : "Waiting for Claude's task plan…"] })
197    return Box({
198      flexDirection: 'column',
199      children: [headerRow(el, v, mark, [Text({ dimColor: true, children: ['planning…'] })]), Box({ flexDirection: 'row', columnGap: 1, children: [spinner(el, v, 'spin-plan'), wait] })],
200    })
201  }
202
203  if (g.status !== 'running') {
204    const finished = g.status === 'met'
205    if (!finished) return Box({ flexDirection: 'column', children: [headerRow(el, v, mark, [Text({ dimColor: true, children: ['stopped'] })])] })
206    if (now - g.endedAt >= FULL_MS) {
207      const ok = Text({ color: roles.ok, children: ['✓ '] })
208      const took = minutes((g.endedAt || now) - g.startedAt)
209      // a parked task outlives the finish here, so green is not read as "all of it is done"
210      const parked = parkedWords(p, v.mask, true)
211      return Box({ flexDirection: 'column', children: [headerRow(el, v, ok, [Text({ dimColor: true, children: [`${p.n} tasks · done in ${took}${parked ? ' · ' + parked : ''}`] })])] })
212    }
213  }
214
215  const tasks = live(g).sort((a, b) => a.id - b.id)
216  const parked = parkedWords(p, v.mask, false)
217  const right = []
218  if (g.status === 'met') {
219    right.push(Text({ color: roles.ok, bold: true, children: [`done ✓ in ${minutes((g.endedAt || now) - g.startedAt)}`] }))
220    if (parked) right.push(Text({ dimColor: true, children: [parked] }))
221  } else {
222    const t = eta(g, now)
223    right.push(
224      Text({
225        children: [
226          Text({ color: roles.activeStill, bold: true, children: [`${p.doneN} of ${p.n} · ${p.pct}%`] }),
227          t ? Text({ dimColor: true, children: [` · ~${minutes(t.ms)}`] }) : null,
228          parked ? Text({ dimColor: true, children: [` · ${parked}`] }) : null,
229        ].filter(Boolean),
230      }),
231    )
232  }
233  const head = headerRow(el, v, mark, right)
234
235  // the route fills the width, less the dim +k when the plan grew (parked tasks count:
236  // they are on the route, and they were in the first plan)
237  const grew = g.firstPlan && tasks.length > g.firstPlan ? ` +${tasks.length - g.firstPlan}` : ''
238  const room = Math.max(8, v.width - grew.length)
239  const { cells } = routeCells(tasks, room, { finished: g.status === 'met' })
240  const parts = { head: [head, routeRow(el, v, cells, grew, tasks)], now: [], next: [], note: [] }
241
242  const active = tasks.filter((t) => t.status === 'active')
243  if (active.length) {
244    active.slice(0, 2).forEach((t, i) => parts.now.push(nowRow(el, v, t, i === 1 ? Math.max(0, active.length - 2) : 0)))
245  } else {
246    const last = lastDone(tasks)
247    if (last) parts.now.push(doneRow(el, v, last))
248  }
249  const pending = tasks.find((t) => t.status === 'pending')
250  if (pending && g.status === 'running') parts.next.push(nextRow(el, v, pending))
251  const note = g.status === 'running' ? notableRow(el, v) : null
252  if (note) parts.note.push(note)
253
254  // short of room, the rows give way in this order: next, notable, now
255  const max = v.maxRows > 0 ? v.maxRows : Infinity
256  let total = parts.head.length + parts.now.length + parts.next.length + parts.note.length
257  for (const k of ['next', 'note', 'now']) {
258    while (total > max && parts[k].length) {
259      parts[k].pop()
260      total -= 1
261    }
262  }
263  return Box({ flexDirection: 'column', children: [...parts.head, ...parts.now, ...parts.next, ...parts.note] })
264}
265
266// ---------- the pane ----------
267
268const BAR_CELLS = 12 // the longest finished task's duration bar
269const NO_CONNECTORS_OVER = 10 // tasks; past this the trail is rows alone
270
271// 30s, 4m, 1h05m: short enough for a column
272export function short(ms) {
273  const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
274  return s < 60 ? s + 's' : minutes(s * 1000)
275}
276
277// Where the plan last changed course: "revised after #3" ("revised" before any task was done)
278function revisedWords(r) {
279  return r.after ? `revised after #${r.after}` : 'revised'
280}
281
282// The line under the pane's title: the numbers, elapsed, the ETA, growth, parked tasks,
283// the latest revision, goal checks
284export function statsLine(g, p, now, mask = (s) => s) {
285  const parked = parkedWords(p, mask, false)
286  if (g.status === 'met') return `done ✓ in ${minutes((g.endedAt || now) - g.startedAt)}` + (parked ? ` · ${parked}` : '')
287  if (g.status !== 'running') return 'stopped'
288  if (!g.planAt) return 'planning…'
289  const parts = [`${p.doneN} of ${p.n} · ${p.pct}%`, `${minutes(now - g.startedAt)} elapsed`]
290  const t = eta(g, now)
291  if (t) parts.push(`~${minutes(t.ms)} left (≈${clock(t.at)})`)
292  else if (p.doneN < 2) parts.push('ETA after 2 tasks finish')
293  const n = live(g).length
294  if (g.firstPlan && n > g.firstPlan) parts.push(`plan grew ${g.firstPlan} → ${n}`)
295  if (parked) parts.push(parked)
296  const revs = g.revisions || []
297  if (revs.length) parts.push(revisedWords(revs[revs.length - 1]))
298  if (g.checks) parts.push(`${g.checks} goal check${g.checks === 1 ? '' : 's'}`)
299  return parts.join(' · ')
300}
301
302function trailRow(el, v, t, i, count, barOf, titleW) {
303  const { Box, Text } = el
304  const { now, mask, roles } = v
305  const last = i === count - 1
306  // the last task's node is the destination flag, raised once that task is done
307  const glyph =
308    t.status === 'dropped' ? '×' : t.status === 'parked' ? PARKED : t.status === 'active' ? RUNNING : last ? (t.status === 'done' ? FLAG_DONE : FLAG) : t.status === 'done' ? '●' : '○'
309  const color = t.status === 'done' ? roles.done(count > 1 ? i / (count - 1) : 0) : t.status === 'active' ? roles.activeStill : roles.pending
310  const title = clip(mask(t.title), titleW - 3)
311  const body =
312    t.status === 'dropped'
313      ? Text({ dimColor: true, strikethrough: true, children: [`${t.size}  ${title}`] })
314      : Text({ bold: t.status === 'active', dimColor: t.status === 'pending', children: [`${t.size}  ${title}`] })
315  const tail = []
316  if (t.status === 'done') {
317    const dur = t.doneAt - t.startedAt
318    if (dur > 0) tail.push(Text({ color: roles.done(count > 1 ? i / (count - 1) : 0), children: ['▅'.repeat(barOf(dur))] }), Text({ dimColor: true, children: [' ' + short(dur)] }))
319  } else if (t.status === 'active') {
320    const by = byOf(t)
321    if (by) tail.push(Text({ color: roles.agent(by), children: [`◆ ${clip(mask(by), 20)}`] }), Text({ dimColor: true, children: [' · '] }))
322    tail.push(Text({ color: runsLong(v.g, t, now) ? roles.heat : undefined, dimColor: !runsLong(v.g, t, now), children: [`running ${short(now - t.startedAt)}`] }))
323  } else if (t.status === 'dropped') {
324    tail.push(Text({ dimColor: true, children: [t.note ? `dropped: ${mask(t.note)}` : 'dropped'] }))
325  } else if (t.status === 'parked') {
326    tail.push(Text({ dimColor: true, children: [`parked: ${mask(t.note || '')}`] }))
327  } else if (t.rev) {
328    tail.push(Text({ dimColor: true, children: ['revised'] }))
329  }
330  // the tail is one Text, so its pieces sit side by side with no column gap between them
331  // the running task's star twinkles here as on the route: a Client on the terminal, still elsewhere
332  const twinkle = t.status === 'active' && v.pulse && !v.svg && v.level !== 'off'
333  const node = twinkle ? v.pulse(`pane-node-${t.id}`, { kind: 'node', colors: [...roles.active], breathe: v.level === 'full' }, 1) : Text({ color, children: [glyph] })
334  return Box({ flexDirection: 'row', columnGap: 1, children: [node, Box({ width: titleW, flexShrink: 0, children: [body] }), ...(tail.length ? [Text({ children: tail })] : [])] })
335}
336
337function connectorRow(el, v, above) {
338  return above.status === 'done' ? el.Text({ color: v.roles.done(0.5), children: ['┃'] }) : el.Text({ dimColor: true, children: ['┆'] })
339}
340
341function otherRow(el, v, g) {
342  const { Box, Text } = el
343  const { now, mask, roles } = v
344  const p = progress(g)
345  const name = clip(mask(`${g.label || basename(g.cwd)}: ${g.title}`), Math.max(16, Math.floor(v.width * 0.35)))
346  let tail
347  if (g.status === 'met') tail = 'done ✓'
348  else if (g.status !== 'running') tail = 'stopped'
349  else if (!g.planAt) tail = 'planning'
350  else {
351    const t = eta(g, now)
352    tail = `${p.pct}% · ${t ? '~' + minutes(t.ms) : p.doneN + '/' + p.n}`
353  }
354  const { cells } = miniRoute(g)
355  const route = runs(cells, (c) => colorOf(roles, c)).map((r) => Text({ color: r.color, children: [r.text] }))
356  return Box({
357    flexDirection: 'row',
358    columnGap: 2,
359    children: [Text({ wrap: 'truncate-end', children: [name] }), Box({ flexDirection: 'row', children: route }), Text(g.status === 'met' ? { color: roles.ok, children: [tail] } : { dimColor: true, children: [tail] })],
360  })
361}
362
363// view adds: others (the other chats' goals), sessionId, commandName, settings
364export function drawPane(el, v) {
365  const { Box, Text } = el
366  const { g, mask, roles } = v
367  const rows = []
368  if (g) {
369    const p = progress(g)
370    rows.push(Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(g)}: ${mask(g.title)}`] }))
371    rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: ['  ' + statsLine(g, p, v.now, mask)] }))
372    rows.push(Text({ children: [' '] }))
373    const tasks = g.tasks.filter((t) => !t.replaced)
374    if (!tasks.length) rows.push(Text({ dimColor: true, children: ['No task plan yet.'] }))
375    const longest = Math.max(1, ...tasks.filter((t) => t.status === 'done').map((t) => t.doneAt - t.startedAt))
376    const barOf = (dur) => Math.max(1, Math.round((BAR_CELLS * dur) / longest))
377    const titleW = Math.max(16, Math.min(48, v.width - 34))
378    tasks.forEach((t, i) => {
379      rows.push(trailRow(el, v, t, i, tasks.length, barOf, titleW))
380      // what a finished task turned out to be, under it: the reason a later step changed
381      if (t.status === 'done' && t.note) rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [`      ${mask(t.note)}`] }))
382      if (i < tasks.length - 1 && tasks.length <= NO_CONNECTORS_OVER) rows.push(connectorRow(el, v, t))
383    })
384    const revs = (g.revisions || []).slice(-3)
385    if (revs.length) {
386      rows.push(Text({ children: [' '] }))
387      for (const r of revs) rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [`${revisedWords(r)}: ${mask(r.note)}`] }))
388    }
389    if (g.check && g.check.reason) {
390      rows.push(Text({ children: [' '] }))
391      rows.push(Text(g.check.met === false ? { color: roles.attention, children: [`Last goal check: not met yet: ${mask(g.check.reason)}`] } : { dimColor: true, children: [`Last goal check: ${mask(g.check.reason)}`] }))
392    }
393  } else {
394    rows.push(Text({ dimColor: true, children: ['No goal in this chat. Type /goal <what done looks like> to start one.'] }))
395  }
396  const rest = (v.others || []).filter((o) => o.sessionId !== v.sessionId)
397  rows.push(Text({ children: [' '] }))
398  rows.push(Text({ bold: true, children: [`Other chats (${rest.length})`] }))
399  if (!rest.length) rows.push(Text({ dimColor: true, children: ['No other chat has a goal in the last hour.'] }))
400  for (const o of rest.slice(0, 15)) rows.push(otherRow(el, v, o))
401  rows.push(Text({ children: [' '] }))
402  const s = v.settings || {}
403  const c = v.commandName || 'waypoints'
404  const f = v.files
405  if (f && f.count > NOTE_AT) rows.push(Text({ dimColor: true, children: [`${f.count} old files in ${f.dir} · /${c} prune, or delete them while no plan is running`] }))
406  rows.push(Text({ dimColor: true, children: [`auto: ${s.auto ? 'on' : 'off'} · /${c} hide|show · clear (stop the goal) · look & pane: /config → Waypoints`] }))
407  rows.push(Text({ dimColor: true, children: [`waypoints v${VERSION}`] }))
408  return Box({ flexDirection: 'column', children: rows })
409}
410
hooks/pane.mjs 35 lines
1// When the pane opens and closes by itself. Pure bookkeeping, no engine calls, so the rules
2// the design lists (a person's close sticks, an unload's does not) can be tested: the test
3// engine cannot raise a ui.close with an origin.
4//
5// - It opens on a goal's first plan, unless the setting is off, the person closed the pane
6//   during this goal, or it is open already.
7// - A pane the mod opened is the mod's until the person opens it themselves; the mod closes
8//   only its own, once the band has gone.
9
10export function createAutoPane() {
11  const closedGoals = new Set() // startedAt of each goal whose pane the person closed
12  let owned = false // the mod opened the pane and the person has not taken it over
13
14  return {
15    canOpen: (mode, goal, isOpen) => mode !== 'off' && !!goal && !isOpen && !closedGoals.has(goal.startedAt),
16    // the mod opened it
17    opened: () => {
18      owned = true
19    },
20    // the person asked for it (/waypoints, g): theirs from now on
21    takenOver: () => {
22      owned = false
23    },
24    // ui.close arrived: only the person's own close is remembered; a plugin's call or the
25    // engine dropping the pane (unload) is not a decision about this goal
26    closed: (origin, goal) => {
27      owned = false
28      if (origin && origin.kind === 'person' && goal) closedGoals.add(goal.startedAt)
29    },
30    // the band has gone: the mod takes back a pane it opened
31    shouldClose: (isOpen, bandUp) => owned && isOpen && !bandUp,
32    owns: () => owned,
33  }
34}
35
hooks/prune.mjs 47 lines
1// The data folder's upkeep: which chats' files the pane lists, when it says the folder has
2// grown, what /waypoints prune deletes and the delete it runs, and how the folder is named
3// to the person. Pure, so the rules are tested without the engine.
4
5export const OTHERS_MS = 3600000 // other chats' goals shown in /waypoints
6export const NOTE_AT = 100 // past this many files the pane names the folder
7
8export const DEAD_MS = 24 * 3600000 // restore() never loads a file this old again
9export const PRUNE_MAX = 200 // files one /waypoints prune deletes at most
10
11// The file names /waypoints prune deletes, oldest first. items: { name, mtimeMs, goal },
12// goal null for a file that did not parse. Never this chat's file; never a running goal
13// touched within DEAD_MS. A file that did not parse is kept for OTHERS_MS, as another chat
14// may be writing it right now.
15export function pickPrunable(items, { now, self }) {
16  const old = (ms) => now - (ms || 0) > OTHERS_MS
17  return items
18    .filter((it) => {
19      if (it.name === self) return false
20      if (now - (it.mtimeMs || 0) > DEAD_MS) return true
21      if (!it.goal) return old(it.mtimeMs)
22      return it.goal.status !== 'running' && old(it.goal.endedAt || it.mtimeMs)
23    })
24    .sort((a, b) => (a.mtimeMs || 0) - (b.mtimeMs || 0))
25    .slice(0, PRUNE_MAX)
26    .map((it) => it.name)
27}
28
29// What cmd would read as syntax rather than as a path
30const CMD_META = /[&|<>^%!"]/
31
32// The host's delete as an argv, no shell: each path is its own argument. On Windows `del`
33// lives in cmd, which parses the line again, so a path holding a cmd metacharacter deletes
34// nothing (null) and the person is sent to the folder instead.
35export function deleteArgv(win, paths) {
36  if (!win) return ['rm', '-f', '--', ...paths]
37  if (paths.some((p) => CMD_META.test(p))) return null
38  return ['cmd', '/d', '/c', 'del', '/f', '/q', ...paths.map((p) => p.replace(/\//g, '\\'))]
39}
40
41// The folder as the person's system writes it, ending in a separator: a Windows home
42// (with a `\`) turns every `/` into `\`
43export function shownPath(path) {
44  const p = String(path || '')
45  return p.includes('\\') ? p.replace(/\//g, '\\') + '\\' : p + '/'
46}
47
hooks/pulse.mjs 57 lines
1// The terminal's animations, one `Client` region each, all a fixed width so the band's
2// layout never moves under them. Each runs on the surface's frame clock; the hooks never
3// run per frame. A new key (a new task, a new finish) is a new instance with its own clock.
4//
5// - spin:  the running task's spinner, cycling `frames` every `ms`, with `colors` alongside
6// - node:  the running task's ✦ (on the route, in the pane): it cycles `colors` and dips to a
7//          hollow ✧ once a cycle, like a star twinkling; with `breathe` (full) it also flares ✷.
8//          It never goes blank, so the route keeps its shape
9// - sweep: the finish, played once: the sparkle `colors` run along the route (`chars`), then
10//          it rests on the solid route in the `done` colours (one per char)
11
12const NODE_MS = 120
13const TWINKLE = 8 // frames a twinkle takes: about once a second
14const SWEEP_MS = 60
15const SWEEP_FRAMES = 25 // 1.5 s
16const TAIL = 4 // cells the sparkle trails behind the head
17
18export default function Pulse(props, surface) {
19  const { Text } = surface.elements
20
21  if (surface.state === undefined) {
22    let frame = 0
23    const ms = props.kind === 'spin' ? props.ms : props.kind === 'node' ? NODE_MS : SWEEP_MS
24    const stop = surface.every(ms, () => {
25      frame += 1
26      // the sweep plays once, then rests
27      if (props.kind === 'sweep' && frame >= SWEEP_FRAMES) stop()
28      surface.setState({ frame })
29    })
30  }
31
32  const frame = surface.state ? surface.state.frame : 0
33
34  if (props.kind === 'spin') {
35    return Text({ color: props.colors[frame % props.colors.length], children: [props.frames[frame % props.frames.length] || ' '] })
36  }
37
38  if (props.kind === 'node') {
39    const at = frame % TWINKLE
40    const glyph = at === TWINKLE - 1 ? '✧' : props.breathe && at === TWINKLE / 2 - 1 ? '✷' : '✦'
41    return Text({ color: props.colors[frame % props.colors.length], children: [glyph] })
42  }
43
44  // sweep: the head runs from the start to past the end; behind it the sparkle, ahead of it the route
45  const chars = [...props.chars]
46  const head = Math.round((frame / SWEEP_FRAMES) * (chars.length + TAIL))
47  const n = props.colors.length
48  const cells = chars.map((ch, i) => ({ ch, color: frame < SWEEP_FRAMES && i <= head && i > head - TAIL ? props.colors[(i + frame) % n] : props.done[i] }))
49  const runs = []
50  for (const c of cells) {
51    const last = runs[runs.length - 1]
52    if (last && last.color === c.color) last.text += c.ch
53    else runs.push({ text: c.ch, color: c.color })
54  }
55  return Text({ children: runs.map((r) => Text({ color: r.color, children: [r.text] })) })
56}
57
hooks/route.mjs 107 lines
1// The route: the plan drawn as a line of nodes and legs. Pure: it returns cells
2// { ch, role, t } and knows nothing of colour or the engine; draw.mjs colours them
3// and svg.mjs draws the same cells as a picture. Roles: 'done', 'active' (the pulsing
4// node), 'pending' (dim, the running leg and a parked task included) and 'sparkle' (the
5// finish mark).
6
7import { weight } from './plan.mjs'
8
9const MAX_UNIT = 6 // cells per unit of work when there is room to spare
10
11// The destination is a flag, so it reads apart from the task nodes (all circles): open
12// while the plan runs, raised when it is done. Both are one-cell text symbols, not emoji.
13export const FLAG = '⚐'
14export const FLAG_DONE = '⚑'
15// A task waiting on someone outside the chat
16export const PARKED = '⏸'
17// The task running now: a star that twinkles (pulse.mjs), still at animation off
18export const RUNNING = '✦'
19
20function route(tasks) {
21  return tasks.filter((t) => t.status !== 'dropped' && !t.replaced).sort((a, b) => a.id - b.id)
22}
23
24function cell(ch, role) {
25  return { ch, role, t: 0 }
26}
27
28// Cells for a route whose legs are already measured. legs[i] is the length of task i's leg.
29function layout(tasks, legs, finished) {
30  const cells = []
31  // node k is the start of task k (the end of task k-1); a running task lights its start node
32  const lit = (k) => tasks[k] && tasks[k].status === 'active'
33  for (let k = 0; k <= tasks.length; k++) {
34    const last = k === tasks.length
35    if (finished) cells.push(last ? cell(FLAG_DONE, 'sparkle') : cell('●', 'done'))
36    else if (lit(k)) cells.push({ ...cell(RUNNING, 'active'), id: tasks[k].id })
37    else if (last) cells.push(cell(FLAG, tasks[k - 1].status === 'done' ? 'done' : 'pending'))
38    else if (k === 0 || tasks[k - 1].status === 'done') cells.push(cell('●', 'done'))
39    else cells.push(cell('○', 'pending'))
40    if (last) break
41    // a parked leg stays dashed, finished or not, with the pause mark in its middle
42    const parked = tasks[k].status === 'parked'
43    const done = !parked && (finished || tasks[k].status === 'done')
44    const mid = Math.floor((legs[k] - 1) / 2)
45    for (let i = 0; i < legs[k]; i++) cells.push(parked && i === mid ? cell(PARKED, 'pending') : done ? cell('━', 'done') : cell('┄', 'pending'))
46  }
47  const span = Math.max(1, cells.length - 1)
48  cells.forEach((c, i) => (c.t = i / span))
49  return cells
50}
51
52// One glyph per task, no legs, cut with … when the plan is longer than the room
53function dense(tasks, width, finished) {
54  const glyph = (t) =>
55    t.status === 'parked'
56      ? cell(PARKED, 'pending')
57      : finished || t.status === 'done'
58        ? cell('●', 'done')
59        : t.status === 'active'
60          ? { ...cell(RUNNING, 'active'), id: t.id }
61          : cell('○', 'pending')
62  let cells = tasks.map(glyph)
63  if (cells.length > width) cells = [...cells.slice(0, Math.max(0, width - 1)), cell('…', 'pending')]
64  const span = Math.max(1, cells.length - 1)
65  cells.forEach((c, i) => (c.t = i / span))
66  return cells
67}
68
69// The band's route, fitted to `width` cells: legs proportional to S/M/L (at least 2 cells),
70// then 1 cell each, then dense. `finished` draws it all solid (a parked leg aside) with a ⚑ at the end.
71export function routeCells(allTasks, width, { finished = false } = {}) {
72  const tasks = route(allTasks || [])
73  const room = Math.max(1, Math.floor(width))
74  if (!tasks.length) return { cells: [], dense: false }
75  // the start node, then a leg and a node for each task
76  const fits = (legs) => 1 + legs.reduce((a, l) => a + l + 1, 0) <= room
77  for (let unit = MAX_UNIT; unit >= 1; unit--) {
78    const legs = tasks.map((t) => Math.max(2, weight(t) * unit))
79    if (fits(legs)) return { cells: layout(tasks, legs, finished), dense: false }
80  }
81  const ones = tasks.map(() => 1)
82  if (fits(ones)) return { cells: layout(tasks, ones, finished), dense: false }
83  return { cells: dense(tasks, room, finished), dense: true }
84}
85
86// Another chat's route in the pane: a node and a leg per task, or a glyph per task past maxTasks
87export function miniRoute(goal, maxTasks = 12) {
88  const tasks = route(goal.tasks || [])
89  if (!tasks.length) return { cells: [], dense: false }
90  const finished = goal.status === 'met'
91  if (tasks.length > maxTasks) return { cells: dense(tasks, tasks.length, finished), dense: true }
92  return { cells: layout(tasks, tasks.map(() => 1), finished), dense: false }
93}
94
95// Merge neighbouring cells that take the same colour into runs { text, color }, so a
96// tick of drawing is a few Text elements, not one per cell. colorOf(cell) → string | null.
97export function runs(cells, colorOf) {
98  const out = []
99  for (const c of cells) {
100    const color = colorOf(c)
101    const last = out[out.length - 1]
102    if (last && last.color === color) last.text += c.ch
103    else out.push({ text: c.ch, color })
104  }
105  return out
106}
107
hooks/svg.mjs 124 lines
1// The Desktop route: the same cells the terminal draws (route.mjs), as one SVG document for
2// `Svg`'s `source`. CSS in the document's own <style> does the moving, so the browser runs
3// the loops and the mod draws only when the plan changes.
4//
5// A source that changes restarts its animations (agent-pulse's rule), so nothing here carries
6// the time, the ETA or anything per tick. The finish effects carry their goal's id instead,
7// so they play once for a finish and again only for the next.
8
9import { FLAG, FLAG_DONE, PARKED } from './route.mjs'
10
11const REDUCED = '@media (prefers-reduced-motion: reduce){*{animation:none!important}}'
12const HEIGHT = 14
13const MID = HEIGHT / 2
14const LEGS = '━┄'
15
16const f = (n) => n.toFixed(1)
17
18// A four-point star centred on (x, y)
19function star(x, y, r, attrs) {
20  const k = r * 0.3
21  const pts = [[x, y - r], [x + k, y - k], [x + r, y], [x + k, y + k], [x, y + r], [x - k, y + k], [x - r, y], [x - k, y - k]]
22  return `<polygon points="${pts.map(([a, b]) => f(a) + ',' + f(b)).join(' ')}" ${attrs}/>`
23}
24
25// The width the row takes: the terminal's columns at 8 px each, less the title's share
26export function svgWidth(bodyColumns) {
27  return Math.max(140, Math.min(640, Math.round(bodyColumns * 8 - 40)))
28}
29
30// hex: { done, glow, pending, sparkle[] }; level: off | subtle | full; finishId: set once the
31// goal has finished, so its one-shot effects have an address
32export function routeSvg(cells, { hex, level, width, finishId = '' }) {
33  const unit = width / Math.max(1, cells.length)
34  const xs = (i) => (i + 0.5) * unit
35  const nodes = []
36  cells.forEach((c, i) => {
37    if (!LEGS.includes(c.ch)) nodes.push({ c, i })
38  })
39  const dense = nodes.length === cells.length // one glyph per task: no legs, smaller nodes
40  const r = dense ? 2.2 : 3.5
41  const live = level !== 'off'
42  const body = []
43  const light = []
44
45  // legs first, so the nodes sit on top
46  for (let k = 0; k + 1 < nodes.length; k++) {
47    const a = nodes[k]
48    const b = nodes[k + 1]
49    if (b.i - a.i < 2) continue
50    const x1 = xs(a.i) + r
51    const x2 = xs(b.i) - r
52    const done = cells[a.i + 1].ch === '━'
53    body.push(
54      done
55        ? `<line x1="${f(x1)}" y1="${MID}" x2="${f(x2)}" y2="${MID}" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>`
56        : `<line x1="${f(x1)}" y1="${MID}" x2="${f(x2)}" y2="${MID}" stroke="${hex.pending}" stroke-width="2" stroke-dasharray="3 3" opacity=".6"/>`,
57    )
58    // at full, a small light travels along the running leg from its start node
59    if (level === 'full' && a.c.role === 'active') {
60      light.push(`<circle class="go" cx="${f(x1)}" cy="${MID}" r="1.8" fill="${hex.glow}" style="--d:${f(Math.max(0, x2 - x1))}px"/>`)
61    }
62  }
63
64  for (const { c, i } of nodes) {
65    const x = xs(i)
66    if (c.role === 'active') {
67      // the running task: a star in its glow, twinkling at subtle and above
68      body.push(`<circle${live ? ' class="halo"' : ''} cx="${f(x)}" cy="${MID}" r="${f(r + 2.5)}" fill="${hex.glow}" opacity=".3"/>`)
69      body.push(star(x, MID, r + 2, `${live ? 'class="run" ' : ''}fill="${hex.done}"`))
70    } else if (c.ch === FLAG || c.ch === FLAG_DONE) {
71      // the destination: a pole and a pennant, outlined until the plan finishes
72      const paint = c.role === 'sparkle' ? hex.sparkle[0] : c.role === 'done' ? 'url(#g)' : hex.pending
73      const dim = c.role === 'pending' ? ' opacity=".7"' : ''
74      const px = x - 3
75      body.push(`<line x1="${f(px)}" y1="1" x2="${f(px)}" y2="13" stroke="${paint}" stroke-width="1.5" stroke-linecap="round"${dim}/>`)
76      const pennant = `points="${f(px)},1.5 ${f(px + 8)},4.5 ${f(px)},7.5"`
77      body.push(c.ch === FLAG_DONE ? `<polygon ${pennant} fill="${paint}"/>` : `<polygon ${pennant} fill="none" stroke="${paint}" stroke-width="1.2" stroke-linejoin="round"${dim}/>`)
78    } else if (c.ch === PARKED) {
79      // a parked task: two short bars on its dashed leg
80      body.push(`<rect x="${f(x - 2.2)}" y="${MID - 3.5}" width="1.6" height="7" rx=".5" fill="${hex.pending}"/><rect x="${f(x + 0.6)}" y="${MID - 3.5}" width="1.6" height="7" rx=".5" fill="${hex.pending}"/>`)
81    } else if (c.role === 'done') {
82      body.push(`<circle cx="${f(x)}" cy="${MID}" r="${f(r)}" fill="url(#g)"/>`)
83    } else {
84      body.push(`<circle cx="${f(x)}" cy="${MID}" r="${f(r - 0.5)}" fill="none" stroke="${hex.pending}" stroke-width="1.5" opacity=".7"/>`)
85    }
86  }
87
88  // the finish, at full: a light sweeps the route once, three stars twinkle at the end
89  const effects = []
90  if (finishId && level === 'full') {
91    const end = nodes.length ? xs(nodes[nodes.length - 1].i) : width
92    effects.push(`<circle class="sweep" cx="0" cy="${MID}" r="5" fill="${hex.sparkle[3] || hex.sparkle[0]}" style="--d:${f(width)}px"/>`)
93    ;[[-7, -3.5, 0], [6, -4, 0.35], [-1, 4.5, 0.7]].forEach(([dx, dy, delay], n) => {
94      effects.push(star(end + dx, MID + dy, 2.4, `class="tw" fill="${hex.sparkle[n + 1] || hex.sparkle[0]}" style="animation-delay:${delay}s"`))
95    })
96  }
97
98  const css = []
99  if (live) {
100    css.push('.halo{animation:halo 1.4s ease-in-out infinite}@keyframes halo{0%,100%{opacity:.15}50%{opacity:.6}}')
101    css.push('.run{transform-box:fill-box;transform-origin:center;animation:run 1s ease-in-out infinite}@keyframes run{0%,70%,100%{transform:scale(1);opacity:1}85%{transform:scale(.6);opacity:.55}}')
102  }
103  if (level === 'full') {
104    css.push('.go{animation:go 1.8s linear infinite;opacity:0}@keyframes go{0%{transform:translateX(0);opacity:0}20%,80%{opacity:1}100%{transform:translateX(var(--d));opacity:0}}')
105    css.push('.sweep{opacity:0;animation:sweep 1.5s ease-in forwards}@keyframes sweep{0%{transform:translateX(0);opacity:0}15%,85%{opacity:.9}100%{transform:translateX(var(--d));opacity:0}}')
106    css.push('.tw{opacity:0;transform-box:fill-box;transform-origin:center;animation:tw 1.2s ease-out 1 forwards}@keyframes tw{0%{transform:scale(0);opacity:0}40%{transform:scale(1.2);opacity:1}100%{transform:scale(1);opacity:.9}}')
107  }
108
109  return (
110    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}">` +
111    `<style>${css.join('')}${REDUCED}</style>` +
112    `<defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="${width}" y2="0"><stop offset="0" stop-color="${hex.done}"/><stop offset="1" stop-color="${hex.glow}"/></linearGradient></defs>` +
113    body.join('') +
114    light.join('') +
115    (effects.length ? `<g id="fin-${finishId}">${effects.join('')}</g>` : '') +
116    '</svg>'
117  )
118}
119
120// What a screen reader hears of the route: numbers only, so there is no title to mask
121export function routeAlt(done, total, running, parked = 0) {
122  return `${done} of ${total} tasks done` + (running.length ? `, task${running.length > 1 ? 's' : ''} ${running.join(' and ')} running` : '') + (parked ? `, ${parked} parked` : '')
123}
124
hooks/version.mjs 6 lines
1// The host gives a hooks module no way to read its own manifest (not through
2// "node:fs", not as a JSON import), so the pane's last row carries a copy.
3// Bump it with plugin.json on every release; CI fails a pull request where the
4// two differ (.github/scripts/check-versions.mjs).
5export const VERSION = '2.2.1'
6