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 Claude Code mods, published as a plugin marketplace named claude-super-mods.
| Mod | What you get | ||||
|---|---|---|---|---|---|
| goal-meter 1.2.1 | A progress bar for /goal, built from Claude's own task plan. One line by default. /goals opens the full task list. | ||||
| cache-keeper 1.3.0 | A band above the prompt with the model, effort, context bar, plan limits (with the 5h reset) and cache state, so no status line is needed. Keeps a long chat's prompt cache warm (/keepwarm 2), asks before an expensive cold send, shows every local chat (/board), and hands a chat off (/handoff). Every compaction without its own instructions is told what to keep, and the band shows how much it saved. | ||||
| lean-explore 1.6.0 | Replaces the built-in Explore subagent (the one that does codebase searches) with a lean one: Haiku, low effort, Read/Grep/Glob only, short answers with file:line. It counts what it really did (searches, reads, tokens delegated to Haiku) from your own transcripts. `/lean-explore on\ | off\ | status\ | stats\ | rescan`. |
| memory-guard 0.2.2 | Spots sessions worth remembering (files written to output folders, MCP writes, meeting transcripts, decisions, commits, accepted plans, long chats) and nudges once to run /memorize. A session that ends without it is queued, and the next session in that project offers to catch up. Files into your wiki if you point it at one, else Claude Code's own auto-memory. Ships its own /memorize skill. |
A plugin cannot replace a built-in agent with an agent of its own (plugin agents get a lean-explore: prefix). So on the first session the mod writes ~/.claude/agents/Explore.md itself, marked with a comment line so it never touches an Explore.md you wrote.
Explore: Haiku · 6 searches counts this chat's Explore runs; hover it for a card with a Turn off button. Amber Explore: built-in means it is off, with a Turn on button. After a toggle it adds what the next session will use. It shares one row with the TL;DR button (the mods-row convention), chip on the left.Explore: Haiku or Explore: built-in, with the same next-session note./lean-explore stats prints all-time searches, reads and tokens delegated to Haiku (measured from usage figures; the "kept out of chat" number is an estimate at 4 characters per token). /lean-explore rescan rebuilds the totals from your transcripts./lean-explore status shows whether it is on, off, or blocked by your own Explore.md./lean-explore off renames it to Explore.md.off; the built-in Explore comes back./lean-explore on brings it back./lean-explore off, or delete ~/.claude/agents/Explore.md. Uninstalling the mod does not remove the file.When a search needs judgment (a review, a design call), ask for a general-purpose agent or pass model: "opus": Haiku finds things well but checks less of what it reads.
Each session scores a point per kind of durable work: a file written to an output folder (docs, reports, deliverables, notes), a write through any MCP tool, a meeting or transcript read, a decision in your prompt ("let's go with", "from now on"), a git commit, an accepted plan. Ten turns or more adds one. At 2 points it nudges once: a toast, a band above the prompt with Memorize now and Not this session, and /memorize as the prompt suggestion. A session that ends without /memorize goes on that project's queue. The next session there shows Catch up, and the hint line says · 3 unmemorized.
With no setup it files into Claude Code's auto-memory (~/.claude/projects/<project>/memory/ and its MEMORY.md). To use a wiki, or your own /memorize skill, add .claude/memory-guard.json to the project (or ~/.claude/memory-guard.json for all of them). Every field is optional:
{
"kb": { "path": "wiki", "index": "wiki/index.md", "log": "wiki/log.md", "rules": "wiki/CLAUDE.md" },
"skill": "memorize",
"threshold": 2,
"longSessionTurns": 10,
"outputDirs": ["docs", "reports", "deliverables", "notes"],
"signals": { "decision": false },
"extraSignals": [{ "name": "Tickets", "tool": "^mcp__.*linear.*__(create|update)" }, { "name": "Release", "bash": "npm publish" }],
"ui": { "band": true, "hint": true, "suggest": true }
}
kb: your knowledge base. The bundled skill reads its rules file and index, files pages there, and logs each one. A write to kb.log counts as memorized.skill: the skill the buttons run. Leave it out to use the bundled memory-guard:memorize.extraSignals: your own signals, by tool-name or Bash-command regex.On desktop a chip above the prompt shows Memory guard: on · 1 of 2 (green) or off (grey); hover it for a Turn off here / Turn on here button. The terminal footer says memory guard on or off. "ui": { "chip": false } hides both.
If it finds a wiki/ folder or an Obsidian vault with no config, it says so once. The band and hint line show in the terminal and the desktop app; elsewhere the toast and /memorize-queue still work.
goal-meter and cache-keeper are built on Nate Herk's claude-code-mods (MIT). goal-meter is a fork of his goal-meter 1.0.0, and cache-keeper is based on his cache-keeper 1.0.0. Each mod's CHANGES.md lists what differs from upstream. cache-keeper 1.3.0 also borrows three ideas from JayGPTPro's Smart PreCompact. MIT, see LICENSE.
Needs a Claude Code build with mods (function-hook plugins). Works on Windows, macOS and Linux.
claude plugin marketplace add bStyler/claude-mods
claude plugin install goal-meter@claude-super-mods
claude plugin install cache-keeper@claude-super-mods
claude plugin install lean-explore@claude-super-mods
claude plugin install memory-guard@claude-super-mods
Then quit Claude Code and start it again. /reload-plugins and /clear do not load mod code. Check: type /goals. It should open the goal meter pane.
If Nate Herk's nateherk-mods is installed on that machine, turn off its copies first (same names, same tools):
claude plugin disable goal-meter@nateherk-mods
claude plugin disable cache-keeper@nateherk-mods
cache-keeper's band replaces a custom status line. If you had one, remove the statusLine block from ~/.claude/settings.json so the same figures don't show twice.
goal-meter
| Command | Does |
|---|---|
/goal <what done looks like> | Starts a goal. Claude plans it as sized tasks and the bar tracks them. |
/goals | Full task list, plus other chats' goals from the last 12 hours. |
/goals compact / /goals full | One-line bar (default) or the full bar. Saved for every chat. |
/goals hide / /goals show | Hide the bar and footer label in every chat, or bring them back. |
/goals clear | Remove this chat's goal, running or finished. |
/goals strict on | No file edits in a /goal until Claude has written the plan. Useful for coding. |
The g key on the bar only works after Ctrl+X Tab focuses the bar. From the prompt, type /goals.
cache-keeper
| Command | Does |
|---|---|
/keepwarm 2 / /keepwarm off | Keep this chat's cache warm for N hours (max 24). |
/cache big 100k | Size at which it asks before a cold send (default 150k). |
/cache guard off | Turn the cold-send question off. |
/cache ttl 5 / 60 / auto | Cache window, if it guesses wrong. |
/board | Every local chat on one board. |
/handoff | Write a handoff and continue in a fresh chat. |
memory-guard
| Command | Does |
|---|---|
/memorize | Save what this session learned (bundled skill: memory-guard:memorize). |
/memorize queue | Catch up on the queued sessions from their transcripts. |
/memorize-queue / /memorize-queue clear | Show this session's score and the queue, or clear it. |
/memory-guard off / on / status | Stop or start watching this project; show where it files and what it counts. |
claude plugin marketplace update claude-super-mods
claude plugin update goal-meter@claude-super-mods
claude plugin update cache-keeper@claude-super-mods
claude plugin update lean-explore@claude-super-mods
claude plugin update memory-guard@claude-super-mods
Restart Claude Code afterwards. When changing code, raise version in the mod's .claude-plugin/plugin.json, or plugin update will not pick it up.
claude plugin uninstall goal-meter@claude-super-mods
claude plugin uninstall cache-keeper@claude-super-mods
claude plugin uninstall lean-explore@claude-super-mods
claude plugin uninstall memory-guard@claude-super-mods
claude plugin marketplace remove claude-super-mods
Saved state lives in ~/.claude/mods-data/ and can be deleted.
hooks/register.mjs 691 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, compact by default: one line with progress, ETA
7// and the running task. /goals full shows the full band: the bar (finished work out of the plan, weighted
8// by size), elapsed time, the ETA at this goal's own pace, what's running and
9// who runs it, the next tasks, and the goal check's latest "not met yet" reason.
10// - The footer: "◎ goal 69% · ~15m" in every view, terminal and Desktop.
11// - /goals: a pane with this chat's whole task list and every other chat's goal.
12// /goals compact|full|hide|show (saved for every chat; hide also hides the footer),
13// /goals clear (removes this chat's goal, running or finished), /goals strict on|off.
14// - A finished goal stays on screen 2 minutes.
15// - Fork of Nate Herk's goal-meter 1.0.0; changes listed in CHANGES.md.
16// - Each chat writes ~/.claude/mods-data/goal-meter/<session>.json for /goals.
17// The mod sends no model requests of its own.
18
19import { minutes, clock, clip, bar, basename } from './fmt.mjs'
20import { makeMasker } from './privacy.mjs'
21import { newGoal, applyAction, progress, eta, parseCheck, isStopWord, normalizeTasks, TOOL_SPEC, instruction, nudge, strictDeny } from './plan.mjs'
22
23const DIR = '/.claude/mods-data/goal-meter'
24const PANE = 'goal-meter'
25const RECENT_MS = 2 * 60000 // a finished goal stays on screen this long
26const OTHERS_MS = 12 * 3600000 // other chats' goals shown in /goals
27const REOPEN_MS = 5 * 60000 // a goal closed on its tasks reopens if Claude carries on this soon
28const NUDGE_AFTER = 4 // tool calls into a goal with no plan before the reminder
29const WRITERS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
30
31let G = null
32let sessionId = ''
33let home = ''
34let cwd = ''
35let now = 0
36let toolName = 'mcp__goal-meter__tasks'
37let commandName = 'goals'
38// view: 'compact' (one line), 'full' (bar and tasks) or 'hidden' (no band, no footer); saved
39let settings = { strict: false, view: 'compact', shown: 'compact' }
40let nudged = false
41let callsWithoutPlan = 0
42let pendingGoal = null
43let paneOpen = false
44let others = []
45let transcriptPath = ''
46const agentNames = new Map()
47const runningAgents = new Set()
48let rec = { on: false, strict: false }
49let mask = (s) => s
50
51function goalFile(id) {
52 return `${home}${DIR}/${id}.json`
53}
54
55function isRecent(g) {
56 return g && g.status !== 'running' && !g.dismissedAt && g.endedAt && now - g.endedAt < RECENT_MS
57}
58
59function onScreen(g) {
60 return g && !g.dismissedAt && (g.status === 'running' || isRecent(g))
61}
62
63function visibleTasks(g) {
64 return g.tasks.filter((t) => !t.replaced && t.status !== 'dropped')
65}
66
67async function save($) {
68 if (!G || !sessionId) return
69 G.updatedAt = now
70 try {
71 await $.fs.write(goalFile(sessionId), JSON.stringify({ ...G, label: basename(cwd) }))
72 } catch {
73 // the pane falls back to this chat alone
74 }
75}
76
77async function restore($) {
78 try {
79 const path = goalFile(sessionId)
80 if (!(await $.fs.exists(path))) return
81 const saved = JSON.parse(await $.fs.read(path))
82 if (saved && Array.isArray(saved.tasks) && now - (saved.updatedAt || 0) < 24 * 3600000) G = saved
83 } catch {
84 G = null
85 }
86}
87
88async function loadOthers($) {
89 const dir = home + DIR
90 const list = []
91 try {
92 for (const entry of await $.fs.list(dir)) {
93 if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name === sessionId + '.json') continue
94 if (now - (entry.mtimeMs || 0) > OTHERS_MS) continue
95 try {
96 const g = JSON.parse(await $.fs.read(dir + '/' + entry.name))
97 if (!g || !Array.isArray(g.tasks)) continue
98 if (g.status !== 'running' && now - (g.endedAt || 0) > OTHERS_MS) continue
99 list.push(g)
100 } catch {
101 // a file another chat is writing right now; next refresh reads it
102 }
103 }
104 } catch {
105 // no folder yet
106 }
107 others = list.sort((a, b) => (a.status === 'running' ? 0 : 1) - (b.status === 'running' ? 0 : 1) || b.updatedAt - a.updatedAt)
108}
109
110async function readRecording($) {
111 try {
112 const path = (home || '.') + '/.claude/mods-data/recording.json'
113 if (!(await $.fs.exists(path))) rec = { on: false, strict: false }
114 else {
115 const flag = JSON.parse(await $.fs.read(path))
116 rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
117 }
118 } catch {
119 rec = { on: false, strict: false }
120 }
121 mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
122}
123
124async function registerCommand($) {
125 const spec = { name: 'goals', description: 'Goal meter: this chat\'s goal task list and every chat\'s goal (/goals compact|full|hide|show|clear|strict on|off)', argumentHint: '[compact|full|hide|show|clear|strict on|off]', immediate: true }
126 try {
127 await $.command.register(spec)
128 return 'goals'
129 } catch {
130 try {
131 await $.command.register({ ...spec, name: 'goal-meter' })
132 return 'goal-meter'
133 } catch {
134 return null
135 }
136 }
137}
138
139async function startGoal($, condition) {
140 G = newGoal({ sessionId, condition, now, cwd })
141 nudged = false
142 callsWithoutPlan = 0
143 pendingGoal = null
144 await save($)
145 $.ui.invalidate('ui.render')
146}
147
148async function stopGoal($, status) {
149 if (!G || G.status !== 'running') return
150 G.status = status
151 G.endedAt = now
152 G.active = false
153 await save($)
154 $.ui.invalidate('ui.render')
155}
156
157async function finishGoal($, how) {
158 if (!G || G.status !== 'running') return
159 G.status = 'met'
160 G.endedAt = now
161 G.active = false
162 G.finishedBy = how
163 await save($)
164 const took = minutes(G.endedAt - G.startedAt)
165 $.ui.toast(`${G.kind === 'plan' ? 'Plan' : 'Goal'} done in ${took}: ${clip(mask(G.title), 60)}`)
166 $.ui.invalidate('ui.render')
167}
168
169async function serveTool($, e) {
170 now = await $.clock.now()
171 const action = String(e.action || 'show').toLowerCase()
172 const first = normalizeTasks(e.tasks)[0]
173 if (!G || (G.status !== 'running' && (action === 'plan' || action === 'add'))) {
174 if (!first) return { result: `Goal meter: no plan in this chat yet. Call action "plan" with the tasks first, each { "title": "...", "size": "S" | "M" | "L" }.` }
175 // a plan outside /goal: tracked the same way, named after its first task
176 G = newGoal({ sessionId, condition: first.title, now, cwd, kind: 'plan' })
177 }
178 const by = e.by ? String(e.by) : e.agentId ? agentNames.get(e.agentId) || 'agent' : ''
179 const r = applyAction(G, e, { now, by })
180 if (r.ok) {
181 await save($)
182 $.ui.invalidate('ui.render')
183 }
184 return { result: r.text }
185}
186
187// The goal check's verdict. Its row reaches session.append with no content (the
188// payload is stored beside it, seen live 2026-10-02), so read the record from
189// the end of the chat's log: a few lines, never the whole file (logs reach 200 MB).
190async function lastGoalStatus($, since) {
191 const path = transcriptPath
192 if (!path) return null
193 const windows = /^[A-Za-z]:/.test(path)
194 const argv = windows
195 ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', `Get-Content -LiteralPath '${path.replace(/'/g, "''")}' -Tail 12 -Encoding UTF8`]
196 : ['tail', '-n', '12', path]
197 let out = ''
198 try {
199 const r = await $.process.run(argv, { timeoutMs: 15000 })
200 out = r.stdout || ''
201 } catch {
202 return null
203 }
204 const lines = out.split(/\r?\n/).filter((l) => l.includes('"goal_status"')).reverse()
205 for (const line of lines) {
206 try {
207 const row = JSON.parse(line)
208 const a = row.attachment
209 if (!a || a.type !== 'goal_status') continue
210 // an older check's record is not this check's verdict
211 const ts = Date.parse(row.timestamp || '') || 0
212 return ts && since && ts < since - 3000 ? null : a
213 } catch {
214 // a line cut by the tail
215 }
216 }
217 return null
218}
219
220// The log is written a moment after the row reaches the hook: read it now, and
221// again after 1.5 and 4 seconds when the verdict isn't there yet
222async function onCheck($, message, at, attempt = 0) {
223 if (!G || G.kind !== 'goal' || G.status !== 'running') return
224 let verdict = await lastGoalStatus($, at)
225 if (!verdict) {
226 // a build that renders the verdict into the row itself
227 const blocks = Array.isArray(message.content) ? message.content : []
228 const c = parseCheck(blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n'))
229 verdict = c.met ? { met: true } : c.notMet ? { met: false, reason: c.reason } : null
230 }
231 if (!verdict && attempt < 2) {
232 $.clock.after(attempt ? 4000 : 1500, async () => {
233 now = await $.clock.now()
234 await onCheck($, message, at, attempt + 1).catch(() => {})
235 })
236 return
237 }
238 if (!verdict || verdict.sentinel) return // the row written when the goal is set
239 if (verdict.met) return finishGoal($, 'check')
240 G.check = { met: false, reason: clip(verdict.reason || 'no reason given', 300), at: now }
241 G.checks += 1
242 await save($)
243 $.ui.invalidate('ui.render')
244}
245
246export function register(on) {
247 on('session.start', async ($, e, next) => {
248 now = await $.clock.now()
249 home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
250 sessionId = await $.session.id()
251 cwd = await $.session.cwd()
252 // where Claude Code keeps this chat's log; a settings-hook event confirms it below
253 transcriptPath = `${home}/.claude/projects/${String(cwd).replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
254 const saved = await $.store.get('settings')
255 if (saved && typeof saved === 'object') settings = { ...settings, ...saved }
256 await readRecording($)
257 try {
258 const reg = await $.tool.register(TOOL_SPEC)
259 if (reg && reg.tool) toolName = reg.tool
260 } catch (err) {
261 $.ui.log(`goal-meter: the task tool did not register (${err && err.message ? err.message : err})`)
262 }
263 commandName = (await registerCommand($)) || commandName
264 await restore($)
265 $.clock.every(15000, async () => {
266 now = await $.clock.now()
267 if (paneOpen) await loadOthers($)
268 if (paneOpen || onScreen(G)) $.ui.invalidate('ui.render')
269 })
270 $.clock.every(10000, () => readRecording($).catch(() => {}))
271 return next(e)
272 })
273
274 // /goal <condition> starts the meter and asks Claude to plan. The ask rides as
275 // the command's hidden note, so the cached prefix is untouched.
276 on('command.run', { command: 'goal' }, async ($, e, next) => {
277 const args = String(e.args || '').trim()
278 const r = await next(e)
279 now = await $.clock.now()
280 pendingGoal = null
281 if (!args) return r
282 if (isStopWord(args)) {
283 await stopGoal($, 'stopped')
284 return r
285 }
286 await startGoal($, args)
287 const notes = r && Array.isArray(r.context) ? r.context : []
288 return { ...(r || {}), context: [...notes, instruction(toolName)] }
289 })
290
291 // Fallback when /goal reaches the session without a command.run: remember it
292 // here and start at turn.start, asking for the plan with an appended note.
293 on('prompt.submit', async ($, e, next) => {
294 const m = String(e.text || '').match(/^\s*\/goal\s+([\s\S]+)$/)
295 if (m && !isStopWord(m[1])) pendingGoal = { args: m[1].trim(), at: await $.clock.now() }
296 return next(e)
297 })
298
299 on('turn.start', async ($, e, next) => {
300 now = await $.clock.now()
301 if (pendingGoal && (!G || G.startedAt < pendingGoal.at)) {
302 const args = pendingGoal.args
303 await startGoal($, args)
304 try {
305 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: instruction(toolName) }] } })
306 } catch {
307 // the nudge after a few tool calls is the second chance
308 }
309 }
310 pendingGoal = null
311 // closed on its tasks, but the goal loop carries on without a new prompt: reopen
312 if (G && G.status === 'met' && !G.dismissedAt && G.finishedBy === 'tasks' && G.kind === 'goal' && !String(e.text || '').trim() && now - G.endedAt < REOPEN_MS) {
313 G.status = 'running'
314 G.endedAt = 0
315 G.finishedBy = ''
316 }
317 if (G && G.status === 'running') {
318 G.active = true
319 G.interrupted = false
320 }
321 return next(e)
322 })
323
324 // The settings hooks' events carry the log's real path
325 on('classic.Stop', async ($, e, next) => {
326 if (e && typeof e.transcript_path === 'string' && e.transcript_path) transcriptPath = e.transcript_path
327 return next(e)
328 })
329
330 on('tool.call', async ($, e, next) => {
331 if (e.tool === toolName) return serveTool($, e)
332 if (G && G.status === 'running' && G.kind === 'goal' && !G.planAt && !e.agentId) {
333 if (settings.strict && WRITERS.has(e.tool)) return { deny: strictDeny(toolName) }
334 if (e.tool !== 'ToolSearch') callsWithoutPlan += 1
335 if (callsWithoutPlan >= NUDGE_AFTER && !nudged) {
336 nudged = true
337 try {
338 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: nudge(toolName) }] } })
339 } catch {
340 // the band says the plan is missing either way
341 }
342 $.ui.invalidate('ui.render')
343 }
344 }
345 return next(e)
346 })
347
348 on('agent.spawn', async ($, e, next) => {
349 const r = await next(e)
350 if (r && r.agentId) {
351 agentNames.set(r.agentId, clip(e.name || e.description || e.subagentType || 'agent', 32))
352 runningAgents.add(r.agentId)
353 if (G && G.status === 'running') $.ui.invalidate('ui.render')
354 }
355 return r
356 })
357
358 // The goal check writes its verdict as an attachment row
359 on('session.append', { door: 'attachment' }, async ($, e, next) => {
360 const r = await next(e)
361 if (!e.agentId && e.message && e.message.name === 'goal_status') {
362 now = await $.clock.now()
363 try {
364 await onCheck($, e.message, now)
365 } catch {
366 // the row is stored whatever the parser makes of it
367 }
368 }
369 return r
370 })
371
372 on('turn.complete', async ($, e, next) => {
373 const r = await next(e)
374 now = await $.clock.now()
375 if (e.agentId) {
376 runningAgents.delete(e.agentId)
377 if (G && G.status === 'running') $.ui.invalidate('ui.render')
378 return r
379 }
380 if (G && G.status === 'running') {
381 G.active = false
382 G.lastTurnEnd = now
383 if (e.isAborted) G.interrupted = true
384 const p = progress(G)
385 // the turn ended with every task done: finished (the goal check's own
386 // verdict, read from the log, usually closed it a moment earlier)
387 if (e.reason === 'answer' && !e.isAborted && p.n > 0 && p.doneN === p.n) await finishGoal($, 'tasks')
388 else await save($)
389 $.ui.invalidate('ui.render')
390 }
391 return r
392 })
393
394 on('command.run', { command: ['goals', 'goal-meter'] }, async ($, e) => {
395 now = await $.clock.now()
396 const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
397 if (key === 'strict') {
398 settings.strict = value !== 'off'
399 await $.store.set('settings', settings)
400 $.ui.toast(`Strict planning ${settings.strict ? 'on: no file edits in a /goal before the plan' : 'off'}`)
401 return {}
402 }
403 if (key === 'compact' || key === 'full' || key === 'hide' || key === 'show') {
404 if (key === 'hide') settings.view = 'hidden'
405 else if (key === 'show') settings.view = settings.shown || 'compact'
406 else settings.view = settings.shown = key
407 await $.store.set('settings', settings)
408 $.ui.toast(settings.view === 'hidden' ? `Goal meter hidden in every chat. /${commandName} show brings it back` : `Goal meter: ${settings.view} view`)
409 $.ui.invalidate('ui.render')
410 return {}
411 }
412 if (key === 'clear') {
413 if (!G || G.dismissedAt) {
414 $.ui.toast('No goal on screen to clear')
415 return {}
416 }
417 await stopGoal($, 'stopped')
418 G.dismissedAt = now
419 await save($)
420 $.ui.toast('Goal cleared')
421 $.ui.invalidate('ui.render')
422 return {}
423 }
424 await loadOthers($)
425 const surface = await $.session.surface()
426 if (!surface) return { text: plainText() }
427 paneOpen = true
428 await $.ui.open({ id: PANE, title: 'Goal meter', focus: true, closeOnEscape: true })
429 return {}
430 })
431
432 on('ui.close', async ($, e, next) => {
433 if (e.id === PANE) paneOpen = false
434 return next(e)
435 })
436
437 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
438 const below = await next(e)
439 if (e.props && e.props.hasSurvey) return below
440 if (settings.view === 'hidden' || !onScreen(G)) return below
441 const el = $.ui.resolve(e)
442 const width = Math.max(40, (e.props && e.props.bodyColumns) || 100)
443 // Desktop draws proportional text, so the █░ bar and task rows of the full
444 // band don't line up there: desktop always gets the one line, /goals the rest
445 const full = settings.view === 'full' && e.surface !== 'desktop'
446 const mine = full ? drawBand($, el, width) : drawCompact(el, width)
447 return el.Box({ flexDirection: 'column', children: below ? [mine, below] : [mine] })
448 })
449
450 // The footer shows while the band is collapsed, never when the meter is hidden.
451 // On desktop the band line already says the same, so the footer stays the app's.
452 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
453 if (e.surface === 'desktop') return next(e)
454 const label = footerLabel()
455 if (!label) return next(e)
456 const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
457 return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
458 })
459
460 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
461 if (e.requestId !== PANE) return next(e)
462 const el = $.ui.resolve(e)
463 const width = Math.max(50, (e.props && e.props.bodyColumns) || 100)
464 return drawPane(el, width)
465 })
466}
467
468// ---------- words ----------
469
470function label(g) {
471 return g.kind === 'plan' ? 'Plan' : 'Goal'
472}
473
474function paused(g) {
475 return g.status === 'running' && !g.active && g.lastTurnEnd > 0
476}
477
478function headline(g, p) {
479 if (g.status === 'met') return `done ✓ in ${minutes((g.endedAt || now) - g.startedAt)}`
480 if (g.status !== 'running') return 'stopped'
481 if (!g.planAt) return 'planning…'
482 return `${p.doneN} of ${p.n} tasks · ${p.pct}%`
483}
484
485function statsLine(g, p) {
486 const parts = []
487 if (g.status === 'running') {
488 parts.push(`${minutes(now - g.startedAt)} elapsed`)
489 const t = eta(g, now)
490 if (t) parts.push(`about ${minutes(t.ms)} left (≈${clock(t.at)})`)
491 else if (g.planAt && p.doneN < 2) parts.push('ETA after 2 tasks finish')
492 }
493 if (g.firstPlan && p.n > g.firstPlan) parts.push(`plan grew ${g.firstPlan} → ${p.n}`)
494 if (runningAgents.size && g.status === 'running') parts.push(`${runningAgents.size} agent${runningAgents.size === 1 ? '' : 's'} running`)
495 if (g.interrupted && g.status === 'running') parts.push('interrupted')
496 else if (paused(g) && p.doneN < p.n) parts.push('paused, waiting on you')
497 if (g.checks) parts.push(`${g.checks} goal check${g.checks === 1 ? '' : 's'}`)
498 return parts.join(' · ')
499}
500
501function footerLabel() {
502 if (!G || G.dismissedAt || settings.view === 'hidden') return ''
503 const word = G.kind === 'plan' ? 'plan' : 'goal'
504 if (G.status === 'running') {
505 if (!G.planAt) return `◎ ${word} · planning`
506 const p = progress(G)
507 const t = eta(G, now)
508 return `◎ ${word} ${p.pct}%` + (t ? ` · ~${minutes(t.ms)}` : ` · ${p.doneN}/${p.n}`)
509 }
510 if (isRecent(G)) return G.status === 'met' ? `◎ ${word} done ✓` : `◎ ${word} stopped`
511 return ''
512}
513
514function taskTail(t) {
515 if (t.status === 'done') return t.doneAt > t.startedAt ? minutes(t.doneAt - t.startedAt) : ''
516 if (t.status === 'active') return (t.by ? t.by + ' · ' : '') + 'running ' + minutes(now - t.startedAt)
517 if (t.status === 'dropped') return 'dropped' + (t.note ? ': ' + t.note : '')
518 return ''
519}
520
521const ICON = { done: '✓', active: '▶', pending: '○', dropped: '×' }
522
523function taskRow(el, t, width) {
524 const { Box, Text } = el
525 const tail = mask(taskTail(t))
526 const title = mask(t.title)
527 const icon = ICON[t.status] || '○'
528 const lead = t.status === 'done'
529 ? Text({ color: 'green', children: [`${icon} `] })
530 : t.status === 'active'
531 ? Text({ color: 'cyan', bold: true, children: [`${icon} `] })
532 : Text({ dimColor: true, children: [`${icon} `] })
533 const body = t.status === 'active'
534 ? Text({ bold: true, wrap: 'truncate-end', children: [`${t.size} ${title}`] })
535 : Text({ dimColor: t.status !== 'pending', wrap: 'truncate-end', children: [`${t.size} ${title}`] })
536 const kids = [lead, body]
537 if (tail) kids.push(Text({ dimColor: true, children: [' ' + clip(tail, Math.max(10, Math.floor(width / 3)))] }))
538 return Box({ flexDirection: 'row', children: kids })
539}
540
541function barRow(el, g, p, width) {
542 const { Box, Text } = el
543 const w = Math.max(10, Math.min(width - 2, 120))
544 const n = Math.round(p.fraction * w)
545 const color = g.status === 'met' ? 'green' : 'cyan'
546 return Box({ flexDirection: 'row', children: [Text({ color, children: ['█'.repeat(n)] }), Text({ dimColor: true, children: ['░'.repeat(w - n)] })] })
547}
548
549// ---------- drawing ----------
550
551// One line: title on the left; progress, ETA, the running task and a failed check on the right
552function drawCompact(el, width) {
553 const { Box, Text } = el
554 const g = G
555 const p = progress(g)
556 const parts = [headline(g, p)]
557 if (g.status === 'running') {
558 const t = eta(g, now)
559 if (t) parts.push(`~${minutes(t.ms)}`)
560 if (g.interrupted) parts.push('interrupted')
561 else if (paused(g) && p.doneN < p.n) parts.push('waiting on you')
562 const active = visibleTasks(g).find((x) => x.status === 'active')
563 if (active) parts.push(`▸ ${clip(mask(active.title), Math.max(12, Math.floor(width / 4)))}`)
564 }
565 const failed = g.status === 'running' && g.check && g.check.met === false
566 const right = [Text({ bold: true, color: g.status === 'met' ? 'green' : 'cyan', children: [parts.join(' · ')] })]
567 if (failed) right.push(Text({ color: 'yellow', children: ['check: not met'] }))
568 return Box({
569 flexDirection: 'row',
570 justifyContent: 'space-between',
571 width: '100%',
572 columnGap: 2,
573 children: [
574 Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(g)}: ${mask(g.title)}`] }),
575 Box({ flexDirection: 'row', columnGap: 2, flexShrink: 0, children: right }),
576 ],
577 })
578}
579
580function drawBand($, el, width) {
581 const { Box, Text, Button } = el
582 const g = G
583 const p = progress(g)
584 const rows = []
585 const right = headline(g, p)
586 const open = Button({ key: 'goal-tasks', label: 'all tasks', hotkey: 'g', plain: true, onPress: () => openPane($) })
587 rows.push(
588 Box({
589 flexDirection: 'row',
590 justifyContent: 'space-between',
591 width: '100%',
592 columnGap: 2,
593 children: [
594 Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(g)}: ${mask(g.title)}`] }),
595 Box({ flexDirection: 'row', columnGap: 2, flexShrink: 0, children: [Text({ bold: true, color: g.status === 'met' ? 'green' : 'cyan', children: [right] }), open] }),
596 ],
597 }),
598 )
599 if (g.planAt) rows.push(barRow(el, g, p, width))
600 const stats = statsLine(g, p)
601 if (stats) rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [stats] }))
602 if (!g.planAt && g.status === 'running') {
603 rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [nudged ? 'No plan yet: reminded Claude to plan with the goal meter' : 'Waiting for Claude\'s task plan…'] }))
604 } else if (g.status === 'running') {
605 // a window on the list: the last two done, everything running, the next ones
606 const tasks = visibleTasks(g)
607 const done = tasks.filter((t) => t.status === 'done').slice(-2)
608 const active = tasks.filter((t) => t.status === 'active')
609 const todo = tasks.filter((t) => t.status === 'pending').slice(0, active.length ? 2 : 3)
610 const shown = [...done, ...active, ...todo].sort((a, b) => a.id - b.id)
611 for (const t of shown) rows.push(taskRow(el, t, width))
612 const more = tasks.length - shown.length
613 if (more > 0) rows.push(Text({ dimColor: true, children: [` +${more} more · /${commandName}: all tasks`] }))
614 }
615 if (g.status === 'running' && g.check && g.check.met === false && g.check.reason) {
616 rows.push(Text({ color: 'yellow', wrap: 'truncate-end', children: [`Goal check: not met yet: ${mask(g.check.reason)}`] }))
617 }
618 return Box({ flexDirection: 'column', children: rows })
619}
620
621async function openPane($) {
622 now = await $.clock.now()
623 await loadOthers($)
624 paneOpen = true
625 await $.ui.open({ id: PANE, title: 'Goal meter', focus: true, closeOnEscape: true })
626}
627
628function otherRow(el, g, width) {
629 const { Box, Text } = el
630 const p = progress(g)
631 const name = clip(mask(`${g.label || basename(g.cwd)}: ${g.title}`), Math.max(16, Math.floor(width * 0.4)))
632 let tail
633 if (g.status === 'met') tail = 'done ✓'
634 else if (g.status !== 'running') tail = 'stopped'
635 else if (!g.planAt) tail = 'planning'
636 else {
637 const t = eta(g, now)
638 tail = `${p.pct}% · ${t ? '~' + minutes(t.ms) : p.doneN + '/' + p.n}`
639 }
640 const w = Math.max(8, Math.min(24, width - name.length - tail.length - 6))
641 return Box({
642 flexDirection: 'row',
643 columnGap: 2,
644 children: [
645 Text({ wrap: 'truncate-end', children: [name] }),
646 Text({ color: g.status === 'met' ? 'green' : 'cyan', children: [bar(p.fraction, w)] }),
647 Text({ dimColor: true, children: [tail] }),
648 ],
649 })
650}
651
652function drawPane(el, width) {
653 const { Box, Text } = el
654 const rows = []
655 if (G) {
656 const p = progress(G)
657 rows.push(Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(G)}: ${mask(G.title)}`] }))
658 rows.push(Text({ color: G.status === 'met' ? 'green' : 'cyan', children: [headline(G, p)] }))
659 if (G.planAt) rows.push(barRow(el, G, p, width))
660 const stats = statsLine(G, p)
661 if (stats) rows.push(Text({ dimColor: true, children: [stats] }))
662 rows.push(Text({ children: [' '] }))
663 const tasks = G.tasks.filter((t) => !t.replaced)
664 if (!tasks.length) rows.push(Text({ dimColor: true, children: ['No task plan yet.'] }))
665 for (const t of tasks) rows.push(taskRow(el, t, width))
666 // a failed check is history once the goal is met or stopped
667 if (G.status === 'running' && G.check && G.check.reason) {
668 rows.push(Text({ children: [' '] }))
669 rows.push(Text(G.check.met === false ? { color: 'yellow', children: [`Last goal check: not met yet: ${mask(G.check.reason)}`] } : { dimColor: true, children: [`Last goal check: ${mask(G.check.reason)}`] }))
670 }
671 } else {
672 rows.push(Text({ dimColor: true, children: ['No goal in this chat. Type /goal <what done looks like> to start one.'] }))
673 }
674 const rest = others.filter((g) => g.sessionId !== sessionId)
675 rows.push(Text({ children: [' '] }))
676 rows.push(Text({ bold: true, children: [`Other chats (${rest.length})`] }))
677 if (!rest.length) rows.push(Text({ dimColor: true, children: ['No other chat has a goal in the last 12 hours.'] }))
678 for (const g of rest.slice(0, 15)) rows.push(otherRow(el, g, width))
679 rows.push(Text({ children: [' '] }))
680 rows.push(Text({ dimColor: true, children: [`/${commandName} compact|full|hide · /${commandName} clear · /${commandName} strict ${settings.strict ? 'off' : 'on'} · now: ${settings.view}`] }))
681 return Box({ flexDirection: 'column', children: rows })
682}
683
684function plainText() {
685 if (!G) return 'No goal in this chat.'
686 const p = progress(G)
687 const lines = [`${label(G)}: ${mask(G.title)}`, `${headline(G, p)} ${bar(p.fraction, 30)}`, statsLine(G, p)]
688 for (const t of G.tasks.filter((x) => !x.replaced)) lines.push(`${ICON[t.status] || '○'} ${t.size} ${mask(t.title)} ${mask(taskTail(t))}`)
689 return lines.filter(Boolean).join('\n')
690}
691hooks/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