SLOPSHOPPER

goal-meter

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

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · goal-meter
│ ┃ Goal meter ✕ › fix the failing auth test and add an audit log call │ ┃ No goal in this chat. Type /goal <what done │ ┃ looks like> to start one. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Other chats (0) ⏺ Update(src/auth.ts) │ ┃ No other chat has a goal in the last 12 ⎿ Added 2 lines, removed 1 line │ ┃ hours. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ /goals hide|show (the band) · /goals strict │ ┃ on · /goals clear ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /goals │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Goal meter
No goal in this chat. Type /goal <what done looks like> to start one. Other chats (0) No other chat has a goal in the last 12 hours. /goals hide|show (the band) · /goals strict on · /goals clear
README

<h1 align="center">Awesome Claude Code Mods</h1>

<a id="showcase"></a>

Mod showcase

<table> <tr><td width="33%" align="center" valign="top"><p><a href="https://github.com/therahul-yo/clawdman"><img src="assets/showcase-01.gif" width="100%" alt="Clawdman: An animated companion that reacts to your coding session. Demo · 10×."></a></p><p><strong><a href="https://github.com/therahul-yo/clawdman">Clawdman</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/pi-agent/"><img src="assets/showcase-02.gif" width="100%" alt="Pi Agent: Run pi-powered models as native Claude Code subagents. Demo · 10×."></a></p><p><strong><a href="AgentMods/pi-agent/">Pi Agent</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="FocusMods/breathing-exercises/"><img src="assets/showcase-03.gif" width="100%" alt="Breathing Exercises: A guided breathing animation while Claude works."></a></p><p><strong><a href="FocusMods/breathing-exercises/">Breathing Exercises</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="sources/creative-toolkit/"><img src="assets/showcase-04.png" width="100%" alt="Creative Toolkit: A collection of live panels, session tools and playful mods. Code Pet screenshot."></a></p><p><strong><a href="sources/creative-toolkit/">Creative Toolkit</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/agent-cartoons/"><img src="assets/showcase-05.gif" width="100%" alt="Agent Cartoons: Coding activity becomes a cartoon in a choice of visual styles."></a></p><p><strong><a href="GameMods/agent-cartoons/">Agent Cartoons</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="https://github.com/adamholter/claude-subway-surfers"><img src="assets/showcase-06.gif" width="100%" alt="Subway Surfers Desktop: Gameplay beside Claude Desktop while it works. Desktop demo · 10×."></a></p><p><strong><a href="https://github.com/adamholter/claude-subway-surfers">Subway Surfers Desktop</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="GameMods/dino-game/"><img src="assets/showcase-07.gif" width="100%" alt="Dino Game: Play the T-rex runner above your prompt."></a></p><p><strong><a href="GameMods/dino-game/">Dino Game</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/spell-bar/"><img src="assets/showcase-08.gif" width="100%" alt="Spell Bar: An animated spell bar for your selected effort level."></a></p><p><strong><a href="GameMods/spell-bar/">Spell Bar</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/agent-dashboard/"><img src="assets/showcase-09.gif" width="100%" alt="Agent Dashboard: A live dashboard for context, costs, permissions and agents."></a></p><p><strong><a href="AgentMods/agent-dashboard/">Agent Dashboard</a></strong></p></td></tr> </table>

<a id="browse-by-category"></a>

<h2>Browse by category <img src="assets/category-magnifier.gif" width="52" height="40" alt="Clawd with a magnifying glass"></h2>

Usage Mods · Agent Mods · Planning Mods · File Mods · Git Mods · Safety Mods · Memory Mods · Interface Mods · Prompt Mods · Testing Mods · Web Mods · Focus Mods · Game Mods · Integration Mods

Source packages available here: 82 mods from 38 repositories, with their complete tracked files and notices. Local package links open inside this collection. Other entries currently lead to upstream references while the wider source import is reviewed.

Entries are grouped by their main purpose. Collections can contain several kinds of mods; hosted mod pages are categorized individually.

<a id="usage"></a>

Usage Mods

Browse Usage Mods

Track model usage, costs, effort and quotas.

  • Auto Effort · arthur-fontaine - Let Jev select Claude's reasoning effort for each prompt, adapting the configured effort level to the incoming request rather than choosing it manually every time.
  • Automatic Model and Effort Router · sofanaja44 - Choose a Claude model and reasoning effort according to task difficulty, with rules that limit unnecessary prompt-cache rewrites. Cost and cache statistics help you follow the routing decisions.
  • Braille Usage · kawase1295 - Check your current model, context usage and five-hour or weekly limits in compact text-based meters above the prompt.
  • Budget and Context Toolkit · Arunjay4213 - Track session spending, subscription quotas, cache use and per-turn token costs. Set budget limits that warn before exhaustion and stop tool calls when a configured limit is exceeded.
  • Budget Governor · ccdwyer - Set session and daily spending caps, monitor them above the prompt, and receive a wrap-up reminder at eighty percent. New prompts are refused once the cap is reached.
  • Cache, Context and Cost Panel · anthonyhungnguyen - Track token spending for the session and recent days, get context-limit warnings, and see what rebuilding the prompt cache cost. A completion alert announces long turns.
  • Clawd Usage Meters · Toshkee - Watch live context, rate-limit and cost meters above the prompt, accompanied by Clawd's wand animations and banner tricks as you work in Claude Code.
  • Cockpit and Bodyguard · Para-FR - Inspect session turns, tools, tokens, durations and changed files in a live cockpit. A companion guard blocks access to .env files and destructive Bash commands, displaying blocked-action alerts.
  • Context and Quota Forecast · imsalik - See a context forecast alongside five-hour and weekly rate-limit usage above the prompt. Reset countdowns help you understand when each plan allowance becomes available again.
  • Context Saver · AlmogBaku - Spot repeated tool calls and wasteful workflows during a Claude Code session. Review their measured time and context cost, then send a suggested correction with one click.
  • Context Weather and Usage · travisoa - Read context fullness as changing weather symbols alongside token totals and quota consumption. Recent-turn bars show how the conversation grew, helping you spot increasing pressure on the context window.
  • Cost and Output Token Band · Luma-Sa - Keep five-hour and seven-day usage limits, session cost and output-token totals visible in a band above the Claude Code prompt while you work.
  • Cost Meter · zaferayan - Track API-equivalent session cost above the prompt, including subagent usage, with a configurable budget warning. On subscription plans, the displayed amount estimates API pricing rather than your bill.
  • Desktop Next Steps and Limits · shichang4fun - See quota limits and reset countdowns above the prompt in Claude Code Desktop. After each turn, suggested next prompts can be sent with a click or copied for editing.
  • Desktop Session Status Bar · sgmonda - Keep five-hour and weekly usage limits, reset times, session clocks and turn duration visible in Claude Code Desktop. The bar also lists active subagents, background jobs and scheduled wakeups.
  • Desktop SVG Usage Meters · s-hiraoku - See context use and the five-hour and seven-day quota windows as SVG meters above the prompt in Claude Code Desktop. The compact display keeps capacity visible while you work.
  • Effort Guard · stefanochieli - Track context and token usage, review an effort log for each turn and receive an escalation alert after repeated command or test failures. It observes activity without blocking it.
  • Effort Router · totally-tim - Choose reasoning effort for each turn with Jev or a compatible classifier, and display the selected level. Shadow mode records the recommendation; enforce mode applies it to the session.
  • Effort Shortcut · daanqq - Cycle Claude's reasoning effort through low, medium, high, and extra-high with one command. Bind that command to a key to change effort quickly while skipping the maximum setting.
  • Focus Band · chaoshengsc - Keep your five-hour and weekly plan usage visible above the prompt. Progress bars, percentages, and reset times help you see how much of each allowance you have used.
  • Jet Router · jetsongdev - Experiment with per-turn reasoning effort recommendations while keeping your chosen model. Use fixed test decisions or opt into Jev classification, with observation and optional application modes.
  • Jev Claude Router · Flam1ngFir3ball - Let Jev choose the model and reasoning effort for each turn while considering the cost of switching. It also provides Jev-guided context compaction.
  • Jev Model and Effort Selection · unclecode - Observe or pin model and effort choices, or opt into automatic selection with Jev. The experimental router lets you compare recommendations before allowing them to steer the session.
  • Jev Model Router · satviksinha - Choose a model and reasoning effort for each turn using TypeSafe's Jev, through its direct API or Vercel AI Gateway. A visible routing decision shows which model will handle the work.
  • Jev Route · drewpayment - Choose a Claude model for each turn using Jev's estimate of what the task needs. Toggle routing with /route to balance model capability against cost.
  • Limitpace · fstandhartinger - Track subscription limits and spending pace through live usage bars. An optional delegation adviser can suggest using another provider as part of managing your available capacity.
  • Model and Effort Router · moritalous - Choose reasoning effort for each prompt while keeping the model fixed, or enable a separate router that selects Haiku, Sonnet, or Opus using Jev. Both tools adjust resources per turn.
  • Model Shortcuts · richkuo - Use keyboard shortcuts to switch models and adjust reasoning effort, with the active choices displayed in the prompt footer.
  • Netrunner HUD · ccdwyer - Watch context use, token activity, tool calls, costs, and job status in an animated cyberpunk dashboard. Git and test widgets keep related session information alongside the live usage gauges.
  • Next Steps and Usage Band · pawandeepdhall - Keep five-hour and weekly usage limits visible with reset countdowns, and choose from suggested next steps after each reply. Buttons also provide shortcuts for starting a new chat or pushing work.
  • Oxide Jev Model Router · Kiy-K - Use Jev to choose subagent models and adjust the main conversation's model and reasoning effort as work progresses. A built-in classifier provides a fallback without an API key.
  • Plan and Context Usage Line · KhadeerBasha1232 - Track five-hour and weekly plan limits, reset times, context usage, the active model and session cost in one line above the Claude Code prompt.
  • Plan Usage and Reset Times · jumoog - Keep five-hour and weekly plan usage visible above the prompt, together with each reset time. Check the current allowance windows without opening a separate usage screen.
  • Power Bottom Status Line · brianSchanbacher - See the model, folder, branch, context fill, cost, elapsed time, cache hits and live agents in a cross-platform status line at the bottom of Claude Code.
  • Project Spend and Cache Status · Sma1lboy - Track project spending alongside context usage, cache hits and the cache countdown. Inspect why a turn missed the cache, and open the project ledger with /usage.
  • Prompt Enhancement and Telemetry · MiguelMachado-dev - Inspect per-turn duration, first-token latency, token throughput, requests and stalls in a live band. An additional command rewrites a draft prompt with Sonnet and returns it for your review.
  • Prompt Footer Usage · WoBok - Keep context usage and rate-limit consumption visible in Claude Code's prompt footer, so you can check those readings without opening a separate usage view.
  • Prompt Usage Timeline · augiefra - Compare five-hour and weekly usage with elapsed time, inspect token bars for recent prompts and watch context status above the prompt. Labels are available in English or French.
  • Quota and Cost Pills · Sh0ckWaveZero - Track five-hour and seven-day rate limits, session tokens and cost in compact indicators above the prompt. The band can sit alongside other status bands.
  • Quota Reserve · chrisns - Reserve part of your five-hour and weekly quota for later work. The mod pauses at configured thresholds, asks whether to continue, and releases the reserve shortly before the reset.
  • Rate-Limit History Band · mohammed-alsalhi - Follow rate-limit consumption since each window last reset, including five-hour, weekly, and per-model weekly allowances. A band above the prompt graphs how usage accumulates over time.
  • Retro Usage Band · iamkhalid2 - Keep your plan's five-hour and weekly usage limits in view through a slim retro-style band above the prompt. Check both allowances as you work.
  • Sessclone · NotTahaAli - Send Claude Code usage to a SessClone deployment so laptop, cloud and CI sessions can contribute to a shared team ledger of usage and cost.
  • Session and Weekly Usage Band · Gooner44 - See session, weekly and Fable 5.1 usage together in a band above the Claude Code prompt, keeping these separate usage readings visible while you work.
  • Session Cockpit · nvr0x5 - Track context, spending, usage limits, reset countdowns and burn rates above the prompt. Plan progress and active subagents share the same cockpit, with display options for terminal and Desktop sessions.
  • Session Cost and Remaining Usage · HydrowZer - See the current session cost and remaining usage in a band above the Claude Code prompt, keeping spending and available capacity visible beside your conversation.
  • Session Dashboard and Alerts · lucenity0 - Follow turn durations, token counts, files touched, and tool activity through a timer and dashboard. Companion mods notify you when long turns finish and block destructive commands or edits to secrets.
  • Session Internals Dashboard · tomstagl - Inspect context, tokens, cost, limits, tools and agents in a live side-pane dashboard. An accompanying insights interface lets the session answer questions using the displayed measurements.
  • Session Sidebar · richard-parayno - Toggle a sidebar showing usage limits, context size, prompt-cache expiry, current activity and touched files. It keeps the session's resource picture visible beside the conversation.
  • Stack and Wait Time · ShahriarBijoy - Follow tasks, subagents, rate limits and monthly cost in a stacked progress band. A companion vocabulary game uses German words related to the session's topic while Claude works.
  • Terminal Usage Monitor · ilkayGkbdk - Monitor context fill, rate limits, token speed, cost, and active subagents in a live terminal dashboard. The display brings the session's resource use together in one place.
  • Turn Receipts and Error Cues · roaringsoul404 - Receive a receipt after each turn listing touched files, commands, tokens and cost. A companion animation turns the spinner into a pixel-art failure cue when a tool call fails.
  • Usage and Active Agent Dashboard · quango2304 - Follow usage bars, limits, session cost, cache expiry and active time alongside the agents currently running. The combined display makes resource consumption visible while work is underway.
  • Usage and Cache Warmth Band · Vosssa - See five-hour and weekly usage, context fill and prompt-cache warmth together above the Claude Code prompt, keeping the main resource readings visible while you work.
  • Usage and Spend Chips · kreddevils18 - Track five-hour and weekly allowances, session cost, and spending history through colored chips above the prompt. Open /usage-mod for a fuller view of the usage details.
  • Usage Line · Steven-ODell - See five-hour, weekly and context usage in the prompt footer, including whether consumption is ahead of or behind an even pace through the usage window.
  • Usage Log · tanuu5 - Record five-hour and weekly usage after each turn, then compare consumption with your chosen pace in a graph. The separate Clawd Dance mod can also show that pacing above the prompt.
  • Usage Report · Schweem - Monitor context usage, five-hour and weekly quotas, and session cost in the status line, with warnings as you approach your limits.
  • Usage Wrapup · DaKev - Ask running agents to wrap up when your remaining plan allowance gets low, helping them finish their current work before the usage limit interrupts it.
  • Weekly Usage and Spend Board · aycandv - Compare spending by model with a weekly-limit pace gauge, using a sliding ticker in the terminal or a strip and details pane in the desktop app.

