SLOPSHOPPER

goal-meter

A progress bar for /goal built from Claude's own task plan: tasks done out of the plan, elapsed time, an ETA at the goal's own pace, and every chat's goal in…

newpanebandspinnerguardcommand
v1.2.1MITupdated 2026-10-21bStyler/claude-mods/goal-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · goal-meter
│ ┃ Goal meter ✕ › 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 12 ⎿ Added 2 lines, removed 1 line │ ┃ hours. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ /goals compact|full|hide · /goals clear · │ ┃ /goals strict on · now: compact ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /goals │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Goal meter
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 12 hours. /goals compact|full|hide · /goals clear · /goals strict on · now: compact
README

claude-mods

Four Claude Code mods, published as a plugin marketplace named claude-super-mods.

ModWhat you get
goal-meter 1.2.1A progress bar for /goal, built from Claude's own task plan. One line by default. /goals opens the full task list.
cache-keeper 1.3.0A band above the prompt with the model, effort, context bar, plan limits (with the 5h reset) and cache state, so no status line is needed. Keeps a long chat's prompt cache warm (/keepwarm 2), asks before an expensive cold send, shows every local chat (/board), and hands a chat off (/handoff). Every compaction without its own instructions is told what to keep, and the band shows how much it saved.
lean-explore 1.6.0Replaces the built-in Explore subagent (the one that does codebase searches) with a lean one: Haiku, low effort, Read/Grep/Glob only, short answers with file:line. It counts what it really did (searches, reads, tokens delegated to Haiku) from your own transcripts. `/lean-explore on\off\status\stats\rescan`.
memory-guard 0.2.2Spots sessions worth remembering (files written to output folders, MCP writes, meeting transcripts, decisions, commits, accepted plans, long chats) and nudges once to run /memorize. A session that ends without it is queued, and the next session in that project offers to catch up. Files into your wiki if you point it at one, else Claude Code's own auto-memory. Ships its own /memorize skill.

lean-explore

A plugin cannot replace a built-in agent with an agent of its own (plugin agents get a lean-explore: prefix). So on the first session the mod writes ~/.claude/agents/Explore.md itself, marked with a comment line so it never touches an Explore.md you wrote.

  • Desktop app: a chip above the prompt. Green Explore: Haiku · 6 searches counts this chat's Explore runs; hover it for a card with a Turn off button. Amber Explore: built-in means it is off, with a Turn on button. After a toggle it adds what the next session will use. It shares one row with the TL;DR button (the mods-row convention), chip on the left.
  • Terminal: a footer label, Explore: Haiku or Explore: built-in, with the same next-session note.
  • /lean-explore stats prints all-time searches, reads and tokens delegated to Haiku (measured from usage figures; the "kept out of chat" number is an estimate at 4 characters per token). /lean-explore rescan rebuilds the totals from your transcripts.
  • While the lean agent is on, the mod adds a short "Searching the codebase" note to the system prompt: delegate open-ended searches to Explore, read directly when the path is known. No CLAUDE.md edit needed.
  • /lean-explore status shows whether it is on, off, or blocked by your own Explore.md.
  • /lean-explore off renames it to Explore.md.off; the built-in Explore comes back.
  • /lean-explore on brings it back.
  • Agent files load when a session starts, so every change applies from the next session.
  • Before uninstalling, run /lean-explore off, or delete ~/.claude/agents/Explore.md. Uninstalling the mod does not remove the file.

When a search needs judgment (a review, a design call), ask for a general-purpose agent or pass model: "opus": Haiku finds things well but checks less of what it reads.

memory-guard

Each session scores a point per kind of durable work: a file written to an output folder (docs, reports, deliverables, notes), a write through any MCP tool, a meeting or transcript read, a decision in your prompt ("let's go with", "from now on"), a git commit, an accepted plan. Ten turns or more adds one. At 2 points it nudges once: a toast, a band above the prompt with Memorize now and Not this session, and /memorize as the prompt suggestion. A session that ends without /memorize goes on that project's queue. The next session there shows Catch up, and the hint line says · 3 unmemorized.

With no setup it files into Claude Code's auto-memory (~/.claude/projects/<project>/memory/ and its MEMORY.md). To use a wiki, or your own /memorize skill, add .claude/memory-guard.json to the project (or ~/.claude/memory-guard.json for all of them). Every field is optional:

{
  "kb": { "path": "wiki", "index": "wiki/index.md", "log": "wiki/log.md", "rules": "wiki/CLAUDE.md" },
  "skill": "memorize",
  "threshold": 2,
  "longSessionTurns": 10,
  "outputDirs": ["docs", "reports", "deliverables", "notes"],
  "signals": { "decision": false },
  "extraSignals": [{ "name": "Tickets", "tool": "^mcp__.*linear.*__(create|update)" }, { "name": "Release", "bash": "npm publish" }],
  "ui": { "band": true, "hint": true, "suggest": true }
}
  • kb: your knowledge base. The bundled skill reads its rules file and index, files pages there, and logs each one. A write to kb.log counts as memorized.
  • skill: the skill the buttons run. Leave it out to use the bundled memory-guard:memorize.
  • extraSignals: your own signals, by tool-name or Bash-command regex.

On desktop a chip above the prompt shows Memory guard: on · 1 of 2 (green) or off (grey); hover it for a Turn off here / Turn on here button. The terminal footer says memory guard on or off. "ui": { "chip": false } hides both.

If it finds a wiki/ folder or an Obsidian vault with no config, it says so once. The band and hint line show in the terminal and the desktop app; elsewhere the toast and /memorize-queue still work.

Credit

goal-meter and cache-keeper are built on Nate Herk's claude-code-mods (MIT). goal-meter is a fork of his goal-meter 1.0.0, and cache-keeper is based on his cache-keeper 1.0.0. Each mod's CHANGES.md lists what differs from upstream. cache-keeper 1.3.0 also borrows three ideas from JayGPTPro's Smart PreCompact. MIT, see LICENSE.

Install

Needs a Claude Code build with mods (function-hook plugins). Works on Windows, macOS and Linux.

