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
★ 8v1.0.0MITupdated 2026-10-02nateherkai/claude-code-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 hide|show (the band) · /goals strict │ ┃ on · /goals clear ● 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 hide|show (the band) · /goals strict on · /goals clear
README

Claude Code mods

Four mods for Claude Code by Nate Herk. Mods are plugins that run inside Claude Code: they can draw on screen (a line above the prompt, a footer label, a side pane), add slash commands, and step in before Claude runs a tool. These four save money on big chats, keep private things off screen while you record, show how far along a /goal is, and stop two chats from editing the same file.

ModCommandsWhat it does
Cache Keeper/cache, /keepwarm, /board, /handoffShows whether a chat's prompt cache is warm or cold and what a cold restart would cost, keeps big chats warm, asks before an expensive cold send, shows every local chat on one board, and hands a chat off to a fresh one
Recording Mode/rec, /rec strict, /rec off, /rec configMasks keys, personal details, and business figures on screen and keeps private files closed while you record
Goal Meter/goalsA progress bar for /goal built from Claude's own task plan, with an ETA and every chat's goal in one pane
Collision Guard/guardAsks before Claude edits a file another open chat changed in the last 30 minutes

Requirements

  • Claude Code 2.1.287 or later. Check with claude --version. The mods work in the terminal and in the Desktop app's Code tab.
  • Mods turned on for your account. Anthropic is turning mods on gradually. Run claude plugin test in any folder: "no hooks module to load" means mods can load, and "served off" means they can't yet. Nothing needs reinstalling once they're on.

Install

Add this repo as a plugin marketplace:

claude plugin marketplace add nateherkai/claude-code-mods

Then install the mods you want, one command each:

claude plugin install cache-keeper@nateherk-mods
claude plugin install recording-mode@nateherk-mods
claude plugin install goal-meter@nateherk-mods
claude plugin install collision-guard@nateherk-mods

Then open a new chat. A mod loads when a chat starts, and /reload-plugins does not reload it.

Update: run claude plugin marketplace update nateherk-mods, then claude plugin update <mod>@nateherk-mods, then open a new chat. Installs are copies, so nothing changes until you update.

Remove: claude plugin uninstall <mod>@nateherk-mods.

Cache Keeper

Every request re-reads the whole chat. From the prompt cache that costs a tenth of the normal input price. Once the cache expires (after 5 minutes or 1 hour idle, depending on your setup), the next message writes the whole chat into the cache again at up to 2x the input price. On a 400k-token chat that one message can cost a few dollars, while keeping the cache warm costs cents.

  • Line above the prompt: ● cache warm 42m │ ctx 412k │ rewrite ≈ $3.30 │ session $5.12. It turns yellow 5 minutes before the cache expires and shows a keep warm button. Your 5-hour and weekly plan limits show there too, yellow at 80% and red at 95%.
  • /keepwarm [hours|off]: keeps this chat's cache warm, 4 hours by default, with a small ping shortly before it would expire. If a ping writes to the cache instead of reading it, keep warm turns itself off and tells you.
  • Cold-send guard: if you send a message into a chat over 150k tokens after its cache expired, it asks first: Send anyway, Compact first, or Cancel.
  • /board: every local chat in one pane, with chats waiting on you first, then working ones, then the ones whose cache cools soonest. Each row shows state, context size, cache, cost, and running agents, with a keep-warm button.
  • Handoff: the handoff button (or /handoff) runs the bundled /session-handoff skill, saves the handoff, then clears the chat and sends the handoff as the fresh chat's first prompt. A short fresh chat is far cheaper than a huge one.
  • /cache shows status and settings: ttl 5|60|auto, guard on|off, big 150k, alerts on|off.

Prices come from the Claude API price list. On a subscription plan they show up as usage against your limits, not a bill.

Recording Mode