<a id="agents"></a>

Agent Mods

Browse Agent Mods

Coordinate agents and follow their actual work.

  • 5dive Telemetry Toolkit · 5dive-ai - Connect Claude Code sessions to 5dive's task and gate controls, with a panel showing assignments and token budgets. Optional telemetry, compaction and tool-call policies support coordinated agent work.
  • Agent Control and Document Desk · masahide - Send messages and interruption requests to Claude through the agentctl command line and collect acknowledgments. A companion document desk gathers questionnaire answers and detailed document-review comments in a browser, then delivers them to Claude.
  • Agent Dashboard · scasella - Monitor the main agent and its subagents in a live dashboard that brings together model usage, permission requests, task activity and session logs.
  • Agent Flow · Charlie0113-T - Open /flow to follow a live tree of the session's subagents and teammates beside the transcript. A text view is available where panes cannot be displayed.
  • Agent Models and Peek · alex2481kobe - Show each subagent's model and reasoning effort beside its task in the native agent list. A companion viewer displays images inline, using real pixels or a terminal-compatible block
Source 4 files
hooks/register.mjs 638 lines
1// Goal Meter: a progress bar for /goal, built from Claude's own task plan.
2// - /goal starts it. The mod asks Claude to plan the goal as sized tasks (S, M, L)
3//   with the mod's tool, mcp__goal-meter__tasks, then mark each task started and
4//   done. Claude Code stopped shipping a task-list tool after 2.1.229, so the mod
5//   brings its own. A plan made outside a /goal shows the same way.
6// - The band above the prompt: the bar (finished work out of the plan, weighted
7//   by size), elapsed time, the ETA at this goal's own pace, what's running and
8//   who runs it, the next tasks, and the goal check's latest "not met yet" reason.
9// - The footer: "◎ goal 69% · ~15m" in every view, terminal and Desktop.
10// - /goals: a pane with this chat's whole task list and every other chat's goal.
11//   /goals hide|show (the band), /goals strict on|off, /goals clear.
12// - Each chat writes ~/.claude/mods-data/goal-meter/<session>.json for /goals.
13//   The mod sends no model requests of its own.
14
15import { minutes, clock, clip, bar, basename } from './fmt.mjs'
16import { makeMasker } from './privacy.mjs'
17import { newGoal, applyAction, progress, eta, parseCheck, isStopWord, normalizeTasks, TOOL_SPEC, instruction, nudge, strictDeny } from './plan.mjs'
18
19const DIR = '/.claude/mods-data/goal-meter'
20const PANE = 'goal-meter'
21const RECENT_MS = 10 * 60000 // a finished goal stays on screen this long
22const OTHERS_MS = 12 * 3600000 // other chats' goals shown in /goals
23const REOPEN_MS = 5 * 60000 // a goal closed on its tasks reopens if Claude carries on this soon
24const NUDGE_AFTER = 4 // tool calls into a goal with no plan before the reminder
25const WRITERS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
26
27let G = null
28let sessionId = ''
29let home = ''
30let cwd = ''
31let now = 0
32let toolName = 'mcp__goal-meter__tasks'
33let commandName = 'goals'
34let settings = { strict: false }
35let hidden = false
36let nudged = false
37let callsWithoutPlan = 0
38let pendingGoal = null
39let paneOpen = false
40let others = []
41let transcriptPath = ''
42const agentNames = new Map()
43const runningAgents = new Set()
44let rec = { on: false, strict: false }
45let mask = (s) => s
46
47function goalFile(id) {
48  return `${home}${DIR}/${id}.json`
49}
50
51function isRecent(g) {
52  return g && g.status !== 'running' && g.endedAt && now - g.endedAt < RECENT_MS
53}
54
55function visibleTasks(g) {
56  return g.tasks.filter((t) => !t.replaced && t.status !== 'dropped')
57}
58
59async function save($) {
60  if (!G || !sessionId) return
61  G.updatedAt = now
62  try {
63    await $.fs.write(goalFile(sessionId), JSON.stringify({ ...G, label: basename(cwd) }))
64  } catch {
65    // the pane falls back to this chat alone
66  }
67}
68
69async function restore($) {
70  try {
71    const path = goalFile(sessionId)
72    if (!(await $.fs.exists(path))) return
73    const saved = JSON.parse(await $.fs.read(path))
74    if (saved && Array.isArray(saved.tasks) && now - (saved.updatedAt || 0) < 24 * 3600000) G = saved
75  } catch {
76    G = null
77  }
78}
79
80async function loadOthers($) {
81  const dir = home + DIR
82  const list = []
83  try {
84    for (const entry of await $.fs.list(dir)) {
85      if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name === sessionId + '.json') continue
86      if (now - (entry.mtimeMs || 0) > OTHERS_MS) continue
87      try {
88        const g = JSON.parse(await $.fs.read(dir + '/' + entry.name))
89        if (!g || !Array.isArray(g.tasks)) continue
90        if (g.status !== 'running' && now - (g.endedAt || 0) > OTHERS_MS) continue
91        list.push(g)
92      } catch {
93        // a file another chat is writing right now; next refresh reads it
94      }
95    }
96  } catch {
97    // no folder yet
98  }
99  others = list.sort((a, b) => (a.status === 'running' ? 0 : 1) - (b.status === 'running' ? 0 : 1) || b.updatedAt - a.updatedAt)
100}
101
102async function readRecording($) {
103  try {
104    const path = (home || '.') + '/.claude/mods-data/recording.json'
105    if (!(await $.fs.exists(path))) rec = { on: false, strict: false }
106    else {
107      const flag = JSON.parse(await $.fs.read(path))
108      rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
109    }
110  } catch {
111    rec = { on: false, strict: false }
112  }
113  mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
114}
115
116async function registerCommand($) {
117  const spec = { name: 'goals', description: 'Goal meter: this chat\'s goal task list and every chat\'s goal (/goals hide|show|strict on|off|clear)', argumentHint: '[hide|show|strict on|off|clear]', immediate: true }
118  try {
119    await $.command.register(spec)
120    return 'goals'
121  } catch {
122    try {
123      await $.command.register({ ...spec, name: 'goal-meter' })
124      return 'goal-meter'
125    } catch {
126      return null
127    }
128  }
129}
130
131async function startGoal($, condition) {
132  G = newGoal({ sessionId, condition, now, cwd })
133  hidden = false
134  nudged = false
135  callsWithoutPlan = 0
136  pendingGoal = null
137  await save($)
138  $.ui.invalidate('ui.render')
139}
140
141async function stopGoal($, status) {
142  if (!G || G.status !== 'running') return
143  G.status = status
144  G.endedAt = now
145  G.active = false
146  await save($)
147  $.ui.invalidate('ui.render')
148}
149
150async function finishGoal($, how) {
151  if (!G || G.status !== 'running') return
152  G.status = 'met'
153  G.endedAt = now
154  G.active = false
155  G.finishedBy = how
156  await save($)
157  const took = minutes(G.endedAt - G.startedAt)
158  $.ui.toast(`${G.kind === 'plan' ? 'Plan' : 'Goal'} done in ${took}: ${clip(mask(G.title), 60)}`)
159  $.ui.invalidate('ui.render')
160}
161
162async function serveTool($, e) {
163  now = await $.clock.now()
164  const action = String(e.action || 'show').toLowerCase()
165  const first = normalizeTasks(e.tasks)[0]
166  if (!G || (G.status !== 'running' && (action === 'plan' || action === 'add'))) {
167    if (!first) return { result: `Goal meter: no plan in this chat yet. Call action "plan" with the tasks first, each { "title": "...", "size": "S" | "M" | "L" }.` }
168    // a plan outside /goal: tracked the same way, named after its first task
169    G = newGoal({ sessionId, condition: first.title, now, cwd, kind: 'plan' })
170    hidden = false
171  }
172  const by = e.by ? String(e.by) : e.agentId ? agentNames.get(e.agentId) || 'agent' : ''
173  const r = applyAction(G, e, { now, by })
174  if (r.ok) {
175    await save($)
176    $.ui.invalidate('ui.render')
177  }
178  return { result: r.text }
179}
180
181// The goal check's verdict. Its row reaches session.append with no content (the
182// payload is stored beside it, seen live 2026-10-02), so read the record from
183// the end of the chat's log: a few lines, never the whole file (logs reach 200 MB).
184async function lastGoalStatus($, since) {
185  const path = transcriptPath
186  if (!path) return null
187  const windows = /^[A-Za-z]:/.test(path)
188  const argv = windows
189    ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', `Get-Content -LiteralPath '${path.replace(/'/g, "''")}' -Tail 12 -Encoding UTF8`]
190    : ['tail', '-n', '12', path]
191  let out = ''
192  try {
193    const r = await $.process.run(argv, { timeoutMs: 15000 })
194    out = r.stdout || ''
195  } catch {
196    return null
197  }
198  const lines = out.split(/\r?\n/).filter((l) => l.includes('"goal_status"')).reverse()
199  for (const line of lines) {
200    try {
201      const row = JSON.parse(line)
202      const a = row.attachment
203      if (!a || a.type !== 'goal_status') continue
204      // an older check's record is not this check's verdict
205      const ts = Date.parse(row.timestamp || '') || 0
206      return ts && since && ts < since - 3000 ? null : a
207    } catch {
208      // a line cut by the tail
209    }
210  }
211  return null
212}
213
214// The log is written a moment after the row reaches the hook: read it now, and
215// again after 1.5 and 4 seconds when the verdict isn't there yet
216async function onCheck($, message, at, attempt = 0) {
217  if (!G || G.kind !== 'goal' || G.status !== 'running') return
218  let verdict = await lastGoalStatus($, at)
219  if (!verdict) {
220    // a build that renders the verdict into the row itself
221    const blocks = Array.isArray(message.content) ? message.content : []
222    const c = parseCheck(blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n'))
223    verdict = c.met ? { met: true } : c.notMet ? { met: false, reason: c.reason } : null
224  }
225  if (!verdict && attempt < 2) {
226    $.clock.after(attempt ? 4000 : 1500, async () => {
227      now = await $.clock.now()
228      await onCheck($, message, at, attempt + 1).catch(() => {})
229    })
230    return
231  }
232  if (!verdict || verdict.sentinel) return // the row written when the goal is set
233  if (verdict.met) return finishGoal($, 'check')
234  G.check = { met: false, reason: clip(verdict.reason || 'no reason given', 300), at: now }
235  G.checks += 1
236  await save($)
237  $.ui.invalidate('ui.render')
238}
239
240export function register(on) {
241  on('session.start', async ($, e, next) => {
242    now = await $.clock.now()
243    home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
244    sessionId = await $.session.id()
245    cwd = await $.session.cwd()
246    // where Claude Code keeps this chat's log; a settings-hook event confirms it below
247    transcriptPath = `${home}/.claude/projects/${String(cwd).replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
248    const saved = await $.store.get('settings')
249    if (saved && typeof saved === 'object') settings = { ...settings, ...saved }
250    await readRecording($)
251    try {
252      const reg = await $.tool.register(TOOL_SPEC)
253      if (reg && reg.tool) toolName = reg.tool
254    } catch (err) {
255      $.ui.log(`goal-meter: the task tool did not register (${err && err.message ? err.message : err})`)
256    }
257    commandName = (await registerCommand($)) || commandName
258    await restore($)
259    $.clock.every(15000, async () => {
260      now = await $.clock.now()
261      if (paneOpen) await loadOthers($)
262      if (paneOpen || (G && (G.status === 'running' || isRecent(G)))) $.ui.invalidate('ui.render')
263    })
264    $.clock.every(10000, () => readRecording($).catch(() => {}))
265    return next(e)
266  })
267
268  // /goal <condition> starts the meter and asks Claude to plan. The ask rides as
269  // the command's hidden note, so the cached prefix is untouched.
270  on('command.run', { command: 'goal' }, async ($, e, next) => {
271    const args = String(e.args || '').trim()
272    const r = await next(e)
273    now = await $.clock.now()
274    pendingGoal = null
275    if (!args) return r
276    if (isStopWord(args)) {
277      await stopGoal($, 'stopped')
278      return r
279    }
280    await startGoal($, args)
281    const notes = r && Array.isArray(r.context) ? r.context : []
282    return { ...(r || {}), context: [...notes, instruction(toolName)] }
283  })
284
285  // Fallback when /goal reaches the session without a command.run: remember it
286  // here and start at turn.start, asking for the plan with an appended note.
287  on('prompt.submit', async ($, e, next) => {
288    const m = String(e.text || '').match(/^\s*\/goal\s+([\s\S]+)$/)
289    if (m && !isStopWord(m[1])) pendingGoal = { args: m[1].trim(), at: await $.clock.now() }
290    return next(e)
291  })
292
293  on('turn.start', async ($, e, next) => {
294    now = await $.clock.now()
295    if (pendingGoal && (!G || G.startedAt < pendingGoal.at)) {
296      const args = pendingGoal.args
297      await startGoal($, args)
298      try {
299        await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: instruction(toolName) }] } })
300      } catch {
301        // the nudge after a few tool calls is the second chance
302      }
303    }
304    pendingGoal = null
305    // closed on its tasks, but the goal loop carries on without a new prompt: reopen
306    if (G && G.status === 'met' && G.finishedBy === 'tasks' && G.kind === 'goal' && !String(e.text || '').trim() && now - G.endedAt < REOPEN_MS) {
307      G.status = 'running'
308      G.endedAt = 0
309      G.finishedBy = ''
310    }
311    if (G && G.status === 'running') {
312      G.active = true
313      G.interrupted = false
314    }
315    return next(e)
316  })
317
318  // The settings hooks' events carry the log's real path
319  on('classic.Stop', async ($, e, next) => {
320    if (e && typeof e.transcript_path === 'string' && e.transcript_path) transcriptPath = e.transcript_path
321    return next(e)
322  })
323
324  on('tool.call', async ($, e, next) => {
325    if (e.tool === toolName) return serveTool($, e)
326    if (G && G.status === 'running' && G.kind === 'goal' && !G.planAt && !e.agentId) {
327      if (settings.strict && WRITERS.has(e.tool)) return { deny: strictDeny(toolName) }
328      if (e.tool !== 'ToolSearch') callsWithoutPlan += 1
329      if (callsWithoutPlan >= NUDGE_AFTER && !nudged) {
330        nudged = true
331        try {
332          await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: nudge(toolName) }] } })
333        } catch {
334          // the band says the plan is missing either way
335        }
336        $.ui.invalidate('ui.render')
337      }
338    }
339    return next(e)
340  })
341
342  on('agent.spawn', async ($, e, next) => {
343    const r = await next(e)
344    if (r && r.agentId) {
345      agentNames.set(r.agentId, clip(e.name || e.description || e.subagentType || 'agent', 32))
346      runningAgents.add(r.agentId)
347      if (G && G.status === 'running') $.ui.invalidate('ui.render')
348    }
349    return r
350  })
351
352  // The goal check writes its verdict as an attachment row
353  on('session.append', { door: 'attachment' }, async ($, e, next) => {
354    const r = await next(e)
355    if (!e.agentId && e.message && e.message.name === 'goal_status') {
356      now = await $.clock.now()
357      try {
358        await onCheck($, e.message, now)
359      } catch {
360        // the row is stored whatever the parser makes of it
361      }
362    }
363    return r
364  })
365
366  on('turn.complete', async ($, e, next) => {
367    const r = await next(e)
368    now = await $.clock.now()
369    if (e.agentId) {
370      runningAgents.delete(e.agentId)
371      if (G && G.status === 'running') $.ui.invalidate('ui.render')
372      return r
373    }
374    if (G && G.status === 'running') {
375      G.active = false
376      G.lastTurnEnd = now
377      if (e.isAborted) G.interrupted = true
378      const p = progress(G)
379      // the turn ended with every task done: finished (the goal check's own
380      // verdict, read from the log, usually closed it a moment earlier)
381      if (e.reason === 'answer' && !e.isAborted && p.n > 0 && p.doneN === p.n) await finishGoal($, 'tasks')
382      else await save($)
383      $.ui.invalidate('ui.render')
384    }
385    return r
386  })
387
388  on('command.run', { command: ['goals', 'goal-meter'] }, async ($, e) => {
389    now = await $.clock.now()
390    const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
391    if (key === 'strict') {
392      settings.strict = value !== 'off'
393      await $.store.set('settings', settings)
394      $.ui.toast(`Strict planning ${settings.strict ? 'on: no file edits in a /goal before the plan' : 'off'}`)
395      return {}
396    }
397    if (key === 'hide' || key === 'show') {
398      hidden = key === 'hide'
399      $.ui.invalidate('ui.render')
400      return {}
401    }
402    if (key === 'clear') {
403      await stopGoal($, 'stopped')
404      return {}
405    }
406    await loadOthers($)
407    const surface = await $.session.surface()
408    if (!surface) return { text: plainText() }
409    paneOpen = true
410    await $.ui.open({ id: PANE, title: 'Goal meter', focus: true, closeOnEscape: true })
411    return {}
412  })
413
414  on('ui.close', async ($, e, next) => {
415    if (e.id === PANE) paneOpen = false
416    return next(e)
417  })
418
419  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
420    const below = await next(e)
421    if (e.props && e.props.hasSurvey) return below
422    if (!G || hidden || !(G.status === 'running' || isRecent(G))) return below
423    const el = $.ui.resolve(e)
424    const width = Math.max(40, (e.props && e.props.bodyColumns) || 100)
425    const mine = drawBand($, el, width)
426    return el.Box({ flexDirection: 'column', children: below ? [mine, below] : [mine] })
427  })
428
429  // The footer shows even when the band is hidden or collapsed
430  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
431    const label = footerLabel()
432    if (!label) return next(e)
433    const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
434    return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
435  })
436
437  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
438    if (e.requestId !== PANE) return next(e)
439    const el = $.ui.resolve(e)
440    const width = Math.max(50, (e.props && e.props.bodyColumns) || 100)
441    return drawPane(el, width)
442  })
443}
444
445// ---------- words ----------
446
447function label(g) {
448  return g.kind === 'plan' ? 'Plan' : 'Goal'
449}
450
451function paused(g) {
452  return g.status === 'running' && !g.active && g.lastTurnEnd > 0
453}
454
455function headline(g, p) {
456  if (g.status === 'met') return `done ✓ in ${minutes((g.endedAt || now) - g.startedAt)}`
457  if (g.status !== 'running') return 'stopped'
458  if (!g.planAt) return 'planning…'
459  return `${p.doneN} of ${p.n} tasks · ${p.pct}%`
460}
461
462function statsLine(g, p) {
463  const parts = []
464  if (g.status === 'running') {
465    parts.push(`${minutes(now - g.startedAt)} elapsed`)
466    const t = eta(g, now)
467    if (t) parts.push(`about ${minutes(t.ms)} left (≈${clock(t.at)})`)
468    else if (g.planAt && p.doneN < 2) parts.push('ETA after 2 tasks finish')
469  }
470  if (g.firstPlan && p.n > g.firstPlan) parts.push(`plan grew ${g.firstPlan} → ${p.n}`)
471  if (runningAgents.size && g.status === 'running') parts.push(`${runningAgents.size} agent${runningAgents.size === 1 ? '' : 's'} running`)
472  if (g.interrupted && g.status === 'running') parts.push('interrupted')
473  else if (paused(g) && p.doneN < p.n) parts.push('paused, waiting on you')
474  if (g.checks) parts.push(`${g.checks} goal check${g.checks === 1 ? '' : 's'}`)
475  return parts.join(' · ')
476}
477
478function footerLabel() {
479  if (!G) return ''
480  const word = G.kind === 'plan' ? 'plan' : 'goal'
481  if (G.status === 'running') {
482    if (!G.planAt) return `◎ ${word} · planning`
483    const p = progress(G)
484    const t = eta(G, now)
485    return `◎ ${word} ${p.pct}%` + (t ? ` · ~${minutes(t.ms)}` : ` · ${p.doneN}/${p.n}`)
486  }
487  if (isRecent(G)) return G.status === 'met' ? `◎ ${word} done ✓` : `◎ ${word} stopped`
488  return ''
489}
490
491function taskTail(t) {
492  if (t.status === 'done') return t.doneAt > t.startedAt ? minutes(t.doneAt - t.startedAt) : ''
493  if (t.status === 'active') return (t.by ? t.by + ' · ' : '') + 'running ' + minutes(now - t.startedAt)
494  if (t.status === 'dropped') return 'dropped' + (t.note ? ': ' + t.note : '')
495  return ''
496}
497
498const ICON = { done: '✓', active: '▶', pending: '○', dropped: '×' }
499
500function taskRow(el, t, width) {
501  const { Box, Text } = el
502  const tail = mask(taskTail(t))
503  const title = mask(t.title)
504  const icon = ICON[t.status] || '○'
505  const lead = t.status === 'done'
506    ? Text({ color: 'green', children: [`${icon} `] })
507    : t.status === 'active'
508      ? Text({ color: 'cyan', bold: true, children: [`${icon} `] })
509      : Text({ dimColor: true, children: [`${icon} `] })
510  const body = t.status === 'active'
511    ? Text({ bold: true, wrap: 'truncate-end', children: [`${t.size}  ${title}`] })
512    : Text({ dimColor: t.status !== 'pending', wrap: 'truncate-end', children: [`${t.size}  ${title}`] })
513  const kids = [lead, body]
514  if (tail) kids.push(Text({ dimColor: true, children: ['  ' + clip(tail, Math.max(10, Math.floor(width / 3)))] }))
515  return Box({ flexDirection: 'row', children: kids })
516}
517
518function barRow(el, g, p, width) {
519  const { Box, Text } = el
520  const w = Math.max(10, Math.min(width - 2, 120))
521  const n = Math.round(p.fraction * w)
522  const color = g.status === 'met' ? 'green' : 'cyan'
523  return Box({ flexDirection: 'row', children: [Text({ color, children: ['█'.repeat(n)] }), Text({ dimColor: true, children: ['░'.repeat(w - n)] })] })
524}
525
526// ---------- drawing ----------
527
528function drawBand($, el, width) {
529  const { Box, Text, Button } = el
530  const g = G
531  const p = progress(g)
532  const rows = []
533  const right = headline(g, p)
534  const open = Button({ key: 'goal-tasks', label: 'all tasks', hotkey: 'g', plain: true, onPress: () => openPane($) })
535  rows.push(
536    Box({
537      flexDirection: 'row',
538      justifyContent: 'space-between',
539      width: '100%',
540      columnGap: 2,
541      children: [
542        Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(g)}: ${mask(g.title)}`] }),
543        Box({ flexDirection: 'row', columnGap: 2, flexShrink: 0, children: [Text({ bold: true, color: g.status === 'met' ? 'green' : 'cyan', children: [right] }), open] }),
544      ],
545    }),
546  )
547  if (g.planAt) rows.push(barRow(el, g, p, width))
548  const stats = statsLine(g, p)
549  if (stats) rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [stats] }))
550  if (!g.planAt && g.status === 'running') {
551    rows.push(Text({ dimColor: true, wrap: 'truncate-end', children: [nudged ? 'No plan yet: reminded Claude to plan with the goal meter' : 'Waiting for Claude\'s task plan…'] }))
552  } else if (g.status === 'running') {
553    // a window on the list: the last two done, everything running, the next ones
554    const tasks = visibleTasks(g)
555    const done = tasks.filter((t) => t.status === 'done').slice(-2)
556    const active = tasks.filter((t) => t.status === 'active')
557    const todo = tasks.filter((t) => t.status === 'pending').slice(0, active.length ? 2 : 3)
558    const shown = [...done, ...active, ...todo].sort((a, b) => a.id - b.id)
559    for (const t of shown) rows.push(taskRow(el, t, width))
560    const more = tasks.length - shown.length
561    if (more > 0) rows.push(Text({ dimColor: true, children: [`  +${more} more · g: all tasks`] }))
562  }
563  if (g.status === 'running' && g.check && g.check.met === false && g.check.reason) {
564    rows.push(Text({ color: 'yellow', wrap: 'truncate-end', children: [`Goal check: not met yet: ${mask(g.check.reason)}`] }))
565  }
566  return Box({ flexDirection: 'column', children: rows })
567}
568
569async function openPane($) {
570  now = await $.clock.now()
571  await loadOthers($)
572  paneOpen = true
573  await $.ui.open({ id: PANE, title: 'Goal meter', focus: true, closeOnEscape: true })
574}
575
576function otherRow(el, g, width) {
577  const { Box, Text } = el
578  const p = progress(g)
579  const name = clip(mask(`${g.label || basename(g.cwd)}: ${g.title}`), Math.max(16, Math.floor(width * 0.4)))
580  let tail
581  if (g.status === 'met') tail = 'done ✓'
582  else if (g.status !== 'running') tail = 'stopped'
583  else if (!g.planAt) tail = 'planning'
584  else {
585    const t = eta(g, now)
586    tail = `${p.pct}% · ${t ? '~' + minutes(t.ms) : p.doneN + '/' + p.n}`
587  }
588  const w = Math.max(8, Math.min(24, width - name.length - tail.length - 6))
589  return Box({
590    flexDirection: 'row',
591    columnGap: 2,
592    children: [
593      Text({ wrap: 'truncate-end', children: [name] }),
594      Text({ color: g.status === 'met' ? 'green' : 'cyan', children: [bar(p.fraction, w)] }),
595      Text({ dimColor: true, children: [tail] }),
596    ],
597  })
598}
599
600function drawPane(el, width) {
601  const { Box, Text } = el
602  const rows = []
603  if (G) {
604    const p = progress(G)
605    rows.push(Text({ bold: true, wrap: 'truncate-end', children: [`◎ ${label(G)}: ${mask(G.title)}`] }))
606    rows.push(Text({ color: G.status === 'met' ? 'green' : 'cyan', children: [headline(G, p)] }))
607    if (G.planAt) rows.push(barRow(el, G, p, width))
608    const stats = statsLine(G, p)
609    if (stats) rows.push(Text({ dimColor: true, children: [stats] }))
610    rows.push(Text({ children: [' '] }))
611    const tasks = G.tasks.filter((t) => !t.replaced)
612    if (!tasks.length) rows.push(Text({ dimColor: true, children: ['No task plan yet.'] }))
613    for (const t of tasks) rows.push(taskRow(el, t, width))
614    if (G.check && G.check.reason) {
615      rows.push(Text({ children: [' '] }))
616      rows.push(Text(G.check.met === false ? { color: 'yellow', children: [`Last goal check: not met yet: ${mask(G.check.reason)}`] } : { dimColor: true, children: [`Last goal check: ${mask(G.check.reason)}`] }))
617    }
618  } else {
619    rows.push(Text({ dimColor: true, children: ['No goal in this chat. Type /goal <what done looks like> to start one.'] }))
620  }
621  const rest = others.filter((g) => g.sessionId !== sessionId)
622  rows.push(Text({ children: [' '] }))
623  rows.push(Text({ bold: true, children: [`Other chats (${rest.length})`] }))
624  if (!rest.length) rows.push(Text({ dimColor: true, children: ['No other chat has a goal in the last 12 hours.'] }))
625  for (const g of rest.slice(0, 15)) rows.push(otherRow(el, g, width))
626  rows.push(Text({ children: [' '] }))
627  rows.push(Text({ dimColor: true, children: [`/${commandName} hide|show (the band) · /${commandName} strict ${settings.strict ? 'off' : 'on'} · /${commandName} clear`] }))
628  return Box({ flexDirection: 'column', children: rows })
629}
630
631function plainText() {
632  if (!G) return 'No goal in this chat.'
633  const p = progress(G)
634  const lines = [`${label(G)}: ${mask(G.title)}`, `${headline(G, p)}  ${bar(p.fraction, 30)}`, statsLine(G, p)]
635  for (const t of G.tasks.filter((x) => !x.replaced)) lines.push(`${ICON[t.status] || '○'} ${t.size}  ${mask(t.title)}  ${mask(taskTail(t))}`)
636  return lines.filter(Boolean).join('\n')
637}
638
hooks/fmt.mjs 70 lines
1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2
3export function tokens(n) {
4  const v = Number(n) || 0
5  if (v >= 1e6) return (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M'
6  if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e5 ? 0 : 1) + 'k'
7  return String(Math.round(v))
8}
9
10export function usd(n) {
11  const v = Number(n) || 0
12  if (v === 0) return '$0'
13  if (v < 0.01) return '<$0.01'
14  if (v < 10) return '$' + v.toFixed(2)
15  return '$' + v.toFixed(0)
16}
17
18export function duration(ms) {
19  const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
20  if (s < 60) return s + 's'
21  const m = Math.floor(s / 60)
22  if (m < 60) return m + 'm' + String(s % 60).padStart(2, '0') + 's'
23  const h = Math.floor(m / 60)
24  return h + 'h' + String(m % 60).padStart(2, '0') + 'm'
25}
26
27export function minutes(ms) {
28  const m = Math.round((Number(ms) || 0) / 60000)
29  if (m <= 60) return m + 'm'
30  return Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0') + 'm'
31}
32
33export function clock(ts) {
34  const d = new Date(ts)
35  let h = d.getHours()
36  const ampm = h >= 12 ? 'pm' : 'am'
37  h = h % 12 || 12
38  return h + ':' + String(d.getMinutes()).padStart(2, '0') + ampm
39}
40
41const SPARKS = '▁▂▃▄▅▆▇█'
42
43export function sparkline(values, width = 12) {
44  const vals = values.slice(-width)
45  if (vals.length === 0) return ''
46  const max = Math.max(...vals, 1)
47  return vals.map((v) => SPARKS[Math.min(7, Math.floor((v / max) * 7.999))]).join('')
48}
49
50export function bar(fraction, width = 20, full = '█', empty = '░') {
51  const f = Math.max(0, Math.min(1, Number(fraction) || 0))
52  const n = Math.round(f * width)
53  return full.repeat(n) + empty.repeat(width - n)
54}
55
56export function clip(text, max) {
57  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
58  return s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s
59}
60
61export function pad(text, width) {
62  const s = String(text ?? '')
63  return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length)
64}
65
66export function basename(path) {
67  const parts = String(path || '').split(/[\\/]+/).filter(Boolean)
68  return parts[parts.length - 1] || String(path || '')
69}
70
hooks/privacy.mjs 337 lines
1// Masks secrets, personal details, and business figures for screen recording.
2// Pure functions: no mods API calls here. The same file ships in each mod that
3// draws text, since a mod may import only its own files.
4
5const DOTS = '••••••••'
6const HIDE = '•••'
7const HIDDEN_OUTPUT = '••• hidden while recording'
8
9const SECRET_PATTERNS = [
10  /sk-ant-[A-Za-z0-9_-]{16,}/g,
11  /\bsk-(?:proj-|live-|test-|svcacct-)?[A-Za-z0-9_-]{20,}/g,
12  /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
13  /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
14  /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
15  /\bAIza[0-9A-Za-z_-]{30,}/g,
16  /\bAKIA[0-9A-Z]{16}\b/g,
17  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
18  /\b(?:hf|r8|pk_live|sk_live|rk_live|whsec|pat|key)_[A-Za-z0-9]{20,}/g,
19  /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/gi,
20]
21
22// NAME=value or "name": "value" where the name looks like a credential
23const ASSIGNMENT =
24  /\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|AUTH)[A-Za-z0-9_.-]*)(["']?\s*[=:]\s*["']?)([^\s"'`,;}{]{6,})/gi
25
26const EMAIL_SRC = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}'
27const EMAIL = new RegExp(`\\b${EMAIL_SRC}\\b`, 'g')
28
29// People: "Jane Doe <jane@x.com>", "displayName": "Jane Doe", and From:/Attendees: lines
30const NAME_WORD = "[A-Z][\\p{L}'’.-]*"
31const DISPLAY_NAME = new RegExp(`(["']?)(${NAME_WORD}(?:[ \\t]+${NAME_WORD}){0,3})\\1([ \\t]*<[ \\t]*${EMAIL_SRC}[ \\t]*>)`, 'gu')
32const PERSON_KEY =
33  /("(?:displayName|display_name|fullName|full_name|firstName|first_name|lastName|last_name|givenName|given_name|familyName|family_name|real_name|realName|senderName|sender_name|authorName|author_name|username|user_name)"\s*:\s*")([^"]{1,80})(")/g
34const PERSON_LINE = /^([ \t>*-]*(?:From|To|Cc|Bcc|Reply-To|Attendees?|Organizer|Invitees?|Guests?|Assignees?|Sender|Owner)[ \t]*:[ \t]*)(\S.*)$/gm
35
36// Personal details
37const PHONE = /(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\w.])/g
38const PHONE_INTL = /(?<![\w.])\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?![\w.])/g
39const SSN = /(?<![\w-])\d{3}-\d{2}-\d{4}(?![\w-])/g
40const EIN = /(?<![\w-])\d{2}-\d{7}(?![\w-])/g
41const CARD = /(?<![\w-])(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d{2,4}){3,4}(?![\w-])/g
42const IBAN = /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g
43const ACCOUNT = /\b((?:routing|account|acct|a\/c|aba|swift|bic|iban|passport|driver'?s license)(?:\s*(?:number|no\.?|num|#))?\s*[:#]?\s*)(\d[\d -]{3,}\d)/gi
44const CARD_ENDING = /\b((?:ending(?:\s+in)?|last\s+(?:4|four)(?:\s+digits)?)\s*[:#]?\s*)(\d{4})\b/gi
45const STREET = /\b\d{1,6}\s+(?:[NSEW]\.?\s+)?(?:[A-Z][\p{L}'.-]*\s+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Place|Pl|Parkway|Pkwy|Highway|Hwy|Circle|Cir|Terrace|Ter|Trail|Trl|Square|Sq)\b\.?(?:,?\s+(?:Apt|Apartment|Suite|Ste|Unit|#)\.?\s*[\w-]+)?/gu
46const PO_BOX = /\bP\.?\s?O\.?\s+Box\s+\d+/gi
47const STATE_ZIP = /(,\s*(?:A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY]))\s+\d{5}(?:-\d{4})?\b/g
48const BIRTH = /\b((?:DOB|D\.O\.B\.|date of birth|birth\s?date|birthday|born(?: on)?)\s*[:-]?\s*)([A-Za-z0-9 ,/.-]{4,20}\d)/gi
49const IPV4 = /(?<![\w.])((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})(?![\w.])/g
50
51// Money in any common currency
52const MONEY = /(?:US|CA|AU)?[$€£¥]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?/g
53const MONEY_WORD = /(?<![\w.])(?:(?:USD|EUR|GBP|CAD|AUD)\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?|\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion))?\s?(?:USD|EUR|GBP|CAD|AUD|dollars|bucks)\b)/gi
54
55// Figures near business words: "MRR is 84k", "40% margin", "profit share 20%"
56const FIN_WORD =
57  /\b(?:revenues?|mrr|arr|gmv|profits?|profit share|margins?|ebitda|income|earnings|payroll|salar(?:y|ies)|wages?|compensation|comp|bonus(?:es)?|equity|stakes?|ownership|valuation|runway|burn(?: rate)?|cash(?:flow)?|balances?|budgets?|spend(?:ing)?|pric(?:e|es|ing)|fees?|invoices?|payouts?|distributions?|dividends?|tax(?:es)?|sales|ltv|cac|aov|sponsor(?:s|ships?)?|cpm|rpm|retainers?|commissions?|royalt(?:y|ies)|refunds?|expenses?|debts?|loans?|funding|investments?|deals?|net|gross|paid|pays?|owed?|costs?)\b/gi
58const FIGURE = /(?<![\w.])\d[\d,]*(?:\.\d+)?(?:\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b|x\b))?/g
59const BIG_FIGURE = /(?<![\w.])(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b))/g
60
61// Single names that are also ordinary words, and names that stay visible (the channel's own)
62const COMMON_WORDS = new Set(
63  'Will Mark Grant Bill Rich Art Hope Faith Joy May June April August Summer Rose Page Chase Hunter Max Ray Dawn Sky Brook Lane Dean Gene Guy Frank Sterling Major Price Young King Long Little Wood Hill Stone Field Ford Hall Bell Rice Banks Case Cook Fox Gray Green Brown White Black Day Lee West North South Love Church Park Street Bishop Mason Miller Baker Carter Cole Wells Hart Moon Star Van Von De Del La Le Da Di St Mr Mrs Ms Dr Jr Sr Claude Code Team Support Admin Info Hello The And Ai Slack Gmail Google Calendar Meeting Sync Notes Update Review Weekly Daily Monday Tuesday Wednesday Thursday Friday Saturday Sunday'.split(' '),
64)
65const ROLE_LOCAL_PARTS = /^(?:info|hello|hi|team|support|help|admin|noreply|no-reply|donotreply|contact|sales|billing|accounts?|notifications?|news|newsletter|marketing|office|mail|security|privacy|legal|press|jobs|careers|hr|ops|dev|bot|alerts?|updates?|calendar|invites?)$/i
66
67// Files that stay closed while recording. Add your own folders and file names
68// in ~/.claude/mods-data/recording-mode/config.json ("privatePaths"); /rec config makes one.
69const PRIVATE_PATHS = [
70  /(^|[\\/\s"'`])\.env(\.[A-Za-z0-9_-]+)?(?=$|[\s"'`;|&)])/i,
71  /\.credentials\.json/i,
72  /[\\/]\.claude\.json\b/i,
73  /\.claude[\\/]projects[\\/][^\\/\s"'`]+[\\/]memory/i,
74  /(^|[\\/\s"'`])\.(?:ssh|aws|gnupg)(?=$|[\\/])/i,
75  /\bid_(?:rsa|ed25519|ecdsa)\b/i,
76  /(^|[\\/\s"'`])\.(?:netrc|npmrc|pypirc)\b/i,
77  /\.(?:pem|p12|pfx)\b/i,
78  /taxes?[-_ ]?20\d\d/i,
79  /[\\/_-](?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|cap[-_ ]?table|term[-_ ]?sheets?|offer[-_ ]?letters?|business[-_ ]?plans?)(?=$|[\\/._\s"'`;|&)-])/i,
80  /(^|[\s"'`])(?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|business[-_ ]?plans?)(?=[\\/.])/i,
81]
82
83// Tools whose results are business data: drawn as hidden while recording
84const BUSINESS_TOOL =
85  /(?:^|__|_)(?:clickup|slack|gmail|outlook|notion|asana|linear|jira|clay|qbo|quickbooks|xero|fireflies|hubspot|salesforce|stripe|calendar|gcal|google_drive|drive|sheets)|search_threads|get_thread|get_message|list_drafts|get_draft|read_file_content|download_file_content|search_files|list_recent_files|get_file_metadata|profit_loss|cash_flow|balance_sheet|payroll|session_transcripts|export_transcript/i
86const BUSINESS_COMMAND = /\bgws(?:\.cmd|\.exe)?\b|fireflies|quickbooks/i
87// Finance tools stay closed while recording
88const FINANCE_TOOL = /__(?:qbo_|quickbooks|profit_loss|cash_flow|balance_sheet|benchmarking_quickbooks|money_onboarding|company_info)/i
89
90function escapeRegExp(s) {
91  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
92}
93
94// Values from .env files: long enough to be secret, and not plain words or numbers.
95export function secretValuesFromEnv(text) {
96  const values = []
97  for (const line of String(text || '').split(/\r?\n/)) {
98    const m = line.match(/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)\s*$/)
99    if (!m) continue
100    let v = m[1].trim()
101    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
102    if (v.length < 8) continue
103    if (/^(true|false|null|none|yes|no)$/i.test(v)) continue
104    if (/^\d+(\.\d+)?$/.test(v)) continue
105    if (/your[-_ ]?(key|token|secret)|here$|^<.*>$|^xxx/i.test(v)) continue
106    values.push(v)
107  }
108  return [...new Set(values)].sort((a, b) => b.length - a.length)
109}
110
111// The forms of one person's name that get masked: the full name, its slug, and each part that isn't an ordinary word
112function nameForms(full, keep) {
113  const clean = String(full || '').replace(/\s+/g, ' ').trim()
114  if (!clean || clean.length > 60 || keep.has(clean)) return []
115  const forms = new Set()
116  const parts = clean.split(/[ -]/).filter((p) => /^[A-Z][\p{L}'’.]*$/u.test(p) && p.length >= 2)
117  if (parts.length >= 2) {
118    forms.add(clean)
119    forms.add(clean.toLowerCase())
120    forms.add(clean.toLowerCase().replace(/ /g, '-'))
121    forms.add(clean.toLowerCase().replace(/ /g, '_'))
122  }
123  for (const p of parts) if (!COMMON_WORDS.has(p) && !keep.has(p)) forms.add(p)
124  return [...forms]
125}
126
127function namesFromEmail(email) {
128  const local = email.split('@')[0]
129  if (ROLE_LOCAL_PARTS.test(local)) return null
130  const parts = local.split(/[._+-]/).filter((p) => /^[a-z]{2,}$/i.test(p))
131  if (parts.length < 2 || parts.length > 4) return null
132  return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ')
133}
134
135// Masks numbers in the same clause as a business word
136function maskFigures(text, strict) {
137  if (strict) text = text.replace(BIG_FIGURE, HIDE)
138  const ranges = []
139  for (const m of text.matchAll(FIN_WORD)) {
140    const s = m.index
141    const e = s + m[0].length
142    let a = Math.max(0, s - 30)
143    let b = Math.min(text.length, e + 45)
144    const before = text.slice(a, s)
145    const stop = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';'), before.search(/[.!?]\s[^.!?]*$/))
146    if (stop >= 0) a += stop + 1
147    const after = text.slice(e, b)
148    const end = after.search(/\n|;|[.!?](?:\s|$)/)
149    if (end >= 0) b = e + end
150    ranges.push([a, b])
151  }
152  if (!ranges.length) return text
153  return text.replace(FIGURE, (fig, offset) => {
154    if (/^(?:19|20)\d\d$/.test(fig)) return fig // a year
155    return ranges.some(([a, b]) => offset >= a && offset < b) ? HIDE : fig
156  })
157}
158
159function luhn(digits) {
160  let sum = 0
161  for (let i = 0; i < digits.length; i++) {
162    let d = Number(digits[digits.length - 1 - i])
163    if (i % 2 === 1) {
164      d *= 2
165      if (d > 9) d -= 9
166    }
167    sum += d
168  }
169  return sum % 10 === 0
170}
171
172function publicIp(ip) {
173  const [a, b] = ip.split('.').map(Number)
174  if (a === 10 || a === 127 || a === 0 || ip === '255.255.255.255') return false
175  if (a === 192 && b === 168) return false
176  if (a === 172 && b >= 16 && b <= 31) return false
177  if (a === 169 && b === 254) return false
178  return true
179}
180
181// Your own name and its parts stay visible
182function keepSet(keepNames) {
183  const keep = new Set()
184  for (const n of keepNames || []) {
185    const clean = String(n || '').replace(/\s+/g, ' ').trim()
186    if (!clean) continue
187    keep.add(clean)
188    keep.add(clean.toLowerCase())
189    keep.add(clean.toLowerCase().replace(/ /g, '-'))
190    for (const part of clean.split(/[ -]/)) if (part) keep.add(part)
191  }
192  return keep
193}
194
195// strict: also every large figure (1,250 / 18% / 40k) wherever it appears
196// names: people to mask; keepNames: names that stay visible (yours)
197export function makeMasker({ strict = false, values = [], names = [], keepNames = [] } = {}) {
198  const KEEP_NAMES = keepSet(keepNames)
199  const valueRe = values.length
200    ? new RegExp(values.map(escapeRegExp).join('|'), 'g')
201    : null
202  // Names: the roster passed in plus any learned from emails and contact fields. Memory only.
203  const people = new Set()
204  let nameRe = null
205  let dirty = false
206  const learn = (full) => {
207    if (people.size > 2000) return
208    for (const f of nameForms(full, KEEP_NAMES)) {
209      if (!people.has(f)) {
210        people.add(f)
211        dirty = true
212      }
213    }
214  }
215  for (const n of names) learn(n)
216  const nameRegex = () => {
217    if (dirty) {
218      const list = [...people].sort((a, b) => b.length - a.length).map(escapeRegExp)
219      nameRe = list.length ? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${list.join('|')})(?:['’]s)?(?![\\p{L}\\p{N}_])`, 'gu') : null
220      dirty = false
221    }
222    return nameRe
223  }
224
225  return function mask(text) {
226    if (typeof text !== 'string' || text.length === 0) return text
227    let out = text
228    if (valueRe) out = out.replace(valueRe, DOTS)
229    for (const re of SECRET_PATTERNS) out = out.replace(re, DOTS)
230    out = out.replace(ASSIGNMENT, (_, name, sep) => name + sep + DOTS)
231
232    // people, learned before the emails that name them are masked
233    for (const m of out.matchAll(EMAIL)) {
234      const n = namesFromEmail(m[0])
235      if (n) learn(n)
236    }
237    out = out.replace(DISPLAY_NAME, (_, q, name, addr) => {
238      learn(name)
239      return KEEP_NAMES.has(name) ? q + name + q + addr : HIDE + addr
240    })
241    out = out.replace(PERSON_KEY, (_, head, value, tail) => {
242      learn(value)
243      return head + (KEEP_NAMES.has(value) ? value : HIDE) + tail
244    })
245    out = out.replace(PERSON_LINE, (line, label, value) => (KEEP_NAMES.has(value.trim()) ? line : label + HIDE))
246    out = out.replace(EMAIL, '•••@•••')
247    const re = nameRegex()
248    if (re) out = out.replace(re, HIDE)
249
250    // personal details
251    out = out.replace(CARD, (m) => {
252      const digits = m.replace(/\D/g, '')
253      return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? '•••• •••• •••• ••••' : m
254    })
255    out = out.replace(SSN, '•••-••-••••')
256    out = out.replace(EIN, '••-•••••••')
257    out = out.replace(IBAN, HIDE)
258    out = out.replace(ACCOUNT, (_, head) => head + HIDE)
259    out = out.replace(CARD_ENDING, (_, head) => head + '••••')
260    out = out.replace(BIRTH, (_, head) => head + HIDE)
261    out = out.replace(STREET, HIDE)
262    out = out.replace(PO_BOX, HIDE)
263    out = out.replace(STATE_ZIP, (_, head) => head + ' •••••')
264    out = out.replace(PHONE, '•••-•••-••••')
265    out = out.replace(PHONE_INTL, '+•• •••')
266    out = out.replace(IPV4, (ip) => (publicIp(ip) ? '•••.•••.•••.•••' : ip))
267
268    // business figures
269    out = out.replace(MONEY, '$•••')
270    out = out.replace(MONEY_WORD, HIDE)
271    out = maskFigures(out, strict)
272    return out
273  }
274}
275
276// Masks every string inside plain data, keeping the shape.
277export function deepMask(value, mask, depth = 0) {
278  if (depth > 12) return value
279  if (typeof value === 'string') return mask(value)
280  if (Array.isArray(value)) return value.map((v) => deepMask(v, mask, depth + 1))
281  if (value && typeof value === 'object') {
282    const proto = Object.getPrototypeOf(value)
283    if (proto !== Object.prototype && proto !== null) return value
284    const out = {}
285    for (const [k, v] of Object.entries(value)) out[k] = deepMask(v, mask, depth + 1)
286    return out
287  }
288  return value
289}
290
291// Hides the readable text inside a business tool's result, keeping short tags (type: 'text') so the row still draws.
292export function hideText(s) {
293  if (typeof s !== 'string' || s.length === 0) return s
294  return s.length > 24 || /\s/.test(s) ? HIDDEN_OUTPUT : s
295}
296
297// extra: your own folder or file names from the config file, matched anywhere in a path, any case
298export function privatePathHit(text, extra = []) {
299  const s = String(text || '')
300  for (const re of PRIVATE_PATHS) {
301    const m = s.match(re)
302    if (m) return m[0].trim().replace(/^["'`\\/_-]/, '')
303  }
304  const flat = s.replace(/\\/g, '/').toLowerCase()
305  for (const item of extra) {
306    const needle = String(item || '').replace(/\\/g, '/').toLowerCase().trim()
307    if (needle && flat.includes(needle)) return String(item)
308  }
309  return null
310}
311
312// The strings in a tool call's arguments that could name a file or carry a command.
313export function toolCallTargets(e) {
314  const keys = ['file_path', 'path', 'pattern', 'glob', 'command', 'notebook_path', 'url']
315  const out = []
316  for (const k of keys) if (typeof e?.[k] === 'string') out.push(e[k])
317  return out
318}
319
320// Whether a tool's result is business data (mail, chat, tasks, files, calendar, finance)
321// extra: more tool or command names (any part of the name) whose results draw as hidden
322export function businessSource(tool, input, extra = []) {
323  const name = String(tool || '')
324  if (BUSINESS_TOOL.test(name)) return true
325  const cmd = input && typeof input.command === 'string' ? input.command : ''
326  const hay = (name + ' ' + cmd).toLowerCase()
327  if (extra.some((x) => x && hay.includes(String(x).toLowerCase()))) return true
328  const command = input && typeof input.command === 'string' ? input.command : ''
329  return command ? BUSINESS_COMMAND.test(command) : false
330}
331
332// extra: more tool names (any part of the name) that stay closed while recording
333export function financeTool(tool, extra = []) {
334  const name = String(tool || '')
335  return FINANCE_TOOL.test(name) || extra.some((x) => x && name.toLowerCase().includes(String(x).toLowerCase()))
336}
337
hooks/plan.mjs 243 lines
1// Goal Meter's plan: the task list Claude keeps through the mod's own tool, and
2// what the bar, the ETA, and the goal check read from it. Pure functions: no mods
3// API calls here, so the harness tests can drive them directly.
4
5export const SIZES = { S: 1, M: 2, L: 3 }
6
7const STOP = /^(clear|stop|off|cancel|end|none|reset)$/i
8
9export function isStopWord(text) {
10  return STOP.test(String(text || '').trim())
11}
12
13export function titleOf(condition) {
14  const line = String(condition || '').split(/\r?\n/).map((l) => l.trim()).find(Boolean) || 'Goal'
15  return line.length > 160 ? line.slice(0, 159) + '…' : line
16}
17
18export function newGoal({ sessionId, condition, now, cwd, kind = 'goal' }) {
19  return {
20    sessionId,
21    kind,
22    title: titleOf(condition),
23    condition: String(condition || '').slice(0, 2000),
24    cwd: cwd || '',
25    startedAt: now,
26    planAt: 0,
27    endedAt: 0,
28    status: 'running',
29    active: true,
30    lastTurnEnd: 0,
31    interrupted: false,
32    tasks: [],
33    nextId: 1,
34    firstPlan: 0,
35    check: null,
36    checks: 0,
37    updatedAt: now,
38  }
39}
40
41function sizeOf(v) {
42  const s = String(v || '').trim().toUpperCase().charAt(0)
43  return SIZES[s] ? s : 'M'
44}
45
46function clean(text, max) {
47  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
48  return s.length > max ? s.slice(0, max - 1) + '…' : s
49}
50
51export function weight(t) {
52  return SIZES[t.size] || 2
53}
54
55// Tasks still part of the plan: dropped and replaced ones don't count
56export function live(goal) {
57  return goal.tasks.filter((t) => t.status !== 'dropped' && !t.replaced)
58}
59
60export function progress(goal) {
61  const tasks = live(goal)
62  const total = tasks.reduce((a, t) => a + weight(t), 0)
63  const done = tasks.filter((t) => t.status === 'done')
64  const doneW = done.reduce((a, t) => a + weight(t), 0)
65  return {
66    total,
67    doneW,
68    doneN: done.length,
69    n: tasks.length,
70    active: tasks.filter((t) => t.status === 'active'),
71    pct: total ? Math.round((100 * doneW) / total) : 0,
72    fraction: total ? doneW / total : 0,
73  }
74}
75
76// The ETA at this goal's own pace: time since the plan per unit of finished
77// work, times the work left. Nothing until two tasks are done.
78export function eta(goal, now) {
79  const p = progress(goal)
80  if (goal.status !== 'running' || p.doneN < 2 || p.doneW <= 0 || p.doneW >= p.total) return null
81  const since = goal.planAt || goal.startedAt
82  const per = Math.max(0, now - since) / p.doneW
83  const ms = Math.round(per * (p.total - p.doneW))
84  return { ms, at: now + ms }
85}
86
87// Accepts [{ title, size }], plain strings, or the same as a JSON string
88export function normalizeTasks(raw) {
89  let list = raw
90  if (typeof list === 'string') {
91    try {
92      list = JSON.parse(list)
93    } catch {
94      list = list.split(/\r?\n/)
95    }
96  }
97  if (!Array.isArray(list)) return []
98  const out = []
99  for (const item of list) {
100    if (typeof item === 'string') {
101      const title = clean(item.replace(/^\s*[-*\d.)\]]+\s*/, ''), 140)
102      if (title) out.push({ title, size: 'M' })
103      continue
104    }
105    if (!item || typeof item !== 'object') continue
106    const title = clean(item.title ?? item.subject ?? item.name ?? item.task ?? item.description, 140)
107    if (title) out.push({ title, size: sizeOf(item.size) })
108  }
109  return out.slice(0, 60)
110}
111
112function idsOf(input) {
113  const raw = Array.isArray(input.ids) ? input.ids : input.id !== undefined ? [input.id] : []
114  return raw.map((v) => Number(String(v).replace(/^#/, ''))).filter((n) => Number.isInteger(n) && n > 0)
115}
116
117function addTasks(goal, list, now, origin) {
118  for (const t of list) {
119    goal.tasks.push({ id: goal.nextId++, title: t.title, size: t.size, status: 'pending', by: '', addedAt: now, startedAt: 0, doneAt: 0, origin, note: '' })
120  }
121}
122
123const WORD = { pending: 'todo', active: 'running', done: 'done', dropped: 'dropped' }
124
125export function listText(goal) {
126  const p = progress(goal)
127  const lines = [`Plan: ${p.doneN} of ${p.n} tasks done, ${p.pct}% of the work.`]
128  for (const t of goal.tasks) {
129    if (t.replaced) continue
130    const who = t.status === 'active' && t.by ? ` by ${t.by}` : ''
131    lines.push(`#${t.id} ${WORD[t.status] || t.status}  ${t.title} (${t.size})${who}`)
132  }
133  return lines.join('\n')
134}
135
136// One call of the task tool. Returns { ok, text } and changes the goal in place.
137export function applyAction(goal, input, { now, by = '' } = {}) {
138  const action = String(input.action || 'show').toLowerCase()
139  const fail = (msg) => ({ ok: false, text: `Goal meter: ${msg}\n${listText(goal)}` })
140  if (action === 'plan' || action === 'add') {
141    const list = normalizeTasks(input.tasks)
142    if (!list.length) return fail(`"${action}" needs tasks: [{ "title": "...", "size": "S" | "M" | "L" }].`)
143    if (action === 'plan' && goal.planAt) {
144      // a new plan replaces the work not started yet; finished and running tasks stay
145      for (const t of goal.tasks) if (t.status === 'pending') t.replaced = true
146    }
147    const first = !goal.planAt
148    addTasks(goal, list, now, first ? 'plan' : 'later')
149    if (first) {
150      goal.planAt = now
151      goal.firstPlan = list.length
152    }
153  } else if (action === 'start' || action === 'done' || action === 'drop') {
154    const ids = idsOf(input)
155    if (!ids.length) return fail(`"${action}" needs the task's id.`)
156    const tasks = ids.map((id) => goal.tasks.find((t) => t.id === id && !t.replaced))
157    const missing = ids.filter((id, i) => !tasks[i])
158    if (missing.length) return fail(`no task #${missing.join(', #')}.`)
159    for (const t of tasks) {
160      if (action === 'start') {
161        t.status = 'active'
162        t.startedAt = t.startedAt || now
163        if (by) t.by = clean(by, 32)
164      } else if (action === 'done') {
165        t.status = 'done'
166        t.startedAt = t.startedAt || now
167        t.doneAt = now
168        if (input.note) t.note = clean(input.note, 140)
169      } else {
170        t.status = 'dropped'
171        t.doneAt = now
172        t.note = clean(input.note || 'no longer needed', 140)
173      }
174    }
175  } else if (action !== 'show') {
176    return fail(`unknown action "${action}". Use plan, add, start, done, drop, or show.`)
177  }
178  goal.updatedAt = now
179  return { ok: true, text: listText(goal) }
180}
181
182// The goal check's verdict, from the text the engine records for it. The shape
183// is not documented, so this reads both a JSON-ish record and plain words.
184export function parseCheck(text) {
185  const t = String(text || '')
186  const notMet = /"met"\s*:\s*false|\bmet\s*[=:]\s*false|\bnot\s+(?:yet\s+)?(?:been\s+)?(?:met|achieved|satisfied|complete)|\bunmet\b|\bnot\s+satisfied\b/i.test(t)
187  const met = !notMet && /"met"\s*:\s*true|\bmet\s*[=:]\s*true|\b(?:condition|goal)\b[^.\n]{0,60}\b(?:is|was|has been)\s+(?:met|achieved|satisfied)\b/i.test(t)
188  const m = t.match(/"reason"\s*:\s*"((?:[^"\\]|\\.)*)"/) || t.match(/\breason\b[^:\n]{0,20}:\s*([\s\S]+)/i)
189  const reason = clean(m ? m[1].replace(/\\"/g, '"').replace(/\\n/g, ' ') : '', 300)
190  return { met, notMet, reason }
191}
192
193export const TOOL_SPEC = {
194  name: 'tasks',
195  description:
196    'Goal meter: the task plan the user watches as a progress bar while a /goal runs. ' +
197    'Call action "plan" first with every task needed to meet the goal, in order, each sized S, M, or L. ' +
198    'Call "start" with a task id when you begin it (set "by" to a subagent\'s short name when one does it) and "done" when it is finished. ' +
199    'Call "add" for work you discover or when the goal check says the goal is not met yet, and "drop" for a task no longer needed. ' +
200    'Keep it accurate: the bar and the ETA come only from this list.',
201  inputSchema: {
202    type: 'object',
203    properties: {
204      action: { type: 'string', enum: ['plan', 'add', 'start', 'done', 'drop', 'show'] },
205      tasks: {
206        type: 'array',
207        description: 'For plan and add: the tasks in order.',
208        items: {
209          type: 'object',
210          properties: {
211            title: { type: 'string', description: 'What the task delivers, in a few words' },
212            size: { type: 'string', enum: ['S', 'M', 'L'], description: 'S: a few minutes. M: a solid chunk. L: the big piece.' },
213          },
214          required: ['title'],
215        },
216      },
217      id: { type: 'number', description: 'For start, done, and drop: the task number' },
218      ids: { type: 'array', items: { type: 'number' }, description: 'Several task numbers at once' },
219      by: { type: 'string', description: 'For start: who does it, "main" or the subagent\'s short name' },
220      note: { type: 'string', description: 'For done or drop: one short line' },
221    },
222    required: ['action'],
223  },
224}
225
226export function instruction(tool) {
227  return (
228    `Goal meter is on for this goal: the user watches your progress as a bar built from your task plan. ` +
229    `Before you start the work, call ${tool} (load it with ToolSearch if it is deferred) with action "plan" and every task needed to meet the goal, in order, each sized S, M, or L. ` +
230    `Call "start" with a task's id when you begin it and "done" when it is finished. ` +
231    `If you find more work, or the goal check says the goal is not met yet, call "add" with the new tasks. ` +
232    `When a subagent does a task, call "start" with "by" set to its short name, and "done" when it reports back.`
233  )
234}
235
236export function nudge(tool) {
237  return `Reminder from the goal meter: this /goal has no task plan yet, so the user's progress bar is empty. Call ${tool} with action "plan" now (every task to meet the goal, in order, sized S, M, or L), then mark tasks as you go.`
238}
239
240export function strictDeny(tool) {
241  return `Goal meter (strict): plan the goal before changing files. Call ${tool} with action "plan" first, then retry this.`
242}
243