claude plugin marketplace add bStyler/claude-mods
claude plugin install goal-meter@claude-super-mods
claude plugin install cache-keeper@claude-super-mods
claude plugin install lean-explore@claude-super-mods
claude plugin install memory-guard@claude-super-mods

Then quit Claude Code and start it again. /reload-plugins and /clear do not load mod code. Check: type /goals. It should open the goal meter pane.

If Nate Herk's nateherk-mods is installed on that machine, turn off its copies first (same names, same tools):

claude plugin disable goal-meter@nateherk-mods
claude plugin disable cache-keeper@nateherk-mods

cache-keeper's band replaces a custom status line. If you had one, remove the statusLine block from ~/.claude/settings.json so the same figures don't show twice.

Commands

goal-meter

CommandDoes
/goal <what done looks like>Starts a goal. Claude plans it as sized tasks and the bar tracks them.
/goalsFull task list, plus other chats' goals from the last 12 hours.
/goals compact / /goals fullOne-line bar (default) or the full bar. Saved for every chat.
/goals hide / /goals showHide the bar and footer label in every chat, or bring them back.
/goals clearRemove this chat's goal, running or finished.
/goals strict onNo file edits in a /goal until Claude has written the plan. Useful for coding.

The g key on the bar only works after Ctrl+X Tab focuses the bar. From the prompt, type /goals.

cache-keeper

CommandDoes
/keepwarm 2 / /keepwarm offKeep this chat's cache warm for N hours (max 24).
/cache big 100kSize at which it asks before a cold send (default 150k).
/cache guard offTurn the cold-send question off.
/cache ttl 5 / 60 / autoCache window, if it guesses wrong.
/boardEvery local chat on one board.
/handoffWrite a handoff and continue in a fresh chat.

memory-guard

CommandDoes
/memorizeSave what this session learned (bundled skill: memory-guard:memorize).
/memorize queueCatch up on the queued sessions from their transcripts.
/memorize-queue / /memorize-queue clearShow this session's score and the queue, or clear it.
/memory-guard off / on / statusStop or start watching this project; show where it files and what it counts.

Update

claude plugin marketplace update claude-super-mods
claude plugin update goal-meter@claude-super-mods
claude plugin update cache-keeper@claude-super-mods
claude plugin update lean-explore@claude-super-mods
claude plugin update memory-guard@claude-super-mods

Restart Claude Code afterwards. When changing code, raise version in the mod's .claude-plugin/plugin.json, or plugin update will not pick it up.

Uninstall

claude plugin uninstall goal-meter@claude-super-mods
claude plugin uninstall cache-keeper@claude-super-mods
claude plugin uninstall lean-explore@claude-super-mods
claude plugin uninstall memory-guard@claude-super-mods
claude plugin marketplace remove claude-super-mods

Saved state lives in ~/.claude/mods-data/ and can be deleted.

