Context, rate-limit and git band above the prompt, plus the 70% context guard

Personal dotfiles managed with GNU Stow. Ansible handles provisioning on new machines.
Theme: Catppuccin Macchiato throughout.
Provisioning uses three mechanisms — know which applies before editing a package: stow (symlink, edits round-trip to git), materialized (copied once from a template by bootstrap-dirs.sh, then drifts silently — edit the template, not the live file), and sync-pushed (one-way push by sync-agent-rules.sh, live edits are invisible to git).
| Package | Stow target | Provisioning |
|---|---|---|
zsh/ | ~/.zshrc, ~/.zprofile, ~/.zsh/ | stow |
starship/ | ~/.config/starship.toml | stow |
nvim/ | ~/.config/nvim/ | stow |
tmux/ | ~/.tmux.conf, ~/.local/bin/tmux-cht | stow |
tmuxinator/ | ~/.config/tmuxinator/dotfiles.yml | stow |
kitty/ | ~/.config/kitty/ | stow |
git/ | ~/.gitconfig | stow |
jj/ | ~/.config/jj/config.toml | stow — not currently applied on this machine |
aerospace/ | ~/.aerospace.toml | stow (macOS tiling WM) — not in the stow line above; add when used |
mouseless/ | ~/Library/Application Support/Mouseless/configs/config.yaml | stow (macOS Mouseless app) — not in the stow line above; back up the live file before first stow mouseless, restart the app after |
sketchybar/ | ~/.config/sketchybar/ | stow (macOS status bar) — not in the stow line above; add when used; sketchybar --reload after edits |
i3/ | ~/.config/i3/config | Linux-only, applied via Ansible (not macOS stow) |
claude/ | ~/.claude/ | stow (partial) — including settings.json; skills/+agents/ mix dotfiles and llm-wiki sources |
opencode/ | ~/.config/opencode/ | partial — plugins/ stow; opencode.json materialized; agents//skills/ unmanaged |
codex/ | ~/.codex/ | no-op stub today — Codex is sync-pushed via sync-agent-rules.sh, not stowed |
pi/ | ~/.pi/ | stow (extensions only); ~/.pi/agent/ runtime state is unmanaged |
herdr/ | ~/.config/herdr/config.toml | stow — not in the stow line above; back up the live file before first stow herdr; ~/.config/herdr/ logs/sockets/session-history are unmanaged runtime state; herdr server reload-config after edits |
launchd/ | ~/Library/LaunchAgents/ | stow — user LaunchAgents such as the Herdr→Sketchybar bridge |
# 1. Clone (submodules required — llm-wiki is bundled as a submodule)
git clone --recurse-submodules git@github.com:vietbui99/dotfiles.git ~/dotfiles
# 2. Install Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 3. Install packages
cd ~/dotfiles && brew bundle
# 4. Create expected directories (uses a standalone ~/repos/llm-wiki clone if present)
./scripts/bootstrap-dirs.sh
# 5. Symlink configs
./scripts/restow.sh
# 6. Sync AI tool rules (AGENTS.md + MCP servers)
./scripts/sync-agent-rules.sh
Covers: base packages, Rust toolchain, modern CLI tools, zsh, starship, tmux, neovim. Kitty only on macOS/desktop.
# Local machine
ansible-playbook ansible/site.yml --limit localhost
# Remote Debian dev server
ansible-playbook ansible/site.yml --limit dev_debian -i ansible/inventory/hosts.yml
# Remote RedHat admin server
ansible-playbook ansible/site.yml --limit admin_redhat -i ansible/inventory/hosts.yml
| Script | Purpose |
|---|---|
scripts/bootstrap-dirs.sh | Create directories, sync shared skills, and materialize default configs |
scripts/restow.sh | Restow all managed packages — single source of truth for the package list |
scripts/sync-agent-rules.sh | Sync shared/AGENTS.md and MCP servers to Claude Code, Codex, OpenCode |
scripts/agent-workflow | Attach/detach/status/doctor for per-repo Commandr/Pi/Neovim workflow |
scripts/agent-session | Legacy per-repo session inbox kept for migration compatibility |
repos/llm-wiki is a git submodule — the shared upstream for Claude Code agents, skills, and rules.
# Update llm-wiki pin to latest upstream commit
git submodule update --remote repos/llm-wiki
git add repos/llm-wiki && git commit -m "chore(submodule): bump llm-wiki"
~/repos/llm-wiki and ~/dotfiles/repos/llm-wiki are independent clones on this machine. bootstrap-dirs.sh creates a redirect to the submodule only when no standalone clone exists; it never replaces an existing clone. Claude rules, agents, and skills resolve to the standalone clone.
| Tool | Install | |
|---|---|---|
pi (pi-coding-agent) | npm install -g @earendil-works/pi-coding-agent | |
| GitHub stacked PRs | gh extension install github/gh-stack (skill synced by bootstrap-dirs.sh) | |
omp (oh-my-pi) | `curl -fsSL https://omp.sh/install \ | sh` |
rtk (Rust Token Killer) | brew install rtk (included in Brewfile) |
omp binaries land in ~/.bun/bin/ — already on PATH via .zprofile.
The pi/ stow package includes these Pi extensions:
pi-statusline.ts — Catppuccin footer statusline (dir, git, ctx%, model) with Nerd Font icons~/repos/DiffViewer/pi-extension — Pi package for the measured review gate: persistent .pi/diff-review/ drafts and submitted decisionspi-side-panel/ — persistent tmux/herdr side panel (session, DiffViewer review, verification, Commandr, Neovim, git); /panel togglespost-run-verifier.ts — owns completed-run verification (format → lint → typecheck → focused tests), inferred per-project config persisted under ~/.pi/agent/verification/projects/, max 3 automatic repair cycles; /verify, /verify-final, /verify-status, /verify-config, /verify-init.rtk.ts — transparently rewrites supported Bash commands through RTK to reduce tool-output tokens; set RTK_DISABLED=1 for passthroughpi/.pi-lens/config.json is the stow-provisioned global pi-lens config: pi-lens owns non-mutating turn-end static safety/context signals; its LSP, test, format, and autofix runners are disabled. post-run-verifier.ts alone owns format, lint, typecheck, and focused behavioral tests.
shared/AGENTS.md is one-way sync-pushed to OpenCode and Codex by scripts/sync-agent-rules.sh; edit the shared source, never the generated targets.~/.claude/plugins/known_marketplaces.json is materialized once from the llm-wiki template, then Claude Code manages it. Treat it as a deliberate local fork.~/.config/agent-workflow/config.json is materialized once from shared/agent-workflow.default.json. Treat it as a deliberate local fork; change the template for new machines and the local file for this machine.Claude Code uses the equivalent rtk hook claude PreToolUse hook from claude/.claude/settings.json, with usage instructions in ~/.claude/RTK.md.
Research tools have exclusive primary scopes:
ketch code onlyFallback is allowed only when the primary tool lacks coverage. Canonical policy lives in shared/research-tool-routing.md; bootstrap-dirs.sh syncs the scoped Firecrawl skill to ~/.agents/skills/firecrawl. It also syncs GitHub's gh-stack skill to ~/.agents/skills/gh-stack for Pi and other compatible agent harnesses.
Herdr owns the live pi, claude, and codex integration files; do not stow them. scripts/bootstrap-dirs.sh runs herdr integration install for all three. The persistent Herdr workspaces are plan (Claude) and build (Pi). com.vietbui.herdr-sketchybar-bridge subscribes to Herdr agent-state events and drives Sketchybar's agents pill; clicking it focuses Pi and opens AeroSpace workspace T.
Global defaults live in shared/agent-workflow.default.json and are copied to ~/.config/agent-workflow/config.json by scripts/bootstrap-dirs.sh. Override per repo with .agent-workflow.json or machine-locally with ignored .agent-workflow.local.json.
# Check global install state (including RTK binary + Pi extension)
scripts/agent-workflow doctor
# Attach a repo to the Commandr bus + DiffViewer sidecars + approval gate
scripts/agent-workflow attach ~/repos/example
# Inspect current bus/board state
scripts/agent-workflow status ~/repos/example
# Remove only the managed hook; keep task history by default
scripts/agent-workflow detach ~/repos/example
Use GitHub's gh stack extension for a single feature split into dependent, reviewable layers. Plan layer boundaries before coding; review and approve each layer through the existing review gate before committing it. Keep unrelated work in separate stacks.
# From a clean trunk, create the first named layer (never invoke bare init).
gh stack init feature/foundation
# Commit its reviewed changes, then add the dependent layer.
gh stack add feature/integration
# Publish draft PRs without interactive prompts.
gh stack submit --auto
# Inspect or update the stack non-interactively.
gh stack view --json
gh stack sync --remote origin
Agent invocations must use named init/add branches, submit --auto, and view --json; see docs/workflows/stacked-prs.md for recovery and review rules.
Pi is the only supported agent harness. Commandr is the task service, DiffView is the review service, and Obsidian is the human UI. Other vendors are model providers or explicit CLI bridges, not parallel harnesses.
# Pi TUI
/sessions
/review
Legacy scripts/agent-session and .agents/sessions/ remain compatibility surfaces. New workflow features should target Pi and Commandr, not add another harness-specific state store.
RTK is a rewrite-only optimization layer: it does not replace permission gates or context-mode. Inspect savings with rtk gain or rtk gain --history.
opencode/plugins/commandr-checkpoint.js and diffviewer.js are symlinks into ~/repos/Commandr and ~/repos/DiffViewer. Clone those repos first.~/.zshrc.local (not tracked).nvim/.config/nvim/.claude/ is gitignored — Claude Code writes local state there.claude/ rule files: learning.md and research.md are niche-domain rules, available at @~/.claude/rules/learning.md but not auto-loaded. @-import them in project CLAUDE.md when working in those domains.opencode/opencode.json uses hardcoded absolute paths for skills.paths and instructions — opencode does not expand ${env:HOME} in those fields.extended-keys is disabled — it breaks readline Ctrl shortcuts (C-a/e/u/k/w/r/d) in zsh. Standard key sequences work correctly without it.zsh/.zsh/functions.zsh wraps nvim to re-emit SGR reset, show-cursor, and bracketed-paste sequences after exit — nvim's terminal cleanup (rs2/RIS) wipes these and breaks Starship colors and fast-syntax-highlighting.vim.fn.exepath().hooks/register.tsx 176 lines1import { atom, read, update, type EngineInterface, type Register } from 'claude-code'
2
3import type { Usage } from '../types'
4import { drawBand } from './band'
5import { captureSession, register as registerBlast } from './blast'
6import { register as registerClear } from './clear'
7import { shortDir, shortModel, whole } from './format'
8import { toSnapshot, type GitFacts } from './git'
9import { toRepoInfo, type RepoInfo } from './repo'
10import { guardAgent, softStop, type SoftStopCheck } from './threshold'
11
12const usage = atom({ plugin: 'session-band', key: 'usage' } as const, {})
13const git = atom({ plugin: 'session-band', key: 'git' } as const, null)
14const outputStyle = atom({ plugin: 'session-band', key: 'outputStyle' } as const, null)
15const isNotified = atom({ plugin: 'session-band', key: 'isNotified' } as const, false)
16
17const asWhole = (percent: number | undefined) => (percent === undefined ? undefined : whole(percent))
18
19const measuredUsage = (context: { percent?: number }, rateLimits: readonly { kind: string; percentUsed: number }[]): Usage => ({
20 ctx: asWhole(context.percent),
21 five: asWhole(rateLimits.find(limit => limit.kind === 'five_hour')?.percentUsed),
22 seven: asWhole(rateLimits.find(limit => limit.kind === 'seven_day')?.percentUsed),
23})
24
25const refreshGit = async ($: EngineInterface) => {
26 const cwd = await $.session.cwd()
27 const run = async (args: string[]) => {
28 const r = await $.process.run(['git', '-C', cwd, '--no-optional-locks', ...args])
29 return r.exitCode === 0 ? r.stdout.trim() : undefined
30 }
31 const branch = await run(['branch', '--show-current'])
32 let facts: GitFacts = { isDirty: false }
33
34 if (branch) {
35 const [unstaged, staged, upstream, stash, gitDir, common] = await Promise.all([
36 $.process.run(['git', '-C', cwd, '--no-optional-locks', 'diff', '--quiet']),
37 $.process.run(['git', '-C', cwd, '--no-optional-locks', 'diff', '--cached', '--quiet']),
38 run(['rev-parse', '--abbrev-ref', `${branch}@{upstream}`]),
39 run(['stash', 'list']),
40 run(['rev-parse', '--absolute-git-dir']),
41 run(['rev-parse', '--path-format=absolute', '--git-common-dir']),
42 ])
43 const counts = upstream ? await run(['rev-list', '--left-right', '--count', `${upstream}...HEAD`]) : undefined
44 facts = { branch, isDirty: unstaged.exitCode !== 0 || staged.exitCode !== 0, upstream, counts, stash, gitDir, common }
45 }
46
47 await update($, git, () => toSnapshot(facts))
48}
49
50const resolveRepo = async ($: EngineInterface): Promise<RepoInfo> => {
51 const out = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'])
52 const [top, common] = out.stdout.trim().split('\n')
53
54 if (out.exitCode !== 0 || !top || !common) return {}
55
56 const taskId = (await $.fs.read(`${top}/.agent-task-id`).catch(() => '')).trim() || undefined
57
58 return toRepoInfo(common, taskId)
59}
60
61const settingStyle = async ($: EngineInterface): Promise<string | null> => {
62 const settings = await $.settings.read()
63 const style = settings.outputStyle
64
65 return typeof style === 'string' ? style : null
66}
67
68const styleForBand = (style: string | null) => (style === 'default' ? null : style)
69
70export const register: Register = on => {
71 registerBlast(on)
72 registerClear(on)
73
74 // The settings fallback only matters before the first prompt.compose names the style.
75 let hasComposed = false
76 let settingsStyle: Promise<string | null> | undefined
77
78 on('session.start', async ($, e, next) => {
79 captureSession(e.isInteractive)
80 const current = await $.session.usage()
81 await Promise.all([
82 update($, usage, () => measuredUsage(current.context, current.rateLimits)),
83 refreshGit($),
84 ])
85
86 return next(e)
87 })
88
89 on('session.measure', async ($, e, next) => {
90 await update($, usage, () => measuredUsage(e.context, e.rateLimits))
91
92 return next(e)
93 })
94
95 on('turn.complete', async ($, e, next) => {
96 await refreshGit($)
97
98 return next(e)
99 })
100
101 on('tool.call', async ($, e, next) => {
102 const { context } = await $.session.usage()
103 const r = await next(e)
104
105 // Not awaited: the band catches up on redraw, and the tool result must not wait on ~7 git calls.
106 if (e.tool === 'Bash') void refreshGit($).catch(() => undefined)
107 if ('deny' in r) return r
108
109 return (async () => {
110 const { taskId, statePath } = await resolveRepo($)
111 const lastSavedMs = statePath
112 ? await $.fs.stat(statePath).then(stat => stat.mtimeMs).catch(() => undefined)
113 : undefined
114 const filePath = 'file_path' in e && typeof e.file_path === 'string' ? e.file_path : undefined
115 const check: SoftStopCheck = {
116 percent: asWhole(context.percent),
117 tool: e.tool,
118 filePath,
119 command: e.tool === 'Bash' ? e.command : undefined,
120 nowMs: await $.clock.now(),
121 isNotified: await read($, isNotified),
122 taskId,
123 lastSavedMs,
124 }
125 const stop = softStop(check)
126
127 if (stop.action === 'clear') {
128 await update($, isNotified, () => false)
129 } else if (stop.action === 'notify') {
130 await update($, isNotified, () => true)
131
132 return { ...r, context: [...(r.context ?? []), stop.text] }
133 }
134
135 return r
136 })().catch(() => r)
137 })
138
139 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
140 const { context } = await $.session.usage()
141 const denial = guardAgent(asWhole(context.percent), e.tool)
142
143 return denial ? { deny: denial } : next(e)
144 }).catch(($, e, next) => next.called ? next(e) : { deny: 'session-band guard failed' })
145
146 on('prompt.compose', async ($, e, next) => {
147 hasComposed = true
148 await update($, outputStyle, () => e.outputStyle?.name ?? null)
149
150 return next(e)
151 })
152
153 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
154 if (e.props.hasSurvey) return next(e)
155
156 const [cwd, home, model, currentUsage, currentGit, capturedStyle] = await Promise.all([
157 $.session.cwd(),
158 $.env.get('HOME'),
159 $.session.model(),
160 read($, usage),
161 read($, git),
162 read($, outputStyle),
163 ])
164 if (!hasComposed) settingsStyle ??= settingStyle($).catch(() => null)
165 const style = styleForBand(capturedStyle ?? (hasComposed ? null : await settingsStyle))
166
167 return drawBand($.ui.resolve(e), {
168 dir: shortDir(cwd, home),
169 model: shortModel(model),
170 usage: currentUsage,
171 git: currentGit,
172 style,
173 })
174 })
175}
176hooks/band.tsx 35 lines1import type { EngineInterface } from 'claude-code'
2
3import type { GitSnapshot, Usage } from '../types'
4import { ctxTone, limitTone, PALETTE, type Tone } from './palette'
5
6type Elements = ReturnType<EngineInterface['ui']['resolve']>
7export type BandData = { dir: string; model: string; usage: Usage; git: GitSnapshot | null; style: string | null }
8
9// Order matches the old status line: dir | git | ctx | 5h | 7d | [style] | (model)
10export const drawBand = ({ Box, Text }: Elements, d: BandData) => {
11 const tone = (t: Tone, text: string) => <Text color={PALETTE[t]}>{text}</Text>
12 const pct = (label: string, v: number | undefined, toneOf: (n: number) => Tone) =>
13 v === undefined ? null : <Text color={PALETTE[toneOf(v)]}> {label}:{v}%</Text>
14 const g = d.git
15
16 return (
17 <Box>
18 <Text>
19 {tone('peach', d.dir)}
20 {g && tone(g.isDirty ? 'yellow' : 'green', ` ${g.branch}${g.isDirty ? '*' : ''}`)}
21 {g && !g.hasUpstream && tone('muted', ' local')}
22 {g && g.ahead > 0 && tone('green', ` ahd:${g.ahead}`)}
23 {g && g.behind > 0 && tone('yellow', ` bhd:${g.behind}`)}
24 {g && g.stash > 0 && tone('peach', ` stsh:${g.stash}`)}
25 {g?.worktree && tone('muted', ` [wt:${g.worktree}]`)}
26 {pct('ctx', d.usage.ctx, ctxTone)}
27 {pct('5h', d.usage.five, limitTone)}
28 {pct('7d', d.usage.seven, limitTone)}
29 {d.style && tone('muted', ` [${d.style}]`)}
30 {tone('mauve', ` (${d.model})`)}
31 </Text>
32 </Box>
33 )
34}
35hooks/blast.tsx 155 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { classify, type Classification, type CommandFact } from './classify'
4import { preview, type PreviewFacts, type PreviewTarget } from './preview'
5
6const hasGlob = (value: string) => /[*?[]/.test(value)
7const cannotExpandGlob = (value: string) => value.includes('**') || value.includes('{') || value.includes('}')
8
9const inClass = (source: string, value: string) => {
10 for (let index = 0; index < source.length; index += 1) {
11 const start = source[index] ?? ''
12 const end = source[index + 2]
13 if (source[index + 1] === '-' && end !== undefined) {
14 if (start <= value && value <= end) return true
15 index += 2
16 } else if (start === value) return true
17 }
18 return false
19}
20
21const matches = (pattern: string, name: string) => {
22 let patternIndex = 0
23 let nameIndex = 0
24 let starIndex = -1
25 let retryNameIndex = 0
26
27 while (nameIndex < name.length) {
28 const token = pattern[patternIndex]
29 if (token === '*') {
30 starIndex = patternIndex
31 patternIndex += 1
32 retryNameIndex = nameIndex
33 } else if (token === '?' || token === name[nameIndex]) {
34 patternIndex += 1
35 nameIndex += 1
36 } else if (token === '[') {
37 const close = pattern.indexOf(']', patternIndex + 1)
38 if (close !== -1 && inClass(pattern.slice(patternIndex + 1, close), name[nameIndex] ?? '')) {
39 patternIndex = close + 1
40 nameIndex += 1
41 } else if (starIndex !== -1) {
42 patternIndex = starIndex + 1
43 retryNameIndex += 1
44 nameIndex = retryNameIndex
45 } else return false
46 } else if (starIndex !== -1) {
47 patternIndex = starIndex + 1
48 retryNameIndex += 1
49 nameIndex = retryNameIndex
50 } else return false
51 }
52
53 while (pattern[patternIndex] === '*') patternIndex += 1
54 return patternIndex === pattern.length
55}
56
57const globParts = (target: string) => {
58 const slash = target.lastIndexOf('/')
59 if (slash === -1) return { directory: '.', pattern: target, prefix: '' }
60 const directory = slash === 0 ? '/' : target.slice(0, slash)
61 return { directory, pattern: target.slice(slash + 1), prefix: target.slice(0, slash + 1) }
62}
63
64const tracked = async ($: EngineInterface, path: string) => {
65 const result = await $.process.run(['git', 'ls-files', '--error-unmatch', '--', path]).catch(() => undefined)
66 return result?.exitCode === 0
67}
68
69const expandedTargets = async ($: EngineInterface, target: string): Promise<{ targets: PreviewTarget[]; unexpanded?: string }> => {
70 if (!hasGlob(target)) {
71 const stat = await $.fs.stat(target).catch(() => undefined)
72 if (!stat) return { targets: [{ path: target }] }
73 return { targets: [{ path: target, bytes: stat.size, tracked: await tracked($, target) }] }
74 }
75 if (cannotExpandGlob(target)) return { targets: [], unexpanded: target }
76
77 const { directory, pattern, prefix } = globParts(target)
78 const entries = await $.fs.list(directory).catch(() => undefined)
79 if (!entries) return { targets: [], unexpanded: target }
80
81 return {
82 targets: await Promise.all(entries.flatMap(entry => {
83 if (!matches(pattern, entry.name)) return []
84 const path = `${prefix}${entry.name}`
85 return [(async () => ({ path, bytes: entry.size, tracked: await tracked($, path) }))()]
86 })),
87 }
88}
89
90const dryRun = async ($: EngineInterface, commands: readonly CommandFact[]) => {
91 const command = commands.find(item => item.program === 'git' && item.args[0] === 'clean')
92 if (command) {
93 const result = await $.process.run(['git', 'clean', '-n', ...command.args.slice(1)]).catch(() => undefined)
94 if (result) return { label: 'git clean -n', output: result.stdout || result.stderr }
95 }
96
97 if (commands.some(item => item.program === 'git' && ['reset', 'checkout', 'restore'].includes(item.args[0] ?? ''))) {
98 const result = await $.process.run(['git', 'diff', '--stat']).catch(() => undefined)
99 if (result) return { label: 'git diff --stat', output: result.stdout || result.stderr }
100 }
101
102 const rsync = commands.find(item => item.program === 'rsync')
103 if (rsync) {
104 const result = await $.process.run(['rsync', '-n', ...rsync.args]).catch(() => undefined)
105 if (result) return { label: 'rsync -n', output: result.stdout || result.stderr }
106 }
107
108 return undefined
109}
110
111const previewFacts = async ($: EngineInterface, command: string, result: Classification): Promise<PreviewFacts> => {
112 const expanded = await Promise.all(result.commands.flatMap(item => item.targets).map(target => expandedTargets($, target)))
113 const targets = expanded.flatMap(item => item.targets)
114 const totalBytes = targets.reduce((total, target) => total + (target.bytes ?? 0), 0)
115
116 return {
117 command,
118 reason: result.reason ?? 'this command may have a broad effect',
119 targets,
120 totalBytes: targets.some(target => target.bytes !== undefined) ? totalBytes : undefined,
121 unexpandedGlobs: expanded.flatMap(item => item.unexpanded ? [item.unexpanded] : []),
122 dryRun: await dryRun($, result.commands),
123 }
124}
125
126const refusal = (command: string, reason: string) => `Bash command ${JSON.stringify(command)} was refused by blast-radius: ${reason}.`
127
128let isInteractive = false
129
130export const captureSession = (interactive: boolean) => {
131 isInteractive = interactive
132}
133
134export const register: Register = on => {
135 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
136 const result = classify(e.command)
137 if (result.tier === 'pass') return next(e)
138 if (result.tier === 'deny') return { deny: refusal(e.command, result.reason ?? 'it is unconditionally dangerous') }
139 if (!isInteractive) return { deny: refusal(e.command, 'nobody is available to answer the blast-radius prompt') }
140
141 const facts = await previewFacts($, e.command, result).catch(() => ({
142 command: e.command,
143 reason: result.reason ?? 'this command may have a broad effect',
144 }))
145 try {
146 const answer = await $.ui.ask(`${preview(facts)}\n\nAllow this Bash command?`, ['Allow', 'Deny'])
147 return answer === 'Allow'
148 ? next(e)
149 : { deny: refusal(e.command, 'the blast-radius prompt was not allowed') }
150 } catch {
151 return { deny: refusal(e.command, 'the blast-radius prompt was dismissed') }
152 }
153 }).catch(($, e, next) => next.called ? next(e) : { deny: 'Bash command was refused by blast-radius: the guard failed closed.' })
154}
155hooks/clear.tsx 188 lines1import {
2 atom,
3 read,
4 update,
5 type CommandRunInput,
6 type CommandRunResult,
7 type EngineInterface,
8 type Next,
9 type Register,
10} from 'claude-code'
11
12import { toRepoInfo, type RepoInfo } from './repo'
13import {
14 buildDigest,
15 extractSavedPath,
16 isRealWork,
17 parseReply,
18 RECENT_SAVE_MS,
19 summaryPrompt,
20 SYSTEM_PROMPT,
21 taskStateFile,
22 type ParsedSummary,
23} from './summary'
24
25const edits = atom({ plugin: 'session-band', key: 'edits' } as const, 0)
26const prompts = atom({ plugin: 'session-band', key: 'prompts' } as const, 0)
27
28const REFUSED =
29 'Clear refused: the transcript is already gone, nothing to summarise. Run save-session first, or re-run /clear.'
30const CANCELLED = 'Clear cancelled: nothing was saved.'
31const SAVE_FAILED = 'Clear cancelled: the save step failed.'
32
33const reset = async ($: EngineInterface) => {
34 await update($, edits, () => 0)
35 await update($, prompts, () => 0)
36}
37
38const resolveRepo = async ($: EngineInterface): Promise<RepoInfo> => {
39 const out = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'])
40 const [top, common] = out.stdout.trim().split('\n')
41
42 if (out.exitCode !== 0 || !top || !common) return {}
43
44 const taskId = (await $.fs.read(`${top}/.agent-task-id`).catch(() => '')).trim() || undefined
45
46 return toRepoInfo(common, taskId)
47}
48
49const sessionsDir = async ($: EngineInterface): Promise<string | undefined> => {
50 const out = await $.process.run(['agent-session', 'path'])
51 return out.exitCode === 0 && out.stdout.trim() !== '' ? out.stdout.trim() : undefined
52}
53
54// A save in the last five minutes counts as "already saved by hand": the task
55// state file's mtime in a task worktree, else the newest file in the sessions
56// directory. (The non-task statePath is .claude/session-state.md, which the CLI
57// maintains, so it is not the signal here.)
58const recentlySaved = async ($: EngineInterface, repo: RepoInfo, nowMs: number): Promise<boolean> => {
59 if (repo.taskId !== undefined && repo.statePath !== undefined) {
60 const stat = await $.fs.stat(repo.statePath).catch(() => undefined)
61 return stat !== undefined && nowMs - stat.mtimeMs <= RECENT_SAVE_MS
62 }
63
64 const dir = await sessionsDir($)
65 if (dir === undefined) return false
66
67 const entries = await $.fs.list(dir).catch(() => undefined)
68 if (entries === undefined) return false
69
70 let newest: number | undefined
71 for (const entry of entries) {
72 // Only session files count; index.json is rewritten by any save, by any harness.
73 if (entry.kind === 'file' && entry.name.endsWith('.md') && (newest === undefined || entry.mtimeMs > newest)) {
74 newest = entry.mtimeMs
75 }
76 }
77
78 return newest !== undefined && nowMs - newest <= RECENT_SAVE_MS
79}
80
81const saveSession = async ($: EngineInterface, repo: RepoInfo, summary: ParsedSummary): Promise<string> => {
82 // Task worktrees write the claimed state file; the orchestrator goes through
83 // the agent-session CLI, which resolves the sessions directory itself.
84 if (repo.statePath !== undefined && repo.taskId !== undefined) {
85 await $.fs.write(repo.statePath, taskStateFile(repo.taskId, summary.body))
86 return repo.statePath
87 }
88
89 const root = await $.session.root()
90 const saved = await $.process.run(
91 ['agent-session', 'save', '--harness', 'cc', '--goal', summary.goal, '--stdin'],
92 { cwd: root, stdin: summary.body, timeoutMs: 30_000 },
93 )
94 if (saved.exitCode !== 0) throw new Error(`agent-session exit ${saved.exitCode}`)
95
96 const path = extractSavedPath(saved.stdout)
97 if (path !== undefined) return path
98
99 const active = await $.process.run(['agent-session', 'active'])
100 if (active.exitCode === 0 && active.stdout.trim() !== '') return active.stdout.trim()
101
102 throw new Error('agent-session printed no path')
103}
104
105const fail = async (
106 $: EngineInterface,
107 e: CommandRunInput,
108 next: Next<'command.run'>,
109 reason: string,
110): Promise<CommandRunResult> => {
111 // A plugin or headless run has nobody to answer; refuse rather than hang.
112 if (e.origin.kind !== 'composer') return { text: CANCELLED }
113
114 try {
115 const answer = await $.ui.ask(`Save failed (${reason}). Clear anyway?`, ['Clear anyway', 'Cancel'])
116 if (answer === 'Clear anyway') {
117 await reset($)
118 return next(e)
119 }
120 return { text: CANCELLED }
121 } catch {
122 return { text: CANCELLED }
123 }
124}
125
126export const register: Register = on => {
127 // The module's own counters: real work is a successful edit tool or enough
128 // prompts. The transcript may already be wiped by the time /clear runs.
129 on('prompt.submit', async ($, e, next) => {
130 await update($, prompts, n => n + 1)
131 return next(e)
132 })
133
134 on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
135 const result = await next(e)
136 if (!('deny' in result)) {
137 await update($, edits, n => n + 1)
138 }
139 return result
140 })
141
142 on('command.run', { command: 'clear' }, async ($, e, next) => {
143 const [editCount, promptCount] = await Promise.all([read($, edits), read($, prompts)])
144 if (!isRealWork({ edits: editCount, prompts: promptCount })) {
145 await reset($)
146 return next(e)
147 }
148
149 const repo = await resolveRepo($)
150 const nowMs = await $.clock.now()
151 if (await recentlySaved($, repo, nowMs)) {
152 await reset($)
153 return next(e)
154 }
155
156 const messages = await $.session.messages()
157 if (messages.length === 0) {
158 // Counters say work happened but the transcript is gone: refuse, and do
159 // not reset, so a later save + /clear still reaches the recent-save skip.
160 return { text: REFUSED }
161 }
162
163 const reply = await $.model.complete({
164 model: 'sonnet',
165 system: SYSTEM_PROMPT,
166 prompt: summaryPrompt(buildDigest(messages)),
167 maxTokens: 2000,
168 effort: 'low',
169 timeoutMs: 60_000,
170 })
171
172 const summary = reply.isAnswered ? parseReply(reply.text) : undefined
173 if (summary === undefined) {
174 return fail($, e, next, reply.isAnswered ? 'the summary was not parseable' : `model ${reply.reason}`)
175 }
176
177 try {
178 const path = await saveSession($, repo, summary)
179 await reset($)
180 // The answer's text, not a toast: /clear ends the session and the toast went with it.
181 const result = await next(e)
182 return { ...result, text: `Session saved: ${path}` }
183 } catch (error) {
184 return fail($, e, next, String(error))
185 }
186 }).catch(($, e, next) => next.called ? next(e) : { text: SAVE_FAILED })
187}
188hooks/format.ts 14 lines1// Last two path segments, with ~ for home; a project root shows its basename alone.
2export const shortDir = (cwd: string, home: string | undefined): string => {
3 if (home && cwd === home) return '~'
4 const rel = home && cwd.startsWith(`${home}/`) ? cwd.slice(home.length + 1) : undefined
5 const parts = (rel ?? cwd).split('/').filter(Boolean)
6 if (rel !== undefined && parts.length <= 1) return rel
7 return parts.slice(-2).join('/')
8}
9
10// claude-opus-5-5 -> opus-5-5, as the status line did with display names.
11export const shortModel = (model: string): string => model.replace(/^claude-/, '')
12
13export const whole = (pct: number): number => Math.round(pct)
14hooks/git.ts 35 lines1import type { GitSnapshot } from '../types'
2
3// Raw outputs of the git calls register.tsx makes. The engine does not let `$` cross an
4// import, so the process calls stay in register.tsx and only this parsing lives here.
5export type GitFacts = {
6 branch?: string
7 isDirty: boolean
8 upstream?: string
9 counts?: string
10 stash?: string
11 gitDir?: string
12 common?: string
13}
14
15// Same facts as the retired shell line: branch, dirty, ahead/behind, stash, worktree.
16export const toSnapshot = (f: GitFacts): GitSnapshot | null => {
17 if (!f.branch) return null
18
19 const [behind = 0, ahead = 0] = (f.counts || '').split(/\s+/).map(Number)
20 const isLinked = !!f.gitDir && !!f.common && f.gitDir !== f.common
21 // A linked worktree's git dir is <main>/.git/worktrees/<name>; the shell script took
22 // the parent of that and so always printed "worktrees".
23 const worktree = isLinked ? f.gitDir?.split('/').pop()?.replace(/^worktree-/, '') : undefined
24
25 return {
26 branch: f.branch,
27 isDirty: f.isDirty,
28 ahead,
29 behind,
30 hasUpstream: !!f.upstream,
31 stash: f.stash ? f.stash.split('\n').length : 0,
32 worktree,
33 }
34}
35hooks/repo.ts 16 lines1export type RepoInfo = { taskId?: string; statePath?: string }
2
3// Mirrors the shell hook: an agent worktree carries .agent-task-id, and its
4// state lives in the main repo's .agents/claimed/, else .claude/session-state.md.
5// `common` is the absolute git-common-dir; its dirname is the main repo.
6export const toRepoInfo = (common: string, taskId: string | undefined): RepoInfo => {
7 const mainRepo = common.slice(0, common.lastIndexOf('/'))
8
9 return {
10 taskId,
11 statePath: taskId
12 ? `${mainRepo}/.agents/claimed/${taskId}.state.md`
13 : `${mainRepo}/.claude/session-state.md`,
14 }
15}
16hooks/threshold.ts 40 lines1import { DENY_AGENT, firstCrossing, stillOver } from './messages'
2
3export const THRESHOLD = 70
4export const SAVE_DEBOUNCE_MS = 5 * 60 * 1000
5
6const SAVE_PATHS = ['session-state.md', '.state.md', 'agents/claimed/', 'memory/', 'MEMORY.md', 'log.md']
7const SAVE_COMMANDS = ['session-state.md', 'agents/claimed/', '.state.md']
8
9export type SoftStopCheck = {
10 percent?: number
11 tool: string
12 filePath?: string
13 command?: string
14 lastSavedMs?: number
15 nowMs: number
16 isNotified: boolean
17 taskId?: string
18}
19
20export type SoftStop = { action: 'pass' } | { action: 'clear' } | { action: 'notify'; text: string }
21
22// PreToolUse half: only Agent spawns are refused, so save workflows never stall.
23export const guardAgent = (percent: number | undefined, tool: string): string | undefined =>
24 tool === 'Agent' && percent !== undefined && percent >= THRESHOLD ? DENY_AGENT : undefined
25
26const isSaveCritical = (c: SoftStopCheck): boolean =>
27 SAVE_PATHS.some(p => c.filePath?.includes(p)) ||
28 (c.tool === 'Bash' && SAVE_COMMANDS.some(p => c.command?.includes(p)))
29
30// PostToolUse half: one directive per crossing, a shorter re-fire while still over.
31export const softStop = (c: SoftStopCheck): SoftStop => {
32 if (c.percent === undefined) return { action: 'pass' }
33 if (c.percent < THRESHOLD) return { action: 'clear' }
34 if (isSaveCritical(c)) return { action: 'pass' }
35 // A save in the last 5 minutes is probably a post-clear false positive.
36 if (c.lastSavedMs !== undefined && c.nowMs - c.lastSavedMs <= SAVE_DEBOUNCE_MS) return { action: 'pass' }
37
38 return { action: 'notify', text: (c.isNotified ? stillOver : firstCrossing)(c.taskId) }
39}
40hooks/palette.ts 20 lines1// Gruvbox Material Dark Soft, the retired shell line's ANSI-256 values as hex.
2export const PALETTE = {
3 peach: '#d7875f',
4 green: '#a9b665',
5 teal: '#87afaf',
6 mauve: '#d787af',
7 yellow: '#d8a657',
8 red: '#d75f5f',
9 muted: '#878787',
10} as const
11
12export type Tone = keyof typeof PALETTE
13
14// Context warns early (the threshold guard fires at 70); limits warn late.
15export const ctxTone = (pct: number): Tone =>
16 pct >= 70 ? 'red' : pct >= 40 ? 'yellow' : 'green'
17
18export const limitTone = (pct: number): Tone =>
19 pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green'
20hooks/classify.ts 359 lines1export type Tier = 'pass' | 'ask' | 'deny'
2
3type Separator = ';' | '&&' | '||' | '|' | '&' | '\n'
4
5type Word = {
6 value: string
7 dynamic: boolean
8 // The word embeds a quoted-heredoc substitution, replaced by a literal placeholder.
9 heredoc?: true
10}
11
12export type CommandFact = {
13 command: string
14 program?: string
15 args: string[]
16 targets: string[]
17}
18
19export type Classification = {
20 tier: Tier
21 reason?: string
22 commands: CommandFact[]
23}
24
25type Segment = {
26 words: Word[]
27 separator?: Separator
28}
29
30type Tokenized = {
31 segments: Segment[]
32 cannotAnalyze?: boolean
33}
34
35const SYSTEM_DIRECTORY = /^\/(?:usr|etc|bin|System|Users)\/?$/
36const SHELL = new Set(['sh', 'bash', 'zsh'])
37const TIER_ORDER: Record<Tier, number> = { pass: 0, ask: 1, deny: 2 }
38
39const basename = (value: string) => value.split('/').filter(Boolean).at(-1) ?? value
40
41const isAssignment = (value: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(value)
42
43const hasRecursiveFlag = (args: readonly string[]) => args.some(arg => arg === '--recursive' || /^-[^-]*[rR]/.test(arg))
44
45const hasForceFlag = (args: readonly string[]) => args.some(arg => arg === '--force' || arg === '--force-with-lease' || /^-[^-]*f/.test(arg))
46
47const nonOptions = (args: readonly string[]) => {
48 const values: string[] = []
49 let afterDoubleDash = false
50 for (const arg of args) {
51 if (arg === '--') {
52 afterDoubleDash = true
53 continue
54 }
55 if (afterDoubleDash || !arg.startsWith('-') || arg === '-') values.push(arg)
56 }
57 return values
58}
59
60const stripLauncher = (words: readonly Word[]): Word[] => {
61 let remaining = [...words]
62
63 while (remaining.length > 0) {
64 while (remaining[0] && isAssignment(remaining[0].value)) remaining = remaining.slice(1)
65 const launcher = basename(remaining[0]?.value ?? '')
66
67 if (launcher === 'rtk') {
68 remaining = remaining.slice(1)
69 if (remaining[0]?.value === 'proxy') remaining = remaining.slice(1)
70 continue
71 }
72
73 if (launcher === 'command' || launcher === 'builtin' || launcher === 'exec' || launcher === 'nohup' || launcher === 'time') {
74 remaining = remaining.slice(1)
75 while (remaining[0]?.value.startsWith('-')) remaining = remaining.slice(1)
76 continue
77 }
78
79 if (launcher === 'env') {
80 remaining = remaining.slice(1)
81 while (remaining[0] && (remaining[0].value.startsWith('-') || isAssignment(remaining[0].value))) remaining = remaining.slice(1)
82 continue
83 }
84
85 if (launcher === 'sudo') {
86 remaining = remaining.slice(1)
87 while (remaining[0]?.value.startsWith('-')) {
88 const option = remaining[0].value
89 remaining = remaining.slice(1)
90 if (['-u', '-g', '-h', '-C', '--user', '--group', '--host', '--chdir', '--close-from', '--prompt', '--role', '--type'].includes(option)) {
91 remaining = remaining.slice(1)
92 }
93 }
94 continue
95 }
96
97 if (launcher === 'xargs') {
98 remaining = remaining.slice(1)
99 while (remaining[0]?.value.startsWith('-')) {
100 const option = remaining[0].value
101 remaining = remaining.slice(1)
102 if (['-a', '-d', '-E', '-e', '-I', '-i', '-L', '-l', '-n', '-P', '-s', '--arg-file', '--delimiter', '--eof', '--replace', '--max-lines', '--max-args', '--max-procs', '--max-chars'].includes(option)) {
103 remaining = remaining.slice(1)
104 }
105 }
106 continue
107 }
108
109 break
110 }
111
112 return remaining
113}
114
115const WHOLE_TREE = new Set(['/', '/*', '~', '~/', '~/*', '$HOME', '$HOME/', '$HOME/*'])
116
117const isAbsoluteOrHome = (target: string) =>
118 target.startsWith('/') || target === '~' || target.startsWith('~/') || target === '$HOME' || target.startsWith('$HOME/')
119
120// Deny the whole tree or a top-level system directory itself. Anything below
121// them is a question, unless it is the old hook's `rm -rf <absolute or home>`.
122const isDangerousRmTarget = (target: string, recursiveAndForce: boolean) =>
123 WHOLE_TREE.has(target) || SYSTEM_DIRECTORY.test(target) || (recursiveAndForce && isAbsoluteOrHome(target))
124
125const isMainRef = (value: string) => {
126 const destination = value.replace(/^\+/, '').split(':').at(-1) ?? ''
127 const branch = destination.replace(/^refs\/heads\//, '')
128
129 return branch === 'main' || branch === 'master'
130}
131
132const isShell = (program: string | undefined) => program !== undefined && SHELL.has(program)
133
134// `$(cat <<'TAG' ... TAG)` with a quoted tag expands nothing: the body is literal text.
135// Returns how many characters it spans, or undefined when it is anything else, including a
136// body holding a line equal to the tag (the shell would end the heredoc there and run the rest).
137const HEREDOC = /^\$\(cat <<'(\w+)'\n([\s\S]*?)\n\1[ \t]*\n?[ \t]*\)/
138
139const literalHeredoc = (rest: string): number | undefined => {
140 const match = HEREDOC.exec(rest)
141 if (!match) return undefined
142
143 return (match[2] ?? '').split('\n').includes(match[1] ?? '') ? undefined : match[0].length
144}
145
146// Only a commit, tag or note message, or a gh pr/issue/release body, may carry one: anywhere
147// else the text could be run (`bash -c`, `python -c`, `git rebase --exec`).
148const takesLiteralHeredoc = (program: string | undefined, args: readonly string[]) =>
149 (program === 'git' && ['commit', 'tag', 'notes'].includes(args[0] ?? '')) ||
150 (program === 'gh' && ['pr', 'issue', 'release'].includes(args[0] ?? ''))
151
152const containsOverwrite = (args: readonly string[]) => args.some((arg, index) => {
153 if (arg !== '>' && arg !== '>>') return false
154 const target = args[index + 1]
155 return target !== '/dev/null' && !/^&\d+$/.test(target ?? '')
156})
157
158const factFor = (segment: Segment): CommandFact => {
159 const normalized = stripLauncher(segment.words)
160 const program = normalized[0] ? basename(normalized[0].value) : undefined
161 const args = normalized.slice(1).map(word => word.value)
162 const values = nonOptions(args)
163 let targets: string[] = []
164
165 if (program === 'rm' || program === 'chmod' || program === 'find') targets = values
166 if (program === 'mv') targets = values.slice(-1)
167 if (program === 'git' && ['clean', 'checkout', 'restore'].includes(args[0] ?? '')) targets = values.slice(1)
168 if (containsOverwrite(args)) {
169 targets.push(...args.flatMap((arg, index) => {
170 const target = args[index + 1]
171 return (arg === '>' || arg === '>>') && target !== undefined && target !== '/dev/null' && !/^&\d+$/.test(target) ? [target] : []
172 }))
173 }
174
175 return { command: segment.words.map(word => word.value).join(' '), program, args, targets }
176}
177
178const classifySegment = (segment: Segment): Classification => {
179 const fact = factFor(segment)
180 const { program, args } = fact
181 const values = nonOptions(args)
182
183 if (segment.words.some(word => word.heredoc) && !takesLiteralHeredoc(program, args)) {
184 return { tier: 'ask', reason: "can't analyze a heredoc outside a git message or gh body", commands: [fact] }
185 }
186 if (containsOverwrite(args)) return { tier: 'ask', reason: 'overwriting a redirect', commands: [fact] }
187 if (program === 'eval') return { tier: 'ask', reason: "can't analyze eval", commands: [fact] }
188 if (isShell(program) && (args.includes('<<') || args.includes('<<-'))) return { tier: 'ask', reason: "can't analyze a here-doc feeding a shell", commands: [fact] }
189 if (segment.separator === '|' && isShell(program)) return { tier: 'ask', reason: "can't analyze a pipe into a shell", commands: [fact] }
190 if (isShell(program) && args.includes('-c')) {
191 const body = args[args.indexOf('-c') + 1]
192 const word = stripLauncher(segment.words).at(args.indexOf('-c') + 2)
193 if (!body || word?.dynamic) return { tier: 'ask', reason: "can't analyze a dynamic shell body", commands: [fact] }
194 }
195
196 if (program === 'rm') {
197 const recursiveAndForce = hasRecursiveFlag(args) && hasForceFlag(args)
198 if (values.some(target => isDangerousRmTarget(target, recursiveAndForce))) {
199 return { tier: 'deny', reason: 'rm targets a root, home, or system path', commands: [fact] }
200 }
201 return { tier: 'ask', reason: 'rm deletes files', commands: [fact] }
202 }
203
204 if (program === 'git') {
205 const subcommand = args[0]
206 // `+ref` forces by refspec without any flag; `HEAD:main` names main as the destination.
207 const refs = values.slice(1)
208 if (subcommand === 'push' && (hasForceFlag(args) || refs.some(ref => ref.startsWith('+'))) && refs.some(isMainRef)) {
209 return { tier: 'deny', reason: 'force-push targets main or master', commands: [fact] }
210 }
211 if (subcommand === 'reset' && args.includes('--hard')) {
212 const ref = values.slice(1).at(-1)
213 if (ref?.startsWith('origin/') || ref === '@{u}') return { tier: 'deny', reason: 'hard reset targets a remote upstream ref', commands: [fact] }
214 return { tier: 'ask', reason: 'git reset --hard discards work', commands: [fact] }
215 }
216 if (subcommand === 'clean') return { tier: 'ask', reason: 'git clean deletes files', commands: [fact] }
217 if ((subcommand === 'checkout' || subcommand === 'restore') && values.slice(1).includes('.')) return { tier: 'ask', reason: `git ${subcommand} . discards work`, commands: [fact] }
218 if (subcommand === 'stash' && ['drop', 'clear'].includes(args[1] ?? '')) return { tier: 'ask', reason: `git stash ${args[1]} discards work`, commands: [fact] }
219 }
220
221 if (program === 'mv') return { tier: 'ask', reason: 'mv may overwrite its destination', commands: [fact] }
222 if (program === 'find' && args.includes('-delete')) return { tier: 'ask', reason: 'find -delete removes files', commands: [fact] }
223 if (program === 'chmod' && hasRecursiveFlag(args)) return { tier: 'ask', reason: 'chmod -R changes permissions recursively', commands: [fact] }
224 if ((program === 'docker' || program === 'kubectl') && args.some(arg => ['rm', 'rmi', 'prune', 'delete', 'kill'].includes(arg))) {
225 return { tier: 'ask', reason: `${program} deletes resources`, commands: [fact] }
226 }
227
228 return { tier: 'pass', commands: [fact] }
229}
230
231export const tokenize = (command: string): Tokenized => {
232 const segments: Segment[] = []
233 let words: Word[] = []
234 let word = ''
235 let dynamic = false
236 let heredoc = false
237 let quote: 'single' | 'double' | undefined
238 let separator: Separator | undefined
239 let cannotAnalyze = false
240
241 const pushWord = () => {
242 if (word) words.push(heredoc ? { value: word, dynamic, heredoc: true } : { value: word, dynamic })
243 word = ''
244 dynamic = false
245 heredoc = false
246 }
247 const pushSegment = (nextSeparator: Separator) => {
248 pushWord()
249 if (words.length > 0) segments.push({ words, separator })
250 words = []
251 separator = nextSeparator
252 }
253
254 for (let index = 0; index < command.length; index += 1) {
255 const char = command[index] ?? ''
256 const next = command[index + 1]
257
258 if (char === '\\' && quote !== 'single') {
259 if (next === undefined) {
260 cannotAnalyze = true
261 } else {
262 word += next
263 index += 1
264 }
265 continue
266 }
267
268 if (char === "'" && quote !== 'double') {
269 quote = quote === 'single' ? undefined : 'single'
270 continue
271 }
272 if (char === '"' && quote !== 'single') {
273 quote = quote === 'double' ? undefined : 'double'
274 continue
275 }
276
277 if (quote !== 'single' && char === '$' && next === '(') {
278 const length = literalHeredoc(command.slice(index))
279 if (length) {
280 word += '<heredoc>'
281 heredoc = true
282 index += length - 1
283 continue
284 }
285 }
286
287 if (quote !== 'single' && char === '`') cannotAnalyze = true
288 if (quote !== 'single' && char === '$') {
289 dynamic = true
290 if (next === '(') cannotAnalyze = true
291 }
292
293 if (quote) {
294 word += char
295 continue
296 }
297
298 if (char === '>' || (char === '<' && next === '<')) {
299 pushWord()
300 if (char === '<') {
301 words.push({ value: next === '<' && command[index + 2] === '-' ? '<<-' : '<<', dynamic: false })
302 index += next === '<' && command[index + 2] === '-' ? 2 : 1
303 } else {
304 words.push({ value: next === '>' ? '>>' : '>', dynamic: false })
305 if (next === '>') index += 1
306 if (command[index + 1] === '&') {
307 let target = '&'
308 index += 1
309 while (/\d/.test(command[index + 1] ?? '')) {
310 target += command[index + 1]
311 index += 1
312 }
313 words.push({ value: target, dynamic: false })
314 }
315 }
316 continue
317 }
318
319 if (/\s/.test(char)) {
320 if (char === '\n') pushSegment('\n')
321 else pushWord()
322 continue
323 }
324
325 if (char === ';') {
326 pushSegment(';')
327 continue
328 }
329 if (char === '|' || char === '&') {
330 const joined = next === char ? `${char}${char}` as Separator : char as Separator
331 pushSegment(joined)
332 if (next === char) index += 1
333 continue
334 }
335
336 word += char
337 }
338
339 pushWord()
340 if (words.length > 0) segments.push({ words, separator })
341 if (quote) cannotAnalyze = true
342
343 return { segments, cannotAnalyze }
344}
345
346export const classify = (command: string): Classification => {
347 const parsed = tokenize(command)
348 if (parsed.cannotAnalyze) return { tier: 'ask', reason: "can't analyze this command", commands: parsed.segments.map(factFor) }
349
350 return parsed.segments.reduce<Classification>((strictest, segment) => {
351 const candidate = classifySegment(segment)
352 if (TIER_ORDER[candidate.tier] > TIER_ORDER[strictest.tier]) return {
353 ...candidate,
354 commands: [...strictest.commands, ...candidate.commands],
355 }
356 return { ...strictest, commands: [...strictest.commands, ...candidate.commands] }
357 }, { tier: 'pass', commands: [] })
358}
359hooks/preview.ts 60 lines1export type PreviewTarget = {
2 path: string
3 bytes?: number
4 tracked?: boolean
5}
6
7export type PreviewFacts = {
8 command: string
9 reason: string
10 targets?: readonly PreviewTarget[]
11 totalBytes?: number
12 unexpandedGlobs?: readonly string[]
13 dryRun?: {
14 label: string
15 output: string
16 }
17}
18
19const MAX_LINES = 40
20
21const bytes = (value: number) => {
22 if (value < 1024) return `${value} B`
23 if (value < 1024 * 1024) return `${Math.round(value / 1024)} KiB`
24 return `${(value / (1024 * 1024)).toFixed(1)} MiB`
25}
26
27const cap = (lines: readonly string[]) => {
28 if (lines.length <= MAX_LINES) return [...lines]
29
30 const omitted = lines.length - (MAX_LINES - 1)
31 return [...lines.slice(0, MAX_LINES - 1), `… ${omitted} preview lines omitted`]
32}
33
34export const preview = (facts: PreviewFacts): string => {
35 const targets = facts.targets ?? []
36 const lines = [
37 'Blast-radius check',
38 `Command: ${facts.command}`,
39 `Why: ${facts.reason}`,
40 `Targets: ${targets.length}${facts.totalBytes === undefined ? '' : `, ${bytes(facts.totalBytes)} total`}`,
41 ]
42
43 for (const target of targets) {
44 const size = target.bytes === undefined ? '' : ` (${bytes(target.bytes)})`
45 let tracked = ''
46 if (target.tracked === true) tracked = ', git-tracked'
47 if (target.tracked === false) tracked = ', not git-tracked'
48 lines.push(` ${target.path}${size}${tracked}`)
49 }
50
51 for (const glob of facts.unexpandedGlobs ?? []) lines.push(`Glob not expanded: ${glob}`)
52
53 if (facts.dryRun) {
54 lines.push(`Dry run (${facts.dryRun.label}):`)
55 lines.push(...facts.dryRun.output.split('\n').flatMap(line => line ? [` ${line}`] : []))
56 }
57
58 return cap(lines).join('\n')
59}
60hooks/summary.ts 148 lines1// Pure logic for the /clear auto-save flow. No engine imports: every `$` call
2// (`$.process`, `$.fs`, `$.model`, `$.ui`) stays in clear.tsx.
3
4export const REAL_WORK_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
5export const MIN_PROMPTS_FOR_REAL_WORK = 4
6export const RECENT_SAVE_MS = 5 * 60 * 1000
7
8export const DIGEST_LAST_MESSAGES = 30
9export const DIGEST_MESSAGE_CHARS = 600
10export const DIGEST_CAP = 30_000
11// Latest failures only: the oldest add the least and, uncapped, crowd the
12// recent messages out of the final cut.
13export const DIGEST_MAX_FAILURES = 10
14
15export const HEADINGS = [
16 'Completed',
17 'In Progress',
18 'Decisions Made',
19 'Blocked / Needs Input',
20 'Files Modified This Session',
21 'Next Session Should',
22] as const
23
24export type Counters = { edits: number; prompts: number }
25
26export type DigestToolUse = {
27 tool: string
28 input: Record<string, unknown>
29 isError?: true
30 text?: string
31}
32
33export type DigestToolResult = {
34 isError?: boolean
35 text: string
36}
37
38export type DigestMessage = {
39 role: 'user' | 'assistant'
40 text: string
41 toolUses: readonly DigestToolUse[]
42 toolResults?: readonly DigestToolResult[]
43}
44
45export type ParsedSummary = { goal: string; body: string }
46
47const cut = (text: string, limit: number): string =>
48 text.length <= limit ? text : `${text.slice(0, limit - 1)}…`
49
50// Real work is one successful edit tool, or a handful of prompts; counted by
51// clear.tsx's own counters, never the transcript (it may already be wiped).
52export const isRealWork = (counters: Counters): boolean =>
53 counters.edits >= 1 || counters.prompts >= MIN_PROMPTS_FOR_REAL_WORK
54
55// A bounded digest of the transcript for the summariser: the first user
56// prompt, the edited files, the failing tool results, and the last few
57// messages, each cut and the whole capped near DIGEST_CAP characters.
58export const buildDigest = (messages: readonly DigestMessage[]): string => {
59 const firstPrompt = messages.find(m => m.role === 'user' && m.text.trim() !== '')?.text ?? ''
60
61 const files = new Set<string>()
62 const failures: string[] = []
63
64 for (const message of messages) {
65 for (const use of message.toolUses) {
66 if (REAL_WORK_TOOLS.has(use.tool)) {
67 const filePath = use.input.file_path
68 if (typeof filePath === 'string' && filePath.trim() !== '') files.add(filePath)
69 }
70 if (use.isError) failures.push(cut(use.text ?? '', DIGEST_MESSAGE_CHARS))
71 }
72 for (const result of message.toolResults ?? []) {
73 if (result.isError) failures.push(cut(result.text, DIGEST_MESSAGE_CHARS))
74 }
75 }
76
77 const shownFailures = failures.slice(-DIGEST_MAX_FAILURES)
78
79 const recent = messages
80 .slice(-DIGEST_LAST_MESSAGES)
81 .map(m => `[${m.role}] ${cut(m.text, DIGEST_MESSAGE_CHARS)}`)
82
83 const parts = [
84 `First user prompt: ${cut(firstPrompt, DIGEST_MESSAGE_CHARS)}`,
85 files.size === 0
86 ? 'Files edited: (none)'
87 : `Files edited:\n${[...files].sort().map(f => `- ${f}`).join('\n')}`,
88 shownFailures.length === 0
89 ? 'Failing tool results: (none)'
90 : `Failing tool results:\n${shownFailures.map(f => `- ${f}`).join('\n')}`,
91 `Recent messages (${recent.length}):\n${recent.join('\n')}`,
92 ]
93
94 return cut(parts.join('\n\n'), DIGEST_CAP)
95}
96
97// Parses the model's reply: a `GOAL:` line then the six headings, in any
98// order. Returns undefined when the goal line or any heading is missing, so
99// a broken reply is never saved.
100export const parseReply = (text: string): ParsedSummary | undefined => {
101 const lines = text.split('\n')
102 const first = lines.find(line => line.trim() !== '')
103 if (first === undefined) return undefined
104
105 const match = /^GOAL:\s*(.+)$/i.exec(first.trim())
106 if (match === null) return undefined
107 const goal = (match[1] ?? '').trim()
108 if (goal === '') return undefined
109
110 const body = lines.slice(lines.indexOf(first) + 1).join('\n').trim()
111 if (body === '') return undefined
112
113 const missing = HEADINGS.find(heading => !body.includes(`## ${heading}`))
114 if (missing !== undefined) return undefined
115
116 return { goal, body }
117}
118
119// A task worktree's state file: frontmatter first (startup rules read it),
120// then the summary body.
121export const taskStateFile = (taskId: string, body: string): string =>
122 `---\nstatus: active\nagent_task: ${taskId}\n---\n\n${body}\n`
123
124// `agent-session save` prints "✓ Saved: <path>"; this picks the path back out.
125export const extractSavedPath = (stdout: string): string | undefined =>
126 /\.agents\/sessions\/[^\s]*\.md/.exec(stdout)?.[0]
127
128export const SYSTEM_PROMPT = `You summarise a finished Claude Code session into a handoff document.
129You see only a bounded digest of the transcript; never invent facts it does not contain.
130
131Reply in exactly this shape. The first line is the goal, then the six headings,
132in this order, each with bullet lines (write "nothing" under a heading that has no content):
133
134GOAL: <one sentence describing what the session set out to do>
135
136## Completed
137## In Progress
138## Decisions Made
139## Blocked / Needs Input
140## Files Modified This Session
141## Next Session Should
142
143Every one of the six headings must appear exactly once, spelled exactly as above.
144Add no other top-level heading, preamble or trailing text.`
145
146export const summaryPrompt = (digest: string): string =>
147 `Summarise this session from the digest below, in the format the system prompt specifies.\n\n${digest}`
148