SLOPSHOPPER

collision-guard

Collision Guard hooks module

newguardcommandpromptprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · collision-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ collision-guard: Collision Guard is on. Before an edit, it asks when another open chat changed the same file in the last 30 min ⎿ collision-guard: No other open chat has edited a file in that window. ⎿ collision-guard: This chat changed 3 files: auth.ts, audit.ts, cache.ts. ⎿ collision-guard: This chat so far: asked 0, proceeded 0, moved to a worktree 0, cancelled 0. ⎿ collision-guard: Settings: /guard on|off, /guard window <minutes>. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Eric's Dotfiles

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.

Set up a new machine

brew install chezmoi
chezmoi init --apply rcliao

That prompts for three things and writes them to ~/.config/chezmoi/chezmoi.toml, which stays local to the machine and is never committed:

PromptWhy it is per-machine
Full namegit author name
Git emailso a work laptop and a personal box can share this repo without cross-signing commits
Ghost hookssee Ghost below

Re-running chezmoi init later is non-interactive — it keeps whatever is already set. To change an answer, edit ~/.config/chezmoi/chezmoi.toml and chezmoi apply. For an unattended install, chezmoi init --apply --promptDefaults takes the personal identity with Ghost off.

The first apply runs brew bundle for everything below. A package failure warns rather than aborting, so the dotfiles land either way.

Day to day

chezmoi diff                 # what would change
chezmoi apply                # everything
chezmoi apply ~/.zshrc       # just one target
chezmoi add ~/.tmux.conf     # pull a local edit back into the repo
chezmoi update               # git pull + apply

This repo is edited from more than one machine, so read chezmoi diff before applying. Whichever machine committed last is not automatically the machine that is more correct.

What is in here

Shell — .zshrc (zsh, pure prompt, shared history across panes, cached compinit), .gitconfig (delta pager, zdiff3 conflicts, rerere, auto-set upstream on push), .config/git/ignore, .ssh/config (keychain-backed agent loading — this is what replaces the ssh-add -A that used to run on every shell start).

Terminals — ghostty.

Multiplexers — herdr (primary, keys mapped to match tmux muscle memory), tmux as the fallback. The old zellij config is kept under archive/, which is not deployed.

Editors — neovim (0.12+: one init.lua, plugins via the built-in vim.pack, LSP for Go, TypeScript, Rust, Python and Terraform from Brewfile-installed servers), emacs.

Runtimes — mise, activated at the end of .zshrc so it wins over the PATH exports above it. Homebrew still provides the global node, go, and python; mise only takes over inside a directory that pins a version in mise.toml or .tool-versions.

Window management — aerospace.

Claude Code — CLAUDE.md, settings.json, and the hook scripts under .claude/hooks. settings.json is a template: hooks belonging to tools that may not be installed (herdr, Zero) are only wired in when their script is actually present, so a machine never ends up pointing at a hook that does not exist.

Other — bat, yazi.

Ghost memory hooks (opt-in)

The ghost-* hooks drive the Ghost MCP memory server. A machine without Ghost has no use for them, so they are off by default: neither the scripts nor their settings.json entries are installed.

To turn them on, set ghost = true in ~/.config/chezmoi/chezmoi.toml and chezmoi apply. To turn them off again, flip it back and apply.

aerospace

Homebrew refuses casks from untrusted taps, and trusting one is a decision worth making deliberately, so the bundle does not install aerospace:

brew trust --cask nikitabobko/tap/aerospace   # this one cask, not the whole tap
brew install --cask nikitabobko/tap/aerospace

What is deliberately not tracked

Anything regenerated, tool-managed, or machine-specific — Claude Code session transcripts and caches, plugin directories, herdr's own integration hook, and ~/.config/chezmoi/chezmoi.toml itself. See .chezmoiignore, which lists each exclusion explicitly so chezmoi add ~/.claude can never sweep one in.

This repo is public, so nothing employer-specific goes in it.

