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…

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.
| Mod | Commands | What it does |
|---|---|---|
| Cache Keeper | /cache, /keepwarm, /board, /handoff | Shows 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 config | Masks keys, personal details, and business figures on screen and keeps private files closed while you record |
| Goal Meter | /goals | A progress bar for /goal built from Claude's own task plan, with an ETA and every chat's goal in one pane |
| Collision Guard | /guard | Asks before Claude edits a file another open chat changed in the last 30 minutes |
claude --version. The mods work in the terminal and in the Desktop app's Code tab.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.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.
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.
● 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./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 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.
Run /rec before you hit record. It applies to every open chat within 3 seconds, and /rec off turns it off everywhere.
.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..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./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": []
}
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.
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.
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.◎ goal 69% · ~17m, visible even when the line above the prompt is hidden./goals: this chat's whole task list, plus every other chat's goal from the last 12 hours./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.
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:
/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.
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.
MIT. See LICENSE.
hooks/register.mjs 638 lines1// 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}
638hooks/fmt.mjs 70 lines1// 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}
70hooks/privacy.mjs 337 lines1// 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}
337hooks/plan.mjs 243 lines1// 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