When a check you broke turns green after a real fix, the band above the prompt sweeps green once with a short click, and the result row keeps the score; silent…

English | 日本語
Personal Claude Code mods (function-hook plugins), published as one marketplace.
| Mod | What it does |
|---|---|
agent-pane | Live pane of the session's subagents: running ones with elapsed time, finished ones with result and duration |
done-gate | Flags unverified edits, stops repeated failing commands, and blocks git --no-verify |
sandbox-lens | Tallies sandbox denials seen in Bash output and reports them with /sandbox-report |
todo-agent | Collects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane. |
tool-row | Redraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat |
image-preview | Thumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane |
self-audit | After each turn, a cached fork of the conversation audits Claude's own reply against the CLAUDE.md reporting rules and flags violations above the prompt |
browser-cam | Live pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions |
prompt-coach | Holds back a typed prompt that looks ambiguous (no done-condition, unclear target) and offers guesses at what it meant, sending it as is, or adding one; on by default |
loopback | Feeds what self-audit, done-gate and sandbox-lens found back to Claude beside the next prompt, instead of only showing it to the person, skipping rules hit-rate found mostly wrong; off by default |
zukan | Error book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person how it was fixed last time |
pr-lamp | The current branch's pull request as a lamp: checks, review, unresolved threads and what is left before a merge, in the status line and a /pr pane; red checks and new review comments go to Claude beside the next prompt |
fog-map | Exploration map: the repository as a tree under fog, cleared where Claude read and marked where it edited, made a file or hit an error, with the importers an edit may have broken unseen; /map |
assertion-guard | Catches edits that weaken tests to pass: assertions removed, expectations loosened or rewritten, tests skipped, errors swallowed; asks Claude for the reason beside the edit |
negative-space | When an edit adds authorization, validation or error handling, lists the refusal and boundary-value tests no test touched this session covers; /negative-space |
mutation-tap | /mutate runs a mutation test limited to the lines changed since HEAD and lists the mutants the tests did not catch |
perf-delta | /perf compares a benchmark before and after (a saved baseline, or a git ref in a temporary worktree): median time, peak memory, spread, and whether the change beats the noise |
port-lantern | The dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports |
artifact-shelf | A shelf of what sessions made (CSV, PDF, HTML, video, patches…), tied to the call and request that made each; /shelf searches by kind, word and time |
side-effect-ledger | A ledger of what a git diff never shows (push, publish, deploy, PRs, API writes, messages) with outcome, how to undo, and whether the undo was checked; /ledger |
visual-diff | Compares browser screenshots of the same URL and size before and after an edit: side by side, a map of changed cells, regions, and the edits in between; /vdiff |
journey-trace | Records Claude's browser actions and the expected states as a replayable recipe; /journey md for a bug report, /journey e2e for a Playwright draft |
hit-rate | Gives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across sessions, and names the rules loopback stops feeding; /hit-rate; off by default |
token-lens | Checks an element's actual colours, spacing and type against the project's design tokens and lists off-token values with the nearest token; /token-lens <selector> |
kachi | When a check you broke turns green after a real fix, the band above the prompt sweeps green once with a short click, and the result row keeps the score: how many tries, how long it stayed red. Silent the rest of the time, but for one dim line under a check's first red |
cross-check | A second opinion from Codex in one press: the band above the prompt reviews the current diff with codex exec in its read-only sandbox, shows the top finding and the count, and puts the findings into the prompt for Claude to fix when asked; /codex lists them |
ci-rehearsal | CI before the push, in one press: the band above the prompt runs the push workflows locally with runthrough, shows the failing job and step with the count, and puts the failures and their log tails into the prompt for Claude to fix when asked; /ci lists every job |
stack-dock | The repository's devrouter stack in one press: the band above the prompt says whether it is unset, stopped or up, starts it with devrouter up, shows each service's URL and health in the status line and /stack, and puts doctor's findings and the failing logs into the prompt for Claude to fix when asked |
At the prompt of a terminal session:
/plugin install <mod> --marketplace simota/claude-mods
From a shell:
claude plugin install <mod> --marketplace simota/claude-mods
This repository is private: installing needs read access on GitHub and a git credential for github.com on the machine (an SSH key, or gh auth login then gh auth setup-git).
Options are set with /plugin configure <mod>@simota-mods in a session, or from a shell:
echo '{"enabled":"true"}' | claude plugin configure <mod>@simota-mods --values-stdin
A change made with /plugin configure reloads the mod; one made from a shell applies after a restart. Bands above the prompt stack: each mod draws its row above what the others draw.
/agent-pane opens the pane: running subagents first with elapsed time, then finished ones with ✓/✗ and duration; a background run that was stopped shows ■ 停止. A background run that left the roster without a result shows ? 結果不明; a teammate or remote launch shows ↗ 起動済み(追跡外).agents: N running while any run.Works on its own, no command:
ls build, npm ci are not checks), and only when its exit status is the command's: the check is last, or only && follows it (npm test | tail does not count).--no-verify (or a prefix of it from --no-ver), and git commit -n / -nm, is denied, also inside subshells, $(…), backticks, bash -c "…", eval and wrappers such as sudo/timeout. The value of an option (-m "…"), a comment or a heredoc never triggers it. So is a git command that moves its hooks for the run (-c core.hooksPath=…, --config-env core.hooksPath=…) or carries a hook manager's off switch in its environment (HUSKY=0, LEFTHOOK=0, pre-commit's SKIP=…), or moves the hooks through config in its environment (GIT_CONFIG_KEY_n=core.hooksPath, GIT_CONFIG_PARAMETERS). ANSI-C quoting ($'--no-\x76erify'), env -S "…", bash -c -- '…' and a shell reading its script from stdin (bash <<< '…', a heredoc, echo '…' | sh) are read through. It is a best-effort guard: git aliases (git ci -n), a hook path written into config beforehand and an environment exported by an earlier command are not detected./sandbox-report lists the sandbox denials seen in Bash output, for this session and all time: denied hosts and paths, counts, last seen, an example command, and which setting would allow each./sandbox-report reset clears the record.127.0.0.1:3000), since an entry without a port opens every port.<sandbox_violations> block, and Operation not permitted lines a program printed about itself, are read. For a command that succeeded or was interrupted, the block is taken from where the engine puts it, apart from the command's own output, so one inside a shown file or page is ignored even when it ends the output. A failed command keeps only its error text: the block that ends it (before an agent-proxy note) is read, and there a block the command printed itself cannot be told from the harness's. Credentials in the example command (tokens, passwords, Authorization/Cookie and other credential headers such as X-API-Key, URL userinfo, keys in env assignments including run-together names such as PGPASSWORD, known key shapes) are masked. Each section shows the top 10; when the record is full (200), the least recently seen entry gives way.#TODO(agent): <action> (after an optional quote or list bullet) in Claude's main-loop replies are collected per project (repo root or cwd). A mention inside a sentence, a table or inline code is not a TODO; subagent replies are ignored./todos opens the pane of this project's open items, each with a 完了 button./todos all lists every project's open items as text, marked as stored data.TODO(agent): N while the project has open items.Finished tool calls are drawn as one compact line with a Nerd Font icon: Edit/Write path +A −D, Read with line count, Bash (red on error), Agent, web and MCP calls. The result block under a compacted row is hidden, so a call is one line; an errored result stays. A row another mod decorated (an image-preview thumbnail) is left as it is, result included. Runs Claude Code folds into one count line (Read 3 files, ran 2 shell commands) stay folded. Terminal only.
| Option | Default | |
|---|---|---|
compact | true | Off leaves every row and result as Claude Code draws them |
/images opens a pane, newest first; h/l step through them.sips after the tool result has gone back, so tool calls never wait on it. Converted files live in a private cache ($TMPDIR or ~/.cache), newest 50 kept. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps./tmp/a b/x.jpg) joins at most 8 preceding pieces and 4096 characters; the first 64 paths named are read, and of those the first 4 that exist are shown.| Option | Default | |
|---|---|---|
inline | true | Thumbnail under the tool row |
maxRows | 12 | Rows a thumbnail may take, 1 to 60 |
After each reply of at least minChars characters, one tool-less fork of the conversation (served mostly from the prompt cache), given the reply itself as quoted data, checks it against the CLAUDE.md rules: claims done without evidence, first line not the result, unverified assertions, bypassed checks, scope inflation. Only violations of the five rules whose quote is found in the reply are kept (a made-up quote or rule is dropped); the quote is compared past markdown marks, bullets, curly quotes, case, spacing and a closing full stop, and one shortened with … is found piece by piece. Violations show in red above the prompt, in one row (the first and how many); 詳細 or /self-audit opens the pane with quotes and the fork's token use. Results belong to the latest reply only: when it was not audited, the pane says why.
| Option | Default | |
|---|---|---|
enabled | true | |
minChars | 200 | Shorter replies are not audited |
Cost: one extra request per turn on the session's model, plus the reply text (up to 12,000 characters) as uncached input; a cold cache (after 5 minutes idle or a model switch) resends the whole conversation.
type ‹7文字›, counted up to 10,000), since the field may be a password; a URL's secrets (token parameters, userinfo, key-shaped parts) are masked./cam toggles the pane; in a fullscreen terminal it opens by itself once per session, on the first browser call. Screenshots are decoded only while the pane is open, and written to a private per-session folder that is removed at session end. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.| Option | Default | |
|---|---|---|
autoOpen | true | Open on the first browser call (fullscreen terminal) |
On by default (enabled: false turns it off). A typed prompt that looks ambiguous (no done-condition and an unclear target, or only evaluative words like いい感じ) is held back, and a band shows what is missing with up to three guesses at what it meant (judged along the lines of the clarify skill: the likeliest reading first, each a different deliverable with a checkable done-condition):
a / b / d: a guess goes into the prompt to edit and send (not held again)s: このまま送る sends it unchangede: 補足して送る puts it back with 完了条件: to fill in (not held again)q: 閉じる puts it back in the promptTyping another prompt instead sends that one and puts the held text back into the prompt. The held text is never lost: when a send fails or is dropped beneath, or putting words back fails, it stays held with its band (unless another prompt was held since) and its words are shown in a toast; a prompt still held at /clear goes straight back into the box, and one held when the session quits comes back at this project's next interactive session start. Words go into an empty box as they are, and are appended after a draft typed meanwhile on a line of their own, never over it.
Slash commands, prompts under 12 or over 1500 characters, prompts with any attachment or an @ mention, prompts sent by a plugin, and prompts typed while a turn runs are not judged. A timeout or error sends the prompt unchanged; the same text is not held twice (within the last 50 judged). Sending can wait up to timeoutMs.
| Option | Default | |
|---|---|---|
enabled | true | |
model | claude-haiku-5-5 | Judging model (alias or id) |
timeoutMs | 3000 | 500 to 3000 |
Off by default. When on, the new findings of the detector mods are attached to the next prompt typed at an idle prompt, as context only the model reads, so the correction does not wait for the person to retype it:
self-audit: the violations of the latest audited replydone-gate: edits with no passing check after them (again after a check passed and new edits were left unverified; 閉じる on its band takes the flag down, and nothing is fed)sandbox-lens: denials seen this session, each onceEach finding goes back once per session, marked as data that may be wrong for Claude to check before acting. A toast says how many went back and from where. Slash commands, hidden prompts, prompts sent by a plugin and prompts typed while a turn runs carry nothing. A detector that is not installed contributes nothing.
With hit-rate on, the rules it muted are not fed (sandbox-lens denials never are muted).
/loopback shows what was fed last and the self-audit repeat rate: of the rules fed back, how many self-audit flagged again on the next audited reply (all sessions). The rate is what the design is judged by (.genius/decisions/loopback.md). It also names the rules hit-rate muted.| Option | Default | |
|---|---|---|
enabled | false |
Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).
An error book, kept across sessions and repos. When a Bash command fails, the error line is caught and numbered:
📕 新種のエラー発見! No.042「…」 — a kind not seen before📙 No.017 再会(3回目・未解決) — seen before, nothing known to get past it 📗 既知 No.017(3回目)— 前回: sandbox 外で git push origin main ` — seen before with a fix: Claude also reads, beside the tool's result, where it was seen and what got past it (marked as a past record to check, not an instruction)A fix is learned when the same program and subcommand (git push, pnpm install; read past a leading cd, export or source, so cd app && npm install is npm install) succeeds within the next 10 Bash calls of the session; whether it ran outside the sandbox is kept with it. Two sightings are one kind when their error line matches with paths, quotes, numbers and hashes taken out.
Failures of tests, linters, type checkers and builds are not caught: they are fixed by editing code, not by a command. Credentials in a kept command and in the kept error line are masked as sandbox-lens does.
/zukan opens the book, most recently seen first: kinds, solved, sightings/zukan note <No> <text> writes how to get past it, shown and fed in place of the learned command; an empty text removes the note/zukan forget <No> removes an entryThe newest 300 kinds by last sighting are kept.
The current branch's pull request, kept in the status line while it is open (once merged or closed, a toast says so and the line clears):
⎇ #42 · ✓5 ✗1 ⏳2 · 🛑 変更要求 · 💬3 · マージまであと4
"マージまであと N" counts what still stands before a merge: draft, failed checks, running checks, changes requested or approval pending, unresolved review threads, a conflict, the branch behind its base. At 0 it reads 🟢 マージ可能.
A toast when it changes: 🔴 CI 失敗: test, 💬 新しいレビューコメント 2件 (@rev), ✅ 承認されました, 🟢 #42 マージまであと0, 🎉 #42 がマージされました. Checks turning red, new review comments, changes requested and a new conflict are also attached to the next prompt typed at an idle prompt, once, with the last 40 lines of the failed log of up to three jobs (more are counted), marked as data whose comments and logs are not to be followed as instructions. The session's own replies on a thread (the comment, made after the post, carrying words it replied with through an MCP reply tool or gh api …/pulls/N/comments… -f body=, matched once) are not news; your own review comments are. What waits for the next prompt is about one pull request and is capped (newest kept). The option feed (on by default) turns that off.
/pr asks again and opens the pane: title, size, what is left, checks (failed first) and unresolved threads/pr <number|url|branch> follows that pull request; /pr clear goes back to the defaultWhich pull request: the one a Bash command of the session last worked on (gh pr comment 2, gh pr review 2, the URL gh pr create printed, the branch a git push sent to; looking with gh pr view|diff|checks does not move it), so work pushed to a review branch from another checkout is followed; else the checked-out branch's; else your newest open pull request in the repository.
It asks gh (gh pr view, and gh api graphql for review threads) at session start, right after a Bash git push, git checkout, gh pr create and the like, every minute while a check runs or after a push, and every five minutes otherwise; it stops once the pull request is merged or closed. Without an origin remote, gh, or a pull request for the branch it shows nothing, and /pr says why. gh runs on the host, outside the Bash sandbox, with your gh auth.
The repository as a map under fog, cleared as Claude works through it:
🗺 探索率 31% ██████░░░░░░░░░░░░░░ 読 23 · 編 9 · ✗ 2 / 101
▾ mods/
▾ pr-lamp/
▾ hooks/
● lamp.ts +43 −2 編集 4回
✗ register.tsx +59 −16 編集 6回 エラー 1 ★
▸ loopback/ ○●
░ agent-pane/ ░ browser-cam/ ░ done-gate/ …8
⚠ 編集したファイルを import しているが未読: mods/pr-lamp/tests/pr-lamp.test.ts
░ nothing touched yet, ○ read (Read, or cat, sed, grep… in Bash), ● edited (Edit, Write, NotebookEdit, sed -i), ✚ made by the session, ✗ named with a line number by a failed Bash command since its last edit, ★ the file touched last. A directory holding an edit, a new file or an error is open; one only read is closed with its marks beside it; untouched directories and files at a level share one fog row.
Two warnings, each a likely oversight: a file edited without being read, and a file that imports an edited one (JS/TS and Python, found by git grep) that was never opened. A very large repository's file list is cut to what one state value holds (3 M characters of paths), so it is still mapped, partly.
/map opens the pane🗺 31% · ●9 · ✗2 · ⚠1 only while there is an error or a warning, and clears otherwise🗺 新しい区域を開拓: mods/zukan/)The map is the session's, subagents' calls included; files outside the repository are not mapped. Explored is touched files over git ls-files, read again after a checkout, pull, mv or rm. Outside a git repository only the edit count is shown. A failed command's output is read once, in time linear in its length, before its result goes back.
On each Edit, Write or MultiEdit it diffs the whole file before and after the call, so an assertion moved within the file is no removal, and one only reformatted (spaces around punctuation, a closing ; or ,, and '…' for "…" in languages that read them alike) is no rewrite; a change inside a string still is. In test files (by name, or a Rust file with a test module anywhere in it) it flags assertions removed or commented out, a strict matcher turned weak (toEqual → toBeDefined, assertEqual → assertTrue, toThrow(X) → toThrow()), an assertion now about a literal (expect(true).toBe(true)), the same assertion now expecting another value or the opposite (toBe → not.toBe), and tests skipped or focused (it.skip, @pytest.mark.skip, #[ignore], t.Skip). In any file but prose (.md, .txt, .rst, .adoc) it flags errors newly swallowed (catch {}, .catch(() => undefined), except: pass, || true); one named in a comment (// or || true, a doc block, /* … */, # …) is not.
Findings ride beside the edit's re
hooks/register.tsx 298 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Band, CheckRecord, Edits, Locale, Tier } from '../types'
5
6import type { Classified } from './win'
7import type { BashOutcome, Globs } from './win'
8import { FRAME_MS, aliveText, classifyOrIgnore, compileGlobs, credit, editsOf, framesFor, isTestPath, localeOf, orNone, resolvePath, scanCommand, stampText, step, sweepFrame, withReceipt } from './win'
9
10// Long enough ago that every cooldown has passed; plain JSON, as -Infinity is not.
11const NEVER = -1e15
12
13const checks = atom({ plugin: 'kachi', key: 'checks' } as const, {} as Record<string, CheckRecord>)
14const edits = atom({ plugin: 'kachi', key: 'edits' } as const, { seq: 0, codeSeq: 0 } as Edits)
15const verifiedSeq = atom({ plugin: 'kachi', key: 'verifiedSeq' } as const, -1)
16const commitTick = atom({ plugin: 'kachi', key: 'commitTick' } as const, { at: NEVER, seq: 0 })
17const lastClipAt = atom({ plugin: 'kachi', key: 'lastClipAt' } as const, NEVER)
18const band = atom({ plugin: 'kachi', key: 'band' } as const, null as Band | null)
19const wins = atom({ plugin: 'kachi', key: 'wins' } as const, {} as Record<string, string>)
20const nextId = atom({ plugin: 'kachi', key: 'nextId' } as const, 0)
21const announced = atom({ plugin: 'kachi', key: 'announced' } as const, [] as string[])
22const MAX_ANNOUNCED = 64
23
24const IGNORED: Classified = { kind: 'ignore' }
25
26const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit'] as const
27const TIERS = { tick: 1, click: 2, chime: 3, bell: 4 } as const
28const GAIN: Record<Tier, number> = { 1: 0.2, 2: 0.35, 3: 0.4, 4: 0.45 }
29// A second clip within this long of the last one plays nothing.
30const REARM_MS = 20_000
31const COMMIT_COOLDOWN_MS = 10 * 60_000
32// The stamp stays this long after the turn ends, unless a prompt takes it down first.
33const LINGER_MS = 60_000
34const NARROW_COLUMNS = 40
35// The most of a persisted output read back for its counts: what one $.fs.read copies at most.
36const MAX_PERSISTED = 4 * 1024 * 1024
37const MAX_RASTER_COLUMNS = 512
38
39const COLOR: Record<Tier, string> = { 1: '#6fae80', 2: '#8fd69a', 3: '#a8efb6', 4: '#c9ffd4' }
40const RECEIPT_COLOR = '#6fae80'
41
42type Win = { tier: Tier; kind: Band['kind']; key: string; label: string; where: string; receipt?: string | undefined }
43
44// Motion and timers are the module's own: a reload drops them, and the band then draws its stamp.
45let sweep: { id: number; frame: number; timer?: Timer } | undefined
46let site: { requestId: string; columns: number } | undefined
47let linger: Timer | undefined
48
49function stopSweep(): void {
50 sweep?.timer?.cancel()
51 sweep = undefined
52}
53
54type Config = {
55 sound: 'on' | 'quiet' | 'off'
56 hasMotion: boolean
57 minTier: Tier
58 hasCommits: boolean
59 /** Read at the first green that could be a win, and kept until the module reloads. */
60 locale?: Locale
61}
62
63async function localeFor($: EngineInterface, config: Config): Promise<Locale> {
64 if (config.locale === undefined) {
65 const { language } = await $.settings.read()
66 config.locale = localeOf([typeof language === 'string' ? language : undefined, await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')])
67 }
68 return config.locale
69}
70
71/** Takes the band down, and stops its motion, when it still shows the win `id`. */
72async function takeDown($: EngineInterface, id: number): Promise<void> {
73 if (sweep?.id === id) stopSweep()
74 await update($, band, shown => (shown?.id === id ? null : shown))
75}
76
77function startSweep($: EngineInterface, id: number, tier: Tier): void {
78 stopSweep()
79 const total = framesFor(tier)
80 const run: { id: number; frame: number; timer?: Timer } = { id, frame: 0 }
81 sweep = run
82 run.timer = $.clock.every(FRAME_MS, () => {
83 if (sweep !== run) return
84 run.frame += 1
85 if (run.frame >= total) {
86 stopSweep()
87 void update($, band, shown => (shown?.id === id ? { ...shown, phase: 'stamp' as const } : shown))
88 return
89 }
90 if (site !== undefined) void $.ui.blit({ requestId: site.requestId, key: 'sweep', cells: sweepFrame(site.columns, tier, run.frame) })
91 })
92}
93
94// The clip goes out on a dispatch of its own, so the Bash result never waits for it.
95function play($: EngineInterface, config: Config, tier: Tier): void {
96 if (config.sound === 'off' || (config.sound === 'quiet' && tier === 1)) return
97 const gain = GAIN[tier] * (config.sound === 'quiet' ? 0.5 : 1)
98 $.clock.after(0, () => {
99 $.audio.play({ asset: `fx/kachi-${tier - 1}.wav` }, { gain }).catch((error: unknown) => {
100 $.ui.log(`kachi: the clip did not play: ${String(error)}`, { to: 'debug' })
101 })
102 })
103}
104
105async function celebrate($: EngineInterface, config: Config, win: Win, toolUseId: string, now: number, locale: Locale): Promise<void> {
106 if (win.tier < config.minTier) return
107 const receipt = win.receipt
108 if (receipt !== undefined) await update($, wins, all => withReceipt(all, toolUseId, receipt))
109 // A second win while the stamp shows only upgrades it, and only to a higher tier.
110 const shown = await read($, band)
111 if (shown !== null && win.tier <= shown.tier) return
112 const id = await update($, nextId, n => n + 1)
113 const isSweep = config.hasMotion && win.tier >= 2
114 await update($, band, () => ({ id, tier: win.tier, kind: win.kind, key: win.key, label: win.label, where: win.where, phase: isSweep ? 'sweep' : 'stamp', locale }))
115 if (isSweep) startSweep($, id, win.tier)
116 let isRearmed = false
117 await update($, lastClipAt, last => {
118 isRearmed = now - last >= REARM_MS
119 return isRearmed ? now : last
120 })
121 if (isRearmed) play($, config, win.tier)
122}
123
124/** Counts an edit to `paths` (absolute); one outside test files is a code edit, credited to the checks it can fix. */
125async function countEdit($: EngineInterface, paths: readonly string[], extraTests: Globs | undefined): Promise<void> {
126 if (paths.length === 0) return
127 const code = paths.filter(path => !isTestPath(path, extraTests))
128 await update($, edits, made => ({ seq: made.seq + 1, codeSeq: code.length > 0 ? made.seq + 1 : made.codeSeq }))
129 if (code.length > 0) await update($, checks, all => code.reduce(credit, all) as Record<string, CheckRecord>)
130}
131
132async function settle($: EngineInterface, config: Config, classified: Classified, toolUseId: string): Promise<void> {
133 if (classified.kind === 'ignore') return
134 const now = await $.clock.now()
135 const made = await read($, edits)
136 if (classified.kind === 'commit') {
137 // A clean commit: edits made, a check passed after the last of them, none failed since.
138 if (!config.hasCommits || made.seq === 0 || (await read($, verifiedSeq)) !== made.seq) return
139 let isTick = false
140 await update($, commitTick, last => {
141 isTick = last.seq < made.seq && now - last.at >= COMMIT_COOLDOWN_MS
142 return isTick ? { at: now, seq: made.seq } : last
143 })
144 if (isTick) await celebrate($, config, { tier: 1, kind: 'commit', key: '', label: classified.sha, where: '' }, toolUseId, now, await localeFor($, config))
145 return
146 }
147 const locale = classified.kind === 'unread' ? 'en' : await localeFor($, config)
148 // Decided inside the write, so two results settling together are judged one after the other.
149 let outcome: ReturnType<typeof step> | undefined
150 await update($, checks, all => {
151 outcome = step(all, classified, made, now, locale)
152 return outcome.checks
153 })
154 if (outcome === undefined) return
155 const isGreen = outcome.isGreen
156 await update($, verifiedSeq, seq => (isGreen ? made.seq : classified.kind === 'fail' ? -1 : seq))
157 if (classified.kind === 'fail') {
158 // A green the band still names is no longer true: nor is a commit stamp's "still verified".
159 const shown = await read($, band)
160 if (shown !== null && (shown.kind === 'commit' || shown.key === classified.check.key)) await takeDown($, shown.id)
161 // The first red of a check this session gets one dim line: kachi is here, and what will make it speak.
162 if (outcome.isFirstRed === true) {
163 const key = classified.check.key
164 let isNew = false
165 await update($, announced, list => {
166 isNew = !list.includes(key)
167 return isNew ? [...list, key].slice(-MAX_ANNOUNCED) : list
168 })
169 if (isNew) await update($, wins, all => withReceipt(all, toolUseId, aliveText(classified.check, locale)))
170 }
171 return
172 }
173 if (outcome.win !== undefined) await celebrate($, config, outcome.win, toolUseId, now, locale)
174}
175
176export const register: Register = (on, options) => {
177 const config: Config = {
178 sound: options.sound === 'quiet' || options.sound === 'off' ? options.sound : 'on',
179 hasMotion: options.motion !== 'none',
180 minTier: TIERS[String(options.minTier) as keyof typeof TIERS] ?? 2,
181 hasCommits: options.commits !== false,
182 }
183 const extraTests = compileGlobs(typeof options.testPaths === 'string' ? options.testPaths : '')
184
185 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
186 const ran = await next(e)
187 if (ran.deny !== undefined || e.run_in_background === true) return ran
188 const record = ran.isError === true ? undefined : ran.result
189 const text = ran.text ?? ''
190 const outcome: BashOutcome = {
191 command: e.command,
192 cwd: await $.session.cwd(),
193 isError: ran.isError === true,
194 text,
195 stdout: record?.stdout ?? '',
196 stderr: record?.stderr ?? '',
197 // A long failure reaches the model cut short; the stored error text may still hold its summary.
198 full: typeof ran.result === 'string' && ran.result.length > text.length ? ran.result : undefined,
199 isInterrupted: record?.interrupted === true,
200 isBackground: record?.backgroundTaskId !== undefined || record?.timedOutAfterMs !== undefined,
201 commit: record?.gitOperation?.commit,
202 }
203 const scanned = orNone(() => scanCommand(e.command, outcome.cwd), undefined)
204 let classified = scanned === undefined ? IGNORED : classifyOrIgnore(outcome, scanned)
205 // An output too large to inline keeps its summary in the file the host persisted: read it back
206 // only when what came inline gave no count.
207 const persisted = record?.persistedOutputPath
208 if (persisted !== undefined && (record?.persistedOutputSize ?? MAX_PERSISTED + 1) <= MAX_PERSISTED && (classified.kind === 'unread' || (classified.kind === 'fail' && classified.failCount === undefined))) {
209 const full = await $.fs.read(persisted).catch(() => undefined)
210 if (typeof full === 'string' && scanned !== undefined) classified = classifyOrIgnore({ ...outcome, full }, scanned)
211 }
212 const checkIndex = classified.kind === 'pass' || classified.kind === 'fail' || classified.kind === 'unread' ? classified.index : -1
213 // Files the command wrote are edits: those before its check come before its verdict.
214 const written = outcome.isError || scanned === undefined ? { before: [], after: [] } : editsOf(e.command, scanned, checkIndex)
215 await countEdit($, written.before, extraTests)
216 await settle($, config, classified, e.tool_use_id)
217 await countEdit($, written.after, extraTests)
218 return ran
219 }).catch(($, e, next) => next(e))
220
221 on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
222 const ran = await next(e)
223 if (ran.deny !== undefined || ran.isError === true) return ran
224 await countEdit($, [resolvePath(await $.session.cwd(), e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path)], extraTests)
225 return ran
226 }).catch(($, e, next) => next(e))
227
228 on('prompt.submit', async ($, e, next) => {
229 if (e.isMeta !== true && (await read($, band)) !== null) {
230 stopSweep()
231 linger?.cancel()
232 linger = undefined
233 await update($, band, () => null)
234 }
235 return next(e)
236 }).catch(($, e, next) => next(e))
237
238 on('turn.complete', async ($, e, next) => {
239 const shown = e.agentId === undefined ? await read($, band) : null
240 if (shown !== null) {
241 linger?.cancel()
242 const id = shown.id
243 linger = $.clock.after(LINGER_MS, () => {
244 linger = undefined
245 void update($, band, now => (now?.id === id ? null : now))
246 })
247 }
248 return next(e)
249 })
250
251 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
252 if (e.props.hasSurvey) return next(e)
253 const shown = await read($, band)
254 if (shown === null) return next(e)
255
256 // Stack this row above what the plugins beneath and the engine draw, rather than in its place.
257 const below = await next(e)
258 const { Box, Text } = $.ui.resolve(e)
259 const columns = e.props.bodyColumns
260 const run = sweep
261 if (e.surface === 'terminal' && shown.phase === 'sweep' && columns >= NARROW_COLUMNS && run?.id === shown.id) {
262 const { Raster } = $.ui.resolve(e)
263 const width = Math.min(columns, MAX_RASTER_COLUMNS)
264 site = { requestId: e.requestId, columns: width }
265 return (
266 <Box flexDirection="column">
267 <Raster key="sweep" columns={width} rows={1} cells={sweepFrame(width, shown.tier, run.frame)} />
268 {below}
269 </Box>
270 )
271 }
272 return (
273 <Box flexDirection="column">
274 <Text key="stamp" color={COLOR[shown.tier]} bold={shown.tier === 4} wrap="truncate-end">
275 {stampText(shown, columns)}
276 </Text>
277 {below}
278 </Box>
279 )
280 }).catch(($, e, next) => next(e))
281
282 on('ui.render', { component: 'ToolResult', props: { tool: 'Bash' } }, async ($, e, next) => {
283 const line = (await read($, wins))[e.props.tool_use_id]
284 if (line === undefined) return next(e)
285 const below = await next(e)
286 const { Box, Text } = $.ui.resolve(e)
287 return (
288 <Box flexDirection="column">
289 {below}
290 {/* A receipt is green; the first red's line is plain dim text. */}
291 <Text key="receipt" color={line.startsWith('✔') ? RECEIPT_COLOR : undefined} dimColor>
292 {line}
293 </Text>
294 </Box>
295 )
296 }).catch(($, e, next) => next(e))
297}
298hooks/win.ts 862 lines1// What counts as an earned green, and how one is drawn. Pure functions: register.tsx feeds
2// them a Bash result and the session's records, and writes back what they return.
3
4import type { Band, CheckRecord, Edits, Locale, Tier } from '../types'
5
6import type { Simple } from './shell'
7import { innerText, isVerification, parse, program } from './shell'
8
9/** The most of a command's output any parser here reads: its last 4 KiB. */
10export const TAIL = 4096
11export const MAX_CHECKS = 32
12export const MAX_WINS = 64
13const MAX_SCOPE_WORDS = 16
14const MAX_SCOPE_WORD = 200
15const MAX_DEPTH = 8
16
17// ---- output -----------------------------------------------------------------------------
18
19// CSI and OSC sequences, and the two-byte escapes; each alternative is linear.
20const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[@-Z\\-_]/g
21
22/** The last 4 KiB of the parts, joined, with colour codes taken out: all the parsers ever see. */
23export function outputTail(...parts: string[]): string {
24 const joined = parts
25 .map(part => part.slice(-TAIL))
26 .filter(part => part.length > 0)
27 .join('\n')
28 return joined.slice(-TAIL).replace(ANSI, '')
29}
30
31const FAIL_COUNT = /(\d{1,7}) (?:failed|failing|fail|failures?|errors?|problems?)\b|failures=(\d{1,7})|\bdue to (\d{1,7}) previous errors?\b/g
32const PASS_COUNT = /(\d{1,7}) (?:passed|passing|pass)\b|\bRan (\d{1,7}) tests?\b|\brunning (\d{1,7}) tests?\b/g
33const NO_TESTS = /\bno tests? (?:ran|found|to run|were found)\b|No test files found|collected 0 items|--passWithNoTests/i
34
35function largest(pattern: RegExp, text: string): number | undefined {
36 // Each pattern's count is in whichever of its groups matched.
37 let most: number | undefined
38 for (const match of text.slice(-TAIL).matchAll(pattern)) {
39 const value = Number(match[1] ?? match[2] ?? match[3])
40 if (most === undefined || value > most) most = value
41 }
42 return most
43}
44
45/** The failure count a test, lint or type-check run printed (`3 failed`, `Found 2 errors`), read from its last 4 KiB. */
46export const parseFailCount = (text: string): number | undefined => largest(FAIL_COUNT, text)
47
48// `go test` prints no count: a package that passed is its `ok` line.
49const GO_OK = /^ok\s+\S+\s+(?:[\d.]+s|\(cached\))/gm
50
51/** The pass count a test run printed (`44 passed`, `42 pass`, `Ran 3 tests`, go's `ok` lines), read from its last 4 KiB. */
52export function parsePassCount(text: string): number | undefined {
53 const counted = largest(PASS_COUNT, text)
54 if (counted !== undefined) return counted
55 const packages = text.slice(-TAIL).match(GO_OK)?.length ?? 0
56 return packages === 0 ? undefined : packages
57}
58
59// A runner's or a compiler's own line for a failure: jest's and vitest's `FAIL x.test.ts`, pytest's
60// `FAILED t.py::x`, go's `--- FAIL:` and `FAIL\tpkg`, TAP's `not ok`, rustc's and swiftc's `error:` /
61// `error[E0308]:` / `x.swift:3:5: error:`, cargo's `could not compile`, make's `*** [x] Error 2`.
62// Only at a line's start, so a test named "handles FAILED payment" or a log line saying FAIL is none.
63const FAIL_MARK = /^\s*FAIL\s+\S|^FAILED\s+\S|^--- FAIL:|^FAIL\t|^not ok\b|^error(?:\[E\d+\])?:|^\S+:\d+:\d+: error:|^error: could not compile|^make(?:\[\d+\])?: \*\*\* .*Error \d+/m
64
65// A check that prints nothing on success, or one closing line: what an empty or quiet pipe of it means.
66const SILENT_PASS: Record<string, RegExp> = {
67 tsc: /^$/,
68 'vue-tsc': /^$/,
69 eslint: /^$/,
70 'cargo build': /^\s*Finished `/m,
71 'cargo check': /^\s*Finished `/m,
72 'cargo clippy': /^\s*Finished `/m,
73 'swift build': /^Build complete!/m,
74}
75
76/** Whether a passing run ran nothing: a count of 0 passed and none above it, or a runner saying no tests ran. */
77export function isZeroRun(tail: string, command: string): boolean {
78 const passed = largest(PASS_COUNT, tail)
79 if (passed !== undefined) return passed === 0
80 return NO_TESTS.test(tail) || /--passWithNoTests\b/.test(command)
81}
82
83// What a package manager prints before running a script: `> app@1.0.0 typecheck /repo`, then `> tsc --noEmit`.
84const SCRIPT_HEADER = /^> \S+@\S* \S+/
85// Programs whose success prints nothing at all.
86const QUIET_PROGRAMS = new Set(['tsc', 'vue-tsc', 'eslint'])
87
88/** Whether an output is only a script's header, for a script that runs nothing but quiet programs. */
89function isQuietScript(tail: string): boolean {
90 const lines = tail
91 .split('\n')
92 .map(line => line.trim())
93 .filter(line => line !== '')
94 if (lines.length === 0 || lines.length > 8 || !lines.every(line => line.startsWith('> '))) return false
95 const run = lines.filter(line => !SCRIPT_HEADER.test(line)).map(line => line.slice(2))
96 return (
97 run.length > 0 &&
98 run.every(text =>
99 parse(text).commands.every(one => {
100 const name = program(one.words)?.name
101 return name !== undefined && QUIET_PROGRAMS.has(name)
102 }),
103 )
104 )
105}
106
107// The shell's or a runner's own words for a check that never ran: a missing program or script
108// (`sh: vitest: command not found`, `bash: ./run: No such file or directory`), a missing script or
109// target, a denial. Read only when the output holds no failure count and no FAIL line, so a test
110// failing on ENOENT or asserting "permission denied" is still a failure.
111const NOT_RUN = /^(?:[^\s:]+: )+(?:command not found|No such file or directory|[Pp]ermission denied|Operation not permitted)(?:: \S+)?\s*$|Missing script|ERR_PNPM_NO_SCRIPT|No rule to make target/m
112
113/** The exit status a failed Bash call reports in its first line, when it reports one. */
114export const exitCodeOf = (text: string): number | undefined => {
115 const match = /^Exit code (\d{1,3})\b/.exec(text.slice(0, 32))
116 return match === null ? undefined : Number(match[1])
117}
118
119// ---- which check ------------------------------------------------------------------------
120
121/** A check by its program and check word (`pnpm test`, `cargo clippy`, `tsc`), and the words that narrow what it runs. */
122export type Check = {
123 /** What its record is kept under: the check and the directory it ran in, so web's and api's never meet. */
124 key: string
125 /** The check by its program and check word: `pnpm test`, `cargo clippy`. */
126 name: string
127 /** The directory it ran in, absolute. */
128 dir: string
129 /** That directory relative to the session's, or '' when it is the session's. */
130 where: string
131 scope: string[]
132 isOverflow: boolean
133}
134
135/** A check as a line names it: `pnpm test`, or `pnpm test (web)` away from the session's directory. */
136export const labelOf = (check: { name: string; where: string }) => (check.where === '' ? check.name : `${check.name} (${check.where})`)
137
138const SUBCOMMANDED = new Set(['cargo', 'go', 'swift', 'deno', 'dotnet', 'prettier', 'biome'])
139const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
140const PM_VALUES = new Set(['-C', '--dir', '--filter', '-F', '--prefix', '-w', '--workspace', '--cwd'])
141const TARGETED = new Set(['make', 'just', 'task', 'gradle', 'gradlew', 'mvn', 'mvnw'])
142const TARGET = /^(?::?[\w-]+:)*(?:test\w*|tests|check|lint\w*|build|verify|typecheck|type-check|assemble\w*)(?:[:-].*)?$/
143// Words that change how a run looks, never which tests it runs.
144const HARMLESS = /^(?:--|--run|--ci|--colou?rs?|--no-colou?r|--color=\w+|-v+|--verbose|-q|--quiet|--silent|-s|--release|--offline|--locked|--frozen|--noEmit|--pretty|--incremental|--coverage|--no-coverage|--bail|-x|--exitfirst|--fail-fast|--no-fail-fast)$/
145
146const quote = (words: readonly string[]) => words.map(word => `'${word.replace(/'/g, `'\\''`)}'`).join(' ')
147
148// shell.ts's checks that are programs of their own: also what `pnpm vitest`, `pnpm exec jest` or `yarn tsc` run.
149const DIRECT_CHECKS = new Set([
150 'tsc', 'vue-tsc', 'mypy', 'pyright', 'eslint', 'ruff', 'vitest', 'jest', 'pytest', 'xcodebuild', 'swiftlint', 'ktlint',
151 'detekt', 'golangci-lint', 'rubocop', 'phpunit', 'phpstan', 'tox', 'nox', 'cargo-nextest', 'flake8', 'pylint', 'black',
152 'shellcheck', 'stylelint', 'playwright', 'rspec', 'ctest',
153])
154// Every program shell.ts reads as a check; any other simple command is one only through the text it runs.
155const CHECK_PROGRAMS = new Set([
156 ...DIRECT_CHECKS, 'prettier', 'biome', 'cargo', 'go', 'swift', 'deno', 'dotnet', 'mvn', 'mvnw', 'node', 'python', 'python3',
157 'npm', 'bun', 'pnpm', 'yarn', 'make', 'just', 'task', 'gradlew', 'gradle',
158])
159// The package-manager words that run the program named after them.
160const RUNS_PROGRAM = new Set(['exec', 'dlx', 'x'])
161// A check's words read at most: past them a pass is narrower by rule, so a 50,000-file argument list costs nothing.
162const MAX_WORDS = MAX_SCOPE_WORDS + 8
163
164/**
165 * The words of the check a package manager runs as a program (`pnpm exec vitest`, `pnpm vitest run a.test.ts`),
166 * and the manager's own words before it, which narrow it (`-C web`); other words as they are.
167 */
168function unwrapped(words: readonly string[]): { words: readonly string[]; scope: string[] } {
169 const run = program(words)
170 if (run === undefined || !PACKAGE_MANAGERS.has(run.name)) return { words, scope: [] }
171 for (let k = 0; k < run.args.length; k++) {
172 const arg = run.args[k] as string
173 if (PM_VALUES.has(arg)) k += 1
174 else if (RUNS_PROGRAM.has(arg)) return { words: run.args.slice(k + 1), scope: run.args.slice(0, k) }
175 else if (DIRECT_CHECKS.has(arg)) return { words: run.args.slice(k), scope: run.args.slice(0, k) }
176 else if (!arg.startsWith('-')) break
177 }
178 return { words, scope: [] }
179}
180
181const isPluginTest = (run: { name: string; args: string[] }) =>
182 run.name === 'claude' && run.args[0] === 'plugin' && run.args[1] === 'test' && !run.args.some(arg => arg === '--help' || arg === '-h')
183
184/** The words of a check, when these words are one: shell.ts's verdict, plus `claude plugin test` and package-manager runs. */
185function asCheck(words: readonly string[]): { words: readonly string[]; scope: string[] } | undefined {
186 const inner = unwrapped(words.length > MAX_WORDS ? words.slice(0, MAX_WORDS) : words)
187 const run = program(inner.words)
188 if (run === undefined) return undefined
189 if (run.name === 'claude') return isPluginTest(run) ? inner : undefined
190 if (!CHECK_PROGRAMS.has(run.name) && innerText(inner.words) === undefined) return undefined
191 return isVerification(quote(inner.words)) ? inner : undefined
192}
193
194/** A check a command runs, whether the command's exit status is the check's, and which top-level command runs it. */
195export type Reading = { check: Check; isStatus: boolean; index: number }
196
197// Between the check and the end, only these keep the check's failure the command's failure.
198const CARRIES_FAILURE = new Set<string>(['&&', '|', '|&'])
199const ENDS = new Set<string>(['', ';', '\n'])
200// Commands read in one command line at most: past them, the line is no reading.
201const MAX_COMMANDS = 256
202
203/** `dir` joined with `to` and normalized; `to` is a literal path (no `~`, `$`, `-`). */
204export function resolvePath(dir: string, to: string): string {
205 const parts: string[] = []
206 for (const part of (to.startsWith('/') ? to : `${dir}/${to}`).split('/')) {
207 if (part === '' || part === '.') continue
208 if (part === '..') parts.pop()
209 else parts.push(part)
210 }
211 return `/${parts.join('/')}`
212}
213
214/** Where a `cd` or `pushd` leaves the shell; null when it cannot be told from the text. */
215function cdTarget(words: readonly string[], dir: string): string | null | undefined {
216 const run = program(words)
217 if (run === undefined || (run.name !== 'cd' && run.name !== 'pushd')) return undefined
218 const to = run.args.find(arg => !arg.startsWith('-') || arg === '-')
219 if (to === undefined || to === '-' || to.startsWith('~') || to.includes('$') || to.includes('`')) return null
220 return resolvePath(dir, to)
221}
222
223// shell.ts reads these, which run another command, for their exit status only with `+` or `-e`.
224function passesStatus(words: readonly string[]): boolean {
225 const run = program(words)
226 if (run === undefined) return true
227 if (run.name === 'find') return run.args.includes('+')
228 if (run.name === 'script') return run.args.some(arg => arg === '--return' || /^-[A-Za-z]*e[A-Za-z]*$/.test(arg))
229 return true
230}
231
232type Place = { dir: string; root: string }
233
234/** One simple command as the reader met it: the top-level command it belongs to, and the directory it ran in. */
235export type Met = { index: number; dir: string; simple: Simple }
236
237/** What reading a command line found: the check, every simple command met, and whether the budget ran out. */
238export type Scan = { reading: Reading | undefined; met: readonly Met[]; isOver: boolean }
239
240// `;`, `&`, `|` and `>` separate commands and open redirections; spaces separate words. Newlines are
241// left out, so a heredoc holding a long file is still read (shell.ts sets its body aside unparsed).
242const MAX_OPERATORS = 8 * MAX_COMMANDS
243const MAX_SPACES = 8192
244
245/** Whether a command line holds more operators or words than a line kachi reads ever needs. */
246function isTooLong(text: string): boolean {
247 let operators = 0
248 let spaces = 0
249 for (let k = 0; k < text.length; k++) {
250 const code = text.charCodeAt(k)
251 if (code === 32) spaces += 1
252 else if (code === 59 || code === 38 || code === 124 || code === 62) operators += 1
253 }
254 return operators > MAX_OPERATORS || spaces > MAX_SPACES
255}
256
257// One budget of commands for a whole line, nested text included: past it the line is no reading.
258type Walker = { left: number; isOver: boolean; met: Met[]; root: string }
259
260/** The reading of one simple command: through the text it runs, or as a check of its own. */
261function checkIn(words: readonly string[], depth: number, dir: string, index: number, walker: Walker): Reading | undefined {
262 const inner = innerText(words)
263 if (inner !== undefined) {
264 if (depth >= MAX_DEPTH) return undefined
265 const found = walk(parse(inner).commands, depth + 1, dir, index, walker)
266 return found !== undefined && !passesStatus(words) ? { ...found, isStatus: false } : found
267 }
268 const run = program(words)
269 if (run === undefined || (!CHECK_PROGRAMS.has(run.name) && run.name !== 'claude')) return undefined
270 const found = asCheck(words)
271 const check = found === undefined ? undefined : named(found.words, found.scope, words.length > MAX_WORDS, { dir, root: walker.root })
272 return check === undefined ? undefined : { check, isStatus: true, index }
273}
274
275/** Reads commands once each, in order, with the directory the `cd`s before each left; `index` is the top-level command's, or -1 at the top. */
276function walk(commands: readonly Simple[], depth: number, start: string, index: number, walker: Walker): Reading | undefined {
277 walker.left -= commands.length
278 if (walker.left < 0) walker.isOver = true
279 if (walker.isOver) return undefined
280 const readings: (Reading | undefined)[] = []
281 let dir: string | null = start
282 for (const [k, one] of commands.entries()) {
283 const moved: string | null | undefined = dir === null ? undefined : cdTarget(one.words, dir)
284 if (moved !== undefined) dir = moved
285 if (dir === null || moved !== undefined || one.words.length === 0) {
286 readings.push(undefined)
287 continue
288 }
289 const at = index === -1 ? k : index
290 walker.met.push({ index: at, dir, simple: one })
291 readings.push(checkIn(one.words, depth, dir, at, walker))
292 if (walker.isOver) return undefined
293 }
294 const last = commands.length - 1
295 for (let k = last; k >= 0; k--) {
296 if (k < last - 1 && !CARRIES_FAILURE.has((commands[k + 1] as Simple).after)) break
297 const after = (commands[k] as Simple).after
298 const found = readings[k]
299 if (found !== undefined && (k === last ? ENDS.has(after) : after === '&&')) return found
300 }
301 let only: Reading | undefined
302 for (const found of readings) {
303 if (found === undefined) continue
304 if (only !== undefined) return undefined
305 only = { ...found, isStatus: false }
306 }
307 return only
308}
309
310/**
311 * Reads a command line once: the check it runs, its record keyed by the directory it runs in
312 * (`cwd`, moved by the command's own `cd`s; one that cannot be told from the text is no reading).
313 * Its exit status is the command's (shell.ts's isVerification) when it is the last command or only
314 * `&&` chains follow it. Otherwise (`pnpm test 2>&1 | tail -20`, `pytest || true`, `make check;
315 * echo done`) the one check in the command is read for its output; two checks printing into one
316 * output are none. 256 commands are read at most, nested text included; a longer line is none.
317 */
318export function scanCommand(command: string, cwd: string): Scan {
319 // Parsing is what costs: a line with more operators or words than it could read is over unparsed.
320 if (isTooLong(command)) return { reading: undefined, met: [], isOver: true }
321 const walker: Walker = { left: MAX_COMMANDS, isOver: false, met: [], root: cwd }
322 const reading = walk(parse(command).commands, 0, cwd, -1, walker)
323 return walker.isOver ? { reading: undefined, met: [], isOver: true } : { reading, met: walker.met, isOver: false }
324}
325
326export const readCheck = (command: string, cwd = '/'): Reading | undefined => scanCommand(command, cwd).reading
327
328function named(words: readonly string[], before: readonly string[], isCut: boolean, place: Place): Check | undefined {
329 const run = program(words)
330 if (run === undefined) return undefined
331 const { name, args } = run
332 let key = name
333 let rest = args
334 if (name === 'claude') {
335 key = 'claude plugin test'
336 rest = args.slice(2)
337 } else if (PACKAGE_MANAGERS.has(name)) {
338 // `pnpm -C web run test:unit -- a.test.ts`: the script is the key, everything else scope.
339 let at = -1
340 for (let k = 0; k < args.length; k++) {
341 const arg = args[k] as string
342 if (PM_VALUES.has(arg) || (name === 'yarn' && arg === 'workspace')) k += 1
343 else if (!arg.startsWith('-') && arg !== 'run' && arg !== 'run-script') {
344 at = k
345 break
346 }
347 }
348 if (at === -1) return undefined
349 key = `${name} ${args[at]}`
350 rest = [...args.slice(0, at).filter(arg => arg !== 'run' && arg !== 'run-script'), ...args.slice(at + 1)]
351 } else if (SUBCOMMANDED.has(name)) {
352 const at = args.findIndex(arg => !arg.startsWith('-') && !arg.startsWith('+'))
353 if (at !== -1) {
354 key = `${name} ${args[at]}`
355 rest = [...args.slice(0, at), ...args.slice(at + 1)]
356 }
357 } else if (TARGETED.has(name)) {
358 const at = args.findIndex(arg => !arg.startsWith('-') && TARGET.test(arg))
359 if (at !== -1) {
360 key = `${name} ${args[at]}`
361 rest = [...args.slice(0, at), ...args.slice(at + 1)]
362 }
363 } else if ((name === 'python' || name === 'python3') && args[0] === '-m' && args[1] !== undefined) {
364 key = args[1]
365 rest = args.slice(2)
366 } else if (name === 'node') {
367 key = 'node --test'
368 rest = args.filter(arg => arg !== '--test')
369 } else if (name === 'vitest' && args[0] === 'run') {
370 rest = args.slice(1)
371 }
372 const scope = [...before, ...rest].filter(word => !HARMLESS.test(word))
373 const where = place.dir === place.root ? '' : place.dir.startsWith(`${place.root}/`) ? place.dir.slice(place.root.length + 1) : place.dir
374 return {
375 key: `${key} @ ${place.dir}`,
376 name: key,
377 dir: place.dir,
378 where,
379 scope: scope.slice(0, MAX_SCOPE_WORDS).filter(word => word.length <= MAX_SCOPE_WORD),
380 isOverflow: isCut || scope.length > MAX_SCOPE_WORDS || scope.some(word => word.length > MAX_SCOPE_WORD),
381 }
382}
383
384/** Whether a pass runs at least what every failure since red began ran: no scope word it adds. */
385export const covers = (pass: Check, failedScope: readonly string[]) =>
386 !pass.isOverflow && pass.scope.every(word => failedScope.includes(word))
387
388// ---- commits ----------------------------------------------------------------------------
389
390const GIT_VALUES = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace'])
391
392/** Whether a command makes a new commit: one of its simple commands is `git commit` with no --amend or --dry-run. */
393export function commitsWith(command: string): boolean {
394 return parse(command).commands.some(simple => {
395 const run = program(simple.words)
396 if (run === undefined || run.name !== 'git') return false
397 let k = 0
398 while (k < run.args.length && (run.args[k] as string).startsWith('-')) k += GIT_VALUES.has(run.args[k] as string) ? 2 : 1
399 if (run.args[k] !== 'commit') return false
400 return !run.args.slice(k + 1).some(arg => arg === '--amend' || arg === '--dry-run')
401 })
402}
403
404/** The short sha `git commit` printed (`[main 4e1a9c2] msg`). */
405export const shaOf = (stdout: string): string => /^\[[^\]\n]{0,200}?\s([0-9a-f]{7,40})\]/m.exec(stdout.slice(0, TAIL))?.[1]?.slice(0, 7) ?? ''
406
407// ---- classifying one Bash result ---------------------------------------------------------
408
409export type BashOutcome = {
410 command: string
411 /** The directory the call ran in: a check's record is kept per directory. */
412 cwd: string
413 isError: boolean
414 /** What the model read: the error text, headed `Exit code N`, when the call failed. */
415 text: string
416 stdout: string
417 stderr: string
418 /** The whole output where the host kept more than `text` or `stdout` carry (a long failure, a persisted output). */
419 full?: string | undefined
420 isInterrupted: boolean
421 isBackground: boolean
422 commit?: { sha: string; kind: string } | undefined
423}
424
425export type Classified =
426 | { kind: 'ignore' }
427 | { kind: 'fail'; check: Check; failCount: number | undefined; index: number }
428 | { kind: 'pass'; check: Check; isZeroRun: boolean; index: number }
429 /** A piped check whose output says neither red nor green. */
430 | { kind: 'unread'; check: Check; index: number }
431 | { kind: 'commit'; sha: string }
432
433const IGNORE: Classified = { kind: 'ignore' }
434
435/** What one Bash result means to kachi: a check failing, passing or unreadable, a new commit, or nothing. */
436export function classify(outcome: BashOutcome, scanned: Scan = scanCommand(outcome.command, outcome.cwd)): Classified {
437 if (outcome.isInterrupted || outcome.isBackground) return IGNORE
438 const { reading } = scanned
439 if (reading === undefined) {
440 if (outcome.isError) return IGNORE
441 if (outcome.commit !== undefined) return outcome.commit.kind === 'committed' ? { kind: 'commit', sha: outcome.commit.sha.slice(0, 7) } : IGNORE
442 if (outcome.command.includes('commit') && commitsWith(outcome.command)) return { kind: 'commit', sha: shaOf(outcome.stdout) }
443 return IGNORE
444 }
445 const { check, index } = reading
446 // No status line on a failed call means interrupted, denied or timed out: nothing ran to the end.
447 const code = outcome.isError ? exitCodeOf(outcome.text) : 0
448 if (code === undefined || code === 126 || code === 127) return IGNORE
449 const tail = outcome.full !== undefined ? outputTail(outcome.full) : outcome.isError ? outputTail(outcome.text) : outputTail(outcome.stdout, outcome.stderr)
450 const failCount = parseFailCount(tail)
451 const isMarked = (failCount ?? 0) > 0 || FAIL_MARK.test(tail)
452 if (reading.isStatus) {
453 if (!outcome.isError) return { kind: 'pass', check, isZeroRun: isZeroRun(tail, outcome.command), index }
454 return !isMarked && NOT_RUN.test(tail.slice(-1024)) ? IGNORE : { kind: 'fail', check, failCount, index }
455 }
456 // The status is another command's (`| tail`, `|| true`, `; echo`): the check's own output decides.
457 // A count beats a FAIL line: a printed pass count with no failure count is green.
458 if ((failCount ?? 0) > 0) return { kind: 'fail', check, failCount, index }
459 const printed = largest(PASS_COUNT, tail)
460 if (printed !== undefined) return { kind: 'pass', check, isZeroRun: printed === 0 || isZeroRun(tail, outcome.command), index }
461 if (isMarked) return { kind: 'fail', check, failCount, index }
462 if (NOT_RUN.test(tail.slice(-1024))) return IGNORE
463 const quiet = SILENT_PASS[check.name]
464 const isScript = PACKAGE_MANAGERS.has(check.name.split(' ')[0] as string) && isQuietScript(tail)
465 if (parsePassCount(tail) !== undefined || (quiet !== undefined && quiet.test(tail.trim())) || isScript) return { kind: 'pass', check, isZeroRun: false, index }
466 return { kind: 'unread', check, index }
467}
468
469// ---- edits made through Bash -----------------------------------------------------------
470
471// A redirection that writes its target: `>`, `>>`, `>|`, `&>`, `2>` and the like (`>&2` duplicates).
472const WRITES = /^(?:\d?>>?\|?|&>>?)$/
473// Where a write is never a code edit: devices, temporary folders, variables, logs.
474const NOT_CODE = /^(?:\/dev\/|\/tmp\/|\/private\/tmp\/|\/private\/var\/folders\/|\/var\/folders\/)|\$|\.(?:log|out|tmp)$/
475// Formatters and fixers, with the option that makes each write in place where it needs one.
476const FIXERS: Record<string, string | null> = {
477 prettier: '--write', eslint: '--fix', black: null, isort: null, rustfmt: null, swiftformat: null, gofmt: '-w', goimports: '-w', 'clang-format': '-i', 'swift-format': '-i',
478}
479const MAX_PATHS = 64
480// A writer's words and redirects read at most: what lies past them is not counted as written.
481const MAX_WRITER_WORDS = 32
482// A word any writer above needs; a command with none of them writes nothing kachi can read.
483const MAY_WRITE = />|\b(?:sed|gsed|perl|tee|cp|mv|install|git|prettier|eslint|black|isort|rustfmt|swiftformat|gofmt|goimports|clang-format|swift-format|ruff|go)\b/
484
485const plain = (args: readonly string[]) => args.filter(arg => !arg.startsWith('-'))
486
487/** The files one simple command writes, as far as its text says: in-place editors, tee, copies, restores, fixers; redirects when `withRedirects`. */
488function written(words: readonly string[], redirects: readonly { op: string; target?: string | undefined }[], withRedirects: boolean): string[] {
489 const out = withRedirects
490 ? redirects
491 .slice(0, MAX_WRITER_WORDS)
492 .filter(one => WRITES.test(one.op) && one.target !== undefined && one.target !== '' && !one.target.startsWith('&'))
493 .map(one => one.target as string)
494 : []
495 const run = program(words.length > MAX_WRITER_WORDS ? words.slice(0, MAX_WRITER_WORDS) : words)
496 if (run === undefined) return out
497 const { name, args } = run
498 if (name === 'sed' || name === 'gsed' || name === 'perl') {
499 const inPlace = args.findIndex(arg => /^(?:-[A-Za-z]*i|--in-place)/.test(arg))
500 if (inPlace === -1) return out
501 // The script is the first plain word unless -e or -f gave it; an empty word after -i is macOS's suffix.
502 const files: string[] = []
503 let hasScript = args.some(arg => arg === '-e' || arg === '-f' || arg === '--expression' || arg === '--file')
504 for (let k = 0; k < args.length; k++) {
505 const arg = args[k] as string
506 if (arg === '-e' || arg === '-f' || arg === '--expression' || arg === '--file' || (k === inPlace + 1 && arg === '')) {
507 if (arg !== '') k += 1
508 } else if (!arg.startsWith('-')) {
509 if (hasScript) files.push(arg)
510 else hasScript = true
511 }
512 }
513 return [...out, ...files]
514 }
515 if (name === 'tee') return [...out, ...plain(args)]
516 if (name === 'cp' || name === 'mv' || name === 'install') {
517 const paths = plain(args)
518 return paths.length >= 2 ? [...out, paths.at(-1) as string] : out
519 }
520 if (name === 'git') {
521 const at = args.findIndex(arg => arg === 'checkout' || arg === 'restore')
522 if (at === -1 || args.slice(0, at).some(arg => !arg.startsWith('-'))) return out
523 const rest = args.slice(at + 1)
524 if (args[at] === 'checkout') {
525 const dash = rest.indexOf('--')
526 return dash === -1 ? out : [...out, ...rest.slice(dash + 1)]
527 }
528 if (rest.includes('--staged') && !rest.includes('--worktree')) return out
529 return [...out, ...plain(rest.filter((arg, k) => rest[k - 1] !== '--source' && rest[k - 1] !== '-s'))]
530 }
531 if (name in FIXERS) {
532 const flag = FIXERS[name]
533 if (flag !== null && flag !== undefined && !args.includes(flag)) return out
534 return [...out, ...plain(args)]
535 }
536 if (name === 'ruff' && (args[0] === 'format' || (args[0] === 'check' && args.includes('--fix')))) return [...out, ...plain(args.slice(1))]
537 if (name === 'go' && args[0] === 'fmt') return [...out, ...plain(args.slice(1))]
538 return out
539}
540
541/**
542 * The files a Bash command writes, as far as its text says, resolved against the directory each
543 * command ran in: `before` the check at top-level command `checkIndex` (the check's own fixer writes
544 * included, its output redirects not), and `after` it. Reuses what the scan met; unknown writes (a
545 * script, `patch`, `git stash`) are none, and 64 paths are kept at most.
546 */
547export function editsOf(command: string, scanned: Scan, checkIndex: number): { before: string[]; after: string[] } {
548 const before: string[] = []
549 const after: string[] = []
550 // Most commands name no writer at all: those are not looked at again.
551 if (!MAY_WRITE.test(command)) return { before, after }
552 for (const { index, dir, simple } of scanned.met) {
553 for (const path of written(simple.words, simple.redirects, index !== checkIndex)) {
554 if (path === '' || NOT_CODE.test(path)) continue
555 ;(checkIndex !== -1 && index <= checkIndex ? before : after).push(resolvePath(dir, path))
556 if (before.length + after.length >= MAX_PATHS) return { before, after }
557 }
558 }
559 return { before, after }
560}
561
562/**
563 * `read()`, with a command shell.ts cannot read taken as `none`: shell.ts (done-gate's, copied byte
564 * for byte) recurses once per nested `"$(`, and past the stack it throws RangeError.
565 */
566export function orNone<T>(read: () => T, none: T): T {
567 try {
568 return read()
569 } catch (error) {
570 if (error instanceof RangeError) return none
571 throw error
572 }
573}
574
575/** classify, a command shell.ts cannot read being nothing. */
576export const classifyOrIgnore = (outcome: BashOutcome, scanned?: Scan): Classified => orNone(() => classify(outcome, scanned), IGNORE)
577
578// ---- the records ------------------------------------------------------------------------
579
580export type Win = { tier: Tier; kind: 'green' | 'first'; key: string; label: string; where: string; receipt?: string }
581
582/** The language of kachi's few words: Japanese for a ja locale or language setting, English otherwise. */
583export function localeOf(values: readonly (string | undefined)[]): Locale {
584 const first = values.find(value => value !== undefined && value.trim() !== '' && value !== 'C' && value !== 'POSIX')
585 return first !== undefined && /^(?:ja(?:$|[_.@-])|japanese|日本語)/i.test(first.trim()) ? 'ja' : 'en'
586}
587
588const WORDS = {
589 ja: {
590 green: '緑', first: '編集後はじめて', kept: '検証済みのまま', redGreen: '赤→緑', tries: (n: number) => `${n} 回目で`, red: (d: string) => `${d} 赤だった`,
591 alive: (check: string) => `kachi: ${check} が赤。本当の修正で鳴ります`,
592 },
593 en: {
594 green: 'green', first: 'first after edits', kept: 'still verified', redGreen: 'red→green', tries: (n: number) => `try ${n}`, red: (d: string) => `red for ${d}`,
595 alive: (check: string) => `kachi: ${check} is red; a real fix rings`,
596 },
597} as const
598
599/** The one dim line under a check's first red of the session: kachi is there, and what will make it speak. */
600export const aliveText = (check: Check, locale: Locale) => `◌ ${WORDS[locale].alive(labelOf(check))}`
601
602const MINUTE = 60_000
603
604/** The tier a recovery earns: it grows with failed runs, minutes spent red and how many tests were failing. */
605export function tierOf(record: Extract<CheckRecord, { isRed: true }>, now: number): Tier {
606 const red = now - record.firstFailAt
607 if (record.failRuns >= 8 || red >= 20 * MINUTE || (record.peakFailCount ?? 0) >= 10) return 4
608 if (record.failRuns >= 3 || red >= 5 * MINUTE) return 3
609 return 2
610}
611
612export function duration(ms: number): string {
613 const seconds = Math.max(0, Math.round(ms / 1000))
614 if (seconds < 60) return `${seconds}s`
615 const minutes = Math.floor(seconds / 60)
616 if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
617 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
618}
619
620/** The line kept under the winning row: what the fight cost, which the row itself does not show. */
621export function receiptText(record: Extract<CheckRecord, { isRed: true }>, now: number, locale: Locale): string {
622 const words = WORDS[locale]
623 const count = record.peakFailCount === undefined ? '' : ` · ${record.peakFailCount} → 0 failing`
624 return `✔ ${words.redGreen} ${words.tries(record.failRuns + 1)} · ${words.red(duration(now - record.firstFailAt))}${count}`
625}
626
627function capped<T extends { at: number }>(all: Record<string, T>): Record<string, T> {
628 const keys = Object.keys(all)
629 if (keys.length <= MAX_CHECKS) return all
630 const oldest = keys.reduce((a, b) => ((all[a] as T).at <= (all[b] as T).at ? a : b))
631 const { [oldest]: _, ...rest } = all
632 return rest
633}
634
635const contains = (dir: string, path: string) => path === dir || path.startsWith(dir === '/' ? '/' : `${dir}/`)
636
637/**
638 * The records after a code edit to `path` (absolute): each red check whose directory holds the
639 * path is credited with it; a path outside every check's directory is credited to all of them.
640 */
641export function credit(checks: Readonly<Record<string, CheckRecord>>, path: string): Readonly<Record<string, CheckRecord>> {
642 const records = Object.values(checks)
643 const isOutside = records.every(record => !contains(record.dir, path))
644 let next: Record<string, CheckRecord> | undefined
645 for (const [key, record] of Object.entries(checks)) {
646 if (!record.isRed || record.isCredited || !(isOutside || contains(record.dir, path))) continue
647 next ??= { ...checks }
648 next[key] = { ...record, isCredited: true }
649 }
650 return next ?? checks
651}
652
653/**
654 * The next records after a check failed or passed, and the win it earned, if any.
655 * A win is a pass of a check that was red, credited with a code edit since its last failure, a scope
656 * at least as broad as every failure's, and tests that ran; or the first whole-check pass of a
657 * check never seen before, once the session has edited code (a tick).
658 */
659export function step(
660 checks: Readonly<Record<string, CheckRecord>>,
661 classified: Extract<Classified, { kind: 'fail' | 'pass' | 'unread' }>,
662 edits: Edits,
663 now: number,
664 locale: Locale,
665): { checks: Record<string, CheckRecord>; win?: Win; isGreen: boolean; isFirstRed?: boolean } {
666 const { check } = classified
667 const prev = checks[check.key]
668 if (classified.kind === 'fail') {
669 const count = classified.failCount
670 const next: CheckRecord =
671 prev?.isRed === true
672 ? {
673 ...prev,
674 at: now,
675 failRuns: prev.failRuns + 1,
676 scope: prev.scope.filter(word => check.scope.includes(word)),
677 isCredited: false,
678 ...(count === undefined ? {} : { peakFailCount: Math.max(prev.peakFailCount ?? 0, count) }),
679 }
680 : {
681 isRed: true,
682 at: now,
683 dir: check.dir,
684 firstFailAt: now,
685 failRuns: 1,
686 scope: check.scope,
687 isCredited: false,
688 ...(count === undefined ? {} : { firstFailCount: count, peakFailCount: count }),
689 }
690 return { checks: capped({ ...checks, [check.key]: next }), isGreen: false, isFirstRed: prev?.isRed !== true }
691 }
692
693 const green: CheckRecord = { isRed: false, at: now, dir: check.dir }
694 // A piped run that says nothing ends what the record knew: a stale red would ring with a false duration later.
695 if (classified.kind === 'unread') return { checks: prev?.isRed === true ? { ...checks, [check.key]: green } : { ...checks }, isGreen: false }
696 if (prev === undefined) {
697 if (classified.isZeroRun) return { checks: { ...checks }, isGreen: false }
698 // Only the whole check counts: a first run narrowed to one file or one test name is no tick.
699 const isFirst = edits.codeSeq > 0 && check.scope.length === 0 && !check.isOverflow
700 return {
701 checks: capped({ ...checks, [check.key]: green }),
702 isGreen: true,
703 ...(isFirst ? { win: { tier: 1 as const, kind: 'first' as const, key: check.key, label: check.name, where: check.where } } : {}),
704 }
705 }
706 if (!prev.isRed) return { checks: { ...checks, [check.key]: green }, isGreen: true }
707 // Still red until the failed scope, or a broader one, passes with tests that ran.
708 if (classified.isZeroRun || !covers(check, prev.scope)) return { checks: { ...checks }, isGreen: false }
709 const after = { ...checks, [check.key]: green }
710 // A flaky re-run, or a pass that only test edits (or edits elsewhere) brought: green, and nothing to celebrate.
711 if (!prev.isCredited) return { checks: after, isGreen: true }
712 return { checks: after, isGreen: true, win: { tier: tierOf(prev, now), kind: 'green', key: check.key, label: check.name, where: check.where, receipt: receiptText(prev, now, locale) } }
713}
714
715/** The receipts with one added, the oldest leaving past 64. */
716export function withReceipt(wins: Readonly<Record<string, string>>, id: string, line: string): Record<string, string> {
717 const next = { ...wins, [id]: line }
718 const keys = Object.keys(next)
719 for (const key of keys.slice(0, Math.max(0, keys.length - MAX_WINS))) delete next[key]
720 return next
721}
722
723// ---- test files -------------------------------------------------------------------------
724
725// Test folders (`tests/`, `__tests__/`, Swift's and Xcode's `Tests/` and `AppTests/`), test files by
726// suffix (`.test.ts`, `_test.go`, `_spec.rb`, `test_x.py`) and by class name (`PriceTests.swift`,
727// `PriceSpec.kt`, `PriceTest.java`).
728const TEST_PATH = /(?:^|\/)(?:[Tt]ests?|__tests__|[Ss]pecs?|__snapshots__|[\w.-]*(?:Tests|Specs))\/|\.(?:test|spec)\.[^/]*$|_(?:test|spec)\.(?:go|py|rs|rb|exs?|dart)$|(?:^|\/)test_[^/]*\.py$|[A-Za-z0-9](?:Tests?|Specs?)\.(?:swift|kt|kts|java|scala|cs|m|mm)$/
729
730// A glob, read once: a literal character, `?`, `*` (within a segment), `**` (anything), `**/` (any directories).
731type GlobToken = string | 'one' | 'star' | 'any' | 'dirs'
732export type Globs = readonly (readonly GlobToken[])[]
733const PATH_TAIL = 256
734
735function tokenize(glob: string): GlobToken[] {
736 // Every glob may start at any directory, as `(^|/)glob$` would.
737 const out: GlobToken[] = ['dirs']
738 for (let k = 0; k < glob.length; k++) {
739 const ch = glob[k] as string
740 const token: GlobToken =
741 glob.startsWith('**/', k) ? 'dirs' : glob.startsWith('**', k) ? 'any' : ch === '*' ? 'star' : ch === '?' ? 'one' : `=${ch}`
742 if (token === 'dirs') k += 2
743 else if (token === 'any') k += 1
744 const prev = out.at(-1)
745 // Runs collapse: `**/**/`, `***`, `**` after `*` match what one of them matches.
746 if (token === prev && token !== 'one' && !token.startsWith('=')) continue
747 if (token === 'star' && prev === 'any') continue
748 out.push(token)
749 }
750 return out
751}
752
753/** The comma-separated globs of `testPaths`, each at most 200 characters and 16 in all; undefined when none are given. */
754export function compileGlobs(spec: string): Globs | undefined {
755 const globs = spec
756 .split(',')
757 .map(one => one.trim().replace(/^\/+/, ''))
758 .filter(one => one.length > 0 && one.length <= 200)
759 .slice(0, 16)
760 return globs.length === 0 ? undefined : globs.map(tokenize)
761}
762
763/** Whether `tokens` match all of `text`: one pass per token over the text, never backtracking. */
764function globMatches(tokens: readonly GlobToken[], text: string): boolean {
765 const n = text.length
766 let prev = new Uint8Array(n + 1)
767 let cur = new Uint8Array(n + 1)
768 prev[0] = 1
769 for (const token of tokens) {
770 cur.fill(0)
771 let seen = 0
772 for (let j = 0; j <= n; j++) {
773 const before = j > 0 ? (text[j - 1] as string) : ''
774 if (token === 'any') cur[j] = prev[j] || (j > 0 ? (cur[j - 1] as number) : 0)
775 else if (token === 'star') cur[j] = prev[j] || (j > 0 && before !== '/' ? (cur[j - 1] as number) : 0)
776 else if (token === 'dirs') cur[j] = prev[j] || (before === '/' ? seen : 0)
777 else if (j > 0) cur[j] = prev[j - 1] && (token === 'one' ? before !== '/' : before === token.slice(1)) ? 1 : 0
778 seen = seen || (prev[j] as number)
779 }
780 ;[prev, cur] = [cur, prev]
781 }
782 return prev[n] === 1
783}
784
785/** Whether an edit's path is a test file, whose change alone never earns a win; only its last 256 characters are read. */
786export const isTestPath = (path: string, extra: Globs | undefined) => {
787 const tail = path.slice(-PATH_TAIL)
788 return TEST_PATH.test(tail) || (extra?.some(tokens => globMatches(tokens, tail)) ?? false)
789}
790
791// ---- drawing ----------------------------------------------------------------------------
792
793export const FRAME_MS = 30
794const TRAIL = 10
795const BLANK = 0x20
796const RULE = 0x2501 // ━
797const DEFAULT = 0x01000000
798const DIM = 0x1f5a35
799const BRIGHT = 0x8ff5aa
800const HEAD = 0xeafff0
801
802/** Frames of motion per tier: one sweep (480 ms), a sweep and a pulse, or two sweeps and a pulse (about 1.1 s). */
803export const framesFor = (tier: Tier) => (tier === 4 ? 36 : tier === 3 ? 24 : 16)
804
805function mix(from: number, to: number, t: number): number {
806 const channel = (shift: number) => {
807 const a = (from >> shift) & 255
808 const b = (to >> shift) & 255
809 return Math.round(a + (b - a) * t) << shift
810 }
811 return channel(16) | channel(8) | channel(0)
812}
813
814/** One frame of the sweep, `cols` Raster cells packed as RasterProps wants them. */
815export function sweepFrame(cols: number, tier: Tier, frame: number): string {
816 const pass = tier === 4 ? 14 : 16
817 const view = new DataView(new ArrayBuffer(cols * 12))
818 const span = Math.max(1, cols - 1)
819 let head = -1
820 let isBack = false
821 let glow = 0
822 if (frame < pass) {
823 head = Math.round(((frame + 1) / pass) * span)
824 } else if (tier === 4 && frame < 2 * pass) {
825 isBack = true
826 head = span - Math.round(((frame - pass + 1) / pass) * span)
827 } else {
828 // The pulse: the whole row brightens once and settles, over 8 frames.
829 const p = frame - (tier === 4 ? 2 * pass : pass)
830 glow = Math.max(0, 1 - Math.abs(p - 4) / 4)
831 }
832 for (let x = 0; x < cols; x++) {
833 let glyph = RULE
834 let color = DIM
835 if (head >= 0) {
836 const behind = isBack ? x - head : head - x
837 if (behind < 0) {
838 if (!isBack) glyph = BLANK
839 } else if (behind === 0) color = HEAD
840 else if (behind <= TRAIL) color = mix(BRIGHT, DIM, behind / TRAIL)
841 } else color = mix(DIM, BRIGHT, glow)
842 view.setUint32(x * 12, glyph, true)
843 view.setUint32(x * 12 + 4, glyph === BLANK ? DEFAULT : color, true)
844 view.setUint32(x * 12 + 8, DEFAULT, true)
845 }
846 const bytes = new Uint8Array(view.buffer)
847 let binary = ''
848 for (let k = 0; k < bytes.length; k += 4096) binary += String.fromCharCode(...bytes.subarray(k, k + 4096))
849 return btoa(binary)
850}
851
852/** The band's one row once the motion has settled: what was won, never the cost the receipt keeps. */
853export function stampText(band: Band, columns: number): string {
854 const words = WORDS[band.locale]
855 // The green comes before the directory, so a cut at the end never takes it.
856 const where = band.where === '' ? '' : ` (${band.where})`
857 if (columns < 40) return `✔ ${words.green} ${band.label}${where}`.slice(0, Math.max(4, columns))
858 if (band.kind === 'commit') return `✔ commit${band.label === '' ? '' : ` ${band.label}`} · ${words.kept}`
859 if (band.kind === 'first') return `✔ ${band.label} ${words.green}${where} · ${words.first}`
860 return `✔ ${band.label} ${words.green}${where}`
861}
862hooks/shell.ts 718 lines1// Reading a Bash command the way a shell would, far enough to judge it. A best-effort
2// reader, not a shell: words split on unquoted whitespace with quotes and escapes taken
3// off; simple commands cut at && || ; | & newlines and ( ) { }; comments, redirections and
4// heredoc bodies set aside; $(…), backticks, <(…) and `bash -c` / `eval` text read again as
5// commands of their own. Text inside quotes is a word, never an operator.
6
7export type Op = '' | '&&' | '||' | ';' | ';;' | '|' | '|&' | '&' | '\n'
8
9/** A redirection; a heredoc keeps its body, which a shell reading its stdin runs. */
10export type Redirect = { op: string; target?: string; body?: string }
11
12/** One simple command: its words, its redirections, and the operator that follows it. */
13export type Simple = { words: string[]; redirects: Redirect[]; after: Op }
14
15export type Parsed = { commands: Simple[]; nested: string[] }
16
17const MAX_DEPTH = 8
18
19/** The index just past the `)` closing a `(` opened just before `open`, quotes and nesting kept. */
20function closing(text: string, open: number): number {
21 let depth = 1
22 let i = open
23 while (i < text.length) {
24 const ch = text[i] as string
25 if (ch === '\\') {
26 i += 2
27 continue
28 }
29 if (ch === "'") {
30 const end = text.indexOf("'", i + 1)
31 i = end === -1 ? text.length : end + 1
32 continue
33 }
34 if (ch === '"') {
35 i = quoted(text, i + 1).end
36 continue
37 }
38 if (ch === '`') {
39 const end = text.indexOf('`', i + 1)
40 i = end === -1 ? text.length : end + 1
41 continue
42 }
43 if (ch === '(') depth += 1
44 if (ch === ')') {
45 depth -= 1
46 if (depth === 0) return i + 1
47 }
48 i += 1
49 }
50 return text.length
51}
52
53/** The text of a double-quoted string starting at `from`, and the substitutions inside it. */
54function quoted(text: string, from: number): { value: string; end: number; nested: string[] } {
55 let value = ''
56 const nested: string[] = []
57 let j = from
58 while (j < text.length && text[j] !== '"') {
59 const ch = text[j] as string
60 if (ch === '\\' && j + 1 < text.length) {
61 value += text[j + 1]
62 j += 2
63 } else if (ch === '$' && text[j + 1] === '(' && text[j + 2] !== '(') {
64 const end = closing(text, j + 2)
65 nested.push(text.slice(j + 2, end - 1))
66 value += text.slice(j, end)
67 j = end
68 } else if (ch === '`') {
69 const end = text.indexOf('`', j + 1)
70 const stop = end === -1 ? text.length : end
71 nested.push(text.slice(j + 1, stop))
72 value += text.slice(j, stop + 1)
73 j = stop + 1
74 } else {
75 value += ch
76 j += 1
77 }
78 }
79 return { value, end: j + 1, nested }
80}
81
82/** The text of an ANSI-C quoted string `$'…'` starting at `from`, escapes decoded as bash does. */
83function ansiC(text: string, from: number): { value: string; end: number } {
84 const simple: Record<string, string> = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', e: '\x1b', E: '\x1b', f: '\f', v: '\v', '\\': '\\', "'": "'", '"': '"', '?': '?' }
85 let value = ''
86 let j = from
87 while (j < text.length && text[j] !== "'") {
88 const ch = text[j] as string
89 if (ch !== '\\' || j + 1 >= text.length) {
90 value += ch
91 j += 1
92 continue
93 }
94 const next = text[j + 1] as string
95 const code = (digits: string, radix: number) => {
96 value += String.fromCodePoint(Number.parseInt(digits, radix))
97 j += 2 + digits.length - (radix === 8 ? 1 : 0)
98 }
99 const hex = /^x([0-9a-fA-F]{1,2})/.exec(text.slice(j + 1))
100 const uni = /^[uU]([0-9a-fA-F]{1,8})/.exec(text.slice(j + 1))
101 const oct = /^([0-7]{1,3})/.exec(text.slice(j + 1))
102 if (simple[next] !== undefined) {
103 value += simple[next]
104 j += 2
105 } else if (hex !== null) code(hex[1] as string, 16)
106 else if (uni !== null && Number.parseInt(uni[1] as string, 16) <= 0x10ffff) code(uni[1] as string, 16)
107 else if (oct !== null) {
108 value += String.fromCharCode(Number.parseInt(oct[1] as string, 8) & 0xff)
109 j += 1 + (oct[1] as string).length
110 } else {
111 value += `\\${next}`
112 j += 2
113 }
114 }
115 return { value, end: j + 1 }
116}
117
118const OPERATOR = /^(&&|\|\||;;|\|&|[;|&\n])/
119// > >> >| >& <& <> < &> &>>, with an fd duplication (2>&1, >&-) taken whole.
120const REDIRECT = /^(&>>?|[<>]&[0-9]+-?|[<>]&-|>>|>\||<>|>&|<&|[<>])/
121
122export function parse(command: string): Parsed {
123 const commands: Simple[] = []
124 const nested: string[] = []
125 let words: string[] = []
126 let redirects: Redirect[] = []
127 let word: string | undefined
128 let pendingRedirect: Redirect | undefined
129 let heredocs: { tag: string; isTabbed: boolean; entry: Redirect }[] = []
130 let i = 0
131
132 const endWord = () => {
133 if (word === undefined) return
134 if (pendingRedirect !== undefined) {
135 pendingRedirect.target = word
136 pendingRedirect = undefined
137 } else words.push(word)
138 word = undefined
139 }
140 const endCommand = (after: Op) => {
141 endWord()
142 pendingRedirect = undefined
143 if (words.length > 0 || redirects.length > 0) {
144 commands.push({ words, redirects, after })
145 } else if (after !== '') {
146 // An operator after a group's `)`: it belongs to the command the group ended with.
147 const last = commands[commands.length - 1]
148 if (last !== undefined && last.after === '') last.after = after
149 }
150 words = []
151 redirects = []
152 }
153 const skipHeredocBodies = () => {
154 for (const doc of heredocs) {
155 const body: string[] = []
156 while (i < command.length) {
157 const lineEnd = command.indexOf('\n', i)
158 const stop = lineEnd === -1 ? command.length : lineEnd
159 const line = command.slice(i, stop)
160 i = stop + 1
161 if ((doc.isTabbed ? line.replace(/^\t+/, '') : line) === doc.tag) break
162 body.push(line)
163 }
164 doc.entry.body = body.join('\n')
165 }
166 heredocs = []
167 }
168
169 while (i < command.length) {
170 const ch = command[i] as string
171 if (ch === '\\' && i + 1 < command.length) {
172 if (command[i + 1] !== '\n') word = (word ?? '') + command[i + 1]
173 i += 2
174 continue
175 }
176 if (ch === "'") {
177 const end = command.indexOf("'", i + 1)
178 const stop = end === -1 ? command.length : end
179 word = (word ?? '') + command.slice(i + 1, stop)
180 i = stop + 1
181 continue
182 }
183 if (ch === '"') {
184 const text = quoted(command, i + 1)
185 word = (word ?? '') + text.value
186 nested.push(...text.nested)
187 i = text.end
188 continue
189 }
190 if (ch === '`') {
191 const end = command.indexOf('`', i + 1)
192 const stop = end === -1 ? command.length : end
193 nested.push(command.slice(i + 1, stop))
194 word = (word ?? '') + command.slice(i, stop + 1)
195 i = stop + 1
196 continue
197 }
198 if (ch === '$' && command[i + 1] === "'") {
199 // ANSI-C quoting: `$'--no-\x76erify'` is --no-verify to the shell.
200 const text = ansiC(command, i + 2)
201 word = (word ?? '') + text.value
202 i = text.end
203 continue
204 }
205 if (ch === '$' && command[i + 1] === '"') {
206 // Locale quoting `$"…"` reads as a double-quoted string.
207 i += 1
208 continue
209 }
210 if (ch === '$' && command.startsWith('$((', i)) {
211 // Arithmetic: one word, whatever `<<` or `)` it holds.
212 const end = closing(command, i + 2)
213 const stop = command.startsWith(')', end) ? end + 1 : end
214 word = (word ?? '') + command.slice(i, stop)
215 i = stop
216 continue
217 }
218 if (ch === '$' && command[i + 1] === '(') {
219 const end = closing(command, i + 2)
220 nested.push(command.slice(i + 2, end - 1))
221 word = (word ?? '') + command.slice(i, end)
222 i = end
223 continue
224 }
225 if (ch === '$' && command[i + 1] === '{') {
226 const end = command.indexOf('}', i + 2)
227 const stop = end === -1 ? command.length : end + 1
228 word = (word ?? '') + command.slice(i, stop)
229 i = stop
230 continue
231 }
232 if ((ch === '<' || ch === '>') && command[i + 1] === '(') {
233 // Process substitution: a command whose output stands in for a file.
234 const end = closing(command, i + 2)
235 nested.push(command.slice(i + 2, end - 1))
236 word = (word ?? '') + command.slice(i, end)
237 i = end
238 continue
239 }
240 if (ch === '#' && word === undefined) {
241 // A comment runs to the end of the line; the newline itself still ends the command.
242 const lineEnd = command.indexOf('\n', i)
243 i = lineEnd === -1 ? command.length : lineEnd
244 continue
245 }
246 if (ch === '<' && command.startsWith('<<', i) && !command.startsWith('<<<', i)) {
247 endWord()
248 let j = i + 2
249 const isTabbed = command[j] === '-'
250 if (isTabbed) j += 1
251 while (command[j] === ' ' || command[j] === '\t') j += 1
252 const match = /^(['"]?)([A-Za-z0-9_.-]+)\1/.exec(command.slice(j))
253 if (match !== null) {
254 const entry: Redirect = { op: isTabbed ? '<<-' : '<<', target: match[2] as string }
255 heredocs.push({ tag: match[2] as string, isTabbed, entry })
256 redirects.push(entry)
257 i = j + match[0].length
258 continue
259 }
260 }
261 if (ch === '<' && command.startsWith('<<<', i)) {
262 endWord()
263 pendingRedirect = { op: '<<<' }
264 redirects.push(pendingRedirect)
265 i += 3
266 continue
267 }
268 const redirect =
269 ch === '<' || ch === '>' || (ch === '&' && command[i + 1] === '>') ? REDIRECT.exec(command.slice(i)) : null
270 if (redirect !== null) {
271 // A bare fd number glued in front (2>, 1>>) is part of the redirection.
272 if (word !== undefined && /^[0-9]+$/.test(word)) word = undefined
273 endWord()
274 const op = redirect[0]
275 const entry: Redirect = { op }
276 redirects.push(entry)
277 if (!/&([0-9]+-?|-)$/.test(op)) pendingRedirect = entry
278 i += op.length
279 continue
280 }
281 const op = OPERATOR.exec(command.slice(i))
282 if (op !== null) {
283 endCommand(op[0] as Op)
284 i += op[0].length
285 if (op[0] === '\n' && heredocs.length > 0) skipHeredocBodies()
286 continue
287 }
288 if (ch === ' ' || ch === '\t') {
289 endWord()
290 i += 1
291 continue
292 }
293 // `{` and `}` are a group's edges only as words of their own (`{ a; }`), so find's `{}` is a word.
294 const isBrace =
295 word === undefined && ((ch === '{' && /\s/.test(command[i + 1] ?? '\n')) || (ch === '}' && /^(?:$|[\s;&|)])/.test(command[i + 1] ?? '')))
296 if (ch === '(' || ch === ')' || isBrace) {
297 // A subshell or group edge, glued to a word or not: what is inside runs as commands.
298 endCommand('')
299 i += 1
300 continue
301 }
302 word = (word ?? '') + ch
303 i += 1
304 }
305 endCommand('')
306 return { commands, nested }
307}
308
309/**
310 * Every simple command a command line runs: substitutions, `bash -c` / `eval` / `env -S` text,
311 * and the script a shell reads on its stdin (`bash <<< '…'`, a heredoc, `echo '…' | sh`) included.
312 */
313export function allCommands(command: string, depth = 0): Simple[] {
314 if (depth > MAX_DEPTH) return []
315 const { commands, nested } = parse(command)
316 const out: Simple[] = []
317 commands.forEach((one, k) => {
318 out.push(one)
319 const inner = innerText(one.words)
320 if (inner !== undefined) out.push(...allCommands(inner, depth + 1))
321 if (readsScriptFromStdin(one.words)) {
322 for (const text of stdinOf(one, commands[k - 1])) out.push(...allCommands(text, depth + 1))
323 }
324 })
325 for (const text of nested) out.push(...allCommands(text, depth + 1))
326 return out
327}
328
329const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
330// Words a shell reads as syntax before the command they lead.
331const PREFIX_KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', 'time', 'fi', 'done', 'esac'])
332// Words that open a clause which is not a command (`for x in a b`, `case x in`).
333const CLAUSE_KEYWORDS = new Set(['for', 'case', 'select', 'function'])
334const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'su', 'script'])
335// Shell options whose value is the next word (`bash -o pipefail -c …`).
336const SHELL_VALUES = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file'])
337
338// Wrappers that run the command after them, with the options of each that take a value.
339const WRAPPERS: Record<string, Set<string>> = {
340 sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U', '-T', '--user', '--group', '--chdir', '--prompt']),
341 doas: new Set(['-u', '-C']),
342 nice: new Set(['-n', '--adjustment']),
343 env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']),
344 command: new Set([]),
345 exec: new Set(['-a']),
346 nohup: new Set([]),
347 time: new Set(['-f', '--format', '-o', '--output']),
348 stdbuf: new Set(['-i', '-o', '-e', '--input', '--output', '--error']),
349 xargs: new Set(['-I', '-L', '-n', '-P', '-s', '-d', '-E', '-a', '--max-args', '--max-procs', '--delimiter', '--arg-file', '--replace', '--max-lines']),
350 xcrun: new Set(['--sdk', '--toolchain']),
351 caffeinate: new Set(['-t', '-w']),
352 ionice: new Set(['-c', '-n', '-p', '-t', '--class', '--classdata']),
353 busybox: new Set([]),
354 chronic: new Set([]),
355 setsid: new Set([]),
356}
357
358/** Words joined back into a command line with each one quoted, so re-reading it keeps them whole. */
359const quoteWords = (words: readonly string[]) => words.map(word => `'${word.replace(/'/g, `'\\''`)}'`).join(' ')
360// timeout takes options, then a duration, then the command.
361const TIMEOUT_VALUES = new Set(['-s', '--signal', '-k', '--kill-after'])
362// Runners whose next word is the program they run; 2 = the second word must be run/exec.
363const RUNNERS: Record<string, number> = { npx: 1, bunx: 1, pnpx: 1, uvx: 1, pipx: 2, uv: 2, poetry: 2, pdm: 2, rye: 2, bundle: 2 }
364
365export const base = (word: string) => word.slice(word.lastIndexOf('/') + 1)
366
367/** The program a simple command runs and its arguments, past assignments, keywords and wrappers. */
368export function program(words: readonly string[]): { name: string; args: string[] } | undefined {
369 let k = 0
370 for (;;) {
371 const word = words[k]
372 if (word === undefined) return undefined
373 const name = base(word)
374 if (CLAUSE_KEYWORDS.has(word)) return undefined
375 if (ASSIGNMENT.test(word) || PREFIX_KEYWORDS.has(word)) {
376 k += 1
377 } else if (WRAPPERS[name] !== undefined && word === name) {
378 const values = WRAPPERS[name] as Set<string>
379 k += 1
380 while (words[k] !== undefined && (words[k] as string).startsWith('-')) {
381 const option = words[k] as string
382 k += 1
383 if (option === '--') break
384 if (values.has(option)) k += 1
385 }
386 if (name === 'env') while (words[k] !== undefined && ASSIGNMENT.test(words[k] as string)) k += 1
387 } else if (name === 'timeout' && word === name) {
388 k += 1
389 while (words[k] !== undefined && (words[k] as string).startsWith('-')) {
390 const option = words[k] as string
391 k += 1
392 if (TIMEOUT_VALUES.has(option)) k += 1
393 }
394 k += 1 // the duration
395 } else if (RUNNERS[name] !== undefined) {
396 const skip = RUNNERS[name] as number
397 if (skip === 2 && words[k + 1] !== 'run' && words[k + 1] !== 'exec') break
398 k += skip
399 while (words[k]?.startsWith('-')) k += 1
400 } else break
401 }
402 const name = words[k]
403 if (name === undefined) return undefined
404 return { name: base(name), args: words.slice(k + 1) }
405}
406
407/** The command text a `bash -c "<text>"`, `eval <words>` or `env -S "<text>"` runs, if the words are one. */
408export function innerText(words: readonly string[]): string | undefined {
409 // env -S splits its value into the command it runs: `env -S "git commit -n"`.
410 const at = words.findIndex(word => base(word) === 'env')
411 if (at !== -1 && (at === 0 || words.slice(0, at).every(word => ASSIGNMENT.test(word) || PREFIX_KEYWORDS.has(word) || WRAPPERS[base(word)] !== undefined))) {
412 for (let k = at + 1; k < words.length && (words[k] as string).startsWith('-'); k++) {
413 const option = words[k] as string
414 const glued = /^(?:--split-string=|-[A-RT-Za-z]*S)(.+)$/.exec(option)
415 if (glued !== null) return [glued[1], ...words.slice(k + 1)].join(' ')
416 if (option === '--split-string' || /^-[A-RT-Za-z]*S$/.test(option)) return words.slice(k + 1).join(' ')
417 if (WRAPPERS.env?.has(option) === true) k += 1
418 }
419 }
420 const run = program(words)
421 if (run === undefined) return undefined
422 if (run.name === 'eval') return run.args.join(' ')
423 if (run.name === 'find') {
424 // `find … -exec git commit -n \;`: the words up to `;` or `+` run as a command, for every -exec.
425 const runs: string[] = []
426 for (let k = 0; k < run.args.length; k++) {
427 if (!/^-(?:exec|execdir|ok|okdir)$/.test(run.args[k] as string)) continue
428 const end = run.args.findIndex((arg, n) => n > k && (arg === ';' || arg === '+'))
429 runs.push(quoteWords(run.args.slice(k + 1, end === -1 ? undefined : end)))
430 k = end === -1 ? run.args.length : end
431 }
432 return runs.length === 0 ? undefined : runs.join(' ; ')
433 }
434 if (run.name === 'su' || run.name === 'script' || run.name === 'fish') {
435 // Their command text comes with -c or --command anywhere, after a user or a log file.
436 for (let k = 0; k < run.args.length; k++) {
437 const arg = run.args[k] as string
438 const glued = /^--command=(.*)$/.exec(arg)
439 if (glued !== null) return glued[1]
440 if (arg === '--command' || /^-[A-Za-z]*c[A-Za-z]*$/.test(arg)) return run.args[k + 1]
441 }
442 return undefined
443 }
444 if (SHELLS.has(run.name)) {
445 for (let k = 0; k < run.args.length; k++) {
446 const arg = run.args[k] as string
447 // The text is the first word after -c that is not an option (`bash -c -- '…'`).
448 if (/^-[A-Za-z]*c[A-Za-z]*$/.test(arg)) return run.args.slice(k + 1).find((one, n, rest) => !one.startsWith('-') || rest[n - 1] === '--')
449 if (SHELL_VALUES.has(arg)) k += 1
450 else if (!arg.startsWith('-') && !arg.startsWith('+')) return undefined // a script file
451 }
452 }
453 return undefined
454}
455
456/** Whether a shell runs the script on its stdin: no -c text and no script file, or -s. */
457function readsScriptFromStdin(words: readonly string[]): boolean {
458 const run = program(words)
459 if (run === undefined || !SHELLS.has(run.name)) return false
460 if (run.args.some(arg => /^-[A-Za-z]*c[A-Za-z]*$/.test(arg))) return false
461 if (run.args.includes('-s')) return true
462 for (let k = 0; k < run.args.length; k++) {
463 const arg = run.args[k] as string
464 if (SHELL_VALUES.has(arg)) k += 1
465 else if (!arg.startsWith('-') && !arg.startsWith('+')) return false // a script file
466 }
467 return true
468}
469
470/** The text a shell reads on its stdin: a here-string, a heredoc's body, or what echo/printf piped into it. */
471function stdinOf(one: Simple, before: Simple | undefined): string[] {
472 const texts = one.redirects.flatMap(redirect => (redirect.op === '<<<' ? [redirect.target ?? ''] : redirect.body !== undefined ? [redirect.body] : []))
473 const piped = before !== undefined && (before.after === '|' || before.after === '|&') ? program(before.words) : undefined
474 if (piped !== undefined && (piped.name === 'echo' || piped.name === 'printf')) {
475 // Only echo's leading options are options: `echo git commit -n` prints the -n.
476 const lead = piped.name === 'echo' ? piped.args.findIndex(arg => !/^-[neE]+$/.test(arg)) : 0
477 const text = piped.args.slice(lead === -1 ? piped.args.length : lead).join(' ')
478 texts.push(piped.name === 'printf' ? text.replace(/\\n/g, '\n') : text)
479 }
480 // `cat <<EOF | bash`: the heredoc cat prints is the script.
481 if (piped !== undefined && piped.name === 'cat' && before !== undefined) {
482 for (const redirect of before.redirects) if (redirect.body !== undefined) texts.push(redirect.body)
483 }
484 return texts
485}
486
487// ---- verification ---------------------------------------------------------------------
488
489const SCRIPT = /^(test|tests|lint|typecheck|type-check|tsc|check|build|verify)([:-].*)?$/
490const MAKE_TARGET = /^(test|tests|check|lint|build|verify|typecheck)([:-].*)?$/
491const GRADLE_TASK = /^(:?[\w-]+:)*(test\w*|check|build|lint\w*|assemble\w*|connected\w*)$/
492const DIRECT = new Set([
493 'tsc', 'vue-tsc', 'mypy', 'pyright', 'eslint', 'ruff', 'vitest', 'jest', 'pytest', 'xcodebuild',
494 'swiftlint', 'ktlint', 'detekt', 'golangci-lint', 'rubocop', 'phpunit', 'phpstan', 'tox', 'nox',
495 'cargo-nextest', 'flake8', 'pylint', 'black', 'shellcheck', 'stylelint', 'playwright', 'rspec', 'ctest',
496])
497// Package-manager options that take the next word as their value.
498const PM_VALUES = new Set(['-C', '--dir', '--filter', '-F', '--prefix', '-w', '--workspace', '--cwd'])
499// Asking a tool its version or usage runs no check.
500const INFO_ONLY = new Set(['--version', '-version', '--help', '-help'])
501
502const firstPlain = (args: string[]) => args.find(arg => !arg.startsWith('-'))
503
504/** The positional words of a package-manager call, its value-taking options stepped over. */
505function pmWords(args: string[]): string[] {
506 const out: string[] = []
507 for (let k = 0; k < args.length; k++) {
508 const arg = args[k] as string
509 if (PM_VALUES.has(arg)) k += 1
510 else if (!arg.startsWith('-')) out.push(arg)
511 }
512 return out
513}
514
515function isCheckProgram(words: readonly string[]): boolean {
516 const run = program(words)
517 if (run === undefined) return false
518 const { name, args } = run
519 if (args.some(arg => INFO_ONLY.has(arg)) || (args.length === 1 && (args[0] === '-v' || args[0] === '-V'))) return false
520 const sub = firstPlain(args)
521
522 if (DIRECT.has(name)) return true
523 switch (name) {
524 case 'prettier':
525 case 'biome':
526 return args.includes('--check') || args.includes('-c') || sub === 'check' || sub === 'lint' || sub === 'ci'
527 case 'cargo':
528 return sub !== undefined && ['test', 'check', 'clippy', 'build', 'nextest'].includes(sub)
529 case 'go':
530 return sub !== undefined && ['test', 'vet', 'build'].includes(sub)
531 case 'swift':
532 return sub !== undefined && ['build', 'test'].includes(sub)
533 case 'deno':
534 return sub !== undefined && ['test', 'check', 'lint'].includes(sub)
535 case 'dotnet':
536 return sub !== undefined && ['test', 'build'].includes(sub)
537 case 'mvn':
538 case 'mvnw':
539 return args.some(arg => arg === 'test' || arg === 'verify')
540 case 'node':
541 return args.includes('--test')
542 case 'claude':
543 // A plugin's own suite and its validation: `claude plugin test <dir>`, `claude plugin validate <dir>`.
544 return args[0] === 'plugin' && (args[1] === 'test' || args[1] === 'validate')
545 case 'python':
546 case 'python3': {
547 const m = args.indexOf('-m')
548 const mod = m === -1 ? undefined : args[m + 1]
549 return mod !== undefined && ['pytest', 'unittest', 'mypy', 'ruff', 'pyright'].includes(mod)
550 }
551 case 'npm':
552 case 'bun':
553 case 'pnpm':
554 case 'yarn': {
555 let plain = pmWords(args)
556 if (name === 'yarn' && plain[0] === 'workspace') plain = plain.slice(2)
557 const [first, second] = plain
558 if (first === undefined) return false
559 if (first === 'run' || first === 'run-script') return second !== undefined && SCRIPT.test(second)
560 if (first === 't') return name === 'npm'
561 return SCRIPT.test(first)
562 }
563 case 'make':
564 case 'just':
565 case 'task':
566 return args.some(arg => !arg.startsWith('-') && MAKE_TARGET.test(arg))
567 case 'gradlew':
568 case 'gradle':
569 return args.some(arg => !arg.startsWith('-') && GRADLE_TASK.test(arg))
570 default:
571 return false
572 }
573}
574
575/** Whether a command that runs another passes on its exit status: `find -exec … \;` and `script` without -e exit 0 though it fails. */
576function passesStatus(words: readonly string[]): boolean {
577 const run = program(words)
578 if (run === undefined) return true
579 if (run.name === 'find') return run.args.includes('+')
580 if (run.name === 'script') return run.args.some(arg => arg === '--return' || /^-[A-Za-z]*e[A-Za-z]*$/.test(arg))
581 return true
582}
583
584function isCheckCommand(simple: Simple, depth: number): boolean {
585 if (isCheckProgram(simple.words)) return true
586 if (!passesStatus(simple.words)) return false
587 const inner = innerText(simple.words)
588 return inner !== undefined && depth < MAX_DEPTH && verifies(inner, depth + 1)
589}
590
591// Between the check and the end, only these keep the check's failure the command's failure.
592const CARRIES_FAILURE = new Set<Op>(['&&', '|', '|&'])
593
594function verifies(command: string, depth: number): boolean {
595 const { commands } = parse(command)
596 const last = commands.length - 1
597 // carries[i]: every command from i up to the one before the last passes a failure on. Built once from
598 // the end, so a command of thousands of checks is not read again after each one.
599 const carries: boolean[] = new Array(commands.length + 1).fill(true)
600 for (let i = last - 1; i >= 0; i -= 1) carries[i] = CARRIES_FAILURE.has((commands[i] as Simple).after) && (carries[i + 1] as boolean)
601 return commands.some((one, k) => {
602 if (!isCheckCommand(one, depth)) return false
603 if (k === last) return one.after === '' || one.after === ';' || one.after === '\n'
604 // `npm test | tail`, `npm test || true`, `npm test; git status`: the exit status is not the check's.
605 if (one.after !== '&&') return false
606 return carries[k + 1] as boolean
607 })
608}
609
610/**
611 * Whether a command's exit status is that of a test, lint, typecheck or build step: the
612 * check is the last command, or only `&&` chains follow it, so a failing check fails the command.
613 */
614export function isVerification(command: string): boolean {
615 return verifies(command, 0)
616}
617
618// ---- hooks off ------------------------------------------------------------------------
619
620const NO_VERIFY = '--no-verify'
621// Options of git itself that take the next word as their value.
622const GIT_VALUE_OPTIONS = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
623// Subcommand options whose next word is a value, never an option of its own.
624const SUB_VALUE_OPTIONS = new Set([
625 '-m', '--message', '-F', '--file', '-e', '--regexp', '--grep', '-S', '-G', '--author', '--committer',
626 '-C', '-c', '--reuse-message', '--reedit-message', '--fixup', '--squash', '--template', '-t', '--date',
627 '--cleanup', '--pathspec-from-file', '-X', '--strategy-option', '-s', '--strategy', '-o', '--push-option',
628 '--exec', '--onto', '--format', '--pretty', '--trailer', '--since', '--until',
629])
630// In these subcommands some of the options above are flags with no value (`git commit -s` signs
631// off, `-S` signs, `-e` edits), so the word after them is read as an option of its own.
632const SUB_FLAGS: Record<string, Set<string>> = {
633 commit: new Set(['-s', '-S', '-e', '-o']),
634 merge: new Set(['-S', '-e']),
635 rebase: new Set(['-S']),
636 'cherry-pick': new Set(['-s', '-S', '-e']),
637 revert: new Set(['-s', '-S', '-e']),
638 am: new Set(['-s', '-S']),
639}
640
641// `git commit` short options whose value is the rest of the cluster, or the next word when last.
642const COMMIT_VALUE_SHORTS = new Set(['m', 'F', 'C', 'c', 't'])
643// `git commit` short options whose optional value can only be glued (-uno, -Skey).
644const COMMIT_GLUED_SHORTS = new Set(['u', 'S'])
645
646/** Whether one word spells --no-verify: the whole option, or a prefix git accepts (--no-ver…). */
647const spellsNoVerify = (arg: string) => arg.length >= 8 && NO_VERIFY.startsWith(arg)
648
649/** In `git commit`, whether a short-option cluster sets -n (git commit's --no-verify). */
650function clusterHasN(cluster: string): { hasN: boolean; takesNext: boolean } {
651 for (let k = 1; k < cluster.length; k++) {
652 const flag = cluster[k] as string
653 if (flag === 'n') return { hasN: true, takesNext: false }
654 if (COMMIT_VALUE_SHORTS.has(flag)) return { hasN: false, takesNext: k === cluster.length - 1 }
655 if (COMMIT_GLUED_SHORTS.has(flag)) return { hasN: false, takesNext: false }
656 }
657 return { hasN: false, takesNext: false }
658}
659
660// Environment assignments that switch off the hook managers git runs (husky, lefthook, pre-commit),
661// or move git's hooks through config given in the environment (GIT_CONFIG_KEY_n, GIT_CONFIG_PARAMETERS).
662const HOOK_OFF_ENV = /^(?:HUSKY=(?:0|false)|HUSKY_SKIP_HOOKS=|LEFTHOOK=(?:0|false)|SKIP=)/
663// Config keys are case-insensitive (core.hooksPath, CORE.HOOKSPATH); the variable names are not.
664const HOOKS_PATH_ENV = /^(?:GIT_CONFIG_KEY_\d+=(?:core\.hookspath)$|GIT_CONFIG_PARAMETERS=.*\bcore\.hookspath=)/i
665
666/** `-c core.hooksPath=…` or `--config-env core.hooksPath=…`: the hooks moved for one command. */
667const movesHooks = (option: string, value: string | undefined) =>
668 (option === '-c' || option === '--config-env') && value !== undefined && /^core\.hookspath=/i.test(value)
669
670function bypassesHooks(simple: Simple): boolean {
671 const run = program(simple.words)
672 if (run === undefined || run.name !== 'git') return false
673 const { args } = run
674 // The words before git: `HUSKY=0 git commit`, `env SKIP=lint git commit`.
675 const before = simple.words.slice(0, simple.words.length - args.length - 1)
676 if (before.some(word => HOOK_OFF_ENV.test(word) || (HOOKS_PATH_ENV.test(word) && /^GIT_CONFIG_/.test(word)))) return true
677
678 let k = 0
679 while (k < args.length && (args[k] as string).startsWith('-')) {
680 const option = args[k] as string
681 if (movesHooks(option, args[k + 1])) return true
682 if (/^--config-env=core\.hookspath=/i.test(option)) return true
683 k += GIT_VALUE_OPTIONS.has(option) ? 2 : 1
684 }
685 const sub = args[k]
686 if (sub === undefined) return false
687
688 const rest = args.slice(k + 1)
689 for (let j = 0; j < rest.length; j++) {
690 const arg = rest[j] as string
691 if (arg === '--') break
692 if (sub === 'commit' && /^-[A-Za-z]+$/.test(arg) && arg.length > 2) {
693 const { hasN, takesNext } = clusterHasN(arg)
694 if (hasN) return true
695 if (takesNext) j += 1
696 continue
697 }
698 if (SUB_VALUE_OPTIONS.has(arg) && SUB_FLAGS[sub]?.has(arg) !== true) {
699 j += 1
700 continue
701 }
702 if (spellsNoVerify(arg)) return true
703 if (sub === 'commit' && arg === '-n') return true
704 }
705 return false
706}
707
708/**
709 * Whether a command runs git with its hooks switched off (--no-verify, a prefix of it,
710 * `git commit -n`, `-c core.hooksPath=…`, or a hook manager's off switch in its environment),
711 * anywhere it runs: chained, grouped, substituted, or inside `bash -c`.
712 * Best effort: a git alias (`git ci -n`), a hook path written into config beforehand, or an
713 * environment exported by an earlier command is not seen.
714 */
715export function skipsHooks(command: string): boolean {
716 return allCommands(command).some(bypassesHooks)
717}
718types/index.d.ts 58 lines1export type Tier = 1 | 2 | 3 | 4
2
3/** The language of the stamp and the receipt. */
4export type Locale = 'ja' | 'en'
5
6/** A check (`pnpm test`, `tsc`, `make check`) in one directory, as the session last saw it. */
7export type CheckRecord =
8 | { isRed: false; at: number; dir: string }
9 | {
10 isRed: true
11 at: number
12 dir: string
13 firstFailAt: number
14 failRuns: number
15 /** The scope words every failure since red began shares: a pass must run at least these. */
16 scope: string[]
17 /** Whether a code edit in its directory (or outside every check's) came since it last failed: a win needs one. */
18 isCredited: boolean
19 firstFailCount?: number
20 peakFailCount?: number
21 }
22
23/** How many edits the session made, and the count at the last one outside test files. */
24export type Edits = { seq: number; codeSeq: number }
25
26export type Band = {
27 id: number
28 tier: Tier
29 kind: 'green' | 'first' | 'commit'
30 /** The check's record key ('' for a commit): what a later failure is matched by. */
31 key: string
32 /** The check's name, or a commit's sha. */
33 label: string
34 /** The check's directory relative to the session's, '' when it is the session's. */
35 where: string
36 phase: 'sweep' | 'stamp'
37 locale: Locale
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 kachi: {
43 checks: Record<string, CheckRecord>
44 edits: Edits
45 /** The edit count a passing check last covered; -1 once a check failed since. */
46 verifiedSeq: number
47 commitTick: { at: number; seq: number }
48 lastClipAt: number
49 band: Band | null
50 /** The one line under a Bash row, by tool_use_id: a win's receipt, or a check's first red of the session. */
51 wins: Record<string, string>
52 /** The checks whose first red already drew its line. */
53 announced: string[]
54 nextId: number
55 }
56 }
57}
58