Source 5 files
hooks/register.mjs 245 lines
1// Collision Guard: before Claude edits or writes a file, it checks whether
2// another open chat changed that file in the last 30 minutes. If one did, it
3// asks in the engine's own question dialog: Proceed, Move to a worktree (only
4// when the file is tracked in git, since a worktree carries tracked files
5// only), or Cancel. The choice goes back to Claude as the edit's result.
6//
7// Why: with several chats open in one repo, two of them can end up changing the
8// same file without knowing about each other.
9// Zero tokens: each chat keeps a small ledger of the files it changed, one file
10// per chat under ~/.claude/mods-data/collision-guard/, and reads the others'
11// before each edit. Nothing is added to the prompt.
12
13import { normPath, chatName } from './coach.mjs'
14import { clip, basename, minutes } from './fmt.mjs'
15import { makeMasker } from './privacy.mjs'
16
17const MIN = 60000
18const DIR = '/.claude/mods-data/collision-guard'
19
20const settings = {
21  on: true,
22  windowMin: 30, // another chat's edit this recent counts
23  liveMin: 15, // a chat whose ledger hasn't moved this long is closed
24  ignore: ['/.claude/projects/', '/.claude/mods-data/'], // memory and mod data: many chats write these on purpose
25}
26
27// This chat's ledger
28const S = { id: '', cwd: '', title: '', files: {} }
29let home = ''
30let dir = ''
31let now = 0
32let commandName = 'guard'
33let wrote = false
34let rec = { on: false, strict: false }
35let mask = (s) => s
36// "key|otherChat" -> the other chat's edit you said Proceed to; a newer edit asks again
37const allowed = new Map()
38const stats = { asked: 0, proceeded: 0, worktree: 0, cancelled: 0 }
39
40function own() {
41  return `${dir}/${S.id}.json`
42}
43
44async function writeOwn($, ended) {
45  if (!S.id || !dir) return
46  for (const [key, f] of Object.entries(S.files)) if (now - f.at > 2 * settings.windowMin * MIN) delete S.files[key]
47  if (!wrote && !ended && Object.keys(S.files).length === 0) return // a chat that never edits leaves no file
48  try {
49    await $.fs.write(own(), JSON.stringify({ id: S.id, title: S.title, cwd: S.cwd, updatedAt: now, ended: !!ended, files: ended ? {} : S.files }))
50    wrote = true
51  } catch {
52    // a locked file: the next edit or heartbeat writes it again
53  }
54}
55
56async function readOthers($) {
57  const out = []
58  let entries = []
59  try {
60    entries = await $.fs.list(dir)
61  } catch {
62    return out
63  }
64  for (const entry of entries) {
65    if (!entry.name.endsWith('.json') || entry.name === S.id + '.json') continue
66    try {
67      const v = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
68      if (!v || v.ended || now - (v.updatedAt || 0) > settings.liveMin * MIN) continue
69      out.push(v)
70    } catch {
71      // half-written or gone: skip it this time
72    }
73  }
74  return out
75}
76
77async function readRecording($) {
78  try {
79    const p = home + '/.claude/mods-data/recording.json'
80    if (!(await $.fs.exists(p))) rec = { on: false, strict: false }
81    else {
82      const flag = JSON.parse(await $.fs.read(p))
83      rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
84    }
85  } catch {
86    rec = { on: false, strict: false }
87  }
88  mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
89}
90
91// The most recent edit of this file by another open chat, unless you already said Proceed to it
92function collisionFor(key, others) {
93  let best = null
94  for (const o of others) {
95    const f = o.files && o.files[key]
96    if (!f || now - f.at > settings.windowMin * MIN) continue
97    if ((allowed.get(key + '|' + o.id) || 0) >= f.at) continue
98    if (!best || f.at > best.at) best = { other: o, at: f.at }
99  }
100  return best
101}
102
103function agoText(ms) {
104  return ms < MIN ? 'just now' : `${minutes(ms)} ago`
105}
106
107// A worktree checks out tracked files only, so it helps only when git tracks this one
108async function isTracked($, raw) {
109  try {
110    const r = await $.process.run(['git', '-C', S.cwd, 'ls-files', '--error-unmatch', '--', raw], { timeoutMs: 5000 })
111    return r.exitCode === 0
112  } catch {
113    return false
114  }
115}
116
117async function decide($, raw, key, hit) {
118  const file = basename(raw)
119  const when = agoText(now - hit.at)
120  const surfaces = await $.session.surfaces()
121  if (!surfaces.length) {
122    // nobody to ask (a -p run): let it through rather than break an unattended job
123    $.ui.log(`collision-guard: another chat edited ${file} ${when}; nobody to ask, so the edit went ahead`, { to: 'debug' })
124    return null
125  }
126  const options = ['Proceed']
127  if (await isTracked($, raw)) options.push('Move to a worktree')
128  options.push('Cancel')
129  stats.asked += 1
130  let answer = 'Cancel'
131  try {
132    const who = rec.on ? 'Another open chat' : `Another open chat, "${clip(mask(chatName(hit.other)), 60)}",`
133    answer = await $.ui.ask(`${who} edited ${file} ${when}. Edit it here too?`, { options, header: 'Collision' })
134  } catch {
135    answer = 'Cancel' // dismissed
136  }
137  const what = `another open Claude Code chat ("${clip(chatName(hit.other), 80)}") edited ${raw} ${when}`
138  if (answer === 'Proceed') {
139    allowed.set(key + '|' + hit.other.id, hit.at)
140    stats.proceeded += 1
141    return null
142  }
143  if (answer === 'Move to a worktree') {
144    stats.worktree += 1
145    return { deny: `Not edited: ${what}. The user wants this chat's work moved into a git worktree so the two chats stop changing the same files. First list the files you already changed in this checkout (they stay here), then use the EnterWorktree tool and make this change inside the worktree.` }
146  }
147  stats.cancelled += 1
148  if (answer === 'Cancel') {
149    return { deny: `Not edited: ${what}, and the user cancelled this edit. Don't change this file again. Tell the user what you were about to change and ask how to proceed.` }
150  }
151  // text typed under "Other"
152  return { deny: `Not edited: ${what}. The user answered: "${clip(answer, 400)}". Follow that before touching this file again.` }
153}
154
155async function statusText($) {
156  now = await $.clock.now()
157  const others = await readOthers($)
158  const lines = [`Collision Guard is ${settings.on ? 'on' : 'off'}. Before an edit, it asks when another open chat changed the same file in the last ${settings.windowMin} minutes.`]
159  const busy = others
160    .map((o) => ({ o, n: Object.values(o.files || {}).filter((f) => now - f.at <= settings.windowMin * MIN).length }))
161    .filter((x) => x.n > 0)
162  if (busy.length) {
163    lines.push(`Other open chats with recent edits: ${busy.map((x) => `${rec.on ? 'a chat' : `"${clip(mask(chatName(x.o)), 40)}"`} (${x.n} file${x.n === 1 ? '' : 's'})`).join(', ')}.`)
164  } else lines.push('No other open chat has edited a file in that window.')
165  const mine = Object.values(S.files).filter((f) => now - f.at <= settings.windowMin * MIN)
166  lines.push(mine.length ? `This chat changed ${mine.length} file${mine.length === 1 ? '' : 's'}: ${mine.slice(-5).map((f) => basename(f.path)).join(', ')}.` : 'This chat has not changed a file in that window.')
167  lines.push(`This chat so far: asked ${stats.asked}, proceeded ${stats.proceeded}, moved to a worktree ${stats.worktree}, cancelled ${stats.cancelled}.`)
168  lines.push(`Settings: /${commandName} on|off, /${commandName} window <minutes>.`)
169  return lines.join('\n')
170}
171
172export function register(on) {
173  on('session.start', async ($, e, next) => {
174    now = await $.clock.now()
175    home = ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
176    dir = home + DIR
177    S.id = await $.session.id()
178    S.cwd = await $.session.cwd()
179    const saved = await $.store.get('settings')
180    if (saved && typeof saved === 'object') Object.assign(settings, saved)
181    for (const name of ['guard', 'collision-guard']) {
182      try {
183        await $.command.register({ name, description: 'Collision Guard: asks before editing a file another open chat just changed', argumentHint: '[on|off|window <minutes>]', immediate: true })
184        commandName = name
185        break
186      } catch {
187        // taken: try the next name
188      }
189    }
190    // the ledger's heartbeat: other chats treat a ledger quiet for 15 minutes as closed
191    $.clock.every(60000, async () => {
192      now = await $.clock.now()
193      await writeOwn($)
194    })
195    return next(e)
196  })
197
198  on('session.end', async ($, e, next) => {
199    now = await $.clock.now()
200    if (wrote) await writeOwn($, true)
201    return next(e)
202  })
203
204  // The first prompt names this chat in other chats' questions
205  on('prompt.submit', async ($, e, next) => {
206    if (!S.title && e.origin && ['composer', 'bridge', 'sdk'].includes(e.origin.kind) && e.text) S.title = clip(e.text, 80)
207    return next(e)
208  })
209
210  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
211    if (!settings.on || !dir) return next(e)
212    const raw = String(e.file_path || e.notebook_path || '')
213    const key = normPath(raw, S.cwd)
214    if (!key || settings.ignore.some((s) => key.includes(s.toLowerCase()))) return next(e)
215    now = await $.clock.now()
216    const hit = collisionFor(key, await readOthers($))
217    if (hit) {
218      await readRecording($)
219      const refusal = await decide($, raw, key, hit)
220      if (refusal) return refusal
221      now = await $.clock.now()
222    }
223    // recorded before the edit runs, so a chat editing at the same moment sees it
224    S.files[key] = { path: raw, at: now }
225    await writeOwn($)
226    return next(e)
227  })
228
229  on('command.run', { command: ['guard', 'collision-guard'] }, async ($, e) => {
230    const [key, value] = String(e.args || '').trim().split(/\s+/)
231    const k = (key || '').toLowerCase()
232    if (k === 'on' || k === 'off') {
233      settings.on = k === 'on'
234      await $.store.set('settings', settings)
235      return { text: `Collision Guard ${settings.on ? 'on' : 'off'} in every chat that starts from now (and this one).` }
236    }
237    if (k === 'window' && Number(value) > 0) {
238      settings.windowMin = Math.round(Number(value))
239      await $.store.set('settings', settings)
240      return { text: `Collision Guard now counts another chat's edits from the last ${settings.windowMin} minutes.` }
241    }
242    return { text: await statusText($) }
243  })
244}
245
hooks/coach.mjs 325 lines
1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2// The Session Coach (Command Center's Coach tab) and the Collision Guard: pure
3// functions over what each open chat reports in its heartbeat. No mods API calls.
4
5import { clip, basename, tokens, minutes, usd } from './fmt.mjs'
6import { rewriteCost } from './pricing.mjs'
7
8const MIN = 60000
9
10// One spelling per file, so two chats' edits compare: forward slashes, no . or
11// .., and lowercase on Windows, where C:\X and c:/x are the same file
12export function normPath(p, cwd) {
13  let s = String(p || '').trim().replace(/\\/g, '/')
14  if (!s) return ''
15  if (/^\/[a-zA-Z]\//.test(s)) s = s[1] + ':' + s.slice(2) // Git Bash /c/Users is C:/Users
16  const isAbs = /^[A-Za-z]:\//.test(s) || s.startsWith('/')
17  if (!isAbs && cwd) s = String(cwd).replace(/\\/g, '/').replace(/\/+$/, '') + '/' + s
18  const parts = []
19  for (const part of s.split('/')) {
20    if (part === '.' || (part === '' && parts.length)) continue
21    if (part === '..') {
22      if (parts.length > 1) parts.pop()
23      continue
24    }
25    parts.push(part)
26  }
27  s = parts.join('/')
28  return /^[A-Za-z]:/.test(s) ? s.toLowerCase() : s
29}
30
31// A path shown relative to a root when it sits inside it
32export function relTo(path, root) {
33  const p = String(path || '').replace(/\\/g, '/')
34  const r = String(root || '').replace(/\\/g, '/').replace(/\/+$/, '')
35  if (r && p.toLowerCase().startsWith(r.toLowerCase() + '/')) return p.slice(r.length + 1)
36  return p
37}
38
39// Where Claude Code keeps a chat's transcript: the folder is the working
40// directory with every character but letters and digits turned into "-"
41export function transcriptPath(home, cwd, id) {
42  if (!home || !cwd || !id) return ''
43  return `${home}/.claude/projects/${String(cwd).replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`
44}
45
46// `git rev-parse --abbrev-ref HEAD --show-toplevel --git-dir --git-common-dir`
47// prints four lines; a worktree's git dir differs from the common one
48export function gitInfo(stdout, cwd) {
49  const lines = String(stdout || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
50  if (lines.length < 4) return null
51  const [branch, root, gitDir, common] = lines
52  return { branch, root, worktree: normPath(gitDir, cwd) !== normPath(common, cwd) }
53}
54
55// A chat's name for people: its first prompt, without the "<folder> · " prefix
56export function chatName(hb) {
57  const name = String(hb?.title || String(hb?.label || '').replace(/^[^·]*·\s*/, '')).trim()
58  return name || basename(hb?.cwd) || 'a chat'
59}
60
61function lastActive(c) {
62  return c.activeAt || c.lastDone?.at || 0
63}
64
65// ---------- rules: facts the Coach sees without AI ----------
66
67// Two open chats changed the same file recently, or a big chat sits idle long
68// enough that its prompt cache has expired
69export function ruleTips(chats, now, opts = {}) {
70  const overlapMs = (opts.overlapMin ?? 60) * MIN
71  const parkIdleMs = (opts.parkIdleMin ?? 60) * MIN
72  const parkTokens = opts.parkTokens ?? 250000
73  const live = (chats || []).filter((c) => c && c.id)
74  const tips = []
75  for (let i = 0; i < live.length; i++) {
76    for (let j = i + 1; j < live.length; j++) {
77      const a = live[i]
78      const b = live[j]
79      const recentA = new Map((a.edits || []).filter((e) => now - e.at <= overlapMs).map((e) => [e.key, e]))
80      const shared = (b.edits || []).filter((e) => now - e.at <= overlapMs && recentA.has(e.key))
81      if (!shared.length) continue
82      const lastA = Math.max(...shared.map((e) => recentA.get(e.key).at))
83      const lastB = Math.max(...shared.map((e) => e.at))
84      // the chat that touched the shared files last is the one that walked in
85      const later = lastB >= lastA ? b : a
86      const earlier = later === a ? b : a
87      // fold into the chat that's further along
88      const into = (earlier.turns || 0) >= (later.turns || 0) ? earlier : later
89      const from = into === a ? b : a
90      const untracked = shared.filter((e) => e.tracked === false || recentA.get(e.key).tracked === false).map((e) => e.path)
91      tips.push({
92        id: 'overlap:' + [a.id, b.id].sort().join('+'),
93        kind: 'overlap',
94        source: 'rule',
95        at: Math.max(lastA, lastB),
96        chats: [earlier.id, later.id],
97        files: shared.map((e) => e.path),
98        untracked,
99        actions: [...(untracked.length ? [] : [{ kind: 'worktree', target: later.id }]), { kind: 'merge', from: from.id, into: into.id }],
100      })
101    }
102  }
103  for (const c of live) {
104    if (c.state === 'working' || c.state === 'waiting') continue
105    const since = lastActive(c)
106    if (!since || !c.ctx || c.ctx < parkTokens || now - since < parkIdleMs) continue
107    tips.push({
108      id: 'park:' + c.id,
109      kind: 'park',
110      source: 'rule',
111      at: since,
112      chats: [c.id],
113      ctx: c.ctx,
114      idleMs: now - since,
115      cost: rewriteCost(c.ctx, c.model),
116      actions: [{ kind: 'handoff', target: c.id }],
117    })
118  }
119  return tips
120}
121
122// ---------- the AI pass ----------
123
124export const AI_KINDS = ['merge', 'split', 'worktree', 'park', 'focus']
125
126export const COACH_SYSTEM = `You coach one person who runs many Claude Code chats at once. You get a card for each open chat. Suggest how to organize them. Reply with JSON only, no prose and no code fence, in this shape:
127{"tips":[{"kind":"merge","chats":["c1","c2"],"into":"c2","target":"c1","title":"...","why":"..."}],"groups":[{"name":"...","chats":["c1","c2"]}]}
128
129Kinds:
130- merge: two or three chats whose prompts describe the same output (the same file, video, page, or document), or one redoing the other's work. "into" is the chat to keep (further along or more context). Chats on different parts of one project are not a merge: put them in the same group instead. A thin card (no prompts or files) is not evidence for a merge.
131- split: one chat's recent prompts started a task unrelated to its first prompt, and its context is large (over 150k tokens). "target" is that chat. Name the new task in the title.
132- worktree: two chats change files in the same project folder at the same time and could overwrite each other. "target" is the chat that should move. Never for chats that only read.
133- park: idle 60 minutes or more with over 200k tokens of context. "target" is that chat.
134- focus: too many chats working at once on unrelated things, or several waiting on the person. At most one.
135
136Rules:
137- 0 to 5 tips, most useful first. No tip beats a weak one. Never invent chats; use only the card ids.
138- The person never sees the card ids, so title and why name chats by what they do, never as c1 or c2.
139- title: under 70 characters, plain words.
140- why: one or two sentences under 200 characters that cite evidence from the cards (prompts, file names, minutes, tokens).
141- groups: put every chat in exactly one group of related work, 2 to 6 groups, names under 24 characters.
142- Write plainly. No em dashes, no hype.`
143
144// One card per chat, with short ids (c1, c2, ...) so the reply can't invent sessions
145export function coachCards(chats, now) {
146  const ids = new Map()
147  const blocks = (chats || []).map((c, i) => {
148    const sid = 'c' + (i + 1)
149    ids.set(sid, c.id)
150    const lines = [`${sid}: ${clip(chatName(c), 90)}`]
151    let state
152    if (c.state === 'working') state = `working (${c.activity || 'thinking'})`
153    else if (c.state === 'waiting') state = `waiting on the person (${c.waitingFor || 'a question'})`
154    else state = lastActive(c) ? `idle ${minutes(now - lastActive(c))}` : 'idle, no prompt yet'
155    lines.push(`  state: ${state}`)
156    const folder = String(c.cwd || '').replace(/\\/g, '/')
157    lines.push(`  folder: ${folder}${c.repo ? ` (git branch ${c.repo.branch}${c.repo.worktree ? ', in a worktree' : ''})` : ''}`)
158    lines.push(c.ctx ? `  context: ${tokens(c.ctx)} tokens, ${c.turns || 0} turns` : `  turns: ${c.turns || 0}`)
159    const prompts = (c.prompts || []).map((p) => `"${clip(p.text, 140)}"`)
160    if (prompts.length) lines.push(`  recent prompts, oldest first: ${prompts.join(' | ')}`)
161    if (c.lastDone?.text) lines.push(`  last answer began: "${clip(c.lastDone.text, 140)}"`)
162    const files = (c.edits || []).slice(-8).map((e) => relTo(e.path, c.repo?.root || c.cwd))
163    if (files.length) lines.push(`  files it changed: ${files.join(', ')}`)
164    return lines.join('\n')
165  })
166  const names = new Map((chats || []).map((c) => [c.id, chatName(c)]))
167  return { text: `${blocks.length} open chats:\n\n${blocks.join('\n\n')}`, ids, names }
168}
169
170// Change detection for the automatic pass: who's open, what they were asked,
171// what they touched, and context in 100k steps
172export function coachBasis(chats) {
173  return (chats || [])
174    .map((c) => [c.id, chatName(c), (c.prompts || []).at(-1)?.at || 0, (c.edits || []).length, Math.round((c.ctx || 0) / 100000)].join('|'))
175    .sort()
176    .join('\n')
177}
178
179function plain(text, max) {
180  return clip(String(text || '').replace(/\s*[—–]\s*/g, ', '), max)
181}
182
183// The model's JSON, checked: known kinds, real chats only, short text. Card ids
184// it slipped into the words ("c3") become the chat's name, since the person
185// never sees the cards. `names` maps a session id to its name.
186export function parseCoachReply(text, ids, names = new Map()) {
187  const words = (s, max) => plain(String(s || '').replace(/\b(c\d+)\b/g, (m) => (ids.has(m) && names.has(ids.get(m)) ? `"${clip(names.get(ids.get(m)), 40)}"` : m)), max)
188  const s = String(text || '')
189  const start = s.indexOf('{')
190  const end = s.lastIndexOf('}')
191  if (start < 0 || end <= start) return null
192  let v
193  try {
194    v = JSON.parse(s.slice(start, end + 1))
195  } catch {
196    return null
197  }
198  if (!v || typeof v !== 'object') return null
199  const real = (x) => ids.get(String(x || '').trim())
200  const tips = []
201  for (const t of Array.isArray(v.tips) ? v.tips : []) {
202    const kind = String(t?.kind || '').toLowerCase()
203    if (!AI_KINDS.includes(kind)) continue
204    const chats = [...new Set((Array.isArray(t.chats) ? t.chats : []).map(real).filter(Boolean))]
205    // nobody folds four chats into one: a merge that wide is a group, not a tip
206    const named = Array.isArray(t.chats) ? t.chats.length : 0
207    if (!chats.length || (kind === 'merge' && (chats.length < 2 || named > 3))) continue
208    const title = words(t.title, 90)
209    if (!title) continue
210    const tip = { id: `ai:${kind}:${[...chats].sort().join('+')}`, kind, source: 'ai', chats, title, why: words(t.why, 260) }
211    if (kind === 'merge') tip.into = chats.includes(real(t.into)) ? real(t.into) : chats[0]
212    if (kind === 'worktree' || kind === 'split' || kind === 'park') tip.target = chats.includes(real(t.target)) ? real(t.target) : chats[chats.length - 1]
213    tips.push(tip)
214    if (tips.length >= 5) break
215  }
216  const groups = []
217  const placed = new Set()
218  for (const g of Array.isArray(v.groups) ? v.groups : []) {
219    const chats = (Array.isArray(g?.chats) ? g.chats : []).map(real).filter((id) => id && !placed.has(id))
220    const name = plain(g?.name, 24)
221    if (!name || !chats.length) continue
222    chats.forEach((id) => placed.add(id))
223    groups.push({ name, chats })
224    if (groups.length >= 8) break
225  }
226  return { tips, groups }
227}
228
229// What each tip's buttons do
230export function tipActions(tip) {
231  if (tip.actions) return tip.actions
232  if (tip.kind === 'merge') return tip.chats.filter((id) => id !== tip.into).slice(0, 2).map((from) => ({ kind: 'merge', from, into: tip.into }))
233  if (tip.kind === 'worktree') return [{ kind: 'worktree', target: tip.target }]
234  if (tip.kind === 'split') return [{ kind: 'split', target: tip.target, topic: tip.title }]
235  if (tip.kind === 'park') return [{ kind: 'handoff', target: tip.target }]
236  return []
237}
238
239// Rule tips are facts and come first. An AI tip about chats that closed, or one
240// a rule already covers (same chats), is dropped.
241export function mergeTips(rule, ai, liveIds, dismissed) {
242  const live = new Set(liveIds)
243  const done = dismissed || {}
244  const covered = new Set(rule.map((t) => [...t.chats].sort().join('+')))
245  const out = [...rule]
246  for (const t of ai || []) {
247    if (!t.chats.every((id) => live.has(id))) continue
248    if (covered.has([...t.chats].sort().join('+')) && t.kind !== 'split' && t.kind !== 'focus') continue
249    out.push(t)
250  }
251  return out.filter((t) => !done[t.id])
252}
253
254// ---------- words on screen ----------
255
256const TAGS = { overlap: 'SAME FILES', park: 'PARK', merge: 'MERGE', split: 'SPLIT', worktree: 'WORKTREE', focus: 'FOCUS' }
257
258export function describeTip(tip, byId, now) {
259  const name = (id, n = 40) => `"${clip(chatName(byId.get(id) || { title: 'a closed chat' }), n)}"`
260  const tag = TAGS[tip.kind] || tip.kind.toUpperCase()
261  if (tip.kind === 'overlap') {
262    const files = tip.files.map(basename)
263    const list = files.length > 2 ? `${files.slice(0, 2).join(', ')} and ${files.length - 2} more` : files.join(' and ')
264    const why = tip.untracked.length
265      ? `Two chats changing the same files overwrite each other. ${basename(tip.untracked[0])} isn't tracked in git, so a worktree wouldn't carry it: fold one chat into the other instead.`
266      : 'Two chats changing the same files overwrite each other. Give the newer chat its own worktree, or fold one chat into the other.'
267    return { tag, title: `${name(tip.chats[0], 32)} and ${name(tip.chats[1], 32)} both edited ${list}`, why, when: tip.at }
268  }
269  if (tip.kind === 'park' && tip.source === 'rule') {
270    return {
271      tag,
272      title: `${name(tip.chats[0])} holds ${tokens(tip.ctx)} of context and has sat idle ${minutes(tip.idleMs)}`,
273      why: `Its prompt cache has expired, so the next message there rewrites it (about ${usd(tip.cost)}). If you're done with it, close it. If not, hand it off now so the next step starts small.`,
274      when: 0,
275    }
276  }
277  return { tag, title: tip.title, why: tip.why, when: 0 }
278}
279
280export function actionLabel(action, byId) {
281  const name = (id) => `"${clip(chatName(byId.get(id) || { title: 'a closed chat' }), 24)}"`
282  if (action.kind === 'worktree') return `worktree ${name(action.target)}`
283  if (action.kind === 'merge') return `merge into ${name(action.into)}`
284  if (action.kind === 'handoff') return `hand off ${name(action.target)}`
285  if (action.kind === 'split') return `split ${name(action.target)}`
286  return action.kind
287}
288
289// The prompt a button puts in a chat's box. `relay` is what another chat sends
290// on when the target runs older mod code and can't take it directly.
291export function actionPrompt(action, tip, byId, transcript) {
292  const name = (id) => `"${clip(chatName(byId.get(id) || { title: 'another chat' }), 80)}"`
293  if (action.kind === 'worktree') {
294    const other = tip.chats.find((id) => id !== action.target)
295    const files = (tip.files || []).map((f) => relTo(f, byId.get(action.target)?.cwd))
296    const text = `Another open Claude Code chat (${name(other)}) is changing the same files as this one${files.length ? `: ${files.join(', ')}` : ''}. Move the rest of this chat's work into a git worktree so the two stop overwriting each other. First list the files you already changed here (they stay in the main checkout), then use the EnterWorktree tool and continue there. Tell me the worktree's path and branch.`
297    return { target: action.target, text, relay: text }
298  }
299  if (action.kind === 'merge') {
300    const where = transcript
301      ? ` Its transcript is ${transcript}. It's JSONL, so read the tail, not the whole file: its user prompts and your final answers.`
302      : ' Ask me to paste its handoff.'
303    const text = `Fold another Claude Code chat into this one. It works on the same thing: ${name(action.from)}.${where} Sum up in five bullets what it did and what's left, then carry on with that work here without redoing what's finished. When you're done, tell me it's safe to close the other chat.`
304    return { target: action.into, text, relay: text }
305  }
306  if (action.kind === 'handoff') {
307    return { target: action.target, text: '/handoff', relay: 'Run the session-handoff skill now so this chat\'s work is saved before I close it.' }
308  }
309  if (action.kind === 'split') {
310    const text = `This chat has picked up a separate task: ${action.topic}. Write a short handoff for only that part (the goal, what's done, the key files, and the next step) so I can start it in a fresh chat. Don't keep working on it here.`
311    return { target: action.target, text, relay: text }
312  }
313  return null
314}
315
316// For a chat on older mod code: ask Claude here to find it and pass the prompt on
317export function relayPrompt(targetHb, relay) {
318  return `Find my other Claude Code chat whose first prompt was "${clip(chatName(targetHb), 120)}" (search_session_transcripts or list_sessions will find it). Send it this message with SendMessage, word for word, then tell me it arrived:\n\n${relay}`
319}
320
321export function organizePrompt(groups, byId) {
322  const lines = groups.map((g) => `${g.name}:\n${g.chats.map((id) => `- "${clip(chatName(byId.get(id) || {}), 100)}"`).join('\n')}`)
323  return `Organize my Claude Code chats in the Desktop sidebar into these groups with your sidebar tools (create_group, move_sessions). Find each chat with list_sessions or search_session_transcripts by its first prompt. Show me the plan first and wait for my yes. Don't archive or delete anything.\n\n${lines.join('\n\n')}`
324}
325
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/pricing.mjs 96 lines
1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2// Edit it here, then run: node mods/_dev/sync-shared.mjs
3
4// Dollars per million tokens, from the Claude API pricing table (cached 2026-09-25).
5// Cache writes cost 1.25x input on the 5-minute TTL and 2x input on the 1-hour TTL.
6const TABLE = [
7  ['fable-5-1', { input: 10, output: 50, read: 0.25 }],
8  ['mythos-5-1', { input: 10, output: 50, read: 0.25 }],
9  ['fable-5', { input: 10, output: 50, read: 1.0 }],
10  ['opus-5-5', { input: 4, output: 20, read: 0.2 }],
11  ['opus-5', { input: 5, output: 25, read: 0.5 }],
12  ['opus-4-8', { input: 5, output: 25, read: 0.5 }],
13  ['opus-4-7', { input: 5, output: 25, read: 0.5 }],
14  ['opus-4-6', { input: 5, output: 25, read: 0.5 }],
15  ['sonnet-5-5', { input: 2, output: 10, read: 0.2 }],
16  ['sonnet-5', { input: 2, output: 10, read: 0.2 }],
17  ['sonnet-4-6', { input: 3, output: 15, read: 0.3 }],
18  ['haiku-4-5', { input: 1, output: 5, read: 0.1 }],
19]
20
21const FAMILY = {
22  fable: 'fable-5-1',
23  mythos: 'mythos-5-1',
24  opus: 'opus-5-5',
25  sonnet: 'sonnet-5-5',
26  haiku: 'haiku-4-5',
27}
28
29export function normalizeModel(model) {
30  return String(model || '')
31    .toLowerCase()
32    .replace(/^claude-/, '')
33    .replace(/\[.*?\]/g, '')
34    .replace(/-\d{8}$/, '')
35    .trim()
36}
37
38export function priceFor(model) {
39  const id = normalizeModel(model)
40  for (const [key, price] of TABLE) {
41    if (id === key || id.startsWith(key + '-') || id.startsWith(key)) {
42      // 'opus-5' must not swallow 'opus-5-5': the table lists longer ids first
43      return { id: key, ...price }
44    }
45  }
46  for (const [family, key] of Object.entries(FAMILY)) {
47    if (id.includes(family)) {
48      const found = TABLE.find(([k]) => k === key)
49      return { id: key, ...found[1] }
50    }
51  }
52  return { id: 'opus-5-5', input: 4, output: 20, read: 0.2 }
53}
54
55export function writeRate(model, ttlMinutes) {
56  const p = priceFor(model)
57  return p.input * (ttlMinutes >= 60 ? 2 : 1.25)
58}
59
60// usage: { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens }
61export function requestCost(usage, model, ttlMinutes = 60) {
62  if (!usage) return 0
63  const p = priceFor(model || usage.model)
64  const w = writeRate(model || usage.model, ttlMinutes)
65  return (
66    ((usage.input_tokens || 0) * p.input +
67      (usage.cache_read_input_tokens || 0) * p.read +
68      (usage.cache_creation_input_tokens || 0) * w +
69      (usage.output_tokens || 0) * p.output) /
70    1e6
71  )
72}
73
74export function rewriteCost(tokens, model, ttlMinutes = 60) {
75  return ((tokens || 0) * writeRate(model, ttlMinutes)) / 1e6
76}
77
78export function readCost(tokens, model) {
79  return ((tokens || 0) * priceFor(model).read) / 1e6
80}
81
82export function totalInput(usage) {
83  if (!usage) return 0
84  return (
85    (usage.input_tokens || 0) +
86    (usage.cache_read_input_tokens || 0) +
87    (usage.cache_creation_input_tokens || 0)
88  )
89}
90
91// Share of a request's input served from the cache, 0 to 1.
92export function cachedShare(usage) {
93  const total = totalInput(usage)
94  return total > 0 ? (usage.cache_read_input_tokens || 0) / total : 0
95}
96