Source 4 files
hooks/register.mjs 691 lines
1// Goal Meter: a progress bar for /goal, built from Claude's own task plan.
2// - /goal starts it. The mod asks Claude to plan the goal as sized tasks (S, M, L)
3//   with the mod's tool, mcp__goal-meter__tasks, then mark each task started and
4//   done. Claude Code stopped shipping a task-list tool after 2.1.229, so the mod
5//   brings its own. A plan made outside a /goal shows the same way.
6// - The band above the prompt, compact by default: one line with progress, ETA
7//   and the running task. /goals full shows the full band: the bar (finished work out of the plan, weighted
8//   by size), elapsed time, the ETA at this goal's own pace, what's running and
9//   who runs it, the next tasks, and the goal check's latest "not met yet" reason.
10// - The footer: "◎ goal 69% · ~15m" in every view, terminal and Desktop.
11// - /goals: a pane with this chat's whole task list and every other chat's goal.
12//   /goals compact|full|hide|show (saved for every chat; hide also hides the footer),
13//   /goals clear (removes this chat's goal, running or finished), /goals strict on|off.
14// - A finished goal stays on screen 2 minutes.
15// - Fork of Nate Herk's goal-meter 1.0.0; changes listed in CHANGES.md.
16// - Each chat writes ~/.claude/mods-data/goal-meter/<session>.json for /goals.
17//   The mod sends no model requests of its own.
18
19import { minutes, clock, clip, bar, basename } from './fmt.mjs'
20import { makeMasker } from './privacy.mjs'
21import { newGoal, applyAction, progress, eta, parseCheck, isStopWord, normalizeTasks, TOOL_SPEC, instruction, nudge, strictDeny } from './plan.mjs'
22
23const DIR = '/.claude/mods-data/goal-meter'
24const PANE = 'goal-meter'
25const RECENT_MS = 2 * 60000 // a finished goal stays on screen this long
26const OTHERS_MS = 12 * 3600000 // other chats' goals shown in /goals
27const REOPEN_MS = 5 * 60000 // a goal closed on its tasks reopens if Claude carries on this soon
28const NUDGE_AFTER = 4 // tool calls into a goal with no plan before the reminder
29const WRITERS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
30
31let G = null
32let sessionId = ''
33let home = ''
34let cwd = ''
35let now = 0
36let toolName = 'mcp__goal-meter__tasks'
37let commandName = 'goals'
38// view: 'compact' (one line), 'full' (bar and tasks) or 'hidden' (no band, no footer); saved
39let settings = { strict: false, view: 'compact', shown: 'compact' }
40let nudged = false
41let callsWithoutPlan = 0
42let pendingGoal = null
43let paneOpen = false
44let others = []
45let transcriptPath = ''
46const agentNames = new Map()
47const runningAgents = new Set()
48let rec = { on: false, strict: false }
49let mask = (s) => s
50
51function goalFile(id) {
52  return `${home}${DIR}/${id}.json`
53}
54
55function isRecent(g) {
56  return g && g.status !== 'running' && !g.dismissedAt && g.endedAt && now - g.endedAt < RECENT_MS
57}
58
59function onScreen(g) {
60  return g && !g.dismissedAt && (g.status === 'running' || isRecent(g))
61}
62
63function visibleTasks(g) {
64  return g.tasks.filter((t) => !t.replaced && t.status !== 'dropped')
65}
66
67async function save($) {
68  if (!G || !sessionId) return
69  G.updatedAt = now
70  try {
71    await $.fs.write(goalFile(sessionId), JSON.stringify({ ...G, label: basename(cwd) }))
72  } catch {
73    // the pane falls back to this chat alone
74  }
75}
76
77async function restore($) {
78  try {
79    const path = goalFile(sessionId)
80    if (!(await $.fs.exists(path))) return
81    const saved = JSON.parse(await $.fs.read(path))
82    if (saved && Array.isArray(saved.tasks) && now - (saved.updatedAt || 0) < 24 * 3600000) G = saved
83  } catch {
84    G = null
85  }
86}
87
88async function loadOthers($) {
89  const dir = home + DIR
90  const list = []
91  try {
92    for (const entry of await $.fs.list(dir)) {
93      if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name === sessionId + '.json') continue
94      if (now - (entry.mtimeMs || 0) > OTHERS_MS) continue
95      try {
96        const g = JSON.parse(await $.fs.read(dir + '/' + entry.name))
97        if (!g || !Array.isArray(g.tasks)) continue
98        if (g.status !== 'running' && now - (g.endedAt || 0) > OTHERS_MS) continue
99        list.push(g)
100      } catch {
101        // a file another chat is writing right now; next refresh reads it
102      }
103    }
104  } catch {
105    // no folder yet
106  }
107  others = list.sort((a, b) => (a.status === 'running' ? 0 : 1) - (b.status === 'running' ? 0 : 1) || b.updatedAt - a.updatedAt)
108}
109
110async function readRecording($) {
111  try {
112    const path = (home || '.') + '/.claude/mods-data/recording.json'
113    if (!(await $.fs.exists(path))) rec = { on: false, strict: false }
114    else {
115      const flag = JSON.parse(await $.fs.read(path))
116      rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
117    }
118  } catch {
119    rec = { on: false, strict: false }
120  }
121  mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
122}
123
124async function registerCommand($) {
125  const spec = { name: 'goals', description: 'Goal meter: this chat\'s goal task list and every chat\'s goal (/goals compact|full|hide|show|clear|strict on|off)', argumentHint: '[compact|full|hide|show|clear|strict on|off]', immediate: true }
126  try {
127    await $.command.register(spec)
128    return 'goals'
129  } catch {
130    try {
131      await $.command.register({ ...spec, name: 'goal-meter' })
132      return 'goal-meter'
133    } catch {
134      return null
135    }
136  }
137}
138
139async function startGoal($, condition) {
140  G = newGoal({ sessionId, condition, now, cwd })
141  nudged = false
142  callsWithoutPlan = 0
143  pendingGoal = null
144  await save($)
145  $.ui.invalidate('ui.render')
146}
147
148async function stopGoal($, status) {
149  if (!G || G.status !== 'running') return
150  G.status = status
151  G.endedAt = now
152  G.active = false
153  await save($)
154  $.ui.invalidate('ui.render')
155}
156
157async function finishGoal($, how) {
158  if (!G || G.status !== 'running') return
159  G.status = 'met'
160  G.endedAt = now
161  G.active = false
162  G.finishedBy = how
163  await save($)
164  const took = minutes(G.endedAt - G.startedAt)
165  $.ui.toast(`${G.kind === 'plan' ? 'Plan' : 'Goal'} done in ${took}: ${clip(mask(G.title), 60)}`)
166  $.ui.invalidate('ui.render')
167}
168
169async function serveTool($, e) {
170  now = await $.clock.now()
171  const action = String(e.action || 'show').toLowerCase()
172  const first = normalizeTasks(e.tasks)[0]
173  if (!G || (G.status !== 'running' && (action === 'plan' || action === 'add'))) {
174    if (!first) return { result: `Goal meter: no plan in this chat yet. Call action "plan" with the tasks first, each { "title": "...", "size": "S" | "M" | "L" }.` }
175    // a plan outside /goal: tracked the same way, named after its first task
176    G = newGoal({ sessionId, condition: first.title, now, cwd, kind: 'plan' })
177  }
178  const by = e.by ? String(e.by) : e.agentId ? agentNames.get(e.agentId) || 'agent' : ''
179  const r = applyAction(G, e, { now, by })
180  if (r.ok) {
181    await save($)
182    $.ui.invalidate('ui.render')
183  }
184  return { result: r.text }
185}
186
187// The goal check's verdict. Its row reaches session.append with no content (the
188// payload is stored beside it, seen live 2026-10-02), so read the record from
189// the end of the chat's log: a few lines, never the whole file (logs reach 200 MB).
190async function lastGoalStatus($, since) {
191  const path = transcriptPath
192  if (!path) return null
193  const windows = /^[A-Za-z]:/.test(path)
194  const argv = windows
195    ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', `Get-Content -LiteralPath '${path.replace(/'/g, "''")}' -Tail 12 -Encoding UTF8`]
196    : ['tail', '-n', '12', path]
197  let out = ''
198  try {
199    const r = await $.process.run(argv, { timeoutMs: 15000 })
200    out = r.stdout || ''
201  } catch {
202    return null
203  }
204  const lines = out.split(/\r?\n/).filter((l) => l.includes('"goal_status"')).reverse()
205  for (const line of lines) {
206    try {
207      const row = JSON.parse(line)
208      const a = row.attachment
209      if (!a || a.type !== 'goal_status') continue
210      // an older check's record is not this check's verdict
211      const ts = Date.parse(row.timestamp || '') || 0
212      return ts && since && ts < since - 3000 ? null : a
213    } catch {
214      // a line cut by the tail
215    }
216  }
217  return null
218}
219
220// The log is written a moment after the row reaches the hook: read it now, and
221// again after 1.5 and 4 seconds when the verdict isn't there yet
222async function onCheck($, message, at, attempt = 0) {
223  if (!G || G.kind !== 'goal' || G.status !== 'running') return
224  let verdict = await lastGoalStatus($, at)
225  if (!verdict) {
226    // a build that renders the verdict into the row itself
227    const blocks = Array.isArray(message.content) ? message.content : []
228    const c = parseCheck(blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n'))
229    verdict = c.met ? { met: true } : c.notMet ? { met: false, reason: c.reason } : null
230  }
231  if (!verdict && attempt < 2) {
232    $.clock.after(attempt ? 4000 : 1500, async () => {
233      now = await $.clock.now()
234      await onCheck($, message, at, attempt + 1).catch(() => {})
235    })
236    return
237  }
238  if (!verdict || verdict.sentinel) return // the row written when the goal is set
239  if (verdict.met) return finishGoal($, 'check')
240  G.check = { met: false, reason: clip(verdict.reason || 'no reason given', 300), at: now }
241  G.checks += 1
242  await save($)
243  $.ui.invalidate('ui.render')
244}
245
246export function register(on) {
247  on('session.start', async ($, e, next) => {
248    now = await $.clock.now()
249    home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
250    sessionId = await $.session.id()
251    cwd = await $.session.cwd()
252    // where Claude Code keeps this chat's log; a settings-hook event confirms it below
253    transcriptPath = `${home}/.claude/projects/${String(cwd).replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
254    const saved = await $.store.get('settings')
255    if (saved && typeof saved === 'object') settings = { ...settings, ...saved }
256    await readRecording($)
257    try {
258      const reg = await $.tool.register(TOOL_SPEC)
259      if (reg && reg.tool) toolName = reg.tool
260    } catch (err) {
261      $.ui.log(`goal-meter: the task tool did not register (${err && err.message ? err.message : err})`)
262    }
263    commandName = (await registerCommand($)) || commandName
264    await restore($)
265    $.clock.every(15000, async () => {
266      now = await $.clock.now()
267      if (paneOpen) await loadOthers($)
268      if (paneOpen || onScreen(G)) $.ui.invalidate('ui.render')
269    })
270    $.clock.every(10000, () => readRecording($).catch(() => {}))
271    return next(e)
272  })
273
274  // /goal <condition> starts the meter and asks Claude to plan. The ask rides as
275  // the command's hidden note, so the cached prefix is untouched.
276  on('command.run', { command: 'goal' }, async ($, e, next) => {
277    const args = String(e.args || '').trim()
278    const r = await next(e)
279    now = await $.clock.now()
280    pendingGoal = null
281    if (!args) return r
282    if (isStopWord(args)) {
283      await stopGoal($, 'stopped')
284      return r
285    }
286    await startGoal($, args)
287    const notes = r && Array.isArray(r.context) ? r.context : []
288    return { ...(r || {}), context: [...notes, instruction(toolName)] }
289  })
290
291  // Fallback when /goal reaches the session without a command.run: remember it
292  // here and start at turn.start, asking for the plan with an appended note.
293  on('prompt.submit', async ($, e, next) => {
294    const m = String(e.text || '').match(/^\s*\/goal\s+([\s\S]+)$/)
295    if (m && !isStopWord(m[1])) pendingGoal = { args: m[1].trim(), at: await $.clock.now() }
296    return next(e)
297  })
298
299  on('turn.start', async ($, e, next) => {
300    now = await $.clock.now()
301    if (pendingGoal && (!G || G.startedAt < pendingGoal.at)) {
302      const args = pendingGoal.args
303      await startGoal($, args)
304      try {
305        await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: instruction(toolName) }] } })
306      } catch {
307        // the nudge after a few tool calls is the second chance
308      }
309    }
310    pendingGoal = null
311    // closed on its tasks, but the goal loop carries on without a new prompt: reopen
312    if (G && G.status === 'met' && !G.dismissedAt && G.finishedBy === 'tasks' && G.kind === 'goal' && !String(e.text || '').trim() && now - G.endedAt < REOPEN_MS) {
313      G.status = 'running'
314      G.endedAt = 0
315      G.finishedBy = ''
316    }
317    if (G && G.status === 'running') {
318      G.active = true
319      G.interrupted = false
320    }
321    return next(e)
322  })
323
324  // The settings hooks' events carry the log's real path
325  on('classic.Stop', async ($, e, next) => {
326    if (e && typeof e.transcript_path === 'string' && e.transcript_path) transcriptPath = e.transcript_path
327    return next(e)
328  })
329
330  on('tool.call', async ($, e, next) => {
331    if (e.tool === toolName) return serveTool($, e)
332    if (G && G.status === 'running' && G.kind === 'goal' && !G.planAt && !e.agentId) {
333      if (settings.strict && WRITERS.has(e.tool)) return { deny: strictDeny(toolName) }
334      if (e.tool !== 'ToolSearch') callsWithoutPlan += 1
335      if (callsWithoutPlan >= NUDGE_AFTER && !nudged) {
336        nudged = true
337        try {
338          await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: nudge(toolName) }] } })
339        } catch {
340          // the band says the plan is missing either way
341        }
342        $.ui.invalidate('ui.render')
343      }
344    }
345    return next(e)
346  })
347
348  on('agent.spawn', async ($, e, next) => {
349    const r = await next(e)
350    if (r && r.agentId) {
351      agentNames.set(r.agentId, clip(e.name || e.description || e.subagentType || 'agent', 32))
352      runningAgents.add(r.agentId)
353      if (G && G.status === 'running') $.ui.invalidate('ui.render')
354    }
355    return r
356  })
357
358  // The goal check writes its verdict as an attachment row
359  on('session.append', { door: 'attachment' }, async ($, e, next) => {
360    const r = await next(e)
361    if (!e.agentId && e.message && e.message.name === 'goal_status') {
362      now = await $.clock.now()
363      try {
364        await onCheck($, e.message, now)
365      } catch {
366        // the row is stored whatever the parser makes of it
367      }
368    }
369    return r
370  })
371
372  on('turn.complete', async ($, e, next) => {
373    const r = await next(e)
374    now = await $.clock.now()
375    if (e.agentId) {
376      runningAgents.delete(e.agentId)
377      if (G && G.status === 'running') $.ui.invalidate('ui.render')
378      return r
379    }
380    if (G && G.status === 'running') {
381      G.active = false
382      G.lastTurnEnd = now
383      if (e.isAborted) G.interrupted = true
384      const p = progress(G)
385      // the turn ended with every task done: finished (the goal check's own
386      // verdict, read from the log, usually closed it a moment earlier)
387      if (e.reason === 'answer' && !e.isAborted && p.n > 0 && p.doneN === p.n) await finishGoal($, 'tasks')
388      else await save($)
389      $.ui.invalidate('ui.render')
390    }
391    return r
392  })
393
394  on('command.run', { command: ['goals', 'goal-meter'] }, async ($, e) => {
395    now = await $.clock.now()
396    const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
397    if (key === 'strict') {
398      settings.strict = value !== 'off'
399      await $.store.set('settings', settings)
400      $.ui.toast(`Strict planning ${settings.strict ? 'on: no file edits in a /goal before the plan' : 'off'}`)
401      return {}
402    }
403    if (key === 'compact' || key === 'full' || key === 'hide' || key === 'show') {
404      if (key === 'hide') settings.view = 'hidden'
405      else if (key === 'show') settings.view = settings.shown || 'compact'
406      else settings.view = settings.shown = key
407      await $.store.set('settings', settings)
408      $.ui.toast(settings.view === 'hidden' ? `Goal meter hidden in every chat. /${commandName} show brings it back` : `Goal meter: ${settings.view} view`)
409      $.ui.invalidate('ui.render')
410      return {}
411    }
412    if (key === 'clear') {
413      if (!G || G.dismissedAt) {
414        $.ui.toast('No goal on screen to clear')
415        return {}
416      }
417      await stopGoal($, 'stopped')
418      G.dismissedAt = now
419      await save($)
420      $.ui.toast('Goal cleared')
421      $.ui.invalidate('ui.render')
422      return {}
423    }
424    await loadOthers($)
425    const surface = await $.session.surface()
426    if (!surface) return { text: plainText() }
427    paneOpen = true
428    await $.ui.open({ id: PANE, title: 'Goal meter', focus: true, closeOnEscape: true })
429    return {}
430  })
431
432  on('ui.close', async ($, e, next) => {
433    if (e.id === PANE) paneOpen = false
434    return next(e)
435  })
436
437  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
438    const below = await next(e)
439    if (e.props && e.props.hasSurvey) return below
440    if (settings.view === 'hidden' || !onScreen(G)) return below
441    const el = $.ui.resolve(e)
442    const width = Math.max(40, (e.props && e.props.bodyColumns) || 100)
443    // Desktop draws proportional text, so the █░ bar and task rows of the full
444    // band don't line up there: desktop always gets the one line, /goals the rest
445    const full = settings.view === 'full' && e.surface !== 'desktop'
446    const mine = full ? drawBand($, el, width) : drawCompact(el, width)
447    return el.Box({ flexDirection: 'column', children: below ? [mine, below] : [mine] })
448  })
449
450  // The footer shows while the band is collapsed, never when the meter is hidden.
451  // On desktop the band line already says the same, so the footer stays the app's.
452  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
453    if (e.surface === 'desktop') return next(e)
454    const label = footerLabel()
455    if (!label) return next(e)
456    const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
457    return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
458  })
459
460  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
461    if (e.requestId !== PANE) return next(e)
462    const el = $.ui.resolve(e)
463    const width = Math.max(50, (e.props && e.props.bodyColumns) || 100)
464    return drawPane(el, width)
465  })
466}
467
468// ---------- words ----------
469
470function label(g) {
471  return g.kind === 'plan' ? 'Plan' : 'Goal'
472}
473
474function paused(g) {
475  return g.status === 'running' && !g.active && g.lastTurnEnd > 0
476}
477
478function headline(g, p) {
479  if (g.status === 'met') return `done ✓ in ${minutes((g.endedAt || now) - g.startedAt)}`
480  if (g.status !== 'running') return 'stopped'
481  if (!g.planAt) return 'planning…'
482  return `${p.doneN} of ${p.n} tasks · ${p.pct}%`
483}
484
485function statsLine(g, p) {
486  const parts = []
487  if (g.status === 'running') {
488    parts.push(`${minutes(now - g.startedAt)} elapsed`)
489    const t = eta(g, now)
490    if (t) parts.push(`about ${minutes(t.ms)} left (≈${clock(t.at)})`)
491    else if (g.planAt && p.doneN < 2) parts.push('ETA after 2 tasks finish')
492  }
493  if (g.firstPlan && p.n > g.firstPlan) parts.push(`plan grew ${g.firstPlan} → ${p.n}`)
494  if (runningAgents.size && g.status === 'running') parts.push(`${runningAgents.size} agent${runningAgents.size === 1 ? '' : 's'} running`)
495  if (g.interrupted && g.status === 'running') parts.push('interrupted')
496  else if (paused(g) && p.doneN < p.n) parts.push('paused, waiting on you')
497  if (g.checks) parts.push(`${g.checks} goal check${g.checks === 1 ? '' : 's'}`)
498  return parts.join(' · ')
499}
500
501function footerLabel() {
502  if (!G || G.dismissedAt || settings.view === 'hidden') return ''
503  const word = G.kind === 'plan' ? 'plan' : 'goal'
504  if (G.status === 'running') {
505    if (!G.planAt) return `◎ ${word} · planning`
506    const p = progress(G)
507    const t = eta(G, now)
508    return `◎ ${word} ${p.pct}%` + (t ? ` · ~${minutes(t.ms)}` : ` · ${p.doneN}/${p.n}`)
509  }
510  if (isRecent(G)) return G.status === 'met' ? `◎ ${word} done ✓` : `◎ ${word} stopped`
511  return ''
512}
513
514function taskTail(t) {
515  if (t.status === 'done') return t.doneAt > t.startedAt ? minutes(t.doneAt - t.startedAt) : ''
516  if (t.status === 'active') return (t.by ? t.by + ' · ' : '') + 'running ' + minutes(now - t.startedAt)
517  if (t.status === 'dropped') return 'dropped' + (t.note ? ': ' + t.note : '')
518  return ''
519}
520
521const ICON = { done: '✓', active: '▶', pending: '○', dropped: '×' }
522
523function taskRow(el, t, width) {
524  const { Box, Text } = el
525  const tail = mask(taskTail(t))
526  const title = mask(t.title)
527  const icon = ICON[t.status] || '○'
528  const lead = t.status === 'done'
529    ? Text({ color: 'green', children: [`${icon} `] })
530    : t.status === 'active'
531      ? Text({ color: 'cyan', bold: true, children: [`${icon} `] })
532      : Text({ dimColor: true, children: [`${icon} `] })
533  const body = t.status === 'active'
534    ? Text({ bold: true, wrap: 'truncate-end', children: [`${t.size}  ${title}`] })
535    : Text({ dimColor: t.status !== 'pending', wrap: 'truncate-end', children: [`${t.size}  ${title}`] })
536  const kids = [lead, body]
537  if (tail) kids.push(Text({ dimColor: true, children: ['  ' + clip(tail, Math.max(10, Math.floor(width / 3)))] }))
538  return Box({ flexDirection: 'row', children: kids })
539}
540
541function barRow(el, g, p, width) {
542  const { Box, Text } = el
543  const w = Math.max(10, Math.min(width - 2, 120))
544  const n = Math.round(p.fraction * w)
545  const color = g.status === 'met' ? 'green' : 'cyan'
546  return Box({ flexDirection: 'row', children: [Text({ color, children: ['█'.repeat(n)] }), Text({ dimColor: true, children: ['░'.repeat(w - n)] })] })
547}
548
549// ---------- drawing ----------
550
551// One line: title on the left; progress, ETA, the running task and a failed check on the right
552function drawCompact(el, width) {
553  const { Box, Text } = el
554  const g = G
555  const p = progress(g)
556  const parts = [headline(g, p)]
557  if (g.status === 'running') {
558    const t = eta(g, now)
559    if (t) parts.push(`~${minutes(t.ms)}`)
560    if (g.interrupted) parts.push('interrupted')
561    else if (paused(g) && p.doneN < p.n) parts.push('waiting on you')
562    const active = visibleTasks(g).find((x) => x.status === 'active')
563    if (active) parts.push(`▸ ${clip(mask(active.title), Math.max(12, Math.floor(width / 4)))}`)
564  }
565  const failed = g.status === 'running' && g.check && g.check.met === false
566  const right = [Text({ bold: true, color: g.status === 'met' ? 'green' : 'cyan', children: [parts.join(' · ')] })]
567  if (failed) right.push(Text({ color: 'yellow', children: ['check: not met'] }))
568  return Box({
569    flexDirection: 'row',
570    justifyContent: 'space-between',
571    width: '100%',
572    columnGap: 2,
573    children: [
574      Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(g)}: ${mask(g.title)}`] }),
575      Box({ flexDirection: 'row', columnGap: 2, flexShrink: 0, children: right }),
576    ],
577  })
578}
579
580function drawBand($, el, width) {
581  const { Box, Text, Button } = el
582  const g = G
583  const p = progress(g)
584  const rows = []
585  const right = headline(g, p)
586  const open = Button({ key: 'goal-tasks', label: 'all tasks', hotkey: 'g', plain: true, onPress: () => openPane($) })
587  rows.push(
588    Box({
589      flexDirection: 'row',
590      justifyContent: 'space-between',
591      width: '100%',
592      columnGap: 2,
593      children: [
594        Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(g)}: ${mask(g.title)}`] }),
595        Box({ flexDirection: 'row', columnGap: 2, flexShrink: 0, children: [Text({ bold: true, color: g.status === 'met' ? 'green' : 'cyan', children: [right] }), open] }),
596      ],
597    }),
598  )
599  if (g.planAt) rows.push(barRow(el, g, p, width))
600  const stats = statsLine(g, p)
601  if (stats) rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [stats] }))
602  if (!g.planAt && g.status === 'running') {
603    rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [nudged ? 'No plan yet: reminded Claude to plan with the goal meter' : 'Waiting for Claude\'s task plan…'] }))
604  } else if (g.status === 'running') {
605    // a window on the list: the last two done, everything running, the next ones
606    const tasks = visibleTasks(g)
607    const done = tasks.filter((t) => t.status === 'done').slice(-2)
608    const active = tasks.filter((t) => t.status === 'active')
609    const todo = tasks.filter((t) => t.status === 'pending').slice(0, active.length ? 2 : 3)
610    const shown = [...done, ...active, ...todo].sort((a, b) => a.id - b.id)
611    for (const t of shown) rows.push(taskRow(el, t, width))
612    const more = tasks.length - shown.length
613    if (more > 0) rows.push(Text({ dimColor: true, children: [`  +${more} more · /${commandName}: all tasks`] }))
614  }
615  if (g.status === 'running' && g.check && g.check.met === false && g.check.reason) {
616    rows.push(Text({ color: 'yellow', wrap: 'truncate-end', children: [`Goal check: not met yet: ${mask(g.check.reason)}`] }))
617  }
618  return Box({ flexDirection: 'column', children: rows })
619}
620
621async function openPane($) {
622  now = await $.clock.now()
623  await loadOthers($)
624  paneOpen = true
625  await $.ui.open({ id: PANE, title: 'Goal meter', focus: true, closeOnEscape: true })
626}
627
628function otherRow(el, g, width) {
629  const { Box, Text } = el
630  const p = progress(g)
631  const name = clip(mask(`${g.label || basename(g.cwd)}: ${g.title}`), Math.max(16, Math.floor(width * 0.4)))
632  let tail
633  if (g.status === 'met') tail = 'done ✓'
634  else if (g.status !== 'running') tail = 'stopped'
635  else if (!g.planAt) tail = 'planning'
636  else {
637    const t = eta(g, now)
638    tail = `${p.pct}% · ${t ? '~' + minutes(t.ms) : p.doneN + '/' + p.n}`
639  }
640  const w = Math.max(8, Math.min(24, width - name.length - tail.length - 6))
641  return Box({
642    flexDirection: 'row',
643    columnGap: 2,
644    children: [
645      Text({ wrap: 'truncate-end', children: [name] }),
646      Text({ color: g.status === 'met' ? 'green' : 'cyan', children: [bar(p.fraction, w)] }),
647      Text({ dimColor: true, children: [tail] }),
648    ],
649  })
650}
651
652function drawPane(el, width) {
653  const { Box, Text } = el
654  const rows = []
655  if (G) {
656    const p = progress(G)
657    rows.push(Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(G)}: ${mask(G.title)}`] }))
658    rows.push(Text({ color: G.status === 'met' ? 'green' : 'cyan', children: [headline(G, p)] }))
659    if (G.planAt) rows.push(barRow(el, G, p, width))
660    const stats = statsLine(G, p)
661    if (stats) rows.push(Text({ dimColor: true, children: [stats] }))
662    rows.push(Text({ children: [' '] }))
663    const tasks = G.tasks.filter((t) => !t.replaced)
664    if (!tasks.length) rows.push(Text({ dimColor: true, children: ['No task plan yet.'] }))
665    for (const t of tasks) rows.push(taskRow(el, t, width))
666    // a failed check is history once the goal is met or stopped
667    if (G.status === 'running' && G.check && G.check.reason) {
668      rows.push(Text({ children: [' '] }))
669      rows.push(Text(G.check.met === false ? { color: 'yellow', children: [`Last goal check: not met yet: ${mask(G.check.reason)}`] } : { dimColor: true, children: [`Last goal check: ${mask(G.check.reason)}`] }))
670    }
671  } else {
672    rows.push(Text({ dimColor: true, children: ['No goal in this chat. Type /goal <what done looks like> to start one.'] }))
673  }
674  const rest = others.filter((g) => g.sessionId !== sessionId)
675  rows.push(Text({ children: [' '] }))
676  rows.push(Text({ bold: true, children: [`Other chats (${rest.length})`] }))
677  if (!rest.length) rows.push(Text({ dimColor: true, children: ['No other chat has a goal in the last 12 hours.'] }))
678  for (const g of rest.slice(0, 15)) rows.push(otherRow(el, g, width))
679  rows.push(Text({ children: [' '] }))
680  rows.push(Text({ dimColor: true, children: [`/${commandName} compact|full|hide · /${commandName} clear · /${commandName} strict ${settings.strict ? 'off' : 'on'} · now: ${settings.view}`] }))
681  return Box({ flexDirection: 'column', children: rows })
682}
683
684function plainText() {
685  if (!G) return 'No goal in this chat.'
686  const p = progress(G)
687  const lines = [`${label(G)}: ${mask(G.title)}`, `${headline(G, p)}  ${bar(p.fraction, 30)}`, statsLine(G, p)]
688  for (const t of G.tasks.filter((x) => !x.replaced)) lines.push(`${ICON[t.status] || '○'} ${t.size}  ${mask(t.title)}  ${mask(taskTail(t))}`)
689  return lines.filter(Boolean).join('\n')
690}
691
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 243 lines
1// Goal Meter's 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
18export function newGoal({ sessionId, condition, now, cwd, kind = 'goal' }) {
19  return {
20    sessionId,
21    kind,
22    title: titleOf(condition),
23    condition: String(condition || '').slice(0, 2000),
24    cwd: cwd || '',
25    startedAt: now,
26    planAt: 0,
27    endedAt: 0,
28    status: 'running',
29    active: true,
30    lastTurnEnd: 0,
31    interrupted: false,
32    tasks: [],
33    nextId: 1,
34    firstPlan: 0,
35    check: null,
36    checks: 0,
37    updatedAt: now,
38  }
39}
40
41function sizeOf(v) {
42  const s = String(v || '').trim().toUpperCase().charAt(0)
43  return SIZES[s] ? s : 'M'
44}
45
46function clean(text, max) {
47  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
48  return s.length > max ? s.slice(0, max - 1) + '…' : s
49}
50
51export function weight(t) {
52  return SIZES[t.size] || 2
53}
54
55// Tasks still part of the plan: dropped and replaced ones don't count
56export function live(goal) {
57  return goal.tasks.filter((t) => t.status !== 'dropped' && !t.replaced)
58}
59
60export function progress(goal) {
61  const tasks = live(goal)
62  const total = tasks.reduce((a, t) => a + weight(t), 0)
63  const done = tasks.filter((t) => t.status === 'done')
64  const doneW = done.reduce((a, t) => a + weight(t), 0)
65  return {
66    total,
67    doneW,
68    doneN: done.length,
69    n: tasks.length,
70    active: tasks.filter((t) => t.status === 'active'),
71    pct: total ? Math.round((100 * doneW) / total) : 0,
72    fraction: total ? doneW / total : 0,
73  }
74}
75
76// The ETA at this goal's own pace: time since the plan per unit of finished
77// work, times the work left. Nothing until two tasks are done.
78export function eta(goal, now) {
79  const p = progress(goal)
80  if (goal.status !== 'running' || p.doneN < 2 || p.doneW <= 0 || p.doneW >= p.total) return null
81  const since = goal.planAt || goal.startedAt
82  const per = Math.max(0, now - since) / p.doneW
83  const ms = Math.round(per * (p.total - p.doneW))
84  return { ms, at: now + ms }
85}
86
87// Accepts [{ title, size }], plain strings, or the same as a JSON string
88export function normalizeTasks(raw) {
89  let list = raw
90  if (typeof list === 'string') {
91    try {
92      list = JSON.parse(list)
93    } catch {
94      list = list.split(/\r?\n/)
95    }
96  }
97  if (!Array.isArray(list)) return []
98  const out = []
99  for (const item of list) {
100    if (typeof item === 'string') {
101      const title = clean(item.replace(/^\s*[-*\d.)\]]+\s*/, ''), 140)
102      if (title) out.push({ title, size: 'M' })
103      continue
104    }
105    if (!item || typeof item !== 'object') continue
106    const title = clean(item.title ?? item.subject ?? item.name ?? item.task ?? item.description, 140)
107    if (title) out.push({ title, size: sizeOf(item.size) })
108  }
109  return out.slice(0, 60)
110}
111
112function idsOf(input) {
113  const raw = Array.isArray(input.ids) ? input.ids : input.id !== undefined ? [input.id] : []
114  return raw.map((v) => Number(String(v).replace(/^#/, ''))).filter((n) => Number.isInteger(n) && n > 0)
115}
116
117function addTasks(goal, list, now, origin) {
118  for (const t of list) {
119    goal.tasks.push({ id: goal.nextId++, title: t.title, size: t.size, status: 'pending', by: '', addedAt: now, startedAt: 0, doneAt: 0, origin, note: '' })
120  }
121}
122
123const WORD = { pending: 'todo', active: 'running', done: 'done', dropped: 'dropped' }
124
125export function listText(goal) {
126  const p = progress(goal)
127  const lines = [`Plan: ${p.doneN} of ${p.n} tasks done, ${p.pct}% of the work.`]
128  for (const t of goal.tasks) {
129    if (t.replaced) continue
130    const who = t.status === 'active' && t.by ? ` by ${t.by}` : ''
131    lines.push(`#${t.id} ${WORD[t.status] || t.status}  ${t.title} (${t.size})${who}`)
132  }
133  return lines.join('\n')
134}
135
136// One call of the task tool. Returns { ok, text } and changes the goal in place.
137export function applyAction(goal, input, { now, by = '' } = {}) {
138  const action = String(input.action || 'show').toLowerCase()
139  const fail = (msg) => ({ ok: false, text: `Goal meter: ${msg}\n${listText(goal)}` })
140  if (action === 'plan' || action === 'add') {
141    const list = normalizeTasks(input.tasks)
142    if (!list.length) return fail(`"${action}" needs tasks: [{ "title": "...", "size": "S" | "M" | "L" }].`)
143    if (action === 'plan' && goal.planAt) {
144      // a new plan replaces the work not started yet; finished and running tasks stay
145      for (const t of goal.tasks) if (t.status === 'pending') t.replaced = true
146    }
147    const first = !goal.planAt
148    addTasks(goal, list, now, first ? 'plan' : 'later')
149    if (first) {
150      goal.planAt = now
151      goal.firstPlan = list.length
152    }
153  } else if (action === 'start' || action === 'done' || action === 'drop') {
154    const ids = idsOf(input)
155    if (!ids.length) return fail(`"${action}" needs the task's id.`)
156    const tasks = ids.map((id) => goal.tasks.find((t) => t.id === id && !t.replaced))
157    const missing = ids.filter((id, i) => !tasks[i])
158    if (missing.length) return fail(`no task #${missing.join(', #')}.`)
159    for (const t of tasks) {
160      if (action === 'start') {
161        t.status = 'active'
162        t.startedAt = t.startedAt || now
163        if (by) t.by = clean(by, 32)
164      } else if (action === 'done') {
165        t.status = 'done'
166        t.startedAt = t.startedAt || now
167        t.doneAt = now
168        if (input.note) t.note = clean(input.note, 140)
169      } else {
170        t.status = 'dropped'
171        t.doneAt = now
172        t.note = clean(input.note || 'no longer needed', 140)
173      }
174    }
175  } else if (action !== 'show') {
176    return fail(`unknown action "${action}". Use plan, add, start, done, drop, or show.`)
177  }
178  goal.updatedAt = now
179  return { ok: true, text: listText(goal) }
180}
181
182// The goal check's verdict, from the text the engine records for it. The shape
183// is not documented, so this reads both a JSON-ish record and plain words.
184export function parseCheck(text) {
185  const t = String(text || '')
186  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)
187  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)
188  const m = t.match(/"reason"\s*:\s*"((?:[^"\\]|\\.)*)"/) || t.match(/\breason\b[^:\n]{0,20}:\s*([\s\S]+)/i)
189  const reason = clean(m ? m[1].replace(/\\"/g, '"').replace(/\\n/g, ' ') : '', 300)
190  return { met, notMet, reason }
191}
192
193export const TOOL_SPEC = {
194  name: 'tasks',
195  description:
196    'Goal meter: the task plan the user watches as a progress bar while a /goal runs. ' +
197    'Call action "plan" first with every task needed to meet the goal, in order, each sized S, M, or L. ' +
198    '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. ' +
199    '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. ' +
200    'Keep it accurate: the bar and the ETA come only from this list.',
201  inputSchema: {
202    type: 'object',
203    properties: {
204      action: { type: 'string', enum: ['plan', 'add', 'start', 'done', 'drop', 'show'] },
205      tasks: {
206        type: 'array',
207        description: 'For plan and add: the tasks in order.',
208        items: {
209          type: 'object',
210          properties: {
211            title: { type: 'string', description: 'What the task delivers, in a few words' },
212            size: { type: 'string', enum: ['S', 'M', 'L'], description: 'S: a few minutes. M: a solid chunk. L: the big piece.' },
213          },
214          required: ['title'],
215        },
216      },
217      id: { type: 'number', description: 'For start, done, and drop: the task number' },
218      ids: { type: 'array', items: { type: 'number' }, description: 'Several task numbers at once' },
219      by: { type: 'string', description: 'For start: who does it, "main" or the subagent\'s short name' },
220      note: { type: 'string', description: 'For done or drop: one short line' },
221    },
222    required: ['action'],
223  },
224}
225
226export function instruction(tool) {
227  return (
228    `Goal meter is on for this goal: the user watches your progress as a bar built from your task plan. ` +
229    `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. ` +
230    `Call "start" with a task's id when you begin it and "done" when it is finished. ` +
231    `If you find more work, or the goal check says the goal is not met yet, call "add" with the new tasks. ` +
232    `When a subagent does a task, call "start" with "by" set to its short name, and "done" when it reports back.`
233  )
234}
235
236export function nudge(tool) {
237  return `Reminder from the goal meter: 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.`
238}
239
240export function strictDeny(tool) {
241  return `Goal meter (strict): plan the goal before changing files. Call ${tool} with action "plan" first, then retry this.`
242}
243