Run /rec before you hit record. It applies to every open chat within 3 seconds, and /rec off turns it off everywhere.

  • Masks on screen: API keys and tokens, every value in a .env file in the project folder or its parents, emails, names, phone numbers, street addresses, card, account, and ID numbers, money, and figures next to business words (revenue, margin, payroll, and so on). /rec strict also masks every large figure and percentage.
  • Hides whole results from mail, chat, task, calendar, file, CRM, and finance tools. Claude still gets the real result; only the screen changes.
  • Keeps files closed: .env files, credentials, SSH and cloud keys, Claude's memory folder, and finance documents (payroll, invoices, contracts, budgets, forecasts, tax files). Finance tools like QuickBooks stay closed too. Claude is told it can't open them while recording.
  • Tells Claude: each prompt carries a hidden note to keep names, figures, and internal plans out of its replies.
  • A red ● REC shows above the prompt and in the footer while it's on.

Make it yours with /rec config

/rec config creates ~/.claude/mods-data/recording-mode/config.json:

{
  "keepNames": ["Your Name"],
  "names": ["A Teammate", "A Client"],
  "privatePaths": ["clients/", "finance/", "my-private-notes.md"],
  "businessTools": [],
  "closedTools": []
}
  • keepNames: your own name (and your brand), shown even while recording.
  • names: people whose names are always masked. Names in emails and contact fields are picked up on their own.
  • privatePaths: folders or file names Claude may not open while recording. Any part of a path matches, in any case.
  • businessTools: extra tool or command names whose results draw as hidden.
  • closedTools: extra tool names Claude may not call while recording.

Save the file and run /rec again.

Limits: it changes what is drawn on screen and what Claude may open, not what is stored. Pattern masking can't catch everything written in plain words, so watch your footage before you publish. The chat titles in the Desktop app's sidebar can't be masked by a mod.

Goal Meter

Type /goal <what done looks like> as usual. Claude first writes its plan with the goal meter's task tool, every task sized S, M, or L, then marks each task started and done as it works.

  • Line above the prompt: 5 of 8 tasks · 69%, a progress bar, 38m elapsed · about 17m left (≈3:40pm) · plan grew 7 → 8 · 1 agent running, the last tasks done, what's running and which agent runs it, the next tasks, and the goal check's last "not met yet" reason. Press g for the full list.
  • Footer: ◎ goal 69% · ~17m, visible even when the line above the prompt is hidden.
  • Percent and ETA: the percentage is finished work out of the plan, with S, M, and L tasks counting 1, 2, and 3. The ETA uses this goal's own pace so far and appears once 2 tasks are done. When Claude finds more work, the plan grows and the bar can step back.
  • /goals: this chat's whole task list, plus every other chat's goal from the last 12 hours.
  • Settings: /goals hide|show (the line above the prompt), /goals strict on (no file edits in a goal before the plan exists), /goals clear.

It sends no extra requests to Claude. The task tool calls Claude makes are a few small ones per goal. A plan Claude makes without /goal shows the same way.

Collision Guard

When you run several chats in one project, two of them can end up changing the same file without knowing it. Before Claude edits or writes a file, Collision Guard checks whether another open chat changed that file in the last 30 minutes. If one did, it asks:

  • Proceed: edit anyway.
  • Move to a worktree: Claude moves this chat's work into a git worktree so the two chats stop touching the same files (offered when git tracks the file).
  • Cancel: Claude leaves the file alone and asks you how to go on.

/guard shows what it sees, /guard off turns it off, and /guard window 60 changes the 30-minute window. Each chat keeps a small list of the files it changed, and nothing is added to Claude's prompt.

Where the data lives

Everything stays on your machine, under ~/.claude/mods-data/: Cache Keeper's session heartbeats and saved handoffs, the recording flag and config, each chat's goal plan, and Collision Guard's file lists. The mods make no network calls of their own. Cache Keeper's keep-warm pings are small model requests, sent only while /keepwarm is on.

License

MIT. See LICENSE.

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