SLOPSHOPPER

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; silent…

bandrowsguardprompttimeraudio
★ 1v0.1.1MITupdated 2026-10-17simota/claude-mods/mods/kachi
A shopper browsing a rack in a slop shop
README

claude-mods

English | 日本語

Personal Claude Code mods (function-hook plugins), published as one marketplace.

ModWhat it does
agent-paneLive pane of the session's subagents: running ones with elapsed time, finished ones with result and duration
done-gateFlags unverified edits, stops repeated failing commands, and blocks git --no-verify
sandbox-lensTallies sandbox denials seen in Bash output and reports them with /sandbox-report
todo-agentCollects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane.
tool-rowRedraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat
image-previewThumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane
self-auditAfter 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-camLive pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions
prompt-coachHolds 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
loopbackFeeds 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
zukanError 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-lampThe 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-mapExploration 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-guardCatches 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-spaceWhen 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-lanternThe dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports
artifact-shelfA 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-ledgerA 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-diffCompares 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-traceRecords 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-rateGives 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-lensChecks 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>
kachiWhen 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-checkA 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-rehearsalCI 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-dockThe 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

Install

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).

Usage

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

  • /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 ↗ 起動済み(追跡外).
  • In a fullscreen terminal the pane opens by itself once per session, on the first Agent call (a reload does not reopen it).
  • The status line shows agents: N running while any run.

done-gate

Works on its own, no command:

  • A turn that edited files without a passing test, lint, typecheck or build afterwards leaves a band: 未検証の変更があります. It stays until a check succeeds or 閉じる is pressed; 検証させる puts in the prompt an ask for Claude to run the checks the edits touch and fix what fails, for you to send, as every band's ask does (the band stays until a check passes). Only the program a command runs counts (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).
  • The second identical failure of the same Bash command tells Claude to stop retrying and diagnose the root cause (again after the command has succeeded in between).
  • Any git command with --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-lens

  • /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.
  • Hints only: it never changes sandbox settings. A loopback or private address keeps its port in the hint (127.0.0.1:3000), since an entry without a port opens every port.
  • A <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

  • Lines starting with #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.
  • The status line shows TODO(agent): N while the project has open items.

tool-row

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.

OptionDefault
compacttrueOff leaves every row and result as Claude Code draws them

image-preview

  • Images a session writes, reads, or that a Bash command produces (png, jpg, jpeg, webp, gif) get a thumbnail under that tool's row in the terminal (kitty graphics: Ghostty, kitty).
  • /images opens a pane, newest first; h/l step through them.
  • Non-PNG files are converted with macOS 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.
  • A Bash command and its output are searched for image paths in time linear in their length, before the result goes back. A path split at spaces (/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.
OptionDefault
inlinetrueThumbnail under the tool row
maxRows12Rows a thumbnail may take, 1 to 60

self-audit

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.

OptionDefault
enabledtrue
minChars200Shorter 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.

browser-cam

  • While Claude drives a browser (Claude in Chrome, Claude Browser, or a remote device's browser), a pane shows the latest screenshot, the page title and URL, and the last 8 actions with ✓/✗. The URL comes only from the navigate call or the tab tools' own header, never from page text. Text typed or filled in is shown as its length only (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.
  • The desktop app shows the title and URL, a caption and the actions, without the picture.
OptionDefault
autoOpentrueOpen on the first browser call (fullscreen terminal)

prompt-coach

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 unchanged
  • e: 補足して送る puts it back with 完了条件: to fill in (not held again)
  • q: 閉じる puts it back in the prompt

Typing 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.

OptionDefault
enabledtrue
modelclaude-haiku-5-5Judging model (alias or id)
timeoutMs3000500 to 3000

loopback

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 reply
  • done-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 once

Each 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.
OptionDefault
enabledfalse

Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).

zukan

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 entry

The newest 300 kinds by last sighting are kept.

pr-lamp

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 default

Which 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.

fog-map

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
  • The status line shows 🗺 31% · ●9 · ✗2 · ⚠1 only while there is an error or a warning, and clears otherwise
  • A toast at 25, 50, 75 and 100% explored, and when an edit first reaches a new area (🗺 新しい区域を開拓: 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.

assertion-guard

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

Source 4 files
hooks/register.tsx 298 lines
1import { 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}
298
hooks/win.ts 862 lines
1// 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}
862
hooks/shell.ts 718 lines
1// 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}
718
types/index.d.ts 58 lines
1export 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