Counts the days in a row you closed a bead, from Bash calls that ran bd close and exited 0. Adds /bead-streak and a toast on a new best. Reads command text…

Small mods for Claude Code. Pick the ones you want and combine them.
Baselane mods are Claude Code plugins made of function hooks. Each mod does one thing: a guard that asks before rm -rf, a band with the session cost, a side pane with your git status, a sound when tests pass, a pirate voice for Claude's prose. There are 100+ mods in families such as guards, panes, meters, sounds and stats. Install one, or stack ten. They do not need each other.
You need Claude Code 2.1.288 or later, with mods.
claude plugin marketplace add baselane-sh/mods-catalog
claude plugin install guard-essentials@baselane-mods
claude plugin install receipt@baselane-mods
Start Claude Code. Now it asks before harsh commands, and /receipt prints a receipt of the session.
Browse every mod at https://baselane-sh.github.io/mods-catalog/. The gallery (baselane-mods) pins each mod to a reviewed commit, so a new mod reaches the gallery a short time after it reaches this repo. For update, uninstall, options and starter sets, read docs/INSTALL.md.
This repo also has a development marketplace, baselane-mods-dev, that tracks main. Use it only to try changes that are not released yet:
claude plugin marketplace add baselane-sh/mods
claude plugin install <name>@baselane-mods-dev
Each mod is released with a git tag <name>--v<version>.
Most mods have one rule. A pack is one mod with several rules from the same family. For example, guard-essentials holds several guards and band-pack puts several meters in one band.
guard-pack and infra-guard installed, the terraform destroy check runs two times, and you can see two questions.guard-essentials, cost-meter and git-pane work well together.The catalog below marks each pack with "pack".
Beads (bd) is an issue tracker that lives in your repo. 19 mods show your beads in Claude Code: bars above the prompt, panes, slash commands, a guard and reminders. They need bd 1.2 or later and a repo with a .beads folder. Without these, they do nothing.
The core set in one go (the bars, the panes, /ready and the guard):
claude plugin install beads-bars@baselane-mods
claude plugin install beads-pane@baselane-mods
claude plugin install epics-pane@baselane-mods
claude plugin install ready@baselane-mods
claude plugin install beads-guard@baselane-mods
Each beads mod is also in the catalog below, in its family.
ntfy-notify or long-run-notify, or a webhook URL for slack-notify or discord-notify. With a destination set, those mods send a short message only: a generic "needs input" text with the project folder name, or the project name and the seconds. One exception needs no setup: ci-band and ops-band ask GitHub, through your own gh login, for the latest Actions run of your branch every 2 minutes, sending the repository and branch name. Without gh, or with gh not logged in, they do nothing.sports-narrator makes one small model call at the end of a turn that used tools. It sends tool names, file names and the first two words of each shell command, never file contents. It spends tokens, so you must turn it on.bd on your machine, such as bd status --json and bd ready --json. No beads mod creates, changes or closes a bead. beads-guard asks before a bd command that deletes or rewrites beads.secret-output-guard tells Claude not to repeat a credential, auto-format and auto-lint tell Claude to read a file again, beads-prime adds the workflow text that bd prime prints from your repo's .beads files, and bead-claim-nudge and bead-commit-nudge add a one-line reminder at your next prompt./wrapped read records that the mods keep on your machine./handoff writes .claude/handoff.md and never overwrites it. session-journal appends to ~/.claude/journal.log. auto-format and auto-lint run your project's own formatter or linter fix, which can rewrite files. The descriptions say what each mod writes.lsof for port-watch, gh for ci-band, bd 1.2 or later for the beads mods, ps for process-pane, and on macOS pmset for battery-band and pgrep with osascript for now-playing.plugins/ and .claude-plugin/marketplace.json are generated. Do not edit them by hand.
node scripts/build.mjs # build one plugin folder per catalog entry
bash scripts/check.sh # rebuild, then validate, test and type-check every mod
bash scripts/check.sh git-pane # check only the mods you name
node scripts/gen-docs.mjs # rewrite the catalog in this README
node --test 'scripts/*.test.mjs' # test the docs generator
A change is done when check.sh prints check: 0 failure(s). Read AGENTS.md for the layout and the API facts.
To add a mod:
engines/<engine>/hooks/rules/<id>.ts, with a test in engines/<engine>/tests/<id>.test.ts.catalog/<name>.json: { "name", "description", "rules": ["<id>"] }. Give the description the slash command, if the mod has one (for example "Open it with /git"). The catalog table reads it from there.node scripts/build.mjs, then bash scripts/check.sh <mod>, then node scripts/gen-docs.mjs.A new engine needs a family in scripts/gen-docs.mjs. Keep files small, do not change inputs in place, and do not use the em-dash character.
MIT. Copyright (c) 2026 Baselane, LLC. Read LICENSE.
<!-- catalog:start -->
199 mods in 10 families.
"needs setup" means the mod has options that you set with claude plugin configure <mod>. Its description tells you if it works before you set them. "pack" means one mod with several rules.
Ask you before a harsh or risky command runs.
| Mod | What it does | Command | Notes | ||
|---|---|---|---|---|---|
beads-guard | Asks before bd commands that delete data or rewrite history: delete, purge, prune, gc, sql, admin, import, rename, forget, restore, migrate and more. Reads only the command text, runs no bd. | ||||
secret-filename-guard | Asks before a Bash command touches a secret-looking file (.env, private keys, credentials). | ||||
secret-guard | Asks before a live API key, token or private key is written, edited or run. | ||||
env-exfil-guard | Asks before a command prints your environment, echoes a secret variable or sends local data to a remote host. | ||||
infra-guard | Asks before terraform destroy, kubectl delete, force-push, DROP TABLE or rm -rf. | ||||
secret-commit-guard | Asks before a git commit that would record a credential or a secret-named file. Reads the staged diff with git. | ||||
protect-main | Asks before a commit, push or merge while you are on main or master. Reads the branch with git. | ||||
gitignore-check | Asks before git add or commit when secret-looking files are not ignored or are already tracked. Checks with git ls-files. | ||||
secret-output-guard | Tells Claude not to repeat a credential that showed up in command or file output, and names the source to rotate. | ||||
curl-pipe-guard | Asks before a download is piped into a shell or interpreter (curl \ | sh, wget -O- \ | bash, bash <(curl ...)). | ||
sudo-guard | Asks before sudo, doas or su -c runs a command with elevated rights. | ||||
no-verify-guard | Asks before git hooks are skipped (--no-verify, commit -n, HUSKY=0, core.hooksPath=/dev/null). | ||||
lockfile-guard | Asks before a lockfile is written or edited by hand; the package manager should change it. | ||||
package-guard | Asks before a new dependency is installed (npm, pnpm, yarn, bun, pip, uv, poetry, cargo, go, gem) and names the packages. | ||||
prod-db-guard | Asks before destructive SQL runs in a command: TRUNCATE, DELETE or UPDATE with no WHERE. | ||||
path-jail | Asks before Write, Edit or NotebookEdit changes a file outside the working directory, by real path. | ||||
docker-guard | Asks before Docker commands that delete data: system, volume or image prune, rm -f, volume rm and compose down -v. | ||||
k8s-guard | Asks before kubectl apply or replace with --force, kubectl drain, helm uninstall, and kubectl delete behind global flags (plain kubectl delete is infra-guard's). | ||||
migration-guard | Asks before Write or Edit changes a migration that already exists; new migration files pass. | ||||
ci-config-guard | Asks before Write or Edit changes CI config: .github/workflows, .gitlab-ci.yml or .circleci/config.yml. | ||||
chmod-guard | Asks before chmod makes files world-writable (777, a+w, o+w) or chmod or chown runs recursively on a broad path (/, a system folder, a home folder). | ||||
git-history-guard | Asks before git commands that throw away work or rewrite history: reset --hard, clean, rebase, filter-branch, filter-repo, push --delete, branch -D and stash clear. | ||||
big-file-guard | Asks before a Write creates content over 1 MB, or git add names a file over 5 MB. Measures the files with find. | ||||
publish-guard | Asks before a package is published: npm, pnpm, yarn or bun publish, cargo publish, twine upload, gem push, poetry or uv publish. Dry runs pass. | ||||
tag-guard | Asks before release tags go to a remote: git push --tags, --follow-tags or --mirror, a push of a tag ref, and a push that deletes a remote tag. Dry runs pass. Reads tags with git. | ||||
deploy-guard | Asks before a production deploy: vercel --prod, netlify deploy --prod, firebase deploy, fly deploy, gcloud app deploy, eb deploy, heroku rollback, serverless deploy to prod. | ||||
db-reset-guard | Asks before a framework wipes a database: prisma migrate reset, rails or rake db:drop and db:reset, alembic downgrade, django flush, supabase db reset, knex rollback --all. | ||||
ssh-guard | Asks before a private key in .ssh is read, authorized_keys or the SSH config is changed, or ssh-keygen would overwrite a key. Public keys and ssh -i pass. Reads HOME with printenv. | ||||
cron-guard | Asks before scheduled jobs or services are wiped or stopped: crontab -r, crontab replaced from stdin or a file, launchctl unload or bootout, systemctl stop, disable or mask. | ||||
upload-guard | Asks before local files go to a remote host: scp or rsync to host:path, piped input to nc, curl -T, sftp put. Local copies, downloads and localhost pass. | ||||
registry-push-guard | Asks before an image or chart is pushed to a registry: docker push, docker buildx --push, podman push, helm push, gcloud artifacts docker push. Local registries pass. | ||||
guard-essentials | Asks only before harsh or disaster commands: destructive infra, git and SQL, piping downloads into a shell, sudo, leaking or committing secrets. Reads git. The quiet choice for daily work. | pack | |||
guard-pack | The 15 core Baselane guards in one mod (secrets, git, infra, packages, path jail). Some read git, measure files with find, read HOME with printenv, or check where a path really lands. | pack | |||
guard-devops | Asks before harsh DevOps commands: destructive Docker, Kubernetes and Helm calls, CI config edits, broad chmod and chown, and git commands that lose work. Add infra-guard for plain kubectl delete. | pack | |||
release-pack | Asks before a release leaves your machine: package publish, tag push, production deploy and registry push guards in one mod. The tag guard reads tags with git. | pack | |||
ship-safe-pack | The release-pack guards plus db-reset-guard and upload-guard: asks before publish, tag push, production deploy, registry push, database wipes and file uploads. Reads tags with git. | pack |
One quiet toast at turn end when something needs your attention.
| Mod | What it does | Command | Notes |
|---|---|---|---|
bead-claim-nudge | At turn end, if Claude edited a file and no bead is in progress, shows a toast and reminds Claude at your next prompt, once per session. Reads with bd count; quiet without bd. | ||
bead-commit-nudge | At turn end, if a git commit named no bead id while a bead is in progress, shows a toast and reminds Claude at your next prompt. Reads with bd count and bd where; quiet without bd. | ||
test-reminder | Reminds you at turn end when source files changed but no test command ran since. | ||
ctx-nudge | Reminds you to /clear or /compact when the context window passes 75 percent. | ||
clippy | One helpful toast per session per trigger, in the classic paperclip voice: when you edit a migrations folder, a Dockerfile or a GitHub workflow, or run rm -rf. At most one toast per turn. | ||
sports-narrator | One line of sports play-by-play at turn end. OFF by default: one haiku call per turn with tools. Turn on the enabled option. Sends tool names, file names and the first two words of each command. | needs setup | |
commit-nudge | Suggests a commit at turn end after 8 or more file edits since the last git commit. One toast per batch of edits; a successful git commit resets the count. | ||
todo-nudge | At turn end, one toast with the count of TODO, FIXME and HACK lines your edits added this turn. Quiet when none were added. | ||
break-nudge | Suggests a short break once the session has run 90 minutes, and again every 90 minutes after that. | ||
docs-nudge | At turn end, one toast when edits added exported code (an export statement in .ts or .js, or a public function signature) and no README or docs file was edited this session. | ||
debug-print-nudge | At turn end, one toast naming the count of console.log, print(, debugger and dbg! lines your edits added this turn. Only code files count. | ||
typecheck-nudge | At turn end, one toast when .ts or .tsx files were edited and no type check (tsc, vue-tsc, a typecheck script or a build) ran since the last edit. | ||
lockfile-nudge | At turn end, one toast when dependencies in package.json, pyproject.toml, Cargo.toml or go.mod changed and neither the matching lockfile was edited nor an install command ran. | ||
big-diff-nudge | Suggests splitting the change once your edits added or removed more than 500 lines since the last git commit. A successful commit resets the count. | ||
migration-nudge | At turn end, one toast when a schema file changed (schema.prisma, models.py, SQL under schema/, a drizzle schema) and no migration file was created this session. | ||
env-example-nudge | At turn end, one toast when edits add a reference to an environment variable (process.env, os.environ, os.getenv, Deno.env) that .env.example does not list. Reads only .env.example names, never .env. | ||
nudge-pack | Two turn-end reminders in one mod: test-reminder and ctx-nudge. | pack | |
focus-pack | Three quiet nudges in one mod: commit after 8 edits, TODO/FIXME/HACK lines added, and debug prints added. | pack | |
quality-pack | Three quiet nudges in one mod: type check after TypeScript edits, lockfile after dependency edits, and a split suggestion past 500 changed lines. | pack |
Slash commands that print a result and, where it helps, copy it.
| Mod | What it does | Command | Notes |
|---|---|---|---|
ready | Adds /ready: the 20 top ready beads, one line each, and the total. Read with bd, the beads tracker. Read-only. Prints only, copies nothing. | /ready | |
bead | Adds /bead <id>: one bead with status, priority, labels, description, blockers and children. Read with bd, the beads tracker. Read-only. Prints only, copies nothing. | /bead | |
beads-standup | Adds /beads-standup: beads closed since yesterday, in progress, and the next 5 ready. Read with bd, the beads tracker. Read-only. Prints only, copies nothing. | /beads-standup | |
receipt | Adds /receipt: a shareable receipt of the session (tools, files, commands, blocks, context, cost), printed and copied. | /receipt | |
standup | Adds /standup: Yesterday, Today and Blockers from your git log and this session's record, printed and copied. | /standup | |
changelog | Adds /changelog: commits since the last tag (or the last 30), read with git log and grouped by conventional-commit type, as Markdown. Copies the result to your clipboard. | /changelog | |
pr-description | Adds /pr-description: title, summary, diff totals and a test plan stub for the current branch against main or master, read with git. Copies the result to your clipboard. | /pr-description | |
handoff | Adds /handoff: writes a session summary with the git status to .claude/handoff.md (never overwriting) and answers with the path. Copies the result to your clipboard. | /handoff | |
todos | Adds /todos: TODO, FIXME and HACK lines in tracked files, found with git grep, grouped by file (50 lines at most). Read-only. Copies the result to your clipboard. | /todos | |
loc | Adds /loc: lines of tracked text files by language, read with git, sorted, with a total. Skips lockfiles and binaries. Read-only. Copies the result to your clipboard. | /loc | |
hotspots | Adds /hotspots: the 10 files changed most often in the last 90 days, from git log, with change counts. Read-only. Copies the result to your clipboard. | /hotspots | |
commit-msg | Adds /commit-msg: a Conventional Commits message proposed from the staged diff, read with git. Heuristic, no model call, writes nothing. Copies the result to your clipboard. | /commit-msg | |
branches | Adds /branches: local branches merged into the default branch or idle for 30 days, as a cleanup list, read with git. Never deletes. Copies the result to your clipboard. | /branches | |
tree | Adds /tree: tracked files (git ls-files) as a tree, 2 levels deep, folders with file counts (80 lines at most). Read-only. Copies the result to your clipboard. | /tree | |
deps | Adds /deps: direct dependencies with versions from package.json, pyproject.toml, requirements.txt, go.mod and Cargo.toml at the repo root. No network. Read-only. Copies the result to your clipboard. | /deps | |
authors | Adds /authors: the top 15 contributors by commit count with their last commit date, from git log. Names only, never email addresses. Read-only. Copies the result to your clipboard. | /authors | |
scripts | Adds /scripts: runnable tasks from package.json scripts, Makefile targets, justfile recipes and pyproject scripts at the git repo root. Read-only. Copies the result to your clipboard. | /scripts | |
env-check | Adds /env-check: variable names in .env.example against .env at the git repo root, listing missing and extra names. Reads names only, never a value. Read-only. Copies the result to your clipboard. | /env-check | |
size | Adds /size: the 15 largest tracked files and the total tracked size at HEAD, read with git. Read-only. Copies the result to your clipboard. | /size | |
licenses | Adds /licenses: the licence of each direct dependency, from node_modules and Python dist-info at the git repo root. Shows unknown when it cannot tell. Read-only. Copies the result to your clipboard. | /licenses | |
conflicts | Adds /conflicts: tracked files that still hold merge conflict markers, with line numbers, found with git grep. Read-only. Copies the result to your clipboard. | /conflicts | |
secret-scan | Adds /secret-scan: tracked files and line numbers that hold secret-shaped text, found with git grep. Never prints a matched value. Read-only. Copies the result to your clipboard. | /secret-scan | |
envinfo | Adds /envinfo: versions of git, node, npm, python3, go, rustc and docker if installed, by running each (2 s limit), and the OS. Read-only. Copies the result to your clipboard. | /envinfo | |
stashes | Adds /stashes: git stashes with their age and the branch each was made on. Read-only, never applies or drops. Copies the result to your clipboard. | /stashes | |
recent | Adds /recent: your last 15 commits across all local branches (author is git user.name), with branch and date. Read-only. Copies the result to your clipboard. | /recent | |
file-owners | Adds /owners <path>: the top 5 authors of a file or folder by lines, from git blame. Names only, never addresses. Read-only. Copies the result to your clipboard. | /owners | |
readme-check | Adds /readme-check: missing README sections (install, usage, licence, contributing) and broken relative links, at the git repo root. Read-only. Copies the result to your clipboard. | /readme-check | |
command-pack | The five original slash commands in one mod: /receipt, /standup, /changelog, /pr-description and /handoff. They read git; /handoff also writes .claude/handoff.md. They copy results to the clipboard. | /receipt, /standup, /changelog, /pr-description, /handoff | pack |
repo-pack | Five read-only repo commands in one mod: /todos, /loc, /hotspots, /commit-msg and /branches. They read git. They copy results to the clipboard. | /todos, /loc, /hotspots, /commit-msg, /branches | pack |
explore-pack | Five read-only repo exploration commands in one mod: /tree, /deps, /authors, /scripts and /env-check. They read git and project files. They copy results to the clipboard. | /tree, /deps, /authors, /scripts, /env-check | pack |
audit-pack | Three read-only audit commands in one mod: /secret-scan, /conflicts and /licenses. They read git, project files and dependency folders. They copy results to the clipboard. | /secret-scan, /conflicts, /licenses | pack |
A one-line band above the prompt.
| Mod | What it does | Command | Notes |
|---|---|---|---|
cost-meter | A band above the prompt with the session cost so far and what the last turn cost. | ||
latte-meter | A band above the prompt that counts the session cost in lattes. Set the latte price in the plugin options. | needs setup | |
context-meter | A band above the prompt with a 10 cell bar of the context window used, yellow from 60 percent, red from 80. | ||
daily-spend | A band above the prompt with today's total cost across all your sessions. |
| pomodoro | A band above the prompt with a 25/5 focus timer (focus 18:42, break 03:10). Start and stop it with /pomodoro; a to
hooks/register.ts 12 lines1import type { Register } from 'claude-code'
2
3import { registerStats } from './engine'
4import { answerStatsCommands } from './hosts/stats-command'
5import { rule as beadStreak } from './rules/bead-streak'
6
7export const register: Register = on => {
8 const rules = [beadStreak]
9 registerStats(on, rules)
10 answerStatsCommands(on, [beadStreak])
11}
12hooks/engine.ts 354 lines1import { atom, read, update } from 'claude-code'
2import type { On, UiCopyResult } from 'claude-code'
3
4import type { Day, Life, Pending, SessionMemo } from '../types'
5import { localDate } from './date'
6import { LANGS, addLangs, readLangs } from './exts'
7import type { Composed, Days, StatsRule, Store, View } from './rule'
8import { EMPTY_LIFE, addToDay, addToLife, prune, readDays, readLife } from './rollup'
9import { EMPTY_PENDING, FRESH_SESSION, MAX_SEEN_FILES, observe } from './tracker'
10
11// A state value is written only by the plugin that owns it, and the owner is
12// the mod's name. The host wants the owner as a literal, so this source writes
13// a token and scripts/build.mjs replaces it with each mod's own name
14// (engine.json, nameToken).
15const pending = atom({ plugin: 'bead-streak', key: 'pending' } as const, EMPTY_PENDING)
16const session = atom({ plugin: 'bead-streak', key: 'session' } as const, FRESH_SESSION)
17
18// Store keys. The rollup is `days` (one entry per local date) and `life`.
19const DAYS = 'days'
20const LIFE = 'life'
21
22// What the engine needs from the host, as closures: a hook builds one from
23// `$` and the engine never holds `$` itself.
24type Host = {
25 takePending: () => Promise<Pending>
26 memo: () => Promise<SessionMemo>
27 patchMemo: (fn: (memo: SessionMemo) => SessionMemo) => Promise<unknown>
28 now: () => Promise<number>
29 store: Store
30}
31
32// The closures a hook hands over, from which the engine derives its Host.
33type Raw = {
34 updatePending: (fn: (pending: Pending) => Pending) => Promise<unknown>
35 readSession: () => Promise<SessionMemo>
36 updateSession: (fn: (memo: SessionMemo) => SessionMemo) => Promise<unknown>
37 now: () => Promise<number>
38 store: Store
39}
40
41const hostOf = (raw: Raw): Host => ({
42 takePending: async () => {
43 let taken = EMPTY_PENDING
44 await raw.updatePending(so_far => {
45 taken = so_far
46 return EMPTY_PENDING
47 })
48 return taken
49 },
50 memo: raw.readSession,
51 patchMemo: raw.updateSession,
52 now: raw.now,
53 store: raw.store,
54})
55
56type Rolled = { date: string; now: number; days: Days; life: Life }
57
58const failed = (where: string, error: unknown): string =>
59 `stats: ${where} skipped, ${error instanceof Error ? error.message : String(error)}`
60
61const attempt = async <T>(run: () => Promise<T>): Promise<T | undefined> => {
62 try {
63 return await run()
64 } catch {
65 return undefined
66 }
67}
68
69const isEmpty = (p: Pending): boolean => p.calls === 0
70
71// The start of this run: a resumed session keeps its first start, and the
72// time it was away is not this run's.
73const runStartOf = (startedAt: number, memo: SessionMemo): number => Math.max(startedAt, memo.since ?? startedAt)
74
75// The rollup as it stands, with nothing added.
76const load = async (host: Host): Promise<Rolled> => {
77 const now = await host.now()
78 const [days, life] = [readDays(await host.store.get(DAYS)), readLife(await host.store.get(LIFE))]
79 return { date: localDate(now), now, days, life }
80}
81
82type Extra = { sessions: number; turns: number; usd?: number }
83
84// Adds what happened since the last flush, plus `extra`, to today's entry.
85//
86// Idempotent: the pending counts are taken (and zeroed) atomically before the
87// store is touched, so a flush that runs twice for one turn adds nothing the
88// second time. The price is that a failed store write loses that one batch
89// rather than adding it twice.
90//
91// Not atomic across sessions: `get` then `set` on one key is a read-modify-
92// write and the store has no compare-and-set. Two sessions flushing in the
93// same instant can lose one flush (the later `set` wins). The window is the
94// two awaits between the reads and the writes, and the loss is one turn of
95// one session.
96//
97// With `keepsLangs`, each newly counted file's type is added to `langs` too.
98const flush = async (host: Host, extra: Extra, keepsLangs = false): Promise<Rolled | undefined> => {
99 const taken = await host.takePending()
100 if (isEmpty(taken) && extra.sessions === 0 && extra.turns === 0) return undefined
101
102 const memo = await host.memo()
103 const fresh = taken.files.filter(file => !memo.seen.includes(file))
104 if (fresh.length > 0) await host.patchMemo(m => ({ ...m, seen: [...m.seen, ...fresh].slice(-MAX_SEEN_FILES) }))
105 if (taken.calls > 0) await host.patchMemo(m => ({ ...m, calls: (m.calls ?? 0) + taken.calls }))
106
107 const now = await host.now()
108 const date = localDate(now)
109 const delta = { ...taken, ...extra, files: fresh.length }
110 const days: Record<string, Day> = prune(addToDay(readDays(await host.store.get(DAYS)), date, delta), date)
111 const life = addToLife(readLife(await host.store.get(LIFE)), taken)
112 await host.store.set(DAYS, days)
113 await host.store.set(LIFE, life)
114 if (keepsLangs && fresh.length > 0) await host.store.set(LANGS, addLangs(readLangs(await host.store.get(LANGS)), fresh))
115 return { date, now, days, life }
116}
117
118export const registerStats = (on: On, rules: readonly StatsRule[]): void => {
119 // Only a mod with a rule that reads file types keeps them.
120 const keepsLangs = rules.some(rule => rule.langs === true)
121
122 const callers = rules.filter(rule => rule.call !== undefined)
123
124 on('tool.call', async ($, e, next) => {
125 const ran = await next(e)
126 try {
127 await update($, pending, so_far => observe(so_far, e, ran))
128 } catch (error) {
129 await $.ui.log(failed(`record ${e.tool}`, error))
130 }
131 if (callers.length > 0) {
132 try {
133 const now = await $.clock.now()
134 const store: Store = { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) }
135 const ctx = {
136 date: localDate(now),
137 now,
138 tool: e.tool,
139 ...(e.tool === 'Bash' ? { command: e.command } : {}),
140 ok: ran.deny === undefined && ran.isError !== true,
141 store,
142 }
143 for (const rule of callers) {
144 try {
145 for (const line of (await rule.call?.(ctx)) ?? []) await $.ui.toast(line)
146 } catch (error) {
147 await $.ui.log(failed(rule.id, error))
148 }
149 }
150 } catch (error) {
151 await $.ui.log(failed('call', error))
152 }
153 }
154 return ran
155 })
156
157 // The cost as the turn begins: what this turn's cost is measured from.
158 on('turn.start', async ($, e, next) => {
159 try {
160 const usage = await $.session.usage()
161 await update($, session, memo => ({ ...memo, turnStartUsd: usage.cost?.usd ?? null }))
162 } catch (error) {
163 await $.ui.log(failed('turn start', error))
164 }
165 return next(e)
166 })
167
168 on('turn.complete', async ($, e, next) => {
169 // A subagent's turn is not the person's turn.
170 if (e.agentId !== undefined) return next(e)
171 try {
172 // Claim the turn id first: a second end of the same turn stops here.
173 let isNew = true
174 await update($, session, memo => {
175 isNew = memo.lastTurn !== e.turnId
176 return isNew ? { ...memo, lastTurn: e.turnId } : memo
177 })
178 if (!isNew) return next(e)
179
180 const usage = await attempt(() => $.session.usage())
181 const usd = usage?.cost?.usd
182 const memo = await read($, session)
183 const base = memo.turnStartUsd ?? memo.lastUsd
184 // A cost that fell (a cleared session) leaves the turn's cost unknown.
185 const spent = usd !== undefined && base !== null && usd >= base ? usd - base : undefined
186 await update($, session, m => ({ ...m, turnStartUsd: null, lastUsd: usd ?? m.lastUsd }))
187
188 const host = hostOf({
189 updatePending: fn => update($, pending, fn),
190 readSession: () => read($, session),
191 updateSession: fn => update($, session, fn),
192 now: () => $.clock.now(),
193 store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
194 })
195 const rolled = await flush(host, { sessions: 0, turns: 1, ...(spent === undefined ? {} : { usd: spent }) }, keepsLangs)
196 if (rolled === undefined) return next(e)
197 const latest = await read($, session)
198 const sessionCalls = latest.calls ?? 0
199
200 for (const rule of rules) {
201 if (rule.after === undefined) continue
202 try {
203 const lines = await rule.after({
204 ...rolled,
205 store: host.store,
206 event: 'turn',
207 sessionCalls,
208 promptAt: rolled.now - e.durationMs,
209 ...(usage === undefined ? {} : { startedAt: usage.startedAt, runStartedAt: runStartOf(usage.startedAt, latest) }),
210 })
211 for (const line of lines) await $.ui.toast(line)
212 } catch (error) {
213 await $.ui.log(failed(rule.id, error))
214 }
215 }
216 } catch (error) {
217 await $.ui.log(failed('turn end', error))
218 }
219 return next(e)
220 })
221
222 // Calls of a turn that never ended (the session was closed) still count.
223 on('session.end', async ($, e, next) => {
224 try {
225 const host = hostOf({
226 updatePending: fn => update($, pending, fn),
227 readSession: () => read($, session),
228 updateSession: fn => update($, session, fn),
229 now: () => $.clock.now(),
230 store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
231 })
232 await flush(host, { sessions: 0, turns: 0 }, keepsLangs)
233 // A /clear goes on in this process as a new session with no session.start,
234 // so its tool calls count from 0 again once the ending one is judged.
235 const isClear = e.reason === 'clear'
236 const enders = rules.filter(rule => rule.ended !== undefined)
237 if (enders.length > 0) {
238 const rolled = await load(host)
239 const usage = await attempt(() => $.session.usage())
240 const usd = usage?.cost?.usd
241 const ctx = {
242 ...rolled,
243 store: host.store,
244 sessionCalls: (await read($, session)).calls ?? 0,
245 ...(usage === undefined ? {} : { startedAt: usage.startedAt }),
246 ...(usd === undefined ? {} : { usd }),
247 }
248 for (const rule of enders) {
249 try {
250 await rule.ended?.(ctx)
251 } catch (error) {
252 await $.ui.log(failed(rule.id, error))
253 }
254 }
255 }
256 if (isClear) await update($, session, memo => ({ ...memo, calls: 0 }))
257 } catch (error) {
258 await $.ui.log(failed('session end', error))
259 }
260 return next(e)
261 })
262
263 on('session.start', async ($, e, next) => {
264 for (const rule of rules) {
265 if (rule.command === undefined) continue
266 await $.command.register({ name: rule.command.name, description: rule.command.description })
267 }
268 try {
269 const host = hostOf({
270 updatePending: fn => update($, pending, fn),
271 readSession: () => read($, session),
272 updateSession: fn => update($, session, fn),
273 now: () => $.clock.now(),
274 store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
275 })
276 // A session is counted once, however often session.start is raised. The
277 // first start this process sees is where this run began.
278 const startNow = await $.clock.now()
279 let isNew = true
280 await update($, session, memo => {
281 isNew = !memo.counted
282 return isNew ? { ...memo, counted: true, since: memo.since ?? startNow } : memo
283 })
284 const rolled = (isNew ? await flush(host, { sessions: 1, turns: 0 }, keepsLangs) : undefined) ?? (await load(host))
285 const usage = await attempt(() => $.session.usage())
286 const memo = await read($, session)
287 const sessionCalls = memo.calls ?? 0
288
289 for (const rule of rules) {
290 if (rule.after === undefined) continue
291 try {
292 const lines = await rule.after({
293 ...rolled,
294 store: host.store,
295 event: 'session',
296 sessionCalls,
297 ...(usage === undefined ? {} : { startedAt: usage.startedAt, runStartedAt: runStartOf(usage.startedAt, memo) }),
298 })
299 for (const line of lines) await $.ui.toast(line)
300 } catch (error) {
301 await $.ui.log(failed(rule.id, error))
302 }
303 }
304 } catch (error) {
305 await $.ui.log(failed('session start', error))
306 }
307 return next(e)
308 })
309}
310
311// What a command's answer needs from the host, as closures over the hook's `$`.
312export type CommandIo = {
313 now: () => Promise<number>
314 store: Store
315 log: (text: string) => unknown
316 // Absent for a host that never copies: the answer is then the text alone, and
317 // the mod does not hold the clipboard call at all.
318 copy?: (text: string) => Promise<UiCopyResult>
319}
320
321// Answers one slash command: the rule composes its text from the rollup, and a
322// result is copied to the clipboard only when the host gives a copy.
323export const answerStatsCommand = async (
324 command: NonNullable<StatsRule['command']>,
325 args: string,
326 io: CommandIo,
327): Promise<{ text: string }> => {
328 const { store } = io
329 let view: View
330 try {
331 const now = await io.now()
332 const [days, life] = [readDays(await store.get(DAYS)), readLife(await store.get(LIFE))]
333 view = { date: localDate(now), now, days, life, store }
334 } catch (error) {
335 await io.log(failed(`${command.name} read`, error))
336 view = { date: localDate(0), now: 0, days: {}, life: EMPTY_LIFE, store }
337 }
338
339 let composed: Composed
340 try {
341 composed = await command.compose(view, args)
342 } catch (error) {
343 return { text: `${command.name}: failed, ${error instanceof Error ? error.message : String(error)}` }
344 }
345 if (typeof composed !== 'string') return { text: composed.text }
346 const text = composed
347 const copier = io.copy
348 if (copier === undefined) return { text }
349
350 const copy = await attempt(() => copier(text))
351 const note = copy === undefined ? 'not copied (clipboard error)' : copy.isCopied ? 'copied to clipboard' : `not copied (${copy.reason})`
352 return { text: `${text}\n\n${note}` }
353}
354hooks/hosts/stats-command.ts 21 lines1import type { On } from 'claude-code'
2
3import { answerStatsCommand } from '../engine'
4import type { StatsRule } from '../rule'
5
6// The commands that only print. This host has no clipboard call, so a mod
7// whose rules never copy does not list one.
8export const answerStatsCommands = (on: On, rules: readonly StatsRule[]): void => {
9 for (const rule of rules) {
10 const command = rule.command
11 if (command === undefined) continue
12 on('command.run', { command: command.name }, async ($, e) =>
13 answerStatsCommand(command, e.args, {
14 now: () => $.clock.now(),
15 store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
16 log: text => $.ui.log(text),
17 }),
18 )
19 }
20}
21hooks/rules/bead-streak.ts 98 lines1import { addDays } from '../date'
2import { closesBead } from '../bead'
3import type { Call, StatsRule, View } from '../rule'
4import { span } from '../streak'
5
6// Days with a closed bead, kept in the mod's own store: `bead-days` maps a
7// local date to how many successful `bd close` calls ran that day, and
8// `bead-best` is the longest streak so far (kept apart because old days are
9// pruned). The first streak is the mark to beat and toasts nothing, as in
10// personal-bests.
11const DAYS_KEY = 'bead-days'
12const BEST_KEY = 'bead-best'
13const KEEP_DAYS = 400
14const RECENT = 14
15
16type BeadDays = Readonly<Record<string, number>>
17
18const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
19
20const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
21
22// The store is outside this code: an entry that is not a date with a count above 0 is dropped.
23const readDays = (raw: unknown): BeadDays =>
24 isRecord(raw)
25 ? Object.fromEntries(
26 Object.entries(raw).filter(([date, n]) => /^\d{4}-\d{2}-\d{2}$/.test(date) && typeof n === 'number' && Number.isFinite(n) && n > 0),
27 ) as BeadDays
28 : {}
29
30const readBest = (raw: unknown): number => (typeof raw === 'number' && Number.isInteger(raw) && raw > 0 ? raw : 0)
31
32const hasClose = (days: BeadDays, date: string): boolean => (days[date] ?? 0) > 0
33
34// Consecutive days with a close, counting back from `date` (0 when `date` has none).
35const runEndingAt = (days: BeadDays, date: string): number => {
36 let n = 0
37 while (hasClose(days, addDays(date, -n))) n += 1
38 return n
39}
40
41// The streak that is alive on `date`: today's run, or yesterday's while today is still open.
42const currentStreak = (days: BeadDays, date: string): number =>
43 hasClose(days, date) ? runEndingAt(days, date) : runEndingAt(days, addDays(date, -1))
44
45const longestRun = (days: BeadDays, date: string): number => {
46 const first = Object.keys(days).sort()[0]
47 if (first === undefined) return 0
48 let best = 0
49 let run = 0
50 for (const d of span(first, date)) {
51 run = hasClose(days, d) ? run + 1 : 0
52 best = Math.max(best, run)
53 }
54 return best
55}
56
57const pruned = (days: BeadDays, date: string): BeadDays => {
58 const oldest = addDays(date, -(KEEP_DAYS - 1))
59 return Object.fromEntries(Object.entries(days).filter(([d]) => d >= oldest))
60}
61
62const compose = async ({ date, store }: View): Promise<string> => {
63 const days = readDays(await store.get(DAYS_KEY))
64 const streak = currentStreak(days, date)
65 const best = Math.max(readBest(await store.get(BEST_KEY)), longestRun(days, date))
66 const cells = span(addDays(date, -(RECENT - 1)), date)
67 .map(d => (hasClose(days, d) ? '#' : '.'))
68 .join('')
69 const hint = !hasClose(days, date) ? [streak > 0 ? 'Close a bead today to keep it going.' : 'Close a bead with bd close to start a streak.'] : []
70 return [`Bead streak: ${plural(streak, 'day')} (best ${plural(best, 'day')})`, `last ${RECENT} days ${cells}`, ...hint].join('\n')
71}
72
73// A close that ended with exit 0: count the day, and say so when the streak passes its best.
74const onCall = async ({ tool, command, ok, date, store }: Call): Promise<readonly string[]> => {
75 if (tool !== 'Bash' || !ok || command === undefined || !closesBead(command)) return []
76 const days = readDays(await store.get(DAYS_KEY))
77 const isNewDay = !hasClose(days, date)
78 const kept = pruned({ ...days, [date]: (days[date] ?? 0) + 1 }, date)
79 await store.set(DAYS_KEY, kept)
80 if (!isNewDay) return []
81
82 const streak = runEndingAt(kept, date)
83 const best = Math.max(readBest(await store.get(BEST_KEY)), longestRun(days, date))
84 if (streak <= best) return []
85 await store.set(BEST_KEY, streak)
86 return best > 0 ? [`New best bead streak: ${plural(streak, 'day')}`] : []
87}
88
89export const rule: StatsRule = {
90 id: 'bead-streak',
91 command: {
92 name: 'bead-streak',
93 description: 'Show your streak of consecutive days with a closed bead',
94 compose,
95 },
96 call: onCall,
97}
98hooks/date.ts 28 lines1const two = (n: number): string => String(n).padStart(2, '0')
2
3// The local calendar date, YYYY-MM-DD: "today" is the person's day, not UTC's.
4export const localDate = (at: number): string => {
5 const date = new Date(at)
6 return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
7}
8
9const parts = (date: string): [number, number, number] => {
10 const [y = 1970, m = 1, d = 1] = date.split('-').map(Number)
11 return [y, m, d]
12}
13
14// Noon, so a daylight saving jump never moves the date.
15export const addDays = (date: string, n: number): string => {
16 const [y, m, d] = parts(date)
17 return localDate(new Date(y, m - 1, d + n, 12).getTime())
18}
19
20const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
21
22export const weekday = (date: string): string => {
23 const [y, m, d] = parts(date)
24 return WEEKDAYS[new Date(y, m - 1, d, 12).getDay()] ?? ''
25}
26
27export const isDate = (text: string): boolean => /^\d{4}-\d{2}-\d{2}$/.test(text)
28hooks/exts.ts 36 lines1// Files edited per file type, across sessions (store key `langs`). Kept apart
2// from the days, whose shape other mods read whole. Counts only: the type of
3// each file, never its path or its contents.
4
5export type Langs = Record<string, number>
6
7export const LANGS = 'langs'
8
9// A store keeps this many types; later new ones count as `(other)`.
10const MAX_TYPES = 50
11export const NO_EXT = '(none)'
12export const OTHER = '(other)'
13
14const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0)
15const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
16
17// The file type a path names: what follows the last dot of its name, lower
18// case. A name with no dot, or only a leading one (`.gitignore`), has none.
19export const extOf = (path: string): string => {
20 const name = path.split('/').pop() ?? ''
21 const dot = name.lastIndexOf('.')
22 if (dot <= 0 || dot === name.length - 1) return NO_EXT
23 const ext = name.slice(dot + 1).toLowerCase()
24 return /^[a-z0-9+_-]{1,10}$/.test(ext) ? ext : OTHER
25}
26
27export const readLangs = (raw: unknown): Langs =>
28 isRecord(raw) ? Object.fromEntries(Object.entries(raw).map(([ext, n]) => [ext, count(n)])) : {}
29
30// A new tally with one more file for each path's type. Pure.
31export const addLangs = (langs: Langs, paths: readonly string[]): Langs =>
32 paths.map(extOf).reduce<Langs>((tally, ext) => {
33 const key = ext in tally || Object.keys(tally).length < MAX_TYPES ? ext : OTHER
34 return { ...tally, [key]: (tally[key] ?? 0) + 1 }
35 }, langs)
36hooks/rule.ts 81 lines1import type { Day, Life } from '../types'
2
3export type Days = Readonly<Record<string, Day>>
4
5// The mod's own store, as closures over `$.store` (a rule never holds `$`).
6export type Store = {
7 get: (key: string) => Promise<unknown>
8 set: (key: string, value: unknown) => Promise<void>
9}
10
11// What a command reads: the rollup as it stands.
12export type View = {
13 /** The local date, YYYY-MM-DD. */
14 date: string
15 /** The clock, in ms. */
16 now: number
17 days: Days
18 life: Life
19 store: Store
20}
21
22// What a rule sees right after the engine wrote a session start or a turn end.
23export type After = View & {
24 event: 'session' | 'turn'
25 /** When the session began, if the host said. A resumed session keeps its first start. */
26 startedAt?: number
27 /**
28 * When this run of the session began: the later of `startedAt` and the
29 * first session start this process saw. A resumed session's time away is
30 * not in it. Absent when the host gave no start.
31 */
32 runStartedAt?: number
33 /** Tool calls counted this session so far. */
34 sessionCalls?: number
35 /** On a turn: when its prompt was sent (the turn's end less its length). */
36 promptAt?: number
37}
38
39// What a rule sees as the session ends, after the last flush. A toast here
40// would go unseen, so `ended` answers nothing.
41export type Ended = View & {
42 /** When the session began, if the host said. */
43 startedAt?: number
44 /** The session's cost in US dollars, if the host said. */
45 usd?: number
46 sessionCalls?: number
47}
48
49// What a rule sees when a tool call has finished (a denied call included).
50export type Call = {
51 /** The local date, YYYY-MM-DD. */
52 date: string
53 /** The clock, in ms. */
54 now: number
55 tool: string
56 /** The command of a Bash call. */
57 command?: string
58 /** True when the call ran and did not fail: not denied, not an error. */
59 ok: boolean
60 store: Store
61}
62
63// Text to print, or `{ text, copy: false }` for a message that is not a result.
64export type Composed = string | { text: string; copy: false }
65
66export type StatsRule = {
67 id: string
68 command?: {
69 name: string
70 description: string
71 compose: (view: View, args: string) => Promise<Composed> | Composed
72 }
73 /** Has the engine keep files edited per type under the store key `langs`. */
74 langs?: boolean
75 /** Toast lines to show after each tool call, in order. Only a mod with such a rule runs it. */
76 call?: (ctx: Call) => Promise<readonly string[]>
77 /** Toast lines to show, in order. */
78 after?: (ctx: After) => Promise<readonly string[]>
79 ended?: (ctx: Ended) => Promise<void>
80}
81hooks/rollup.ts 89 lines1import type { Day, Life, Pending } from '../types'
2import { addDays, isDate } from './date'
3
4export const KEEP_DAYS = 400
5// A day keeps this many distinct tool names; later new ones count as `other`.
6const MAX_TOOLS = 25
7
8export const EMPTY_DAY: Day = { sessions: 0, turns: 0, calls: 0, files: 0, passed: 0, failed: 0, blocked: 0, tools: {} }
9export const EMPTY_LIFE: Life = { calls: 0, blocked: 0, passed: 0 }
10
11const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0)
12const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
13
14const readDay = (raw: unknown): Day | undefined => {
15 if (!isRecord(raw)) return undefined
16 const tools = isRecord(raw['tools']) ? Object.fromEntries(Object.entries(raw['tools']).map(([name, n]) => [name, count(n)])) : {}
17 const usd = typeof raw['usd'] === 'number' && Number.isFinite(raw['usd']) ? raw['usd'] : undefined
18 return {
19 sessions: count(raw['sessions']),
20 turns: count(raw['turns']),
21 calls: count(raw['calls']),
22 files: count(raw['files']),
23 passed: count(raw['passed']),
24 failed: count(raw['failed']),
25 blocked: count(raw['blocked']),
26 ...(usd === undefined ? {} : { usd }),
27 tools,
28 }
29}
30
31// The store is outside this code: anything in `days` that is not a dated,
32// well-formed entry is dropped, and a missing field reads as zero.
33export const readDays = (raw: unknown): Record<string, Day> => {
34 if (!isRecord(raw)) return {}
35 const entries = Object.entries(raw).flatMap(([date, value]) => {
36 const day = isDate(date) ? readDay(value) : undefined
37 return day === undefined ? [] : [[date, day] as const]
38 })
39 return Object.fromEntries(entries)
40}
41
42export const readLife = (raw: unknown): Life =>
43 isRecord(raw) ? { calls: count(raw['calls']), blocked: count(raw['blocked']), passed: count(raw['passed']) } : EMPTY_LIFE
44
45// What one flush adds.
46export type Delta = Pick<Pending, 'calls' | 'blocked' | 'passed' | 'failed' | 'tools'> & {
47 sessions: number
48 turns: number
49 files: number
50 usd?: number
51}
52
53const mergeTools = (have: Record<string, number>, add: Record<string, number>): Record<string, number> =>
54 Object.entries(add).reduce((tools, [name, n]) => {
55 const key = name in tools || Object.keys(tools).length < MAX_TOOLS ? name : 'other'
56 return { ...tools, [key]: (tools[key] ?? 0) + n }
57 }, have)
58
59// A new days map with `delta` added to `date`. Pure.
60export const addToDay = (days: Record<string, Day>, date: string, delta: Delta): Record<string, Day> => {
61 const day = days[date] ?? EMPTY_DAY
62 const usd = delta.usd === undefined ? day.usd : (day.usd ?? 0) + delta.usd
63 return {
64 ...days,
65 [date]: {
66 sessions: day.sessions + delta.sessions,
67 turns: day.turns + delta.turns,
68 calls: day.calls + delta.calls,
69 files: day.files + delta.files,
70 passed: day.passed + delta.passed,
71 failed: day.failed + delta.failed,
72 blocked: day.blocked + delta.blocked,
73 ...(usd === undefined ? {} : { usd }),
74 tools: mergeTools(day.tools, delta.tools),
75 },
76 }
77}
78
79export const prune = (days: Record<string, Day>, today: string): Record<string, Day> => {
80 const oldest = addDays(today, -KEEP_DAYS)
81 return Object.fromEntries(Object.entries(days).filter(([date]) => date >= oldest))
82}
83
84export const addToLife = (life: Life, delta: Pick<Delta, 'calls' | 'blocked' | 'passed'>): Life => ({
85 calls: life.calls + delta.calls,
86 blocked: life.blocked + delta.blocked,
87 passed: life.passed + delta.passed,
88})
89hooks/tracker.ts 34 lines1import type { Pending, SessionMemo } from '../types'
2import { isTestCommand } from './testcmd'
3
4export const EMPTY_PENDING: Pending = { calls: 0, blocked: 0, passed: 0, failed: 0, tools: {}, files: [] }
5export const FRESH_SESSION: SessionMemo = { counted: false, lastTurn: null, turnStartUsd: null, lastUsd: null, seen: [], calls: 0 }
6
7const MAX_PENDING_FILES = 500
8export const MAX_SEEN_FILES = 2000
9const FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
10
11type Call = { readonly tool: string; readonly [argument: string]: unknown }
12type Answer = { readonly deny?: string; readonly isError?: boolean }
13
14const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
15
16// The pending counts after one more tool call. Pure. A denied call is a call
17// and a block, but touched no file and ran no test.
18export const observe = (pending: Pending, e: Call, ran: Answer): Pending => {
19 const isDenied = ran.deny !== undefined
20 const file = isDenied || !FILE_TOOLS.has(e.tool) ? undefined : (text(e.file_path) ?? text(e.notebook_path))
21 const command = isDenied || e.tool !== 'Bash' ? undefined : text(e.command)
22 const isTest = command !== undefined && isTestCommand(command)
23 const isNewFile = file !== undefined && !pending.files.includes(file) && pending.files.length < MAX_PENDING_FILES
24
25 return {
26 calls: pending.calls + 1,
27 blocked: pending.blocked + (isDenied ? 1 : 0),
28 passed: pending.passed + (isTest && ran.isError !== true ? 1 : 0),
29 failed: pending.failed + (isTest && ran.isError === true ? 1 : 0),
30 tools: { ...pending.tools, [e.tool]: (pending.tools[e.tool] ?? 0) + 1 },
31 files: isNewFile ? [...pending.files, file] : pending.files,
32 }
33}
34hooks/bead.ts 94 lines1// Says whether a Bash command line closes a bead: some command in it is
2// `bd close` or `bd done` (the alias), run for real. It only reads the text;
3// it never runs anything. The sound and stats engines each keep their own
4// copy of this file (a mod may import only its own files), and a test in each
5// holds the same table.
6//
7// Quoted text is one word, so `echo "bd close x"` is an echo. A command that
8// is written inside a string for another shell (`bash -c "bd close x"`) is
9// not seen. The caller checks the exit code: a line that ends with exit 0
10// but hid a failed close (`bd close x || true`) still counts, a limit of
11// reading only the text.
12
13// bd's global flags that take a value as the next word (`--flag=value` is one word).
14const VALUE_FLAGS = new Set(['-C', '--directory', '--actor', '--db', '--dolt-auto-commit'])
15// Words that may stand before a command and leave it the same command.
16const PREFIXES = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', 'time', 'command', 'exec', 'nohup', 'sudo', 'env'])
17const CLOSERS = new Set(['close', 'done'])
18const HEREDOC = /<<-?\s*(['"]?)([A-Za-z_][\w-]*)\1/g
19
20const withoutHeredocs = (command: string): string => {
21 const lines = command.split('\n')
22 const kept: string[] = []
23 let ends: string | undefined
24 for (const line of lines) {
25 if (ends !== undefined) {
26 if (line.trim() === ends) ends = undefined
27 continue
28 }
29 kept.push(line)
30 const match = [...line.matchAll(HEREDOC)].at(-1)
31 if (match !== undefined) ends = match[2]
32 }
33 return kept.join('\n')
34}
35
36// The commands of a line as lists of words. Quotes group a word and are
37// dropped; `;`, `&`, `|`, a newline and a parenthesis or brace end a command.
38const commandsOf = (line: string): string[][] => {
39 const commands: string[][] = []
40 let words: string[] = []
41 let word = ''
42 let inWord = false
43 let quote: '"' | "'" | undefined
44 const endWord = () => {
45 if (inWord) words = [...words, word]
46 word = ''
47 inWord = false
48 }
49 const endCommand = () => {
50 endWord()
51 if (words.length > 0) commands.push(words)
52 words = []
53 }
54 for (let i = 0; i < line.length; i += 1) {
55 const ch = line.charAt(i)
56 if (quote !== undefined) {
57 if (ch === quote) quote = undefined
58 else if (ch === '\\' && quote === '"' && i + 1 < line.length) word += line.charAt(++i)
59 else word += ch
60 } else if (ch === '"' || ch === "'") {
61 quote = ch
62 inWord = true
63 } else if (ch === '\\' && i + 1 < line.length) {
64 word += line.charAt(++i)
65 inWord = true
66 } else if (ch === ' ' || ch === '\t') endWord()
67 else if (';&|\n(){}`'.includes(ch)) endCommand()
68 else {
69 word += ch
70 inWord = true
71 }
72 }
73 endCommand()
74 return commands
75}
76
77const isBd = (word: string): boolean => word === 'bd' || word.endsWith('/bd')
78
79const closes = (words: readonly string[]): boolean => {
80 let i = 0
81 // Leading words that do not change the command: keywords, wrappers, NAME=value.
82 while (i < words.length && (PREFIXES.has(words[i] ?? '') || /^[A-Za-z_]\w*=/.test(words[i] ?? ''))) i += 1
83 if (!isBd(words[i] ?? '')) return false
84 i += 1
85 while (i < words.length && (words[i] ?? '').startsWith('-')) {
86 i += VALUE_FLAGS.has(words[i] ?? '') ? 2 : 1
87 }
88 if (!CLOSERS.has(words[i] ?? '')) return false
89 // Help and a read-only run change nothing.
90 return !words.some(word => word === '-h' || word === '--help' || word === '--readonly')
91}
92
93export const closesBead = (command: string): boolean => commandsOf(withoutHeredocs(command)).some(closes)
94hooks/streak.ts 37 lines1import type { Day } from '../types'
2import { addDays } from './date'
3
4type Days = Readonly<Record<string, Day>>
5
6export const hasTurn = (days: Days, date: string): boolean => (days[date]?.turns ?? 0) > 0
7
8// Consecutive days with a turn, counting back from `date` (0 when `date` has none).
9export const runEndingAt = (days: Days, date: string): number => {
10 let n = 0
11 while (hasTurn(days, addDays(date, -n))) n += 1
12 return n
13}
14
15// "Day N": today's run when today has a turn, else yesterday's run plus today,
16// which the person is about to start. A missed day leaves yesterday's run at 0.
17export const dayNumber = (days: Days, today: string): number =>
18 hasTurn(days, today) ? runEndingAt(days, today) : runEndingAt(days, addDays(today, -1)) + 1
19
20// The longest run of days with a turn anywhere in `dates`, oldest first.
21export const longestRun = (days: Days, dates: readonly string[]): number => {
22 let best = 0
23 let run = 0
24 for (const date of dates) {
25 run = hasTurn(days, date) ? run + 1 : 0
26 best = Math.max(best, run)
27 }
28 return best
29}
30
31// Every date from `from` to `to`, inclusive, oldest first.
32export const span = (from: string, to: string): string[] => {
33 const dates: string[] = []
34 for (let date = from; date <= to; date = addDays(date, 1)) dates.push(date)
35 return dates
36}
37hooks/testcmd.ts 16 lines1// A guess at "this Bash command ran a test suite": some segment of the line
2// starts with a known runner. Quoted text is dropped first, so a commit
3// message that names jest does not count.
4const RUNNER = new RegExp(
5 '^(?:\\w+=\\S+\\s+)*(?:(?:npx|pnpm exec|pnpm dlx|yarn|uv run|poetry run|python3? -m|bunx)\\s+)?' +
6 '(?:(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?test|vitest|jest|pytest|mocha|rspec|phpunit|ctest|' +
7 '(?:go|cargo|dotnet|swift|deno|mvn|gradle|make)\\s+test|cargo\\s+nextest|(?:npx\\s+)?playwright\\s+test)\\b',
8)
9
10const unquoted = (command: string): string => command.replace(/"[^"]*"|'[^']*'/g, '""')
11
12export const isTestCommand = (command: string): boolean =>
13 unquoted(command)
14 .split(/&&|\|\||;|\||\n/)
15 .some(segment => RUNNER.test(segment.trim()))
16