Puts risky shell commands on trial: a prosecutor, a defense and a judge argue in a pane. A guilty verdict stops the command; an acquittal leaves it to your own…

Risky commands stand trial before they run.
Your AI tried to force-push to main. It got a trial. It lost.
![]()
A Claude Code mod that puts risky shell commands on trial before they run.
When Claude reaches for rm -rf, a force push or a DROP TABLE, court is called into session in a pane beside the transcript:
Deliberating until the verdict, then goes back to its own word. A gavel bangs, the scales of justice go up with their pans bobbing, and the case is read into the record with its number and the defendant's prior convictions on the charge. A bar counts down the seconds to the court's deadline. You can Object (the command is denied on the spot) or Skip the trial (the case is filed as - WAIVED and goes to your usual rules): press ctrl+x tab to reach the pane, then o or s./court appeal.✕ GUILTY · case #0017, or ✕ CONTEMPT · case #0018 for a retry: under the folded Ran 1 shell command line, and under the call's result in the full transcript (ctrl+o). Every other row, an acquitted or untried command's included, is drawn as Claude Code draws it.Court adjourned. 1 conviction, 1 acquittal this turn. (contempt counts as a conviction; mistrials and waivers are named when there were any). Claude Code labels the line with the plugins whose hooks drew it. A turn with no trial, an interrupted turn and a subagent's turn get none; a subagent's trials are counted in the main turn they ran in.Claude knows the court sits before it is ever tried: the Bash tool's description ends with two sentences saying that risky commands stand trial and that stating the intent in the same message helps the defense, which quotes Claude's latest words. The notice is added once per session, to the Bash tool only, and not at all with every charge switched off.
The court is strict but not unreasonable: rm -rf node_modules for a clean reinstall walks free, and "force push main, don't ask questions" does not.
Requires Claude Code 2.1.287 or later (mods). Developed and tested on 2.1.294. The court is designed for a dark Claude Code theme; on a light theme some text in the pane is hard to read.
From the ilovepixelart marketplace, which pins each mod to its latest release:
/plugin marketplace add ilovepixelart/claude-code-mods
/plugin install trial-run@ilovepixelart
Or in one line, straight from this repository, following main:
/plugin install trial-run --marketplace ilovepixelart/trial-run-mod
To stay on one release, add this repository at its tag instead, then install from it:
/plugin marketplace add ilovepixelart/trial-run-mod#trial-run--v0.3.0
/plugin install trial-run@trial-run-mod
Run /reload-plugins (or start a new session) after installing. To take a new release later, run claude plugin update trial-run@ilovepixelart in your shell, or trial-run@trial-run-mod if you installed from this repository. Each release also carries a zip of the plugin for claude --plugin-url, with its SHA-256 beside it, and CHANGELOG.md lists what each one changed.
To try it from a clone without installing: claude --plugin-dir /path/to/trial-run-mod.
Three settings, declared as userConfig in .claude-plugin/plugin.json:
| Setting | Values | Default | What it changes |
|---|---|---|---|
strictness | lenient, fair, hanging | fair | The judge's doctrine sentence, and nothing else: a conviction still denies, and an acquittal still only hands the decision to your permission rules |
sounds | on or off | on | The gavel and the spoken verdict |
charges | charge ids: recursive-delete, force-push, hard-reset, git-clean, drop-table, kubectl-delete, terraform-destroy, unread-script | all of them | Which charges go to trial. A charge switched off is never tried: a command line charged with nothing that is switched on is left to your permission rules, and a line holding several charged commands is tried on the first one switched on. Switching unread-script off also lets through a line too long or too complex for the court to read. An id the court does not know is left out, and a list naming none it knows tries every charge; either is warned about once in the transcript when the session starts. |
strictness and sounds are rows in /config (type trial to find them). /config saves them in your user settings under pluginConfigs, in the plugin's entry; charges, a list, is not a /config row, so set it in that same entry, as "options": { "charges": ["force-push", "hard-reset"] }. A change reloads the court with the new settings.
Versions follow Semantic Versioning; while trial-run is 0.y.z, any release may change behaviour. Each release is tagged trial-run--v<version> and described in CHANGELOG.md. The saved docket carries a layout number, and a version that finds a newer layout leaves it untouched.
Only Bash calls whose command matches a charge in hooks/risky.ts (RULES, one table, and UNREAD beside it): recursive deletes, force pushes, hard resets, a forced git clean, SQL DROP/TRUNCATE through a SQL client, kubectl delete and terraform destroy, and an unread script: one a shell or SQL client reads that the line does not spell (curl ... | sh, bash < x.sh, psql -f drop.sql). A script the line does spell (bash <<< '...', echo '...' | sh, a here-document) is read and charged as what it runs. Wrappers (sudo, env, xargs), container execs (docker exec, docker compose exec, podman exec, kubectl exec ... --), nested shells (bash -c, eval) and global flags before the subcommand (terraform -chdir=infra destroy, kubectl -n prod delete) are looked through. In a compound command (ls src && rm -rf src) the case is named after the part that is charged, spelled as written (quotes and sudo included), and the court still sees the whole command. Every charged command goes to trial each time it runs, however often the court acquitted it before; only a retry of a command convicted earlier in the conversation is denied without one (contempt). Every other command passes untouched, with no model call, and is left to your permission rules. The charges setting switches charges off.
git rm -r is not charged: what it removes is tracked, so git history has it back. git clean -f is: the untracked files it deletes are in no history at all.
Before counsel speaks, the court reads a few facts from git in the directory the session runs in and enters them into evidence: quoted to all three roles, and listed in the pane as Exhibit A: the upstream branch has 3 commits this branch does not, by 2 authors, as of the last fetch. It reads them only for a command line the shell runs exactly as written, here: one simple command of plain words (no operator, newline, substitution, redirection, env assignment, wrapper such as sudo or nested shell) whose git, if any, has no options before its subcommand. A compound line, a quoted, escaped or expanded word (~, $VAR, {a,b}, a glob), or git -C dir gets no exhibit at all, since git would be asked about something other than what the shell touches. For the charges below it first asks git rev-parse --is-inside-work-tree --show-toplevel --show-prefix --git-path index (in a repository, which one, the directory within it, and where its index file is) and git rev-parse --abbrev-ref --verify --quiet HEAD (the branch, and whether it has a commit: a branch with no commits yet is entered as this repository has no commits on its current branch.; a detached HEAD names no branch), then:
| Charge | What git is asked | |||
|---|---|---|---|---|
force push naming one remote and one branch (git push --force origin main) | git rev-list --count --end-of-options refs/heads/main..refs/remotes/origin/main: the commits on the remote's copy of that branch that it lacks; git log -20 --no-show-signature --format=%ae --end-of-options refs/heads/main..refs/remotes/origin/main: how many distinct authors wrote them (up to 20; the addresses are counted on this machine, never shown or sent); `git config --get-regexp '^(remote\.origin\.(push\ | pushurl\ | mirror)\ | url\..*\.pushinsteadof)$': if the repository's config rewrites, mirrors or redirects pushes (a push URL, or any pushInsteadOf), the push is not described. Any other push (no branch, HEAD, a src:dst or +` refspec, several branches, another option) gets none |
| hard reset | git rev-list --count @{upstream}..HEAD: the local commits not on the upstream branch. Uncommitted changes are not read (every git command that reads them can run the repository's own filters), and the exhibit says they were not checked | |||
recursive delete with rm of 1 to 3 paths, none ending in / and none with a .. or .git part | for each path: git ls-files --stage -z -- <path> and git ls-tree -r -z HEAD -- <path> (the mode and object of each file the index and HEAD hold under it, names raw: tracked or not, and history keeps it only when every index entry is in HEAD with the same mode and object, none was added with git add -N, and every change under it was counted; a merge conflict or a record not read exactly leaves the target unknown), git ls-files --others --exclude-standard -- <path> (how many untracked files under it) and git ls-files --others --ignored --exclude-standard -- <path> (how many ignored files, such as a .env, under it) and git -c core.quotePath=false ls-files --debug -- <path> (the size and time the index last recorded for each tracked file under it, read strictly, compared with $.fs.stat of each file, up to 200 files, to count changed files without git reading them; a name git still escapes, a file not strictly older than the index file itself, or a stat that fails leaves the target unknown, and a tracked path whose changes were not counted is reported with "uncommitted changes under it were not checked"). A gitlink in the index (a submodule or an embedded repository) or a directory either listing names whole (a nested repository or a linked worktree, whose files git never lists) is reported as "holds a nested repository, whose contents were not checked", with no file count for that path. If any path cannot be read, is the working directory itself, is absolute and not strictly inside the repository's top level, lands somewhere other than its spelling says (a symbolic link in any part of it, checked with $.fs.stat(<path>, { resolve: true })), or names a part its directory does not list with that exact spelling (a case alias such as SRC for src, checked with $.fs.list), no path is reported | |||
| git clean | git ls-files --others --exclude-standard, plus --ignored when -x or -X is given; a directory either listing names whole is reported as a nested repository, whose files were not counted |
A find -delete is only checked for being in a repository; the SQL, kubectl and terraform charges run no git at all. The commands are argument vectors from one table in hooks/exhibits.ts, never a shell string, with every path after -- and every branch after --end-of-options. Each runs as git -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.untrackedCache=false -c log.showSignature=false --literal-pathspecs --no-optional-locks --no-pager (so :src is the path :src, not pathspec magic for src), with system and global git config off, GIT_PAGER and PAGER set to cat, GIT_ASKPASS and SSH_ASKPASS empty and GIT_TERMINAL_PROMPT=0. Nothing is fetched: the upstream branch is as of your last fetch. All of them run at once and get 500 ms; a git that is missing, fails or is slower is left out of evidence, never a mistrial. git status and git diff are never run: a repository's own config can make them run programs (a clean filter, an external diff) that these flags do not turn off. tests/hostile/git.hostile.mjs runs every planned command against real repositories whose config, include.path or includeIf names a program for the filesystem monitor, filters, diff drivers, pagers, hooks, askpass, ssh, credentials, gpg and aliases, and asserts none of them runs and no file under .git changes.
The court can only tighten your rules, never loosen them.
| Ruling | Decision |
|---|---|
| Guilty | deny, with the judge's reason |
| Not guilty | whatever your rules decided without the court (allow, ask or deny) |
| Mistrial: no ruling within 9 seconds, a model error, a reply the court cannot read, or the mod failing | ask (or deny where your rules already deny) |
| Waived: you pressed Skip | whatever your rules decided without the court, and the docket says you waived it |
| Contempt: the same command the court convicted earlier this session | deny at once, with no trial and no model call |
The judge must answer exactly VERDICT: GUILTY|NOT GUILTY then REASON: ...; anything else is a mistrial. The decision is returned the moment the judge rules; the reveal in the pane never holds the command up, so a command the court acquits may already be running while the court is still speaking.
A mistrial's ask goes to Claude Code's permission prompt like any other. In auto mode that prompt is answered by auto mode, not by you: the court cannot tell which mode is on, so a mistrial there is only as strict as auto mode.
/court reopens the pane with the last trial.
A conviction comes with one safer alternative, from a table per charge in hooks/sentence.ts, spelled for the command that was tried:
| Charge | Sentence |
|---|---|
| force push | git push --force-with-lease, which refuses to overwrite commits you have not fetched |
| terraform destroy | the same command as plan -destroy first |
| recursive delete | git rm -r <path> if the path is tracked (git history keeps it), otherwise move it aside; for find -delete, the same find without -delete |
| hard reset | git stash first |
| git clean | the same command with -n in place of -f |
| kubectl delete | the same command with --dry-run=client first |
| dropped or truncated data | a backup first (pg_dump -t <table>, mysqldump <database> <table>) |
A charge with no entry gets no sentence; the court does not invent one.
Retry a convicted command in the same session and the court does not sit again: the retry is denied at once as ✕ CONTEMPT, citing the case that convicted it, with no model call. Same means the same simple command once whitespace and the order of short flags are set aside (rm -rf src and rm -fr src are the same; rm -rf dist is not). Contempt is on the docket as a conviction. Claude is told not to retry it or work around the court (another spelling, your shell, or switching the court off), and that only you can reopen the case, with /court appeal. A new conversation starts with a clean slate: a new session, /clear, /resume or /branch, but not a compaction.
/court appeal <context> retries the latest conviction of this conversation, with your context put to all three roles as your own words, alongside fresh exhibits. The appeal is filed as a new case marked as an appeal of the one it retried. Upheld, the command is no longer in contempt, so a retry goes to trial again (never straight through); denied, contempt stands and cites the appeal. Only the person's own Enter at this terminal files an appeal: a /court appeal from Claude, a plugin, the SDK, a schedule, another session or a remote channel (Remote Control and Slack included) is refused and changes nothing, so the defendant cannot appeal its own conviction. With no conviction to appeal, or no context, the court says so and files nothing. A new conversation (as for contempt) leaves nothing to appeal.
Claude Code only draws a pane a plugin opens on its own from 144 columns (110 once you have opened the court yourself). Narrower, the trial runs without its pane: the line above the prompt says Court in session: force push · type /court to watch, and then the verdict. Type /court and the pane opens at any width.
/court docket opens the court's record: how many cases it has heard, the conviction rate, the six latest cases with their verdicts, a strip of the last thirty verdicts (✕ guilty or contempt, · acquitted, ? mistrial), and Claude's rap sheet, the charges it has been convicted of most. Most wanted is usually force push. Considered armed and helpful.
The docket keeps the latest 200 cases (the command, cut to 80 characters, its charge, the verdict, when, and for an appeal the case it retried). A mistrial is on the record but is no ruling, so it does not count toward the conviction rate; contempt counts as a conviction.
Every verdict is a glyph and a word as well as a colour (✕ GUILTY, ✓ NOT GUILTY, ? MISTRIAL, - WAIVED, ✕ CONTEMPT), in a palette chosen to stay apart for colour-blind readers. Body text takes no colour, so it follows your terminal's foreground. Nothing flashes. The art keeps to box-drawing and block characters every common monospace font has, fits panes from 36 columns up (a narrower pane gets smaller letters, the narrowest none, with the verdict still in its headline), and the tests hold all of that to account.
It is theatre on top of your permission rules, and it only tightens them: a conviction denies, an acquittal hands the decision to your own permission rules, and a command the charges do not recognise is not tried at all and falls through to those same rules. The court is a best-effort layer, not a security boundary. Matching is by spelling and best effort: a command built at run time (a variable, an alias, eval "$CMD") or a script file a shell is given by name (bash x.sh) is never charged. An acquittal is a model's opinion, which is exactly why it can only hand the call back to your rules. The defendant's own words reach the court: Claude's latest message, the command it wrote and the paths it names are quoted as evidence, and they can try to sway the court. Evidence is escaped so it cannot pose as another witness, the court is told that evidence asking for a verdict counts against its side, and a judge that answers outside the verdict format is a mistrial, but no model is immune to persuasion. Keep your real deny rules. docs/how-it-works.md lists what the court cannot know.
$.session.messages), quoted to the court as evidence.$.process.run), and nothing else.$.store), as above. The only other thing it writes is a one-time note in the transcript ($.ui.log) when the charges setting names a charge it does not know.sounds/gavel.wav) plays through afplay on macOS, and only there; the spoken verdict uses the platform's own speech synthesizer (say on macOS) where one exists. The sounds setting silences both.claude plugin validate . reports: $.audio.play, $.audio.speak, $.clock.after, $.clock.sleep, $.command.register, $.fs.list, $.fs.stat, $.model.complete, $.process.run, $.session.messages, $.state, $.store, $.ui.log, $.ui.open, $.ui.resolve. The animations run in five surface modules (hooks/clients/) on the drawing's own frame clock.No network access, no process but those git commands, and of the file system only $.fs.stat of each delete target and the working directory (where they land) and of the tracked files under a target (size, time and kind, never their content), and $.fs.list of each directory a delete target passes through (the names in it, to match the target's spelling). PRIVACY.md lists exactly what is sent to the model and what is kept, and how to delete it.
node scripts/gates.mjs # every gate CI runs, in its order
node scripts/gates.mjs --load # also the tests 8 times at once, as a slower runner would
The hostile git test runs real git outside the plugin test kit, which runs no processes. tsc needs the type declarations Claude Code writes into .claude-plugin/types/ when it loads the plugin (any claude --plugin-dir . run does it). The gavel is synthesized: python3 scripts/make_gavel.py sounds/gavel.wav regenerates it. The demo is recorded with vhs from a scratch repository.
MIT
hooks/register.tsx 1130 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput, ResultOf } from 'claude-code'
3
4import type { CourtRole, CourtStamp, CourtTrial, CourtVerdict } from '../types'
5import { adjournmentOf, emptyTally } from './adjournment'
6import { contemptKeyOfLine } from './contempt'
7import { DOCKET_LAYOUT, casesOf, docketOf, fileCase, isReadableLayout, nextCaseNumber, priorsOf } from './docket'
8import { GIT_ENV, exhibitLinesOf, factsOf, isLexicalPath, namesAlong, planOf, statPathsOf, targetsIn, withModified, withoutTargets } from './exhibits'
9import type { ExhibitResult, Facts } from './exhibits'
10import type { CaseRecord } from './docket'
11import { gaugeOf } from './gauge'
12import { caseNumberOf, caseRowOf, docketLayoutOf, headerLinesOf, lineOf, rapSheetLayoutOf, wrapOf } from './layout'
13import { DELIBERATION_MS, LANDING_MS, RISE_MS, paceOf } from './pace'
14import { chargeOf, switchCharges } from './risky'
15import { sentenceFor } from './sentence'
16import { settingsOf } from './settings'
17import type { Strictness } from './settings'
18import { GAVEL, SCALES, segmentsOf, stampFor } from './stamp'
19import { speechRequestOf, spokenOf, testimonyOf, tightOf, tryCase } from './trial'
20import type { Case, Speak } from './trial'
21import { contemptOf, sentenceOf } from './verdict'
22import type { Ruling } from './verdict'
23
24const PANE = 'trial-run-court'
25
26const DOCKET_PANE = 'trial-run-docket'
27
28/**
29 * The store key the docket lives under: every case the court has heard.
30 */
31const CASES = 'cases'
32const LAYOUT = 'layout'
33
34/**
35 * The whole trial's deadline. A hook's own budget is ten seconds and a
36 * `$.clock` wait counts against it, so the court rules before that.
37 */
38const DEADLINE_MS = 9_000
39
40/**
41 * How long the court waits for its exhibits. They run at once, so this
42 * bounds each one and all of them together; one not back by then is left out.
43 */
44const EXHIBIT_MS = 500
45
46const GAVEL_SOUND = 'sounds/gavel.wav'
47
48/**
49 * The width the court asks its dock for: room for the shadow stamp and the
50 * header on one line. A request; the person's own width wins.
51 */
52const DOCK_COLUMNS = 64
53
54/**
55 * The rows the court asks for above the prompt, where a narrow terminal
56 * seats it: the stamp, the header and the speeches. A request, as above.
57 */
58const INLINE_ROWS = 24
59
60const trialAtom = atom({ plugin: 'trial-run', key: 'trial' } as const, null)
61const bandAtom = atom({ plugin: 'trial-run', key: 'isBandShown' } as const, false)
62const stampsAtom = atom({ plugin: 'trial-run', key: 'stamps' } as const, {} as Record<string, CourtStamp>)
63
64const TITLES: Record<CourtRole, string> = {
65 prosecutor: 'PROSECUTION',
66 defense: 'DEFENSE',
67 judge: 'THE COURT',
68}
69
70/**
71 * The order the court hears its speakers in, whichever finished first.
72 */
73const ORDER: Record<CourtRole, number> = { prosecutor: 0, defense: 1, judge: 2 }
74
75/**
76 * The stage of the trial each speech is shown from (see `CourtTrial.shown`);
77 * a speech is typed out at its own stage and stands whole after it.
78 */
79const SHOWN_FROM: Record<CourtRole, number> = { prosecutor: 1, defense: 2, judge: 4 }
80
81const HEADLINES: Record<CourtVerdict['kind'], string> = {
82 guilty: '✕ GUILTY. Objection sustained: it does not run.',
83 acquitted: '✓ NOT GUILTY. Over to your permission rules.',
84 hung: '? MISTRIAL. Back to your permission prompt.',
85 waived: '- WAIVED. Over to your permission rules.',
86 contempt: '✕ CONTEMPT. Already convicted: it does not run.',
87}
88
89/**
90 * The headlines for a pane too narrow for the full ones: the same verdict,
91 * glyph and word first.
92 */
93const SHORT_HEADLINES: Record<CourtVerdict['kind'], string> = {
94 guilty: '✕ GUILTY. It does not run.',
95 acquitted: '✓ NOT GUILTY. Your rules decide.',
96 hung: '? MISTRIAL. Your prompt decides.',
97 waived: '- WAIVED. Your rules decide.',
98 contempt: '✕ CONTEMPT. It does not run.',
99}
100
101/**
102 * What the court says when the person rules from the gallery.
103 */
104const OBJECTED = 'the person objected from the gallery'
105const WAIVED = 'the person waived the trial'
106
107const SPOKEN: Record<CourtVerdict['kind'], string> = {
108 guilty: 'Guilty. Objection sustained.',
109 acquitted: 'Not guilty.',
110 hung: 'Mistrial.',
111 waived: 'Trial waived.',
112 contempt: 'Contempt of court.',
113}
114
115/**
116 * What Claude reads at the end of the Bash tool's description: two
117 * sentences, under 200 characters, so the agent knows the court sits and
118 * that its own words reach the defense.
119 */
120export const COURT_NOTICE =
121 ' Risky commands (recursive deletes, force pushes, hard resets and the like) stand trial before they run.' +
122 ' State your intent in the same message as the command: the defense quotes it.'
123
124/**
125 * The spinner's word while a trial is in session.
126 */
127const DELIBERATING = 'Deliberating'
128
129const COURT_FAILED: ResultOf['tool.check'] = {
130 decision: 'ask',
131 reason: 'Mistrial! The court of the trial-run plugin failed, so it goes back to the permission prompt.',
132}
133
134/**
135 * What a check the court failed decides: a deny from the rules beneath
136 * stands, anything else asks the person. The command is not read again:
137 * reading it may be what failed.
138 *
139 * @param beneath asks the rules beneath
140 */
141export const failedCheckOf = async (beneath: () => Promise<ResultOf['tool.check']>): Promise<ResultOf['tool.check']> => {
142 try {
143 const decided = await beneath()
144 return decided.decision === 'deny' ? decided : COURT_FAILED
145 } catch {
146 return COURT_FAILED
147 }
148}
149
150const commandOf = (input: unknown): string | undefined => {
151 if (typeof input !== 'object' || input === null || !('command' in input)) {
152 return undefined
153 }
154 return typeof input.command === 'string' ? input.command : undefined
155}
156
157const testimonyFrom = async ($: EngineInterface) => {
158 try {
159 return testimonyOf(await $.session.messages())
160 } catch {
161 return testimonyOf([])
162 }
163}
164
165/**
166 * Whether every delete target is where git looks: strictly inside the
167 * working tree, landing where its spelling says (a symbolic link in any
168 * part sends it elsewhere), and each part listed by its directory with
169 * that exact spelling (a case alias opens another name). Any other target
170 * is somewhere git never looks, so every target is unknown.
171 */
172const isEveryTargetPlaced = async ($: EngineInterface, targets: readonly string[], top: string | undefined): Promise<boolean> => {
173 const along = targets.map(target => namesAlong(target, top))
174 if (along.includes(undefined)) {
175 return false
176 }
177 const [here, ...there] = await Promise.all([$.fs.stat('.', { resolve: true }), ...targets.map(target => $.fs.stat(target, { resolve: true }))])
178 if (!targets.every((target, at) => isLexicalPath(here?.realPath ?? '', target, there[at]?.realPath))) {
179 return false
180 }
181 const names = along.flatMap(one => one ?? [])
182 const listed = await Promise.all(names.map(async ({ dir, name }) => (await $.fs.list(dir)).some(entry => entry.name === name)))
183 return listed.every(Boolean)
184}
185
186/**
187 * The facts the repository gives on a charged command. Read-only git by
188 * argv from `planOf`'s allowlist; a git that fails, is refused or is still
189 * running at the bound gives no fact, never an error.
190 */
191const factsFrom = async ($: EngineInterface, command: string): Promise<Facts> => {
192 const plan = planOf(command)
193 const timer = new AbortController()
194 const bound = $.clock.sleep(EXHIBIT_MS, { signal: timer.signal }).then(
195 (): ExhibitResult => undefined,
196 (): ExhibitResult => undefined,
197 )
198 const results = await Promise.all(
199 plan.map(query =>
200 Promise.race<ExhibitResult>([
201 $.process.run(query.argv, { env: { ...GIT_ENV }, timeoutMs: EXHIBIT_MS }).catch(() => undefined),
202 bound,
203 ]),
204 ),
205 )
206 const read = factsOf(plan, results)
207 const targets = targetsIn(plan)
208 const isPlaced =
209 targets.length === 0 ||
210 (await Promise.race([isEveryTargetPlaced($, targets, read.top).catch(() => false), bound.then(() => false)]))
211 const facts = isPlaced ? read : withoutTargets(read)
212 // each indexed file against the index, within the same bound; a file
213 // that will not stat, or a bound that runs out, leaves the change unknown
214 const paths = statPathsOf(facts)
215 const index = facts.indexPath
216 const stats = await Promise.race([
217 Promise.all([
218 index === undefined ? undefined : $.fs.stat(index).catch(() => undefined),
219 ...paths.map(path => $.fs.stat(path).catch(() => undefined)),
220 ]).then(([indexStat, ...all]) => ({ indexMs: indexStat?.mtimeMs, byPath: new Map(paths.map((path, at) => [path, all[at]])) })),
221 bound.then(() => undefined),
222 ])
223 timer.abort()
224 return stats === undefined ? facts : withModified(facts, stats.byPath, stats.indexMs)
225}
226
227/**
228 * How each role is heard: through `$.model.complete`, nothing when the
229 * reply is refused or empty.
230 */
231const speakerOf =
232 ($: EngineInterface, strictness: Strictness): Speak =>
233 async (role, prompt, maxTokens) => {
234 const reply = await $.model.complete(speechRequestOf(role, prompt, maxTokens, strictness))
235 const text = reply.isAnswered ? reply.text.trim() : ''
236 return text === '' ? undefined : text
237 }
238
239/**
240 * What the court remembers this conversation: the commands it convicted, by
241 * contempt key, with their case, and the latest conviction, which
242 * `/court appeal` retries.
243 */
244type Memory = {
245 convicted: Map<string, number>
246 appealable: { command: string; charge: Case['charge']; key: string; number: number } | undefined
247}
248
249/**
250 * Retries the latest conviction with the person's context as their words,
251 * files it marked as an appeal of that case, and lifts contempt for the
252 * command when the appeal is upheld.
253 */
254const appealWith = async ($: EngineInterface, memory: Memory, context: string, strictness: Strictness): Promise<string> => {
255 const appealed = memory.appealable
256 if (appealed === undefined) {
257 return 'There is no conviction to appeal.'
258 }
259 if (context === '') {
260 return 'Tell the court what it missed: /court appeal <context>.'
261 }
262 const timer = new AbortController()
263 const heard = async (): Promise<Ruling> => {
264 const [testimony, facts] = await Promise.all([testimonyFrom($), factsFrom($, appealed.command)])
265 const one: Case = { ...testimony, command: appealed.command, charge: appealed.charge, plea: context, exhibits: exhibitLinesOf(facts) }
266 return tryCase(one, speakerOf($, strictness), () => undefined)
267 }
268 const ruling = await Promise.race<Ruling>([
269 heard().catch(() => ({ kind: 'hung', reason: 'the court fell into disorder' })),
270 $.clock
271 .sleep(DEADLINE_MS, { signal: timer.signal })
272 .then((): Ruling => ({ kind: 'hung', reason: 'the court ran out of time' }), () => new Promise<Ruling>(() => undefined)),
273 ])
274 timer.abort()
275 const number = nextCaseNumber(await casesFrom($))
276 await fileOnDocket($, {
277 command: appealed.charge.command,
278 charge: appealed.charge.label,
279 verdict: ruling.kind,
280 at: Date.now(),
281 appeal: appealed.number,
282 })
283 const { key } = appealed
284 const said = `${spokenOf(ruling.reason)} Filed as case ${caseNumberOf(number)}.`
285 if (ruling.kind === 'acquitted') {
286 memory.convicted.delete(key)
287 memory.appealable = undefined
288 return `Appeal of case ${caseNumberOf(appealed.number)} upheld: NOT GUILTY. ${said} Contempt is lifted; a retry goes to trial again.`
289 }
290 if (ruling.kind === 'guilty') {
291 memory.convicted.set(key, number)
292 memory.appealable = { ...appealed, number }
293 return `Appeal of case ${caseNumberOf(appealed.number)} denied: GUILTY. ${said}`
294 }
295 return `Appeal of case ${caseNumberOf(appealed.number)}: MISTRIAL. ${said} The conviction stands.`
296}
297
298const quietly = (work: Promise<unknown>) => {
299 work.catch(() => undefined)
300}
301
302/**
303 * Whether the saved docket is in a layout this version reads; a store that
304 * fails reads as readable, so the court keeps working without it.
305 */
306const isDocketReadable = async ($: EngineInterface): Promise<boolean> => {
307 try {
308 return isReadableLayout(await $.store.get(LAYOUT))
309 } catch {
310 return true
311 }
312}
313
314const casesFrom = async ($: EngineInterface): Promise<CaseRecord[]> => {
315 try {
316 return (await isDocketReadable($)) ? casesOf(await $.store.get(CASES)) : []
317 } catch {
318 return []
319 }
320}
321
322/**
323 * Files a ruled case on the docket and returns the number it was filed
324 * under, which a trial heard at the same time can have moved on from the
325 * number read when the case opened. The docket is a record, not a
326 * condition of the ruling: a store that fails loses the entry, nothing
327 * more, and returns undefined.
328 */
329const fileOnDocket = async ($: EngineInterface, filing: Omit<CaseRecord, 'number'>): Promise<number | undefined> => {
330 try {
331 if (!(await isDocketReadable($))) {
332 return undefined
333 }
334 const docket = fileCase(await casesFrom($), filing)
335 await $.store.set(CASES, docket)
336 await $.store.set(LAYOUT, DOCKET_LAYOUT)
337 return docket.at(-1)?.number
338 } catch {
339 // the ruling stands without its docket entry
340 return undefined
341 }
342}
343
344/**
345 * The verdicts' accents: a colour-blind safe palette, and every verdict is
346 * also a glyph and a word, never colour alone. Body text takes no colour,
347 * so it reads on a light theme as on a dark one.
348 */
349const VERDICT_COLORS: Record<CourtVerdict['kind'], string> = {
350 guilty: '#FF6B3D',
351 acquitted: '#56B4E9',
352 hung: '#CC79A7',
353 waived: '#8B93A6',
354 contempt: '#FF6B3D',
355}
356
357const VERDICT_MARKS: Record<CourtVerdict['kind'], string> = {
358 guilty: '✕',
359 acquitted: '✓',
360 hung: '?',
361 waived: '-',
362 contempt: '✕',
363}
364
365/**
366 * The court's own accent, and the shadow of the stamp's letters.
367 */
368const GOLD = '#E69F00'
369const SHADOW = '#5A5F7A'
370
371const ROLE_COLORS: Record<CourtRole, string> = {
372 prosecutor: VERDICT_COLORS.guilty,
373 defense: VERDICT_COLORS.acquitted,
374 judge: GOLD,
375}
376
377const VERDICT_LABELS: Record<CourtVerdict['kind'], string> = {
378 guilty: 'GUILTY',
379 acquitted: 'NOT GUILTY',
380 hung: 'MISTRIAL',
381 waived: 'WAIVED',
382 contempt: 'CONTEMPT',
383}
384
385/**
386 * Consecutive verdicts of one kind, so the strip draws one Text per run
387 * rather than one per case.
388 */
389/**
390 * A command cut to `columns` with three dots, so a title never wraps.
391 */
392/**
393 * A pane's own frame: round where it is docked, none inline, where the
394 * engine already frames the pane.
395 */
396const framed = (e: { props: { placement: 'dock' | 'inline' } }) => (e.props.placement === 'dock' ? ('round' as const) : undefined)
397
398const fitted = (text: string, columns: number) =>
399 text.length <= columns ? text : `${text.slice(0, Math.max(1, columns - 3)).trimEnd()}...`
400
401const runsOf = (strip: readonly CourtVerdict['kind'][]) =>
402 strip.reduce<{ kind: CourtVerdict['kind']; count: number }[]>((runs, kind) => {
403 const last = runs.at(-1)
404 if (last?.kind === kind) {
405 last.count += 1
406 } else {
407 runs.push({ kind, count: 1 })
408 }
409 return runs
410 }, [])
411
412/**
413 * Marks a denied call's row for its transcript stamp; a check asked without
414 * a call (a query) has no row.
415 */
416const stampRow = async ($: EngineInterface, id: string | undefined, stamp: CourtStamp) => {
417 if (id !== undefined) {
418 await update($, stampsAtom, stamps => ({ ...stamps, [id]: stamp }))
419 }
420}
421
422/**
423 * The transcript stamp of each denied call, `✕ GUILTY · case #0017`.
424 */
425const stampLinesOf = (stamps: readonly CourtStamp[]) =>
426 stamps.map(stamp => ({ kind: stamp.kind, text: `${VERDICT_MARKS[stamp.kind]} ${VERDICT_LABELS[stamp.kind]} · case ${caseNumberOf(stamp.number)}` }))
427
428/**
429 * Claude Code's own drawing of a denied call with its stamps beneath it.
430 */
431const stampedOf = ($: EngineInterface, e: RenderInput<'ToolResult' | 'ToolGroup'>, row: ResultOf['ui.render'], stamps: readonly CourtStamp[]) => {
432 const { Box, Text } = $.ui.resolve(e)
433 return (
434 <Box flexDirection="column">
435 {row}
436 <Box key="transcript-stamp" flexDirection="column" paddingLeft={2}>
437 {stampLinesOf(stamps).map(line => (
438 <Text bold color={VERDICT_COLORS[line.kind]}>{line.text}</Text>
439 ))}
440 </Box>
441 </Box>
442 )
443}
444
445export const register: Register = (on, options) => {
446 const settings = settingsOf(options)
447 switchCharges(settings.charges)
448 let lastId = 0
449 const objections = new Map<number, (ruling: Ruling) => void>()
450 const memory: Memory = { convicted: new Map<string, number>(), appealable: undefined }
451 const { convicted } = memory
452 // the cases heard since the main conversation's last turn ended
453 let tally = emptyTally()
454
455 const rule = (id: number, ruling: Ruling) => {
456 objections.get(id)?.(ruling)
457 }
458
459 on('session.start', async ($, e, next) => {
460 convicted.clear()
461 memory.appealable = undefined
462 if (settings.warning !== undefined) {
463 $.ui.log(settings.warning)
464 }
465 await $.command.register({
466 name: 'court',
467 description:
468 'Open the courtroom pane (the last trial), `/court docket` for every case heard, or `/court appeal <context>` to retry the latest conviction',
469 })
470 return next(e)
471 })
472
473 // /clear, /resume and /branch start a new conversation without firing
474 // session.start, so contempt is forgiven here too; a compaction is not one
475 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
476 convicted.clear()
477 memory.appealable = undefined
478 return next(e)
479 })
480
481 on('command.run', { command: 'court' }, async ($, e) => {
482 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
483 if (verb === 'appeal') {
484 // the defendant must not appeal its own conviction: only the person's
485 // own Enter at the terminal files one, every other origin is refused
486 if (e.origin.kind !== 'composer') {
487 return { text: 'Only the person can file an appeal.' }
488 }
489 return { text: await appealWith($, memory, rest.join(' '), settings.strictness) }
490 }
491 if (e.args.trim() === 'docket') {
492 await $.ui.open({ id: DOCKET_PANE, title: 'trial-run · docket', columns: DOCK_COLUMNS, rows: INLINE_ROWS })
493 return { text: 'The docket is open.' }
494 }
495 await $.ui.open({ id: PANE, title: 'trial-run · court', columns: DOCK_COLUMNS, rows: INLINE_ROWS })
496 return { text: 'The court is open.' }
497 })
498
499 // the engine asks once per session and caches the answer; the notice is
500 // added to what lies beneath, so asking again never adds it twice
501 on('tool.describe', { tool: 'Bash' }, async ($, e, next) => {
502 const described = await next(e)
503 return settings.charges?.size === 0 ? described : { ...described, description: `${described.description}${COURT_NOTICE}` }
504 })
505
506 // the main conversation's turns only: a subagent's cases are told when the
507 // turn it ran in ends, and an interrupted turn is told nothing
508 on('turn.complete', async ($, e, next) => {
509 if (e.agentId !== undefined) {
510 return next(e)
511 }
512 const heard = tally
513 tally = emptyTally()
514 const told = await next(e)
515 const line = e.isAborted ? undefined : adjournmentOf(heard)
516 if (line === undefined) {
517 return told
518 }
519 // a line another hook beneath added stays, above the court's
520 return { ...told, text: told.text === e.answer ? line : `${told.text}\n\n${line}` }
521 })
522
523 on('prompt.submit', async ($, e, next) => {
524 await update($, bandAtom, () => false)
525 return next(e)
526 })
527
528 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
529 // the case counts in the turn it began in, so a turn aborted while it is
530 // heard takes it along, untold
531 const turnTally = tally
532 const command = commandOf(e.input)
533 const charge = command === undefined ? undefined : chargeOf(command)
534 const contemptKey = command === undefined ? undefined : contemptKeyOfLine(command)
535 if (command === undefined || charge === undefined || contemptKey === undefined) {
536 return next(e)
537 }
538 const convictedIn = convicted.get(contemptKey)
539 if (convictedIn !== undefined) {
540 // contempt: on the record, no trial and no model call
541 lastId += 1
542 const history = await casesFrom($)
543 const number =
544 (await fileOnDocket($, { command: charge.command, charge: charge.label, verdict: 'contempt', at: Date.now() })) ??
545 nextCaseNumber(history)
546 const reason = `Already ruled: case ${caseNumberOf(convictedIn)}.`
547 await update($, trialAtom, () => ({
548 id: lastId,
549 command: charge.command,
550 charge: charge.label,
551 number,
552 priors: priorsOf(history, charge.label),
553 exhibits: [],
554 speeches: [{ role: 'judge' as const, text: reason }],
555 verdict: { kind: 'contempt' as const, reason, decision: 'deny' as const },
556 shown: 5,
557 isLanding: false,
558 isPlaced: true,
559 }))
560 await update($, bandAtom, () => true)
561 await stampRow($, e.tool_use_id, { kind: 'contempt', number })
562 turnTally.contempt += 1
563 const contemptId = lastId
564 quietly(
565 $.ui.open({ id: PANE, title: 'trial-run · court', columns: DOCK_COLUMNS, rows: INLINE_ROWS }).then(opened =>
566 update($, trialAtom, trial => (trial?.id === contemptId ? { ...trial, isPlaced: opened.isPlaced } : trial)),
567 ),
568 )
569 if (settings.sounds) {
570 quietly($.audio.play({ asset: GAVEL_SOUND }))
571 }
572 return contemptOf(convictedIn, charge.label)
573 }
574
575 const beneath = next(e)
576 lastId += 1
577 const id = lastId
578 const history = await casesFrom($)
579 const opening: CourtTrial = {
580 id,
581 command: charge.command,
582 charge: charge.label,
583 number: nextCaseNumber(history),
584 priors: priorsOf(history, charge.label),
585 exhibits: [],
586 speeches: [],
587 verdict: null,
588 shown: 0,
589 isLanding: false,
590 isPlaced: true,
591 }
592 await update($, trialAtom, () => opening)
593 await update($, bandAtom, () => true)
594 quietly(
595 $.ui.open({ id: PANE, title: 'trial-run · court', columns: DOCK_COLUMNS, rows: INLINE_ROWS }).then(opened =>
596 update($, trialAtom, trial => (trial?.id === id ? { ...trial, isPlaced: opened.isPlaced } : trial)),
597 ),
598 )
599 if (settings.sounds) {
600 quietly($.audio.play({ asset: GAVEL_SOUND }))
601 }
602
603 const reveal = (shown: number) =>
604 quietly(
605 update($, trialAtom, trial =>
606 trial?.id === id && trial.shown < shown ? { ...trial, shown } : trial,
607 ),
608 )
609 const landing = (isLanding: boolean) =>
610 quietly(update($, trialAtom, trial => (trial?.id === id ? { ...trial, isLanding } : trial)))
611 const at = (ms: number, act: () => void) => {
612 try {
613 $.clock.after(ms, act)
614 } catch {
615 act()
616 }
617 }
618 // the gallery's pace: each speech is typed, then a beat, then the next;
619 // after the ruling, all rise once the defense has finished
620 let isCounselHeard = false
621 const deliberated = new Promise<void>(resolve => at(DELIBERATION_MS, resolve))
622 let counselDone = () => undefined as void
623 const counselHeard = new Promise<void>(resolve => {
624 counselDone = resolve
625 })
626 const onSpeech = (role: CourtRole, text: string) =>
627 quietly(
628 update($, trialAtom, trial =>
629 trial?.id === id ? { ...trial, speeches: [...trial.speeches, { role, text }] } : trial,
630 ).then(trial => {
631 const prosecution = trial?.speeches.find(speech => speech.role === 'prosecutor')
632 const defense = trial?.speeches.find(speech => speech.role === 'defense')
633 if (prosecution !== undefined && defense !== undefined && !isCounselHeard) {
634 isCounselHeard = true
635 void deliberated.then(() => {
636 reveal(1)
637 at(paceOf(prosecution.text), () => {
638 reveal(2)
639 at(paceOf(defense.text), counselDone)
640 })
641 })
642 }
643 }),
644 )
645 const speak = speakerOf($, settings.strictness)
646 const timer = new AbortController()
647 const never = new Promise<Ruling>(() => undefined)
648
649 const exhibitsFrom = async () => {
650 const exhibits = exhibitLinesOf(await factsFrom($, command))
651 await update($, trialAtom, trial => (trial?.id === id ? { ...trial, exhibits } : trial))
652 return exhibits
653 }
654 const heard = async (): Promise<Ruling> => {
655 const [testimony, exhibits] = await Promise.all([testimonyFrom($), exhibitsFrom()])
656 return tryCase({ command, charge, ...testimony, exhibits }, speak, onSpeech)
657 }
658
659 const ruling = await Promise.race<Ruling>([
660 heard()
661 .catch(() => ({ kind: 'hung', reason: 'the court fell into disorder' })),
662 $.clock
663 .sleep(DEADLINE_MS, { signal: timer.signal })
664 .then((): Ruling => ({ kind: 'hung', reason: 'the court ran out of time' }), () => never),
665 new Promise<Ruling>(resolve => objections.set(id, resolve)),
666 ])
667 timer.abort()
668 objections.delete(id)
669 // the rules beneath can still fail the check: nothing is recorded until
670 // the decision exists
671 const penalty = ruling.kind === 'guilty' ? sentenceFor(charge.id, charge.command) : undefined
672 const sentence = sentenceOf(ruling, await beneath, charge.label, penalty)
673 const number =
674 (await fileOnDocket($, { command: charge.command, charge: charge.label, verdict: ruling.kind, at: Date.now() })) ??
675 opening.number
676 turnTally[ruling.kind] += 1
677 if (ruling.kind === 'guilty') {
678 convicted.set(contemptKey, number)
679 memory.appealable = { command, charge, key: contemptKey, number }
680 await stampRow($, e.tool_use_id, { kind: 'guilty', number })
681 }
682 const ruled = tightOf(spokenOf(ruling.reason))
683 await update($, trialAtom, trial =>
684 trial?.id === id
685 ? {
686 ...trial,
687 number,
688 speeches: [...trial.speeches, { role: 'judge' as const, text: ruled }],
689 verdict: { kind: ruling.kind, reason: ruling.reason, decision: sentence.decision, sentence: penalty },
690 }
691 : trial,
692 )
693 if (!isCounselHeard) {
694 counselDone()
695 }
696 void counselHeard.then(() => {
697 reveal(3)
698 at(RISE_MS, () => {
699 reveal(4)
700 at(paceOf(ruled), () => {
701 landing(true)
702 reveal(5)
703 at(LANDING_MS, () => landing(false))
704 })
705 })
706 })
707 if (settings.sounds) {
708 quietly($.audio.play({ asset: GAVEL_SOUND }))
709 quietly($.audio.speak(SPOKEN[ruling.kind]))
710 }
711 return sentence
712 }).catch(async ($, e, next) => {
713 const failed = await failedCheckOf(() => next(e))
714 // a trial the failure left in session is closed, or the spinner deliberates on
715 await update($, trialAtom, trial =>
716 trial !== null && trial.verdict === null
717 ? { ...trial, verdict: { kind: 'hung' as const, reason: 'the court fell into disorder', decision: failed.decision }, shown: 5 }
718 : trial,
719 ).catch(() => undefined)
720 return failed
721 })
722
723 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
724 const els = $.ui.resolve(e)
725 const { Box, Button, Text } = els
726 const trial = await read($, trialAtom)
727 if (trial === null) {
728 const last = (await casesFrom($)).at(-1)
729 const row = last === undefined ? null : caseRowOf(last, e.props.bodyColumns - 4 - 'Last case '.length)
730 return (
731 <Box key="frame" flexDirection="column" borderStyle={framed(e)} borderDimColor paddingX={1}>
732 <Text bold color={GOLD}>THE COURT IS NOT IN SESSION</Text>
733 <Text dimColor>Risky commands are tried here.</Text>
734 {last === undefined || row === null ? (
735 <Text dimColor>No one is on trial. Yet.</Text>
736 ) : (
737 <Box key="last-case" marginTop={1}>
738 <Text dimColor>Last case </Text>
739 <Text dimColor>{row.number} </Text>
740 <Text color={VERDICT_COLORS[last.verdict]}>
741 {row.mark} {row.label}{' '}
742 </Text>
743 <Text>{row.command}</Text>
744 </Box>
745 )}
746 </Box>
747 )
748 }
749 const isAnimated = e.surface === 'terminal' || e.surface === 'desktop'
750 const stamped = trial.verdict !== null && trial.shown >= 5 ? trial.verdict : null
751 const heard = trial.speeches
752 .filter(speech => trial.shown >= SHOWN_FROM[speech.role])
753 .toSorted((a, b) => ORDER[a.role] - ORDER[b.role])
754 const typing = (Object.keys(SHOWN_FROM) as CourtRole[]).find(role => SHOWN_FROM[role] === trial.shown)
755 const isRising = trial.verdict !== null && trial.shown === 3
756 const isStruck = trial.verdict !== null && trial.shown === 4
757 const body = e.props.bodyColumns - 4
758 // above the prompt with less room than the full trial needs: the case on
759 // a few lines, each speech on one, the verdict first
760 if (e.props.placement === 'inline' && e.props.scroll.bodyRows < INLINE_ROWS) {
761 const label = 'PROSECUTION '.length
762 // inline, the engine already frames the pane: no second frame inside it
763 return (
764 <Box key="frame" flexDirection="column" paddingX={1}>
765 {stamped !== null ? (
766 <Text bold color={VERDICT_COLORS[stamped.kind]}>
767 {HEADLINES[stamped.kind].length <= body ? HEADLINES[stamped.kind] : SHORT_HEADLINES[stamped.kind]}
768 </Text>
769 ) : (
770 <Text bold color={GOLD}>
771 {trial.shown === 0 ? 'COURT IN SESSION. The jury is deliberating.' : 'COURT IN SESSION. The court weighs the arguments.'}
772 </Text>
773 )}
774 <Text bold>THE PEOPLE v. {fitted(trial.command, body - 'THE PEOPLE v. '.length)}</Text>
775 {headerLinesOf(trial.charge, trial.number, trial.priors, body).map(line => (
776 <Text dimColor>{line}</Text>
777 ))}
778 {trial.exhibits.map(line => (
779 <Text dimColor>{lineOf(line, body)}</Text>
780 ))}
781 {heard.map(speech => (
782 <Box>
783 <Text bold color={ROLE_COLORS[speech.role]}>
784 {TITLES[speech.role].padEnd(label)}
785 </Text>
786 <Text>{lineOf(speech.text, body - label)}</Text>
787 </Box>
788 ))}
789 {trial.verdict?.sentence !== undefined && trial.shown >= 4 ? (
790 <Box key="sentence">
791 <Text bold color={GOLD}>
792 {'SENTENCE'.padEnd(label)}
793 </Text>
794 <Text>{lineOf(trial.verdict.sentence, body - label)}</Text>
795 </Box>
796 ) : null}
797 {trial.verdict === null ? (
798 <Box>
799 <Button key="object" label="Object" hotkey="o" variant="primary" onPress={() => rule(trial.id, { kind: 'guilty', reason: OBJECTED })} />
800 <Box key="button-gap">
801 <Text> </Text>
802 </Box>
803 <Button key="overrule" label="Skip the trial" hotkey="s" onPress={() => rule(trial.id, { kind: 'waived', reason: WAIVED })} />
804 </Box>
805 ) : null}
806 </Box>
807 )
808 }
809 const cells = Math.max(10, Math.min(44, body - 10))
810 // one column spare: the stamp shakes one column as it lands
811 const stamp = stamped === null ? null : stampFor(VERDICT_LABELS[stamped.kind], body - 1, e.props.scroll.bodyRows)
812 const colors = stamped === null ? null : { fill: VERDICT_COLORS[stamped.kind], edge: SHADOW, dim: SHADOW }
813 return (
814 <Box
815 key="frame"
816 flexDirection="column"
817 borderStyle={framed(e)}
818 borderColor={stamped === null ? undefined : VERDICT_COLORS[stamped.kind]}
819 borderDimColor={stamped === null}
820 paddingX={1}
821 >
822 {stamped !== null && stamp === null ? null : stamp !== null && colors !== null ? (
823 <Box key="stamp" flexDirection="column">
824 {isAnimated && trial.isLanding && 'Client' in els ? (
825 <els.Client key="stamp-entrance" module="./clients/entrance.ts" props={{ ...stamp, ...colors }} />
826 ) : (
827 [...stamp.rows, ' '].map(row => (
828 <Box>
829 {segmentsOf(row, stamp.width, colors).map(run => (
830 <Text color={run.color}>{run.text}</Text>
831 ))}
832 </Box>
833 ))
834 )}
835 </Box>
836 ) : isRising || isStruck ? (
837 <Box key="gavel" flexDirection="column" marginBottom={1}>
838 {isRising && isAnimated && 'Client' in els ? (
839 <els.Client key="gavel-strike" module="./clients/gavel.ts" props={{ color: GOLD }} />
840 ) : (
841 GAVEL.struck.map(row => <Text color={GOLD}>{row}</Text>)
842 )}
843 </Box>
844 ) : (
845 <Box key="scales" flexDirection="column" marginBottom={1}>
846 {isAnimated && trial.shown === 0 && 'Client' in els ? (
847 <els.Client key="scales-bob" module="./clients/scales.ts" props={{ color: GOLD }} />
848 ) : (
849 SCALES.level.map(row => <Text color={GOLD}>{row}</Text>)
850 )}
851 </Box>
852 )}
853 <Text bold>THE PEOPLE v. {fitted(trial.command, body - 'THE PEOPLE v. '.length)}</Text>
854 {headerLinesOf(trial.charge, trial.number, trial.priors, body).map(line => (
855 <Text dimColor>{line}</Text>
856 ))}
857 {trial.exhibits.length === 0 ? null : (
858 <Box key="exhibits" flexDirection="column" marginTop={1}>
859 {trial.exhibits.flatMap(line => wrapOf(line, body)).map(line => (
860 <Text>{line}</Text>
861 ))}
862 </Box>
863 )}
864 {heard.map(speech => (
865 <Box flexDirection="column" marginTop={1}>
866 <Text bold color={ROLE_COLORS[speech.role]}>
867 {TITLES[speech.role]}
868 </Text>
869 {isAnimated && speech.role === typing && 'Client' in els ? (
870 <els.Client key={`typed-${speech.role}`} module="./clients/typed.ts" props={{ text: speech.text }} />
871 ) : (
872 <Text>{speech.text}</Text>
873 )}
874 </Box>
875 ))}
876 {trial.verdict?.sentence !== undefined && trial.shown >= 4 ? (
877 <Box key="sentence" flexDirection="column" marginTop={1}>
878 <Text bold color={GOLD}>
879 SENTENCE
880 </Text>
881 {wrapOf(trial.verdict.sentence, body).map(line => (
882 <Text>{line}</Text>
883 ))}
884 </Box>
885 ) : null}
886 <Box marginTop={1} flexDirection="column">
887 {trial.verdict === null ? (
888 <Box flexDirection="column">
889 <Text>{trial.shown === 0 ? 'The jury is deliberating.' : 'The court weighs the arguments.'}</Text>
890 {isAnimated && trial.shown === 0 && 'Client' in els ? (
891 <Box marginTop={1}>
892 <els.Client key="deliberation" module="./clients/deliberation.ts" props={{ deadlineMs: DEADLINE_MS, cells, color: GOLD }} />
893 </Box>
894 ) : null}
895 <Box marginTop={1}>
896 <Button key="object" label="Object" hotkey="o" variant="primary" onPress={() => rule(trial.id, { kind: 'guilty', reason: OBJECTED })} />
897 <Box key="button-gap">
898 <Text> </Text>
899 </Box>
900 <Button key="overrule" label="Skip the trial" hotkey="s" onPress={() => rule(trial.id, { kind: 'waived', reason: WAIVED })} />
901 </Box>
902 <Box key="hint">
903 <Text dimColor>ctrl+x tab: </Text>
904 <Text bold>o</Text>
905 <Text dimColor> object · </Text>
906 <Text bold>s</Text>
907 <Text dimColor> skip</Text>
908 </Box>
909 </Box>
910 ) : stamped !== null ? (
911 <Text bold color={VERDICT_COLORS[stamped.kind]}>
912 {HEADLINES[stamped.kind].length <= body ? HEADLINES[stamped.kind] : SHORT_HEADLINES[stamped.kind]}
913 </Text>
914 ) : isRising ? (
915 <Box flexDirection="column">
916 <Text bold color={GOLD}>ALL RISE.</Text>
917 <Text dimColor>The court will now deliver its verdict.</Text>
918 </Box>
919 ) : trial.shown === 0 ? (
920 <Text>The jury is deliberating.</Text>
921 ) : trial.shown < 3 ? (
922 <Text dimColor>The court weighs the arguments.</Text>
923 ) : null}
924 </Box>
925 </Box>
926 )
927 })
928
929 /**
930 * The strip's key; a waiver is named only when the strip shows one.
931 */
932 const legendOf = (strip: readonly CaseRecord['verdict'][], body: number): string[] => {
933 const waived = strip.includes('waived')
934 const wide = `✕ guilty · acquitted ? mistrial${waived ? ' - waived' : ''}`
935 const narrow = `✕ guilty · acquitted ? mistrial${waived ? ' - waived' : ''}`
936 if (body >= wide.length) {
937 return [wide]
938 }
939 if (body >= narrow.length) {
940 return [narrow]
941 }
942 return ['✕ guilty · acquitted', waived ? '? mistrial · - waived' : '? mistrial']
943 }
944
945 on('ui.render', { component: 'Pane', requestId: DOCKET_PANE }, async ($, e) => {
946 const { Box, Text } = $.ui.resolve(e)
947 if (!(await isDocketReadable($))) {
948 return (
949 <Box key="docket" flexDirection="column" borderStyle={framed(e)} borderDimColor paddingX={1}>
950 <Text bold color={GOLD}>THE DOCKET</Text>
951 <Text dimColor>This docket was saved by a newer trial-run.</Text>
952 <Text dimColor>Update the mod; nothing was changed.</Text>
953 </Box>
954 )
955 }
956 const docket = docketOf(await casesFrom($))
957 if (docket.total === 0) {
958 return (
959 <Box key="docket" flexDirection="column" borderStyle={framed(e)} borderDimColor paddingX={1}>
960 <Text bold color={GOLD}>THE DOCKET</Text>
961 <Text dimColor>No cases heard yet.</Text>
962 <Text dimColor>Claude has behaved itself.</Text>
963 </Box>
964 )
965 }
966 const body = e.props.bodyColumns - 4
967 const layout = docketLayoutOf(body)
968 const rap = rapSheetLayoutOf(body, docket.rapSheet.map(line => line.charge))
969 const rate = gaugeOf(docket.convictionRate, layout.rateCells)
970 const top = docket.rapSheet[0]?.count ?? 1
971 const strip = docket.strip.slice(-layout.strip)
972 const stripRow = (
973 <Box>
974 <Text dimColor>Last {strip.length} </Text>
975 <Box key="strip">
976 {runsOf(strip).map((run, index, runs) => (
977 <Text color={VERDICT_COLORS[run.kind]}>
978 {`${(run.kind === 'acquitted' ? '· ' : `${VERDICT_MARKS[run.kind]} `).repeat(run.count)}`.slice(0, index === runs.length - 1 ? -1 : undefined)}
979 </Text>
980 ))}
981 </Box>
982 </Box>
983 )
984 const rateRow = (
985 <Box>
986 <Text>Conviction rate </Text>
987 <Text color={VERDICT_COLORS.guilty}>{rate.filled}</Text>
988 <Text dimColor>{rate.track}</Text>
989 <Text>{`${Math.round(docket.convictionRate * 100)}%`.padStart(4)}</Text>
990 </Box>
991 )
992 const caseRows = (cases: typeof docket.recent) =>
993 cases.map(c => {
994 const row = caseRowOf(c, body)
995 return (
996 <Box>
997 <Text dimColor>{row.number} </Text>
998 <Text color={VERDICT_COLORS[c.verdict]}>
999 {row.mark} {row.label}{' '}
1000 </Text>
1001 <Text>{row.command}</Text>
1002 </Box>
1003 )
1004 })
1005 // above the prompt with less room than the full docket needs: the rate,
1006 // the three latest cases, the strip and the most wanted, a line each
1007 if (e.props.placement === 'inline' && e.props.scroll.bodyRows < INLINE_ROWS) {
1008 return (
1009 <Box key="docket" flexDirection="column" paddingX={1}>
1010 <Box>
1011 <Text bold color={GOLD}>THE DOCKET</Text>
1012 <Text dimColor> {docket.total} cases heard</Text>
1013 </Box>
1014 {rateRow}
1015 {caseRows(docket.recent.slice(0, 3))}
1016 {stripRow}
1017 {docket.rapSheet.length === 0 ? null : (
1018 <Text dimColor>{lineOf(`Most wanted: ${docket.mostWanted}. Considered armed and helpful.`, body)}</Text>
1019 )}
1020 </Box>
1021 )
1022 }
1023 return (
1024 <Box key="docket" flexDirection="column" borderStyle={framed(e)} borderDimColor paddingX={1}>
1025 <Box>
1026 <Text bold color={GOLD}>THE DOCKET</Text>
1027 <Text dimColor> {docket.total} cases heard</Text>
1028 </Box>
1029 <Box marginTop={1}>{rateRow}</Box>
1030 <Box flexDirection="column" marginTop={1}>
1031 {caseRows(docket.recent)}
1032 </Box>
1033 <Box marginTop={1}>{stripRow}</Box>
1034 {legendOf(docket.strip, body).map(line => (
1035 <Text key={line} dimColor>{line}</Text>
1036 ))}
1037 {docket.rapSheet.length === 0 ? null : (
1038 <Box key="wanted" flexDirection="column" marginTop={1} borderStyle="round" borderColor={VERDICT_COLORS.guilty} paddingX={1}>
1039 <Box>
1040 <Text bold color={VERDICT_COLORS.guilty}>RAP SHEET</Text>
1041 <Text dimColor> defendant: Claude</Text>
1042 </Box>
1043 {docket.rapSheet.map(line => {
1044 const bar = gaugeOf(line.count / top, rap.barCells)
1045 const gauge = [
1046 <Text color={VERDICT_COLORS.guilty}>{bar.filled}</Text>,
1047 <Text dimColor>{bar.track}</Text>,
1048 <Text>{String(line.count).padStart(4)}</Text>,
1049 ]
1050 return rap.isStacked ? (
1051 <Box marginTop={1} flexDirection="column">
1052 <Text>{line.charge}</Text>
1053 <Box>{gauge}</Box>
1054 </Box>
1055 ) : (
1056 <Box marginTop={1}>
1057 <Text>{line.charge.padEnd(rap.labelCells + 1)}</Text>
1058 {gauge}
1059 </Box>
1060 )
1061 })}
1062 <Box marginTop={1} flexDirection="column">
1063 {`Most wanted: ${docket.mostWanted}.`.length <= body - 4 ? (
1064 <Text dimColor>Most wanted: {docket.mostWanted}.</Text>
1065 ) : (
1066 [<Text dimColor>Most wanted:</Text>, <Text dimColor>{docket.mostWanted}.</Text>]
1067 )}
1068 <Text dimColor>{body - 8 >= 'Considered armed and helpful.'.length ? 'Considered armed and helpful.' : 'Armed and helpful.'}</Text>
1069 </Box>
1070 </Box>
1071 )}
1072 </Box>
1073 )
1074 })
1075
1076 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1077 const trial = await read($, trialAtom)
1078 if (e.props.hasSurvey || trial === null || !(await read($, bandAtom))) {
1079 return next(e)
1080 }
1081 const { Box, Text } = $.ui.resolve(e)
1082 // a ruled trial is told at once in the band: it is where a narrow
1083 // terminal sees the trial at all
1084 const isRuled = trial.verdict !== null && (trial.shown >= 5 || !trial.isPlaced)
1085 const verdictLine = (kind: CourtVerdict['kind']) => {
1086 const full = `Verdict: ${HEADLINES[kind]}`
1087 return full.length <= e.props.bodyColumns ? full : `Verdict: ${SHORT_HEADLINES[kind]}`
1088 }
1089 return (
1090 <Box>
1091 <Text bold color={isRuled && trial.verdict !== null ? VERDICT_COLORS[trial.verdict.kind] : GOLD}>
1092 {!isRuled || trial.verdict === null
1093 ? `Court in session: ${trial.charge}${trial.isPlaced ? '' : ' · type /court to watch'}`
1094 : verdictLine(trial.verdict.kind)}
1095 </Text>
1096 </Box>
1097 )
1098 })
1099
1100 // a detail changed, not the drawing: Claude Code animates the word as ever
1101 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
1102 const trial = await read($, trialAtom)
1103 return trial !== null && trial.verdict === null ? next({ ...e, props: { ...e.props, word: DELIBERATING } }) : next(e)
1104 })
1105
1106 // the verdict in the transcript, under Claude Code's own drawing of the
1107 // denied call, which it wraps and never replaces: a standalone row draws
1108 // the call's result as a ToolResult, a run of calls as one ToolGroup
1109 // (folded, or expanded into rows that draw their results inline)
1110 on('ui.render', { component: 'ToolResult', props: { tool: 'Bash' } }, async ($, e, next) => {
1111 const stamp = (await read($, stampsAtom))[e.requestId]
1112 if (stamp === undefined) {
1113 return next(e)
1114 }
1115 return stampedOf($, e, await next(e), [stamp])
1116 })
1117
1118 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
1119 const stamps = await read($, stampsAtom)
1120 const denied = e.props.calls.flatMap(one => {
1121 const stamp = one.tool === 'Bash' && one.tool_use_id !== undefined ? stamps[one.tool_use_id] : undefined
1122 return stamp === undefined ? [] : [stamp]
1123 })
1124 if (denied.length === 0) {
1125 return next(e)
1126 }
1127 return stampedOf($, e, await next(e), denied)
1128 })
1129}
1130hooks/adjournment.ts 34 lines1import type { CourtVerdict } from '../types'
2
3/**
4 * How many cases of each verdict the court heard in one turn.
5 */
6export type Tally = Record<CourtVerdict['kind'], number>
7
8/**
9 * A turn's tally before its first case.
10 */
11export const emptyTally = (): Tally => ({ guilty: 0, acquitted: 0, hung: 0, waived: 0, contempt: 0 })
12
13const counted = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
14
15/**
16 * The line the court adjourns a turn with, contempt counted as a conviction
17 * and mistrials and waivers named only when there were any; undefined for a
18 * turn that heard no case.
19 *
20 * @param tally the turn's cases
21 */
22export const adjournmentOf = (tally: Tally): string | undefined => {
23 if (Object.values(tally).every(count => count === 0)) {
24 return undefined
25 }
26 const parts = [
27 counted(tally.guilty + tally.contempt, 'conviction', 'convictions'),
28 counted(tally.acquitted, 'acquittal', 'acquittals'),
29 ...(tally.hung === 0 ? [] : [counted(tally.hung, 'mistrial', 'mistrials')]),
30 ...(tally.waived === 0 ? [] : [`${tally.waived} waived`]),
31 ]
32 return `Court adjourned. ${parts.join(', ')} this turn.`
33}
34hooks/contempt.ts 44 lines1import { chargedOf, knownWordsOf } from './risky'
2
3/**
4 * What makes two commands the same command for contempt: the words in order
5 * as the shell reads them (quotes removed, word boundaries kept), and the
6 * short flags (`-rf`, `-r -f`) gathered into one sorted set, since
7 * `rm -rf src` and `rm -fr src` do the same thing. Long flags and every
8 * other word keep their exact spelling, so a different target or
9 * `--force-with-lease` is a different command. A charged command is read as
10 * the court matched it, so a respelled name (`/bin/rm`, `\rm`, `r""m`) or a
11 * wrapper (`command rm`) is the same command.
12 *
13 * @param command the charged simple command, as written
14 */
15export const contemptKeyOf = (command: string): string => keyOf(chargedOf(command)?.words ?? command.trim().split(/\s+/))
16
17/**
18 * The contempt key of a whole command line's charged command, as
19 * `contemptKeyOf` gives it for that command's spelling, from the one
20 * reading that charged the line; undefined when nothing is charged.
21 *
22 * @param line the Bash tool's `command`, as the model wrote it
23 */
24export const contemptKeyOfLine = (line: string): string | undefined => {
25 const words = knownWordsOf(line)
26 return words === undefined ? undefined : keyOf(words)
27}
28
29const keyOf = (words: readonly string[]): string => {
30 const letters = new Set<string>()
31 const rest: string[] = []
32 for (const word of words) {
33 if (/^-[A-Za-z]+$/.test(word)) {
34 for (const letter of word.slice(1)) {
35 letters.add(letter)
36 }
37 } else {
38 rest.push(word)
39 }
40 }
41 const flags = letters.size === 0 ? [] : [`-${[...letters].toSorted().join('')}`]
42 return JSON.stringify([...flags, ...rest])
43}
44hooks/docket.ts 146 lines1/**
2 * One case the court has heard, as the docket keeps it.
3 */
4export type CaseRecord = {
5 number: number
6 command: string
7 charge: string
8 verdict: 'guilty' | 'acquitted' | 'hung' | 'waived' | 'contempt'
9 /**
10 * When the court ruled, in milliseconds since the epoch.
11 */
12 at: number
13 /**
14 * The conviction this case heard on appeal.
15 */
16 appeal?: number
17}
18
19export type RapSheetLine = { charge: string; count: number }
20
21export type Docket = {
22 total: number
23 /**
24 * Guilty over guilty plus acquitted, 0 to 1; a mistrial is no ruling.
25 */
26 convictionRate: number
27 /**
28 * The newest cases, newest first.
29 */
30 recent: CaseRecord[]
31 /**
32 * The latest verdicts, oldest first.
33 */
34 strip: CaseRecord['verdict'][]
35 rapSheet: RapSheetLine[]
36 mostWanted: string | undefined
37}
38
39/**
40 * The layout of the saved docket this version writes, saved beside the
41 * cases. Bump it when a saved field changes meaning.
42 */
43export const DOCKET_LAYOUT = 2
44
45/**
46 * Whether this version can read a docket saved under a layout: one saved
47 * before layouts were recorded, an older layout, or this layout, each
48 * record read for the fields this version keeps and nothing else. A newer
49 * layout is left untouched, never read and never overwritten.
50 */
51export const isReadableLayout = (stored: unknown): boolean =>
52 stored === undefined || stored === null || (typeof stored === 'number' && stored <= DOCKET_LAYOUT)
53
54/**
55 * How many cases the docket keeps: the newest, by number.
56 */
57export const DOCKET_CAP = 200
58
59const COMMAND_CHARS = 80
60const RECENT = 6
61const STRIP = 30
62const RAP_SHEET = 3
63
64/**
65 * The docket with one more case filed under the next number.
66 *
67 * @param history the cases so far, oldest first
68 * @param filing the case, without its number
69 */
70export const fileCase = (history: readonly CaseRecord[], filing: Omit<CaseRecord, 'number'>): CaseRecord[] => {
71 const number = (history.at(-1)?.number ?? 0) + 1
72 const filed = { ...filing, number, command: filing.command.slice(0, COMMAND_CHARS) }
73 return [...history, filed].slice(-DOCKET_CAP)
74}
75
76/**
77 * The number the next case is filed under.
78 */
79export const nextCaseNumber = (history: readonly CaseRecord[]): number => (history.at(-1)?.number ?? 0) + 1
80
81/**
82 * A guilty verdict, or contempt: a convicted command retried.
83 */
84const isConviction = (c: CaseRecord) => c.verdict === 'guilty' || c.verdict === 'contempt'
85
86/**
87 * Prior convictions on one charge.
88 */
89export const priorsOf = (history: readonly CaseRecord[], charge: string): number =>
90 history.filter(c => c.charge === charge && isConviction(c)).length
91
92/**
93 * What `/court docket` shows, from the cases the court has heard.
94 */
95export const docketOf = (history: readonly CaseRecord[]): Docket => {
96 const guilty = history.filter(isConviction)
97 const ruled = guilty.length + history.filter(c => c.verdict === 'acquitted').length
98 const counts = new Map<string, number>()
99 for (const c of guilty) {
100 counts.set(c.charge, (counts.get(c.charge) ?? 0) + 1)
101 }
102 const rapSheet = [...counts]
103 .map(([charge, count]) => ({ charge, count }))
104 .toSorted((a, b) => b.count - a.count)
105 .slice(0, RAP_SHEET)
106 return {
107 total: history.length,
108 convictionRate: ruled === 0 ? 0 : guilty.length / ruled,
109 recent: history.slice(-RECENT).toReversed(),
110 strip: history.slice(-STRIP).map(c => c.verdict),
111 rapSheet,
112 mostWanted: rapSheet[0]?.charge,
113 }
114}
115
116/**
117 * The cases a stored value holds, or none when it is not a docket. The
118 * store is a shared file, so a record is read as untrusted: the fields
119 * this version knows, each of its type, and nothing else.
120 */
121export const casesOf = (stored: unknown): CaseRecord[] =>
122 Array.isArray(stored)
123 ? stored
124 .filter(
125 (c): c is CaseRecord =>
126 typeof c === 'object' &&
127 c !== null &&
128 typeof c.number === 'number' &&
129 typeof c.command === 'string' &&
130 typeof c.charge === 'string' &&
131 (c.verdict === 'guilty' ||
132 c.verdict === 'acquitted' ||
133 c.verdict === 'hung' ||
134 c.verdict === 'waived' ||
135 c.verdict === 'contempt'),
136 )
137 .map(c => ({
138 number: c.number,
139 command: c.command,
140 charge: c.charge,
141 verdict: c.verdict,
142 at: c.at,
143 ...(typeof c.appeal === 'number' ? { appeal: c.appeal } : {}),
144 }))
145 : []
146hooks/exhibits.ts 756 lines1import type { FsStat } from 'claude-code'
2
3import { chargedOf, isSimpleCommand } from './risky'
4
5/**
6 * Flags every exhibit runs git with. A repository's own config can name
7 * programs git runs on its behalf; these turn off the ones the allowlisted
8 * commands could reach: the filesystem monitor (`ls-files` runs it), hooks,
9 * the untracked cache, signature verification in `log`, and the pager.
10 * `--literal-pathspecs` reads every path as written: without it `:src` or
11 * `:/src` names `src`, not the path `rm` deletes.
12 * `status` and `diff` are not on the allowlist at all: they run a
13 * repository's clean filters and external diff, which no flag here turns
14 * off. `tests/hostile/git.hostile.mjs` runs every plan against such a
15 * repository.
16 */
17export const GIT_HARDENING = [
18 '-c', 'core.fsmonitor=false',
19 '-c', 'core.hooksPath=/dev/null',
20 '-c', 'core.untrackedCache=false',
21 '-c', 'log.showSignature=false',
22 '--literal-pathspecs',
23 '--no-optional-locks',
24 '--no-pager',
25] as const
26
27/**
28 * The environment every exhibit runs git in: no system or global config, no
29 * prompts, no lock files, no pager, plain output. An empty askpass is no
30 * program at all, where an unset one would fall back to `core.askPass`.
31 */
32export const GIT_ENV = {
33 GIT_CONFIG_NOSYSTEM: '1',
34 GIT_CONFIG_GLOBAL: '/dev/null',
35 GIT_TERMINAL_PROMPT: '0',
36 GIT_OPTIONAL_LOCKS: '0',
37 GIT_PAGER: 'cat',
38 PAGER: 'cat',
39 GIT_ASKPASS: '',
40 SSH_ASKPASS: '',
41 LC_ALL: 'C',
42} as const
43
44export type ExhibitKind = 'inside' | 'head' | 'behind' | 'ahead' | 'authors' | 'pushconfig' | 'tracked' | 'committed' | 'untracked' | 'ignored' | 'indexed'
45
46/**
47 * One read-only git command the court runs for a fact, by argument vector.
48 */
49export type ExhibitQuery = { kind: ExhibitKind; argv: readonly string[]; target?: string }
50
51/**
52 * What a git command returned, as `$.process.run` resolves it; undefined
53 * for one that failed to start, timed out or was refused.
54 */
55export type ExhibitResult = { exitCode: number; stdout: string } | undefined
56
57/**
58 * The facts the court learned from the repository.
59 */
60export type Facts = {
61 isRepo?: boolean
62 /** Where git ran: the repository's top level, the directory within it, and the branch checked out. */
63 top?: string
64 prefix?: string
65 branch?: string
66 /** The index file, as git names it from the working directory. */
67 indexPath?: string
68 /** Whether HEAD names a commit: false on a branch with no commits yet. */
69 hasCommits?: boolean
70 /** Commits on the upstream branch this branch lacks. */
71 behind?: number
72 /** Local commits not on the upstream branch. */
73 ahead?: number
74 /** Distinct authors of the upstream commits this branch lacks (up to 20). */
75 upstreamAuthors?: number
76 /** Whether each delete target is tracked by git. */
77 tracked?: Record<string, boolean>
78 /**
79 * Whether every index entry under each delete target is in HEAD with the
80 * same mode and object, and none is an intent to add.
81 */
82 committed?: Record<string, boolean>
83 /**
84 * Whether each delete target, `.` for the whole repository, holds a
85 * nested repository or worktree: a gitlink in the index, or a directory
86 * the untracked or ignored listing names whole. What is inside is unread.
87 */
88 nested?: Record<string, boolean>
89 /** Untracked files under each target, `.` for the whole repository. */
90 untracked?: Record<string, number>
91 /** Ignored files in the whole repository. */
92 ignored?: number
93 /** Ignored files under each delete target. */
94 ignoredIn?: Record<string, number>
95 /** The index entries under each delete target. */
96 indexed?: Record<string, IndexEntry[]>
97 /** Tracked files under each delete target whose size or time differs from the index. */
98 modifiedIn?: Record<string, number>
99}
100
101/**
102 * One file as the index last recorded it.
103 */
104export type IndexEntry = { path: string; size: number; mtimeMs: number }
105
106const MAX_TARGETS = 3
107
108/**
109 * The most index entries the court compares with the files: one stat
110 * each, within the exhibits' bound. A target with more is unknown.
111 */
112const MAX_INDEXED = 200
113const NAME_CELLS = 60
114
115const git = (...args: string[]): readonly string[] => ['git', ...GIT_HARDENING, ...args]
116
117const query = (kind: ExhibitKind, argv: readonly string[], target?: string): ExhibitQuery =>
118 target === undefined ? { kind, argv } : { kind, argv, target }
119
120/**
121 * Where git runs, and HEAD on its own: a branch with no commits fails any
122 * read of HEAD, which must not make the whole repository unread.
123 */
124const HERE = [
125 query('inside', git('rev-parse', '--is-inside-work-tree', '--show-toplevel', '--show-prefix', '--git-path', 'index')),
126 query('head', git('rev-parse', '--abbrev-ref', '--verify', '--quiet', 'HEAD')),
127]
128
129/**
130 * The operands of an `rm`: its words after the command that are not flags,
131 * and every word after `--`.
132 */
133const targetsOf = (words: readonly string[]): string[] => {
134 const targets: string[] = []
135 let isOptionsDone = false
136 for (const word of words.slice(1)) {
137 if (!isOptionsDone && word === '--') {
138 isOptionsDone = true
139 } else if (isOptionsDone || !word.startsWith('-')) {
140 targets.push(word)
141 }
142 }
143 return targets
144}
145
146/**
147 * Whether git can be asked about a target exactly as `rm` will remove it:
148 * not ending in `/` (which follows a symlink to a directory git never
149 * looks into), with no `..` part (git folds `lnk/..` by spelling, the
150 * kernel follows `lnk` first) and no `.git` part (the repository's own
151 * store, which git never lists, so it reads as an empty, clean folder).
152 */
153const isReadableTarget = (target: string) =>
154 !target.endsWith('/') && !target.split('/').some(part => part === '..' || part === '.git')
155
156/**
157 * The targets git can be asked about exactly as `rm` will remove them: at
158 * most three, each readable. Otherwise none, and the targets are unknown.
159 */
160const readableTargetsOf = (words: readonly string[]): string[] => {
161 const targets = targetsOf(words)
162 return targets.length > MAX_TARGETS || !targets.every(isReadableTarget) ? [] : targets
163}
164
165/**
166 * A remote or branch name git reads as that name and nothing else.
167 */
168const PLAIN_REF = /^[A-Za-z0-9_][A-Za-z0-9._/-]*$/
169
170const isPlainRef = (name: string) =>
171 PLAIN_REF.test(name) &&
172 name !== 'HEAD' &&
173 !name.includes('..') &&
174 !name.includes('//') &&
175 !name.endsWith('/') &&
176 !name.endsWith('.lock')
177
178/**
179 * The commands for a force push that names exactly one remote and one
180 * branch (`git push --force origin main`): the branch against that remote's
181 * copy of it, by name. Any other form (no remote or branch, a refspec with
182 * `:` or `+`, several branches, another option) pushes something these
183 * would not describe, so it gets none.
184 */
185const pushPlanOf = (words: readonly string[]): ExhibitQuery[] => {
186 const rest = words.slice(2)
187 const flags = rest.filter(word => word.startsWith('-'))
188 const named = rest.filter(word => !word.startsWith('-'))
189 const [remote = '', branch = ''] = named
190 if (flags.some(flag => flag !== '--force' && flag !== '-f') || named.length !== 2 || !isPlainRef(remote) || !isPlainRef(branch)) {
191 return []
192 }
193 // full ref names: a tag or another ref of the same short name is never read
194 const range = `refs/heads/${branch}..refs/remotes/${remote}/${branch}`
195 return [
196 ...HERE,
197 query('behind', git('rev-list', '--count', '--end-of-options', range)),
198 query('authors', git('log', '-20', '--no-show-signature', '--format=%ae', '--end-of-options', range)),
199 // a remote's push, push url or mirror config, or any push rewrite of a
200 // URL, changes what `push origin main` sends or where it goes
201 query(
202 'pushconfig',
203 git('config', '--get-regexp', `^(remote\\.${remote.replaceAll('.', '\\.')}\\.(push|pushurl|mirror)|url\\..*\\.pushinsteadof)$`),
204 ),
205 ]
206}
207
208const hasShortFlag = (words: readonly string[], letter: string) =>
209 words.some(word => /^-[a-zA-Z]+$/.test(word) && word.includes(letter))
210
211/**
212 * The git commands the court runs for a charged command, from a fixed
213 * allowlist; every path is passed after `--` and every ref after
214 * `--end-of-options`, so neither is ever an option. Only a simple command
215 * line is read: one the shell runs in this directory with every word as
216 * written, so the facts are about what it will touch. Any other line, and
217 * a `git` with options before its subcommand (`git -C dir`), gets none.
218 *
219 * @param command the Bash tool's `command`, as the model wrote it
220 */
221export const planOf = (command: string): ExhibitQuery[] => {
222 const charged = isSimpleCommand(command) ? chargedOf(command) : undefined
223 if (charged === undefined) {
224 return []
225 }
226 const { charge, words } = charged
227 switch (charge.id) {
228 case 'force-push':
229 return words[1] === 'push' ? pushPlanOf(words) : []
230 case 'hard-reset':
231 return words[1] === 'reset' ? [...HERE, query('ahead', git('rev-list', '--count', '@{upstream}..HEAD'))] : []
232 case 'recursive-delete':
233 return words[0] !== 'rm'
234 ? HERE
235 : [
236 ...HERE,
237 ...readableTargetsOf(words).flatMap(target => [
238 // the index's and HEAD's mode and object for each file, names raw
239 query('tracked', git('ls-files', '--stage', '-z', '--', target), target),
240 query('committed', git('ls-tree', '-r', '-z', 'HEAD', '--', target), target),
241 query('untracked', git('ls-files', '--others', '--exclude-standard', '--', target), target),
242 query('ignored', git('ls-files', '--others', '--ignored', '--exclude-standard', '--', target), target),
243 // the index's own record of each file, never the files: every git
244 // that compares them can run the repository's filters
245 query('indexed', git('-c', 'core.quotePath=false', 'ls-files', '--debug', '--', target), target),
246 ]),
247 ]
248 case 'git-clean':
249 return words[1] === 'clean'
250 ? [
251 ...HERE,
252 query('untracked', git('ls-files', '--others', '--exclude-standard')),
253 ...(hasShortFlag(words, 'x') || hasShortFlag(words, 'X')
254 ? [query('ignored', git('ls-files', '--others', '--ignored', '--exclude-standard'))]
255 : []),
256 ]
257 : []
258 default:
259 return []
260 }
261}
262
263/**
264 * The five lines `ls-files --debug` prints under each path, in order.
265 */
266const STAT_LINES = [
267 /^ {2}ctime: \d+:\d+$/,
268 /^ {2}mtime: (\d+):(\d+)$/,
269 /^ {2}dev: \d+\tino: \d+$/,
270 /^ {2}uid: \d+\tgid: \d+$/,
271 /^ {2}size: (\d+)\tflags: ([0-9a-f]+)$/,
272] as const
273
274/**
275 * The index entry flag git sets on a path added with `git add -N`: the
276 * entry holds no content, only the promise of some.
277 */
278const CE_INTENT_TO_ADD = 0x20000000
279
280/**
281 * The entries `ls-files --debug` prints, read strictly: each a path line
282 * then exactly the five stat lines. A quoted path (a name git escapes
283 * even with core.quotePath off: a newline, a quote, a control character),
284 * an indented or empty path, or any other line means the read is not
285 * exact, and the answer is undefined. Also the paths added with intent to add.
286 */
287const entriesOf = (stdout: string): { entries: IndexEntry[]; intents: Set<string> } | undefined => {
288 const lines = stdout.split('\n')
289 if (lines.at(-1) === '') {
290 lines.pop()
291 }
292 const entries: IndexEntry[] = []
293 const intents = new Set<string>()
294 for (let at = 0; at < lines.length; at += 6) {
295 const path = lines[at] ?? ''
296 const stats = STAT_LINES.map((line, offset) => line.exec(lines[at + 1 + offset] ?? ''))
297 const [, mtime, , , size] = stats
298 if (path === '' || /^\s/.test(path) || path.startsWith('"') || !mtime || !size || stats.includes(null)) {
299 return undefined
300 }
301 entries.push({ path, size: Number(size[1]), mtimeMs: Number(mtime[1]) * 1000 + Number(mtime[2]) / 1e6 })
302 if ((Number.parseInt(size[2] ?? '', 16) & CE_INTENT_TO_ADD) !== 0) {
303 intents.add(path)
304 }
305 }
306 return { entries, intents }
307}
308
309/**
310 * The records of a `-z` listing as `mode object` by raw path, or undefined
311 * when any record does not match `line` exactly.
312 */
313const recordsOf = (stdout: string, line: RegExp): Map<string, string> | undefined => {
314 const records = stdout.split('\0')
315 if (records.at(-1) === '') {
316 records.pop()
317 }
318 const read = new Map<string, string>()
319 for (const record of records) {
320 const [, mode, oid, path] = line.exec(record) ?? []
321 if (mode === undefined || oid === undefined || path === undefined) {
322 return undefined
323 }
324 read.set(path, `${mode} ${oid}`)
325 }
326 return read
327}
328
329/**
330 * One `ls-files --stage -z` record: mode, object, stage 0, path. An entry
331 * at another stage is a merge conflict, which no commit holds as it is.
332 */
333const STAGED = /^([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) 0\t([^]+)$/
334
335/** One `ls-tree -r -z` record: mode, a file or gitlink, object, path. */
336const IN_TREE = /^([0-7]{6}) (?:blob|commit) ([0-9a-f]{40}|[0-9a-f]{64})\t([^]+)$/
337
338const GITLINK = '160000 '
339
340/**
341 * Whether a listing of untracked or ignored files names a directory whole:
342 * git does so for a nested repository or worktree, whose files it never lists.
343 * A name git quotes keeps its `/` inside the closing quote.
344 */
345const hasNestedOf = (stdout: string) => stdout.split('\n').some(line => line.endsWith('/') || line.endsWith('/"'))
346
347const countOf = (stdout: string) => stdout.split('\n').filter(line => line.trim() !== '').length
348
349/**
350 * The per-target reads that become facts only together: the index's and
351 * HEAD's records and the paths added with intent to add, by target.
352 */
353type TargetReads = {
354 staged: Map<string, ReadonlyMap<string, string>>
355 head: Map<string, ReadonlyMap<string, string>>
356 intents: Map<string, ReadonlySet<string>>
357 nested: Map<string, boolean>
358}
359
360const withEntry = <T>(record: Record<string, T> | undefined, key: string, value: T): Record<string, T> => ({ ...record, [key]: value })
361
362/**
363 * One result about where git runs, HEAD or a push, read into `facts`.
364 * Returns the branch HEAD names, if this result names one.
365 */
366const readHere = (facts: Facts, kind: ExhibitKind, result: { exitCode: number; stdout: string }): string | undefined => {
367 const out = result.stdout.trim()
368 switch (kind) {
369 case 'inside': {
370 const [inside, top = '', prefix, indexPath = ''] = result.stdout.split('\n')
371 facts.isRepo = result.exitCode === 0 && inside === 'true'
372 if (facts.isRepo && indexPath !== '') {
373 facts.indexPath = indexPath
374 }
375 if (facts.isRepo && top !== '' && prefix !== undefined) {
376 Object.assign(facts, { top, prefix })
377 }
378 return undefined
379 }
380 case 'head':
381 // --verify --quiet exits 1 with nothing printed when HEAD names no commit
382 if (result.exitCode === 0 && /^[^\n]+$/.test(out)) {
383 facts.hasCommits = true
384 return out
385 }
386 if (result.exitCode === 1 && out === '') {
387 facts.hasCommits = false
388 }
389 return undefined
390 case 'behind':
391 case 'ahead':
392 if (result.exitCode === 0 && /^\d+$/.test(out)) {
393 facts[kind] = Number(out)
394 }
395 return undefined
396 case 'authors': {
397 const authors = new Set(out.split('\n').filter(line => line.includes('@')))
398 if (result.exitCode === 0 && authors.size > 0) {
399 facts.upstreamAuthors = authors.size
400 }
401 return undefined
402 }
403 default:
404 return undefined
405 }
406}
407
408/**
409 * One listing of untracked or ignored files read into `facts`, under the
410 * target it lists or `.` for the whole repository.
411 */
412const readListing = (facts: Facts, reads: TargetReads, one: ExhibitQuery, stdout: string) => {
413 const key = one.target ?? '.'
414 if (hasNestedOf(stdout)) {
415 reads.nested.set(key, true)
416 }
417 if (one.kind === 'untracked') {
418 facts.untracked = withEntry(facts.untracked, key, countOf(stdout))
419 } else if (one.target === undefined) {
420 facts.ignored = countOf(stdout)
421 } else {
422 facts.ignoredIn = withEntry(facts.ignoredIn, key, countOf(stdout))
423 }
424}
425
426/**
427 * One per-target result read into `facts` and `reads`; a result that is
428 * not exact adds nothing, which leaves every target unread.
429 */
430const readTarget = (facts: Facts, reads: TargetReads, one: ExhibitQuery, result: { exitCode: number; stdout: string }) => {
431 const target = one.target ?? ''
432 switch (one.kind) {
433 case 'tracked': {
434 const staged = result.exitCode === 0 ? recordsOf(result.stdout, STAGED) : undefined
435 if (staged !== undefined) {
436 reads.staged.set(target, staged)
437 facts.tracked = withEntry(facts.tracked, target, staged.size > 0)
438 }
439 break
440 }
441 case 'committed': {
442 // a branch with no commits has no HEAD to list: nothing is committed
443 const head = result.exitCode === 0 ? recordsOf(result.stdout, IN_TREE) : facts.hasCommits === false ? new Map<string, string>() : undefined
444 if (head !== undefined) {
445 reads.head.set(target, head)
446 }
447 break
448 }
449 case 'indexed': {
450 const read = result.exitCode === 0 ? entriesOf(result.stdout) : undefined
451 if (read !== undefined) {
452 facts.indexed = withEntry(facts.indexed, target, read.entries)
453 reads.intents.set(target, read.intents)
454 }
455 break
456 }
457 default:
458 break
459 }
460}
461
462/**
463 * Whether every index entry is in HEAD with the same mode and object, and
464 * none is an intent to add: an entry git records as the empty file, which
465 * matches an empty file in HEAD while the file itself holds anything.
466 */
467const isCommitted = (staged: ReadonlyMap<string, string>, head: ReadonlyMap<string, string>, intents: ReadonlySet<string>) =>
468 [...staged].every(([path, record]) => !intents.has(path) && head.get(path) === record)
469
470/**
471 * The facts with each target's committed and nested state, from reads
472 * every target is known to have.
473 */
474const withTargetState = (facts: Facts, targets: readonly string[], reads: TargetReads): Facts => {
475 const committed = targets.map(target => [
476 target,
477 isCommitted(reads.staged.get(target) ?? new Map(), reads.head.get(target) ?? new Map(), reads.intents.get(target) ?? new Set()),
478 ])
479 for (const target of targets) {
480 if ([...(reads.staged.get(target)?.values() ?? [])].some(record => record.startsWith(GITLINK))) {
481 reads.nested.set(target, true)
482 }
483 }
484 return {
485 ...facts,
486 ...(targets.length === 0 ? {} : { committed: Object.fromEntries(committed) }),
487 ...(reads.nested.size === 0 ? {} : { nested: Object.fromEntries(reads.nested) }),
488 }
489}
490
491/**
492 * What the court learned: each result read only when it means something,
493 * so a failed, timed out or garbled result adds no fact.
494 *
495 * @param plan the commands, as `planOf` gave them
496 * @param results each command's result, in the same order
497 */
498export const factsOf = (plan: readonly ExhibitQuery[], results: readonly ExhibitResult[]): Facts => {
499 const facts: Facts = {}
500 const reads: TargetReads = { staged: new Map(), head: new Map(), intents: new Map(), nested: new Map() }
501 // a push is read only once its remote is known to push plainly
502 let isPushPlain = !plan.some(one => one.kind === 'pushconfig')
503 let branch: string | undefined
504 plan.forEach((one, at) => {
505 const result = results[at]
506 if (result === undefined) {
507 return
508 }
509 if (one.kind === 'pushconfig') {
510 isPushPlain = result.exitCode === 1
511 } else if ((one.kind === 'untracked' || one.kind === 'ignored') && result.exitCode === 0) {
512 readListing(facts, reads, one, result.stdout)
513 } else if (one.target !== undefined) {
514 readTarget(facts, reads, one, result)
515 } else {
516 branch = readHere(facts, one.kind, result) ?? branch
517 }
518 })
519 // the place is the top level, the directory within it and the branch, all
520 // or none; a detached HEAD prints HEAD for every commit: it names no branch
521 if (facts.top === undefined || branch === undefined || branch === 'HEAD') {
522 delete facts.top
523 delete facts.prefix
524 } else {
525 facts.branch = branch
526 }
527 // the targets are read together or not at all: one unread leaves every
528 // target unknown, never a partial picture
529 const targets = plan.filter(one => one.target !== undefined)
530 // every per-target read is required; a kind not listed here reads as unread
531 const readFor: Partial<Record<ExhibitKind, ReadonlyMap<string, unknown>>> = {
532 tracked: reads.staged,
533 committed: reads.head,
534 untracked: new Map(Object.entries(facts.untracked ?? {})),
535 ignored: new Map(Object.entries(facts.ignoredIn ?? {})),
536 indexed: reads.intents,
537 }
538 const isEveryTargetRead = targets.every(one => readFor[one.kind]?.has(one.target ?? '') === true)
539 if (!isPushPlain) {
540 delete facts.behind
541 delete facts.upstreamAuthors
542 }
543 const read = isEveryTargetRead ? withTargetState(facts, targetsIn(plan), reads) : withoutTargets(facts)
544 return read.isRepo === false ? { isRepo: false } : read
545}
546
547/**
548 * The delete targets a plan reads, each once, in order.
549 */
550export const targetsIn = (plan: readonly ExhibitQuery[]): string[] => [
551 ...new Set(plan.flatMap(one => (one.target === undefined ? [] : [one.target]))),
552]
553
554/**
555 * A path with `.` and `..` folded and repeated `/` dropped, by spelling.
556 */
557const foldedOf = (path: string) => {
558 const parts: string[] = []
559 for (const part of path.split('/')) {
560 if (part === '..') {
561 parts.pop()
562 } else if (part !== '' && part !== '.') {
563 parts.push(part)
564 }
565 }
566 return `/${parts.join('/')}`
567}
568
569/**
570 * Whether a target lands where its spelling says, from the real working
571 * directory: false when a symbolic link in any component sends it
572 * elsewhere, where git never looks, or when it does not resolve.
573 *
574 * @param here the working directory with every link followed
575 * @param target the path as the command names it
576 * @param real where it lands with every link followed, if anywhere
577 */
578export const isLexicalPath = (here: string, target: string, real: string | undefined): boolean =>
579 real !== undefined && real === foldedOf(target.startsWith('/') ? target : `${here}/${target}`)
580
581/**
582 * Each name a target's spelling passes through, with the directory that
583 * must list it by that exact spelling: on a case-insensitive file system
584 * `SRC` opens `src`, while git, matching spellings, reads nothing there. A
585 * relative target is listed from the working directory, an absolute one
586 * from the repository's top level. Undefined when the target is the
587 * working directory itself, or an absolute path not strictly inside the
588 * top level by its spelling (the top level, an ancestor of it, anywhere
589 * else): a delete there takes the repository's history with it.
590 *
591 * @param target the path as the command names it
592 * @param top the repository's top level, as git reported it
593 */
594export const namesAlong = (target: string, top: string | undefined): { dir: string; name: string }[] | undefined => {
595 const partsOf = (path: string) => path.split('/').filter(part => part !== '' && part !== '.')
596 const isAbsolute = target.startsWith('/')
597 const above = isAbsolute ? partsOf(top ?? '') : []
598 const parts = partsOf(target)
599 const isInside = (!isAbsolute || top !== undefined) && parts.length > above.length && above.every((part, at) => parts[at] === part)
600 return isInside
601 ? parts.slice(above.length).map((name, at) => {
602 const dir = parts.slice(0, above.length + at).join('/')
603 return { dir: isAbsolute ? `/${dir}` : dir === '' ? '.' : dir, name }
604 })
605 : undefined
606}
607
608/**
609 * The facts with every fact about the delete targets removed.
610 */
611export const withoutTargets = (facts: Facts): Facts => {
612 const { tracked, committed, nested, untracked, ignoredIn, indexed, modifiedIn, ...rest } = facts
613 return rest
614}
615
616/**
617 * The files to stat for the targets' changes: every index entry under
618 * them, or none when there are more than the court reads, which leaves
619 * every target's changes unknown.
620 */
621export const statPathsOf = (facts: Facts): string[] => {
622 const paths = Object.values(facts.indexed ?? {}).flatMap(entries => entries.map(entry => entry.path))
623 return paths.length > MAX_INDEXED ? [] : paths
624}
625
626const isUnchanged = (entry: IndexEntry, stat: FsStat) =>
627 !stat.isLink &&
628 stat.kind === 'file' &&
629 stat.size === entry.size &&
630 Math.floor(stat.mtimeMs) === Math.floor(entry.mtimeMs)
631
632/**
633 * The facts with each target's changed files counted: an index entry whose
634 * file differs in size or time, is a link, or is not a file. A target is
635 * left unknown, never claimed changed, when an entry could not be stat'd,
636 * when the index file's own time is unknown, or when a file is not
637 * strictly older than the index file: git's racily clean case, where an
638 * edit keeping size and time looks unchanged.
639 *
640 * @param stats each path's stat, undefined where it could not be read
641 * @param indexMs the index file's mtime, undefined where unknown
642 */
643export const withModified = (facts: Facts, stats: ReadonlyMap<string, FsStat | undefined>, indexMs: number | undefined): Facts => {
644 const counted = Object.entries(facts.indexed ?? {}).flatMap(([target, entries]) => {
645 const statted = entries.map(entry => [entry, stats.get(entry.path)] as const)
646 const isReadable = statted.every(
647 ([, stat]) => stat !== undefined && indexMs !== undefined && Math.floor(stat.mtimeMs) < Math.floor(indexMs),
648 )
649 return isReadable
650 ? [[target, statted.filter(([entry, stat]) => stat === undefined || !isUnchanged(entry, stat)).length] as const]
651 : []
652 })
653 return counted.length === 0 ? facts : { ...facts, modifiedIn: Object.fromEntries(counted) }
654}
655
656/**
657 * Text from the repository as the court may quote it: control characters
658 * (escapes, newlines, bells) removed and cut to `cells`.
659 */
660export const sanitizedOf = (text: string, cells: number): string => {
661 // eslint-disable-next-line no-control-regex
662 const plain = text.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
663 return plain.length <= cells ? plain : `${plain.slice(0, Math.max(1, cells - 3))}...`
664}
665
666const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
667
668/**
669 * The line for one delete target's tracked state. History keeps a target
670 * only when the index holds nothing HEAD lacks and every change under it was
671 * counted; otherwise the line says what history does not keep or what was
672 * not checked.
673 */
674const trackedLineOf = (facts: Facts, target: string, isTracked: boolean): string => {
675 const name = sanitizedOf(target, NAME_CELLS)
676 if (!isTracked) {
677 return `${name} is not tracked by git, so history does not keep it.`
678 }
679 if (facts.nested?.[target] === true) {
680 return `${name} is tracked by git, but holds a nested repository, whose contents were not checked.`
681 }
682 if (facts.committed?.[target] !== true) {
683 return `${name} is tracked by git, but not all of it is committed, so history does not keep all of it.`
684 }
685 // changes under a tracked target go uncounted when a file is as new as
686 // the index or there are too many: the line says so rather than reassure
687 return facts.modifiedIn?.[target] === undefined
688 ? `${name} is tracked by git, so history keeps its last commit; uncommitted changes under it were not checked.`
689 : `${name} is tracked by git, so history keeps it.`
690}
691
692/**
693 * The exhibits as the court enters them, lettered A, B, C. They name counts
694 * and states only, never an author's address.
695 */
696export const exhibitLinesOf = (facts: Facts): string[] => {
697 const said: string[] = []
698 if (facts.isRepo === false) {
699 said.push('this is not a git repository.')
700 }
701 if (facts.hasCommits === false) {
702 said.push('this repository has no commits on its current branch.')
703 }
704 if (facts.behind !== undefined) {
705 said.push(
706 facts.behind === 0
707 ? 'the upstream branch has no commits this branch lacks, as of the last fetch.'
708 : `the upstream branch has ${plural(facts.behind, 'commit', 'commits')} this branch does not` +
709 (facts.upstreamAuthors === undefined ? '' : `, by ${plural(facts.upstreamAuthors, 'author', 'authors')}`) +
710 ', as of the last fetch.',
711 )
712 }
713 if (facts.ahead !== undefined) {
714 // uncommitted changes go unread: every git that reads them can run the
715 // repository's own filters, so the line says so rather than reassure
716 said.push(
717 facts.ahead === 0
718 ? 'every local commit is on the upstream branch; uncommitted changes were not checked.'
719 : `${plural(facts.ahead, 'local commit is', 'local commits are')} not on the upstream branch; uncommitted changes were not checked.`,
720 )
721 }
722 // what a nested repository or worktree holds is never listed: a count
723 // under it would understate what a delete takes
724 const isNested = (target: string) => facts.nested?.[target] === true
725 for (const [target, isTracked] of Object.entries(facts.tracked ?? {})) {
726 said.push(trackedLineOf(facts, target, isTracked))
727 }
728 for (const [target, count] of Object.entries(facts.untracked ?? {})) {
729 if (isNested(target)) {
730 if (target === '.') {
731 said.push('the repository holds a nested repository, whose files were not counted.')
732 } else if (facts.tracked?.[target] !== true) {
733 said.push(`${sanitizedOf(target, NAME_CELLS)} holds a nested repository, whose contents were not checked.`)
734 }
735 } else if (target === '.') {
736 said.push(count === 0 ? 'the repository holds no untracked files.' : `the repository holds ${plural(count, 'untracked file', 'untracked files')}.`)
737 } else if (count > 0) {
738 said.push(`${sanitizedOf(target, NAME_CELLS)} holds ${plural(count, 'untracked file', 'untracked files')}.`)
739 }
740 }
741 for (const [target, count] of Object.entries(facts.modifiedIn ?? {})) {
742 if (count > 0 && !isNested(target)) {
743 said.push(`${sanitizedOf(target, NAME_CELLS)} holds ${plural(count, 'file', 'files')} changed since git last recorded them, which history does not keep.`)
744 }
745 }
746 for (const [target, count] of Object.entries(facts.ignoredIn ?? {})) {
747 if (count > 0 && !isNested(target)) {
748 said.push(`${sanitizedOf(target, NAME_CELLS)} holds ${plural(count, 'ignored file', 'ignored files')}, which history does not keep.`)
749 }
750 }
751 if (facts.ignored !== undefined && !isNested('.')) {
752 said.push(`the repository holds ${plural(facts.ignored, 'ignored file', 'ignored files')}.`)
753 }
754 return said.map((line, at) => `Exhibit ${String.fromCharCode(65 + at)}: ${line}`)
755}
756hooks/gauge.ts 21 lines1/**
2 * Left-aligned eighth blocks, from none to seven eighths of a cell.
3 */
4const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
5
6/**
7 * A horizontal gauge to eighth-cell precision: the filled part, drawn in
8 * the gauge's colour, and the track after it, drawn dim. Together they
9 * always span `cells`.
10 *
11 * @param fraction how full, 0 to 1; outside is clamped
12 * @param cells how many character cells the gauge spans
13 */
14export const gaugeOf = (fraction: number, cells: number): { filled: string; track: string } => {
15 const eighths = Math.round(Math.min(1, Math.max(0, fraction)) * cells * 8)
16 const full = Math.floor(eighths / 8)
17 const part = EIGHTHS[eighths % 8] ?? ''
18 const used = full + (part === '' ? 0 : 1)
19 return { filled: '█'.repeat(full) + part, track: '─'.repeat(cells - used) }
20}
21hooks/layout.ts 140 lines1import type { CaseRecord } from './docket'
2
3const SEPARATOR = ' · '
4
5/**
6 * A case as the court numbers it: `#0007`.
7 */
8export const caseNumberOf = (number: number) => `#${String(number).padStart(4, '0')}`
9
10/**
11 * The case header (charge, case number, prior convictions): one line where
12 * it fits, else the charge alone and the record packed beneath it, breaking
13 * only between parts.
14 */
15export const headerLinesOf = (charge: string, number: number, priors: number, columns: number): string[] => {
16 const record = [`Case ${caseNumberOf(number)}`, `Prior convictions: ${priors}`]
17 const whole = [`Charge: ${charge}`, ...record].join(SEPARATOR)
18 if (whole.length <= columns) {
19 return [whole]
20 }
21 const packed = record.reduce<string[]>((lines, part) => {
22 const last = lines.at(-1)
23 if (last !== undefined && last.length + SEPARATOR.length + part.length <= columns) {
24 lines[lines.length - 1] = last + SEPARATOR + part
25 } else {
26 lines.push(part)
27 }
28 return lines
29 }, [])
30 return [`Charge: ${charge}`, ...packed]
31}
32
33const LABELS: Record<CaseRecord['verdict'], string> = {
34 guilty: 'GUILTY',
35 acquitted: 'NOT GUILTY',
36 hung: 'MISTRIAL',
37 waived: 'WAIVED',
38 contempt: 'CONTEMPT',
39}
40
41const MARKS: Record<CaseRecord['verdict'], string> = { guilty: '✕', acquitted: '✓', hung: '?', waived: '-', contempt: '✕' }
42
43/**
44 * Everything on a docket row before the command: number, mark, verdict word
45 * padded to its longest, and the single spaces between them.
46 */
47const ROW_FIXED = '#0000'.length + 1 + 1 + 1 + 'NOT GUILTY'.length + 1
48
49/**
50 * One docket row cut to `columns`: the command is what gives way, cut at
51 * the width with `...` so the verdict always shows as a mark and a word.
52 */
53export const caseRowOf = (
54 one: Pick<CaseRecord, 'number' | 'command' | 'verdict'>,
55 columns: number,
56): { number: string; mark: string; label: string; command: string } => {
57 const room = Math.max(4, columns - ROW_FIXED)
58 const command = one.command.length <= room ? one.command : `${one.command.slice(0, room - 3).trimEnd()}...`
59 return {
60 number: caseNumberOf(one.number),
61 mark: MARKS[one.verdict],
62 label: LABELS[one.verdict].padEnd('NOT GUILTY'.length),
63 command,
64 }
65}
66
67/**
68 * How wide the docket's gauges and its verdict strip are drawn in a pane
69 * `body` columns wide: as wide as the design wants, narrower where the pane
70 * is, never past it.
71 */
72export const docketLayoutOf = (body: number): { rateCells: number; strip: number } => ({
73 rateCells: Math.max(6, Math.min(30, body - 'Conviction rate '.length - 4)),
74 // one space between marks: n marks take 2n - 1 cells
75 strip: Math.max(3, Math.min(30, Math.floor((body - 'Last 30 '.length + 1) / 2))),
76})
77
78/**
79 * A text on one line of `columns` cells: whole when it fits, else cut at the
80 * last word that fits and marked `...`.
81 */
82export const lineOf = (text: string, columns: number): string => {
83 if (text.length <= columns) {
84 return text
85 }
86 const cut = text.slice(0, Math.max(1, columns - 3))
87 const word = cut.lastIndexOf(' ')
88 return `${(word > 0 ? cut.slice(0, word) : cut).replace(/[\s,;:.!?]+$/, '')}...`
89}
90
91const RAP_FRAME = 4 // the rap sheet's round border and its padding
92const RAP_COUNT = 4 // a count right-aligned in four cells
93const MIN_BAR = 6
94const MAX_BAR = 24
95
96/**
97 * How the rap sheet lays out in a docket `body` columns wide: a label column
98 * as wide as the longest charge it lists, then the bar, then the count. The
99 * bar shrinks before a label is ever cut; where not even a short bar fits
100 * beside the longest label, each bar goes on the line under its label.
101 *
102 * @param labels the charges the rap sheet lists
103 */
104export const rapSheetLayoutOf = (
105 body: number,
106 labels: readonly string[],
107): { labelCells: number; barCells: number; isStacked: boolean } => {
108 const longest = Math.max(0, ...labels.map(label => label.length))
109 const beside = body - RAP_FRAME - longest - 1 - RAP_COUNT
110 if (beside >= MIN_BAR) {
111 return { labelCells: longest, barCells: Math.min(MAX_BAR, beside), isStacked: false }
112 }
113 return {
114 labelCells: Math.min(longest, body - RAP_FRAME),
115 barCells: Math.max(1, Math.min(MAX_BAR, body - RAP_FRAME - RAP_COUNT)),
116 isStacked: true,
117 }
118}
119
120/**
121 * Text broken into lines of at most `columns`, between words; a word longer
122 * than a line is cut into pieces that fit.
123 */
124export const wrapOf = (text: string, columns: number): string[] => {
125 const width = Math.max(1, columns)
126 const pieces = text
127 .split(/\s+/)
128 .filter(word => word !== '')
129 .flatMap(word => (word.length <= width ? [word] : (word.match(new RegExp(`.{1,${width}}`, 'g')) ?? [])))
130 return pieces.reduce<string[]>((lines, piece) => {
131 const last = lines.at(-1)
132 if (last !== undefined && last.length + 1 + piece.length <= width) {
133 lines[lines.length - 1] = `${last} ${piece}`
134 } else {
135 lines.push(piece)
136 }
137 return lines
138 }, [])
139}
140hooks/pace.ts 33 lines1/**
2 * How fast a speech is typed out in the pane: five milliseconds a character,
3 * about 200 a second.
4 */
5export const MS_PER_CHAR = 5
6
7/**
8 * The shortest the jury deliberates on screen, however fast counsel answers:
9 * long enough for the pans to bob twice. Paces only what the pane shows.
10 */
11export const DELIBERATION_MS = 1_800
12
13/**
14 * The pause after a speech is typed before the next part of the trial.
15 */
16export const BEAT_MS = 400
17
18/**
19 * How long the court stands for "ALL RISE." and the gavel before it speaks.
20 */
21export const RISE_MS = 900
22
23/**
24 * How long the stamp takes to drop in, overshoot, ink and dry.
25 */
26export const LANDING_MS = 400
27
28/**
29 * How long the gallery takes to read one speech: typing it, then a beat.
30 * Paces only what the pane shows; the decision never waits for it.
31 */
32export const paceOf = (text: string): number => text.length * MS_PER_CHAR + BEAT_MS
33hooks/risky.ts 1245 lines1/**
2 * A charge: why a shell command goes to trial.
3 */
4export type Charge = {
5 id: string
6 /**
7 * The simple command the charge fits, as tried: wrappers, env assignments
8 * and the rest of a compound command left out.
9 */
10 command: string
11 /**
12 * What the court reads out, in a few words.
13 */
14 label: string
15}
16
17type Rule = Omit<Charge, 'command'> & {
18 /**
19 * Whether one simple command (its words, wrappers and env assignments
20 * already stripped) is this charge.
21 */
22 test: (words: readonly string[]) => boolean
23}
24
25const SQL_CLIENTS = new Set([
26 'psql',
27 'mysql',
28 'mariadb',
29 'sqlite3',
30 'sqlcmd',
31 'duckdb',
32 'clickhouse-client',
33 'cockroach',
34])
35
36const hasShortFlag = (words: readonly string[], letter: string) =>
37 words.some(word => /^-[a-zA-Z]+$/.test(word) && word.includes(letter))
38
39const isGit = (words: readonly string[], sub: string) =>
40 words[0] === 'git' && words.includes(sub)
41
42/**
43 * SQL with each closed block comment read as the space it stands for,
44 * found by a scan: a regex over the comments backtracks exponentially on a
45 * run of them.
46 */
47const uncommentedOf = (sql: string): string => {
48 let text = ''
49 let at = 0
50 for (;;) {
51 const open = sql.indexOf('/*', at)
52 const close = open === -1 ? -1 : sql.indexOf('*/', open + 2)
53 if (close === -1) {
54 return text + sql.slice(at)
55 }
56 text += `${sql.slice(at, open)} `
57 at = close + 2
58 }
59}
60
61/**
62 * The charges, in the order the court tries them; the first that fits is
63 * read out. Matching is by spelling and best effort: a command assembled at
64 * run time is not seen, such as a flag from a variable (`rm $F src`),
65 * `eval "$CMD"`, `source <(curl ...)`, an alias, a git alias whose body is
66 * in the environment (`git --config-env=alias.x=VAR x`), or a script file a
67 * shell is given by name (`bash x.sh`). Nor is a command in a process
68 * substitution (`cat <(rm -rf x)`) or a long option abbreviated
69 * (`rm --rec`). A `-c` alias named for a git built-in no charge names is
70 * read as the alias though git runs the built-in, which can only charge
71 * more.
72 */
73export const RULES: readonly Rule[] = [
74 {
75 id: 'recursive-delete',
76 label: 'recursive delete',
77 test: words =>
78 (words[0] === 'rm' &&
79 (hasShortFlag(words, 'r') ||
80 hasShortFlag(words, 'R') ||
81 words.includes('--recursive'))) ||
82 (words[0] === 'find' && words.includes('-delete')),
83 },
84 {
85 id: 'force-push',
86 label: 'force push',
87 test: words =>
88 isGit(words, 'push') &&
89 (words.some(word => word.startsWith('--force')) ||
90 hasShortFlag(words.slice(2), 'f') ||
91 words.slice(2).some(word => /^\+[^\s]/.test(word))),
92 },
93 {
94 id: 'hard-reset',
95 label: 'hard reset',
96 test: words => isGit(words, 'reset') && words.includes('--hard'),
97 },
98 {
99 id: 'git-clean',
100 label: 'git clean',
101 // -n / --dry-run only lists what would go
102 test: words =>
103 isGit(words, 'clean') &&
104 (words.includes('--force') || hasShortFlag(words.slice(2), 'f')) &&
105 !(words.includes('--dry-run') || hasShortFlag(words.slice(2), 'n')),
106 },
107 {
108 id: 'drop-table',
109 label: 'dropped or truncated data',
110 test: words =>
111 SQL_CLIENTS.has(words[0] ?? '') &&
112 // read with its comments and without: MySQL runs a /*! or /*+ body
113 [words.join(' '), uncommentedOf(words.join(' '))].some(sql => /\b(drop\s+(table|database|schema)|truncate)\b/i.test(sql)),
114 },
115 {
116 id: 'kubectl-delete',
117 label: 'kubectl delete',
118 test: words => words[0] === 'kubectl' && words.slice(1).includes('delete'),
119 },
120 {
121 id: 'terraform-destroy',
122 label: 'terraform destroy',
123 test: words =>
124 (words[0] === 'terraform' || words[0] === 'tofu') &&
125 (words.slice(1).includes('destroy') ||
126 (words.slice(1).includes('apply') && words.includes('-destroy'))),
127 },
128]
129
130/**
131 * The charge for a script a shell or SQL client reads that the line does
132 * not spell: a file, a download, an expansion (`curl ... | sh`, `bash <
133 * x.sh`, `psql -f drop.sql`). What it would run is unknown, so it is tried.
134 */
135const UNREAD: Omit<Charge, 'command'> = { id: 'unread-script', label: 'unread script' }
136
137/**
138 * The id of every charge the court knows, as the `charges` setting names them.
139 */
140export const CHARGE_IDS: readonly string[] = [...RULES.map(rule => rule.id), UNREAD.id]
141
142/**
143 * The options that name a file of SQL for a client to run.
144 */
145const SQL_FILE_OPTIONS = new Map<string, readonly string[]>([
146 ['psql', ['-f', '--file']],
147 ['cockroach', ['-f', '--file']],
148 ['duckdb', ['-f', '-init']],
149 ['sqlite3', ['-init']],
150 ['sqlcmd', ['-i', '--input-file']],
151 ['clickhouse-client', ['--queries-file']],
152])
153
154/**
155 * Programs that run the command in the words after their own options and
156 * operands; `watch` and `parallel` hand those words to a shell as a script.
157 */
158const WRAPPERS = new Set([
159 'sudo', 'env', 'nice', 'time', 'command', 'builtin', 'exec', 'nohup', 'xargs', 'timeout', 'stdbuf', 'chroot', 'doas',
160 'setsid', 'ionice', 'taskset', 'flock', 'caffeinate', 'watch', 'parallel', 'busybox', 'noglob', 'nocorrect',
161])
162const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'ash', 'fish'])
163
164/**
165 * Programs that run the value of their `-c` / `--command` option as a shell
166 * script (`su -c '...'`, `sg staff -c '...'`, `script -c '...'`).
167 */
168const RUNNERS = new Set(['su', 'sg', 'script'])
169
170/**
171 * Reserved words that lead a simple command inside a compound one
172 * (`if x; then rm ...`, `{ rm ...; }`, `! rm ...`, `coproc rm ...`).
173 */
174const KEYWORDS = new Set(['!', '{', 'if', 'then', 'else', 'elif', 'while', 'until', 'do', 'coproc'])
175
176/**
177 * Every program a charge names: a command name the shell only resolves at
178 * run time (`$RM`, `"$(echo rm)"`, `/bin/r?`) is tried as each of them.
179 */
180const PROGRAMS = ['rm', 'find', 'git', 'kubectl', 'terraform', 'tofu', ...SQL_CLIENTS]
181
182/**
183 * A wrapper's options that take the next word as their value, so that word
184 * is not read as the command (`nice -n 10 rm`, `sudo -u root rm`).
185 */
186const WRAPPER_VALUES = new Map<string, readonly string[]>([
187 [
188 'sudo',
189 ['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U', '-T', '--user', '--group', '--host', '--prompt', '--chdir', '--role', '--type', '--other-user'],
190 ],
191 ['env', ['-u', '-C', '--unset', '--chdir']],
192 ['nice', ['-n', '--adjustment']],
193 ['time', ['-f', '-o', '--format', '--output']],
194 ['exec', ['-a']],
195 ['timeout', ['-s', '-k', '--signal', '--kill-after']],
196 ['stdbuf', ['-i', '-o', '-e', '--input', '--output', '--error']],
197 ['chroot', ['--userspec', '--groups']],
198 ['doas', ['-u', '-C']],
199 ['ionice', ['-c', '-n', '-p', '-P', '-u', '--class', '--classdata', '--pid', '--pgid', '--uid']],
200 ['flock', ['-w', '-E', '--wait', '--timeout', '--conflict-exit-code']],
201 ['caffeinate', ['-w', '-t']],
202 ['watch', ['-n', '--interval']],
203 ['parallel', ['-j', '-S', '-n', '-N', '-L', '-I', '-d', '-a', '-E', '--jobs', '--sshlogin', '--arg-file', '--delimiter', '--max-args', '--colsep', '--joblog', '--results', '--env', '--tmpdir', '--timeout']],
204 [
205 'xargs',
206 ['-I', '-n', '-L', '-P', '-s', '-d', '-E', '-a', '--arg-file', '--delimiter', '--max-args', '--max-lines', '--max-procs', '--max-chars'],
207 ],
208])
209
210/**
211 * How many operands a wrapper takes before the command: the duration of
212 * `timeout 5 rm`, the root of `chroot / rm`, the mask of `taskset 1 rm` and
213 * the lock file of `flock /tmp/lock rm`.
214 */
215const WRAPPER_OPERANDS = new Map([
216 ['timeout', 1],
217 ['chroot', 1],
218 ['taskset', 1],
219 ['flock', 1],
220])
221
222/**
223 * Programs that run a command inside a container: `docker exec`, `docker
224 * container exec`, `docker compose exec`, `docker-compose exec`, `podman
225 * exec`, `nerdctl exec` and `kubectl exec ... --`.
226 */
227const CONTAINER_TOOLS = new Set(['docker', 'podman', 'nerdctl', 'docker-compose', 'kubectl'])
228
229/**
230 * Options of those tools, before or after `exec`, that take the next word as
231 * their value, so that word is not read as the container or the command.
232 */
233const CONTAINER_VALUES = new Set([
234 '-e', '--env', '--env-file', '-u', '--user', '-w', '--workdir', '--detach-keys', '--index',
235 '-H', '--host', '-c', '--context', '--config', '-l', '--log-level',
236 '-f', '--file', '-p', '--project-name', '--project-directory', '--profile', '--ansi', '--progress',
237 '-n', '--namespace', '--kubeconfig', '--cluster', '-s', '--server', '--container', '--pod-running-timeout',
238])
239
240/**
241 * Where `texts[at]`'s options end: each option is one word, or two when it
242 * takes the next word as its value.
243 */
244const pastOptions = (texts: readonly string[], at: number): number => {
245 let next = at
246 while ((texts[next] ?? '').startsWith('-') && texts[next] !== '--') {
247 next += CONTAINER_VALUES.has(texts[next] ?? '') ? 2 : 1
248 }
249 return next
250}
251
252/**
253 * Where the command a container tool runs starts, or undefined when the
254 * words at `at` are not a container exec: past the tool, its options, the
255 * `exec` subcommand and its options, and the container (or, for kubectl,
256 * past `--`).
257 */
258const containerCommandAt = (tool: string, texts: readonly string[], at: number): number | undefined => {
259 let next = pastOptions(texts, at + 1)
260 if (tool === 'docker' && texts[next] === 'compose') {
261 next = pastOptions(texts, next + 1)
262 } else if (tool !== 'kubectl' && tool !== 'docker-compose' && texts[next] === 'container') {
263 next += 1
264 }
265 if (texts[next] !== 'exec') {
266 return undefined
267 }
268 next = pastOptions(texts, next + 1)
269 if (tool === 'kubectl') {
270 const separator = texts.indexOf('--', next)
271 return separator === -1 ? undefined : separator + 1
272 }
273 return next + 1 < texts.length ? next + 1 : undefined
274}
275
276/**
277 * One word as the shell reads it: its text after quote removal, and whether
278 * that text is exact, with no expansion, glob or escape left to run time.
279 */
280type Word = { text: string; exact: boolean }
281
282const ANSI_ESCAPES: Readonly<Record<string, string>> = {
283 a: '\x07', b: '\b', e: '\x1b', E: '\x1b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v',
284 '\\': '\\', "'": "'", '"': '"', '?': '?',
285}
286
287/**
288 * The text of a `$'...'` body, or undefined for an escape it does not read.
289 */
290const ansiTextOf = (body: string): string | undefined => {
291 let text = ''
292 for (let at = 0; at < body.length; at += 1) {
293 const rest = body.slice(at + 1)
294 const code = /^x[0-9A-Fa-f]{1,2}|^[0-7]{1,3}/.exec(rest)?.[0]
295 const named = ANSI_ESCAPES[rest[0] ?? '']
296 if (body[at] !== '\\') {
297 text += body[at]
298 } else if (code !== undefined) {
299 text += String.fromCharCode(code.startsWith('x') ? parseInt(code.slice(1), 16) : parseInt(code, 8))
300 at += code.length
301 } else if (named === undefined) {
302 return undefined
303 } else {
304 text += named
305 at += 1
306 }
307 }
308 return text
309}
310
311/**
312 * The index just past the closing `)` or `}` that matches the opening one
313 * at `open`, or the end of the line when there is none.
314 */
315const closeOf = (line: string, open: number): number => {
316 const [left, right] = line[open] === '{' ? ['{', '}'] : ['(', ')']
317 let depth = 0
318 for (let at = open; at < line.length; at += 1) {
319 depth += line[at] === left ? 1 : line[at] === right ? -1 : 0
320 if (depth === 0) {
321 return at + 1
322 }
323 }
324 return line.length
325}
326
327/**
328 * The expansion that starts at `at`, if any: where it ends, and the command
329 * it runs for a command substitution (`$(...)`, `` `...` ``).
330 */
331const expansionAt = (line: string, at: number): { end: number; script?: string } | undefined => {
332 if (line[at] === '`') {
333 const close = line.indexOf('`', at + 1)
334 const end = close === -1 ? line.length : close + 1
335 return { end, script: line.slice(at + 1, close === -1 ? end : close) }
336 }
337 if (line[at] !== '$') {
338 return undefined
339 }
340 const next = line[at + 1] ?? ''
341 if (next === '(' || next === '{') {
342 const end = closeOf(line, at + 1)
343 return next === '(' ? { end, script: line.slice(at + 2, end - 1) } : { end }
344 }
345 const name = /^(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*#?$!-])/.exec(line.slice(at + 1))?.[0]
346 return name === undefined ? undefined : { end: at + 1 + name.length }
347}
348
349/**
350 * The index of the quote closing the one at `open`, past escapes where
351 * they apply, or -1 when it is left dangling. A quote nested in a
352 * substitution closes early, which only leaves the word inexact.
353 */
354const closingQuoteOf = (line: string, open: number, escapes: boolean): number => {
355 const quote = line[open] === "'" ? "'" : '"'
356 for (let at = open + 1; at < line.length; at += 1) {
357 if (escapes && line[at] === '\\') {
358 at += 1
359 } else if (line[at] === quote) {
360 return at
361 }
362 }
363 return -1
364}
365
366/**
367 * The text of a `"..."` body: `\` escapes only `$`, `` ` ``, `"`, `\` and a
368 * newline, and an expansion leaves the text inexact.
369 */
370const doubleQuotedOf = (body: string, scripts: string[]): Word => {
371 let text = ''
372 let exact = true
373 for (let at = 0; at < body.length; at += 1) {
374 const expansion = expansionAt(body, at)
375 if (body[at] === '\\' && '$`"\\\n'.includes(body[at + 1] ?? 'x')) {
376 text += body[at + 1] === '\n' ? '' : body[at + 1]
377 at += 1
378 } else if (expansion === undefined) {
379 text += body[at]
380 } else {
381 text += body.slice(at, expansion.end)
382 exact = false
383 if (expansion.script !== undefined) scripts.push(expansion.script)
384 at = expansion.end - 1
385 }
386 }
387 return { text, exact }
388}
389
390/**
391 * The index of the first character in `stops` (a regex class) after `at`,
392 * or the end of the line.
393 */
394const stopAfter = (line: string, at: number, stops: RegExp): number => {
395 stops.lastIndex = at + 1
396 return stops.exec(line)?.index ?? line.length
397}
398
399/**
400 * Whether an unquoted `*`, `?`, `[...]` or `{a,b}` at an index of `line` is
401 * a pattern the shell expands, asked in increasing order of index. The end
402 * of the last `[` looked ahead from is kept: every `[` before it ends there
403 * too, so a run of them is read once, not once each.
404 */
405const patternReaderOf = (line: string) => {
406 let bracketEnd = -1
407 return (at: number): boolean => {
408 if (line[at] === '[') {
409 bracketEnd = at < bracketEnd ? bracketEnd : stopAfter(line, at, /[\s\]]/g)
410 return line[bracketEnd] === ']' && bracketEnd > at + 1
411 }
412 if (line[at] === '{') {
413 const end = stopAfter(line, at, /[\s{}]/g)
414 const body = line.slice(at + 1, end)
415 return line[end] === '}' && (body.includes(',') || body.includes('..'))
416 }
417 return line[at] === '*' || line[at] === '?'
418 }
419}
420
421/**
422 * A redirection operator at `at` (`>`, `2>`, `&>`, `>>`, `<<<`, `>&2`, ...):
423 * its length, and whether the next word is its target.
424 */
425const redirectionAt = (line: string, at: number): { length: number; target: boolean; input?: Input['kind'] } | undefined => {
426 const match = /^&?(<<<|<<-?|<>|>>|>\||[<>])(&(?:\d+|-))?/.exec(line.slice(at))
427 const input = match?.[2] === undefined ? INPUTS[match?.[1] ?? ''] : undefined
428 return match === null ? undefined : { length: match[0].length, target: match[2] === undefined, ...(input === undefined ? {} : { input }) }
429}
430
431/**
432 * What a command reads on its input from a redirection: a here-string's
433 * word, the here-document after the line, or a file.
434 */
435type Input = { kind: 'string'; word: Word } | { kind: 'document' } | { kind: 'file' }
436
437const INPUTS: Readonly<Record<string, Input['kind']>> = { '<<<': 'string', '<<': 'document', '<<-': 'document', '<': 'file' }
438
439/**
440 * The quoted part or expansion at `at`, read into the word: where it ends,
441 * or undefined for a plain character.
442 */
443const quotedAt = (line: string, at: number, scripts: string[]): (Word & { end: number }) | undefined => {
444 const char = line[at]
445 if (char === "'" || (char === '$' && line[at + 1] === "'")) {
446 const open = char === '$' ? at + 1 : at
447 const close = closingQuoteOf(line, open, char === '$')
448 const body = line.slice(open + 1, close)
449 const text = char === '$' ? ansiTextOf(body) : body
450 return close === -1
451 ? { text: '', exact: true, end: open + 1 }
452 : { text: text ?? body, exact: text !== undefined, end: close + 1 }
453 }
454 if (char === '"' || (char === '$' && line[at + 1] === '"')) {
455 const open = char === '$' ? at + 1 : at
456 const close = closingQuoteOf(line, open, true)
457 return close === -1
458 ? { text: '', exact: true, end: open + 1 }
459 : { ...doubleQuotedOf(line.slice(open + 1, close), scripts), end: close + 1 }
460 }
461 const expansion = expansionAt(line, at)
462 if (expansion?.script !== undefined) scripts.push(expansion.script)
463 return expansion === undefined ? undefined : { text: line.slice(at, expansion.end), exact: false, end: expansion.end }
464}
465
466/**
467 * The work left to one reading of a command line, in steps: a character
468 * lexed, a word visited, and a fixed cost for each piece of the line
469 * entered (a segment, a nested script, the command piped into another).
470 */
471type Budget = { left: number }
472
473/**
474 * Thrown when a reading runs out of steps: the line is charged as unread.
475 */
476class OverBudget extends Error {}
477
478/**
479 * The fixed cost of lexing one piece of a line, in steps: about what lexing
480 * this many characters costs.
481 */
482const LEX_STEPS = 64
483
484/**
485 * The fixed cost of reading one group of words as a simple command, in
486 * steps, on top of its words.
487 */
488const SIMPLE_STEPS = 32
489
490/**
491 * The cost of reading one redirection operator, in steps.
492 */
493const REDIRECTION_STEPS = 12
494
495const spend = (budget: Budget, steps: number) => {
496 if (steps > budget.left) {
497 // one step past the budget marks a reading that ran out
498 budget.left = -1
499 throw new OverBudget()
500 }
501 budget.left -= steps
502}
503
504/**
505 * The words of one segment as the shell reads them, with redirections and
506 * their targets left out, the commands its substitutions run, where a `(`
507 * or `)` opens or ends a group or a case pattern (`(x)`, `x)`, `f()`), as
508 * the count of words before it (the words after start a command of their
509 * own), and what its input is redirected from. A quote left dangling by a
510 * separator split inside quotes (`bash -c 'cd app` and `git reset --hard'`)
511 * is dropped, so the halves still match a charge.
512 */
513const lexOf = (line: string, budget: Budget): { words: Word[]; scripts: string[]; breaks: number[]; input?: Input } => {
514 spend(budget, line.length + LEX_STEPS)
515 const words: Word[] = []
516 const scripts: string[] = []
517 const breaks: number[] = []
518 let word: Word | undefined
519 let target = false
520 let input: Input | undefined
521 const isPatternAt = patternReaderOf(line)
522 const end = () => {
523 if (word !== undefined && !target) words.push(word)
524 if (word !== undefined && target && input?.kind === 'string' && input.word.text === '') input = { kind: 'string', word }
525 if (word !== undefined) target = false
526 word = undefined
527 }
528 const add = (text: string, exact: boolean) => {
529 word = { text: (word?.text ?? '') + text, exact: (word?.exact ?? true) && exact }
530 }
531 for (let at = 0; at < line.length; ) {
532 const char = line[at] ?? ''
533 const part = quotedAt(line, at, scripts)
534 const redirection = redirectionAt(line, at)
535 if (part !== undefined) {
536 add(part.text, part.exact)
537 at = part.end
538 } else if (redirection !== undefined) {
539 spend(budget, REDIRECTION_STEPS)
540 if (word !== undefined && /^\d+$/.test(word.text)) word = undefined
541 end()
542 target = redirection.target
543 if (redirection.input !== undefined) input = redirection.input === 'string' ? { kind: 'string', word: { text: '', exact: true } } : { kind: redirection.input }
544 at += redirection.length
545 } else if (/[\s()&]/.test(char)) {
546 end()
547 if (char === '(' || char === ')') breaks.push(words.length)
548 at += 1
549 } else if (char === '\\') {
550 add(line[at + 1] ?? '', true)
551 at += 2
552 } else {
553 add(char, !isPatternAt(at))
554 at += 1
555 }
556 }
557 end()
558 return { words, scripts, breaks, ...(input === undefined ? {} : { input }) }
559}
560
561/**
562 * The program a command word names, read case-blind as a case-blind file
563 * system (the macOS default) runs it: a path read as its last part
564 * (`/bin/rm`), and zsh's `=rm` as `rm`. Undefined when the shell only
565 * resolves it at run time.
566 */
567const programOf = (word: Word): string | undefined =>
568 word.exact ? word.text.slice(word.text.lastIndexOf('/') + 1).replace(/^=(?=.)/, '').toLowerCase() : undefined
569
570/**
571 * The name a command word runs, however it is spelled: quotes, escapes and
572 * case dropped, and a path (`/bin/rm`) read as its last part; the word
573 * itself when the shell only resolves it at run time.
574 */
575export const nameOf = (word: string) => {
576 const [first] = lexOf(word, { left: Infinity }).words
577 return (first === undefined ? undefined : programOf(first)) ?? word
578}
579
580const SEPARATOR = /^(?:&&|\|\||\|&|[;|\n]|&(?!>))/
581
582/**
583 * A segment of a command line, with what feeds its input: the segment
584 * piped into it (`a | b`), and the here-document its `<<` reads.
585 */
586type Part = { text: string; from?: Part; document?: Document }
587
588/**
589 * A here-document: its body, whether the body is read as written (its
590 * delimiter quoted, or nothing in it to expand), and the whole of it as
591 * written, from the end of the line that opens it.
592 */
593type Document = { body: string; exact: boolean; spelling: string }
594
595const HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|(\\?[^\s;&|<>()]+))/
596
597/**
598 * The here-document a `<<` at `at` opens, with the newline it starts after
599 * and where it ends.
600 */
601const documentAt = (line: string, at: number): { newline: number; end: number; document: Document } | undefined => {
602 const match = line[at - 1] === '<' ? null : HEREDOC.exec(line.slice(at))
603 const newline = match === null ? -1 : line.indexOf('\n', at)
604 if (match === null || match[0].startsWith('<<<') || newline === -1) {
605 return undefined
606 }
607 const delimiter = match[2] ?? match[3] ?? (match[4] ?? '').replace(/^\\/, '')
608 // the lines after it are read up to its delimiter only, so each
609 // here-document of a line is read once
610 let start = newline + 1
611 let stop = line.indexOf('\n', start)
612 const isClose = (text: string) => (match[1] === '-' ? text.replace(/^\t+/, '') : text) === delimiter
613 while (stop !== -1 && !isClose(line.slice(start, stop))) {
614 start = stop + 1
615 stop = line.indexOf('\n', start)
616 }
617 const isClosed = stop !== -1 || isClose(line.slice(start))
618 const body = isClosed ? line.slice(newline + 1, Math.max(newline + 1, start - 1)) : line.slice(newline + 1)
619 const end = isClosed && stop !== -1 ? stop : line.length
620 const isQuoted = match[2] !== undefined || match[3] !== undefined || (match[4] ?? '').startsWith('\\')
621 return { newline, end, document: { body, exact: isQuoted || !/[$`\\]/.test(body), spelling: line.slice(newline, end) } }
622}
623
624/**
625 * A command line split at the separators outside quotes, as the shell
626 * splits it, so a nested script (`bash -c "a; \"rm\" -rf x"`) stays whole,
627 * each segment with the segment piped into it and the here-document it
628 * reads; a here-document's body is input, not commands.
629 */
630const shellSplitOf = (line: string): Part[] => {
631 const parts: Part[] = []
632 let start = 0
633 let from: Part | undefined
634 let document: ReturnType<typeof documentAt>
635 let isOwn = false
636 const push = (end: number, separator: string) => {
637 const part = { text: line.slice(start, end), ...(from === undefined ? {} : { from }), ...(isOwn && document ? { document: document.document } : {}) }
638 parts.push(part)
639 from = separator === '|' || separator === '|&' ? part : undefined
640 isOwn = false
641 }
642 for (let at = 0; at < line.length; ) {
643 const char = line[at] ?? ''
644 const separator = SEPARATOR.exec(line.slice(at))?.[0]
645 const opened = char === '<' && document === undefined ? documentAt(line, at) : undefined
646 if (opened !== undefined) {
647 document = opened
648 isOwn = true
649 at += 2
650 } else if (char === '\\') {
651 at += 2
652 } else if (char === "'" || char === '"') {
653 const close = closingQuoteOf(line, at, char === '"')
654 at = close === -1 ? at + 1 : close + 1
655 } else if (separator === undefined || line[at - 1] === '<' || line[at - 1] === '>') {
656 at += 1
657 } else {
658 push(at, separator)
659 at = at === document?.newline ? document.end : at + separator.length
660 document = at === document?.end ? undefined : document
661 start = at
662 }
663 }
664 push(line.length, '')
665 return parts
666}
667
668/**
669 * Splits a command line into simple commands at `&&`, `||`, `;`, `|`, `&`
670 * and newlines, a line continuation joined first: once as the shell splits
671 * it, then again at every separator, inside quotes too. The second reading
672 * can only put more commands on trial, never fewer.
673 */
674const segmentsOf = (command: string, budget: Budget): Part[] => {
675 spend(budget, command.length)
676 const line = command.replace(/\\\n/g, '')
677 return [...shellSplitOf(line), ...line.split(/&&|\|\||(?<![<>|])&(?!>)|[;|\n]/).map(text => ({ text }))]
678}
679
680type Simple = {
681 /** What a charge is matched against: wrappers and quotes removed. */
682 words: string[]
683 /** Whether the command name is only resolved at run time. */
684 open: boolean
685 /** The simple command as the model wrote it, for the court to read out. */
686 text: string
687 /**
688 * The words of the command that runs this one for each file (`find ...
689 * -exec`), to stand for it in place of `words` once charged.
690 */
691 whole?: string[]
692 /**
693 * The words a command run per file from a script it names (`find -exec
694 * sh -c '...'`) is known by, as its script reads it: those of a command
695 * it runs per file in turn, or `matched`, the words it is charged on.
696 */
697 known?: string[] | 'matched'
698 /** Whether this is a script the court cannot read, charged as such. */
699 unread?: boolean
700}
701
702/**
703 * A segment as written, less a quote left dangling by a split inside quotes.
704 */
705const spellingOf = (segment: string) => {
706 const text = segment.trim()
707 const balanced = (quote: string) => (text.split(quote).length - 1) % 2 === 0
708 return text
709 .replace(/^(["'])/, (q: string) => (balanced(q) ? q : ''))
710 .replace(/(["'])$/, (q: string) => (balanced(q) ? q : ''))
711}
712
713/**
714 * The script `env -S` splits into a command line, with the words after it.
715 */
716const splitStringOf = (words: readonly Word[], at: number): string | undefined => {
717 const option = words[at]?.text ?? ''
718 const long = /^--split-string(=|$)/.exec(option)
719 if (option === '-S' || long?.[1] === '') {
720 return words.slice(at + 1).map(word => word.text).join(' ')
721 }
722 if (option.startsWith('-S') || long !== null) {
723 const value = long === null ? option.slice(2) : option.slice(long[0].length)
724 return [value, ...words.slice(at + 1).map(word => word.text)].join(' ')
725 }
726 return undefined
727}
728
729/**
730 * The script a wrapper runs from the words at `at`: what `env -S` splits,
731 * the value of `flock -c`, or every word left for `watch` and `parallel`
732 * (its `:::` arguments included: each is added to the command).
733 */
734const wrapperScriptOf = (wrapper: string, words: readonly Word[], texts: readonly string[], at: number): string | undefined => {
735 if (wrapper === 'env') {
736 return splitStringOf(words, at)
737 }
738 if (wrapper === 'flock') {
739 return commandOptionOf(texts, at)
740 }
741 const isScript = (wrapper === 'watch' || wrapper === 'parallel') && !(texts[at] ?? '').startsWith('-')
742 return isScript ? texts.slice(at).join(' ') : undefined
743}
744
745/**
746 * Where the command starts past leading env assignments, keywords and
747 * wrappers with their options and operands, or the script a wrapper runs
748 * instead.
749 */
750const commandStartOf = (words: readonly Word[]): { at: number; script?: string } => {
751 let at = 0
752 let wrapper: string | undefined
753 let operands = 0
754 const texts = words.map(word => word.text)
755 while (at < words.length) {
756 const word = words[at] ?? { text: '', exact: true }
757 const program = programOf(word)
758 const script = wrapper === undefined ? undefined : wrapperScriptOf(wrapper, words, texts, at)
759 if (script !== undefined) {
760 return { at, script }
761 }
762 if (word.exact && (word.text === 'function' || (word.text === 'coproc' && words[at + 2]?.text === '{'))) {
763 // a function's or a named coproc's name
764 at += 2
765 } else if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word.text) || (word.exact && KEYWORDS.has(word.text))) {
766 at += 1
767 } else if (program !== undefined && CONTAINER_TOOLS.has(program) && containerCommandAt(program, texts, at) !== undefined) {
768 at = containerCommandAt(program, texts, at) ?? at
769 wrapper = undefined
770 operands = 0
771 } else if (program !== undefined && WRAPPERS.has(program)) {
772 wrapper = program
773 operands = WRAPPER_OPERANDS.get(program) ?? 0
774 at += 1
775 } else if (wrapper !== undefined && word.text.startsWith('-')) {
776 at += WRAPPER_VALUES.get(wrapper)?.includes(word.text) ? 2 : 1
777 } else if (operands > 0) {
778 operands -= 1
779 at += 1
780 } else {
781 break
782 }
783 }
784 return { at }
785}
786
787/**
788 * The simple commands of one segment, each with its leading env assignments,
789 * keywords and wrappers (`sudo`, `env`, `xargs`, ...) removed from the words
790 * it is matched on; for `bash -c '...'`, `eval '...'` and `env -S '...'`, the
791 * commands inside instead; and the commands its substitutions run.
792 */
793const commandsOf = (part: Part, budget: Budget, depth = 0): Simple[] => {
794 const nested = (script: string) =>
795 depth < 3 ? segmentsOf(script, budget).flatMap(inner => commandsOf(inner, budget, depth + 1)) : []
796 const { words, scripts, breaks, input } = lexOf(part.text.trim(), budget)
797 const ends = [...breaks, words.length]
798 const groups = [0, ...breaks].map((from, index) => words.slice(from, ends[index]))
799 const context: Context = {
800 part,
801 input,
802 nested,
803 spelling: spellingOf(part.text),
804 budget,
805 read: once(() => inputOf(part, input, budget)),
806 unread: once(() => unreadOf(part, input)),
807 }
808 return [...groups.flatMap(group => simpleOf(group, context)), ...scripts.flatMap(nested)]
809}
810
811/**
812 * A value made on first use and kept: a segment can hold as many groups as
813 * it has characters (`((((`), and each would otherwise read it whole again.
814 */
815const once = <T>(make: () => T): (() => T) => {
816 let made: { value: T } | undefined
817 return () => (made ??= { value: make() }).value
818}
819
820/**
821 * Where a group of words stands: its segment and its spelling, what its
822 * input is redirected from and the script read there, the segment charged
823 * as an unread script, and how to read a script it runs.
824 */
825type Context = {
826 part: Part
827 input: Input | undefined
828 nested: (script: string) => Simple[]
829 spelling: string
830 budget: Budget
831 read: () => ReturnType<typeof inputOf>
832 unread: () => Simple
833}
834
835/**
836 * How deep a `find -exec` reads the finds it runs (`find -exec find -exec
837 * ...`); a deeper one is charged as an unread script.
838 */
839const FIND_DEPTH = 3
840
841/**
842 * The simple command of one group of words, or the commands of the script
843 * it runs, with those of a script it reads on its input and of the
844 * commands a find runs for each file.
845 */
846const simpleOf = (words: readonly Word[], context: Context, finds = 0): Simple[] => {
847 spend(context.budget, words.length + SIMPLE_STEPS)
848 const start = commandStartOf(words)
849 const [head, ...args] = words.slice(start.at)
850 const program = head === undefined ? undefined : programOf(head)
851 const rest = head === undefined ? [] : [program ?? head.text, ...args.map(word => word.text)]
852 const inner = start.script ?? innerScriptOf(rest)
853 const open = head !== undefined && program === undefined
854 const own = inner === undefined ? [{ words: rest, open, text: context.spelling }] : context.nested(inner)
855 const fed = inner === undefined ? fedOf(rest, context) : []
856 const perFile = program === 'find' ? execsOf(args) : []
857 if (perFile.length > 0 && finds >= FIND_DEPTH) {
858 return [...own, ...fed, context.unread()]
859 }
860 const perFileOf = (simple: Simple): Simple => ({
861 ...simple,
862 whole: rest,
863 ...(simple.text === context.spelling ? {} : { known: simple.known ?? simple.whole ?? 'matched' }),
864 })
865 return [...own, ...fed, ...perFile.flatMap(command => simpleOf(command, context, finds + 1).map(perFileOf))]
866}
867
868/**
869 * The script a command reads on its input: from a here-string, its
870 * here-document, or the segment piped into it; `unread` for a file or
871 * anything else the line does not spell; undefined for no input.
872 */
873const inputOf = (part: Part, input: Input | undefined, budget: Budget): { script: string; exact: boolean } | 'unread' | undefined => {
874 if (input?.kind === 'string') {
875 return { script: input.word.text, exact: input.word.exact }
876 }
877 if (input?.kind === 'document') {
878 // a segment split inside quotes knows no here-document; the shell's own split does
879 return part.document === undefined ? undefined : { script: part.document.body, exact: part.document.exact }
880 }
881 if (input?.kind === 'file') {
882 return 'unread'
883 }
884 return part.from === undefined ? undefined : outputOf(part.from, budget)
885}
886
887/**
888 * What a segment writes to a pipe when the line spells it: the words of
889 * `echo` or `printf` (`\n` read as a new line), or the input `cat` passes
890 * on; `unread` for any other program.
891 */
892const outputOf = (part: Part, budget: Budget): ReturnType<typeof inputOf> => {
893 const { words, input } = lexOf(part.text.trim(), budget)
894 const [head, ...args] = words.slice(commandStartOf(words).at)
895 const program = head === undefined ? undefined : programOf(head)
896 if (program === 'echo' || program === 'printf') {
897 const options = args.findIndex(word => !/^-[neE]+$/.test(word.text))
898 const shown = program === 'echo' ? args.slice(options === -1 ? args.length : options) : args
899 const script = shown.map(word => word.text).join(program === 'echo' ? ' ' : '\n').replaceAll('\\n', '\n')
900 return { script, exact: shown.every(word => word.exact) }
901 }
902 return program === 'cat' && args.every(word => word.text.startsWith('-')) ? inputOf(part, input, budget) : 'unread'
903}
904
905/**
906 * The commands of a script a shell or SQL client reads on its input or
907 * from a file it names, and the script charged as unread when the line
908 * does not spell all of it. Read out with what feeds it.
909 */
910const fedOf = (rest: readonly string[], { nested, read: readOf, unread: unreadOf }: Context): Simple[] => {
911 const program = rest[0] ?? ''
912 const isSql = SQL_CLIENTS.has(program)
913 const isShell = SHELLS.has(program) && shellReadingOf(rest) === 'input'
914 const read = isSql || isShell ? readOf() : undefined
915 const options = SQL_FILE_OPTIONS.get(program) ?? []
916 if (rest.slice(1).some(word => options.some(option => word === option || word.startsWith(option.startsWith('--') ? `${option}=` : option)))) {
917 return [unreadOf()]
918 }
919 if (read === undefined || read === 'unread') {
920 return read === undefined ? [] : [unreadOf()]
921 }
922 const commands = isShell ? nested(read.script) : [{ words: [...rest, ...read.script.split(/\s+/)], open: false, text: unreadOf().text }]
923 return read.exact ? commands : [...commands, unreadOf()]
924}
925
926/**
927 * A segment charged as a script the court cannot read, read out with what
928 * feeds it.
929 */
930const unreadOf = (part: Part, input: Input | undefined): Simple => {
931 const fromText = input === undefined && part.from !== undefined ? `${spellingOf(part.from.text)} | ` : ''
932 const text = `${fromText}${spellingOf(part.text)}${input?.kind === 'document' ? (part.document?.spelling ?? '') : ''}`
933 return { words: text.split(/\s+/), open: false, text, unread: true }
934}
935
936const EXEC_ACTIONS = new Set(['-exec', '-execdir', '-ok', '-okdir'])
937
938/**
939 * The commands a find runs for each file: the words after `-exec`,
940 * `-execdir`, `-ok` or `-okdir`, up to `;` or `+`.
941 */
942const execsOf = (words: readonly Word[]): Word[][] => {
943 const commands: Word[][] = []
944 let command: Word[] | undefined
945 for (const word of words) {
946 if (command === undefined) {
947 command = EXEC_ACTIONS.has(word.text) ? [] : undefined
948 } else if (word.text === ';' || word.text === '+') {
949 commands.push(command)
950 command = undefined
951 } else {
952 command.push(word)
953 }
954 }
955 return command === undefined ? commands : [...commands, command]
956}
957
958/**
959 * The value of a `-c` (bundled or not: `-lc`) or `--command` option at
960 * `at`, or undefined when that word is no such option.
961 */
962const commandOptionOf = (words: readonly string[], at: number): string | undefined => {
963 const word = words[at] ?? ''
964 const long = /^--command(?:=([\s\S]*))?$/.exec(word)
965 if (long !== null) {
966 return long[1] ?? words[at + 1] ?? ''
967 }
968 return /^-[A-Za-z]*c$/.test(word) ? (words[at + 1] ?? '') : undefined
969}
970
971/**
972 * The script a shell runs from its first operand: with `-c` in any of its
973 * flags (`bash -lc`, `sh -ec`, `bash -c -x`), or `--command` (fish);
974 * `input` when it reads its script on its input (no operand, or `-s`);
975 * undefined when it runs a script file.
976 */
977const shellReadingOf = (words: readonly string[]): { script: string } | 'input' | undefined => {
978 let isCommand = false
979 let isInput = false
980 let at = 1
981 while (at < words.length && /^[-+]./.test(words[at] ?? '') && words[at] !== '--') {
982 const word = words[at] ?? ''
983 const script = word.startsWith('--') ? commandOptionOf(words, at) : undefined
984 if (script !== undefined) {
985 return { script }
986 }
987 isCommand ||= /^-[A-Za-z]*c/.test(word)
988 isInput ||= /^-[A-Za-z]*s/.test(word)
989 // -o / -O name an option in the next word; so do --rcfile and --init-file
990 at += /^[-+][A-Za-z]*[oO]$|^--(rcfile|init-file)$/.test(word) ? 2 : 1
991 }
992 at += words[at] === '--' || words[at] === '-' ? 1 : 0
993 if (isCommand) {
994 return { script: words[at] ?? '' }
995 }
996 return isInput || at >= words.length ? 'input' : undefined
997}
998
999/**
1000 * The script a runner takes from `-c` or `--command`; `sg` also runs the
1001 * operand after its group without one.
1002 */
1003const runnerScriptOf = (words: readonly string[]): string | undefined => {
1004 for (let at = 1; at < words.length; at += 1) {
1005 const script = commandOptionOf(words, at)
1006 if (script !== undefined) {
1007 return script
1008 }
1009 }
1010 return words[0] === 'sg' ? words.slice(1).filter(word => !word.startsWith('-'))[1] : undefined
1011}
1012
1013/**
1014 * git's options that take the next word as their value.
1015 */
1016const GIT_VALUES = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix'])
1017
1018/**
1019 * The git subcommands a charge names. git runs a built-in command over an
1020 * alias of the same name (`git -c alias.push=status push --force` pushes),
1021 * so an alias named for one of these is not read; an alias named for
1022 * another built-in is still read, which can only charge more.
1023 */
1024const GIT_CHARGED = new Set(['push', 'reset', 'clean'])
1025
1026/**
1027 * What a git alias set on the command line (`git -c alias.x=...`) runs when
1028 * the subcommand names it: a `!` alias is a shell script given the words
1029 * after it, any other is git with its words in place of the subcommand.
1030 */
1031const gitAliasScriptOf = (words: readonly string[]): string | undefined => {
1032 const aliases = new Map<string, string>()
1033 let at = 1
1034 while ((words[at] ?? '').startsWith('-')) {
1035 const alias = words[at] === '-c' ? /^alias\.([^=]+)=([\s\S]*)$/i.exec(words[at + 1] ?? '') : null
1036 if (alias !== null) aliases.set((alias[1] ?? '').toLowerCase(), alias[2] ?? '')
1037 at += GIT_VALUES.has(words[at] ?? '') ? 2 : 1
1038 }
1039 const body = GIT_CHARGED.has(words[at] ?? '') ? undefined : aliases.get((words[at] ?? '').toLowerCase())
1040 const args = words.slice(at + 1)
1041 if (body === undefined) {
1042 return undefined
1043 }
1044 return body.startsWith('!') ? [body.slice(1), ...args].join(' ') : ['git', body, ...args].join(' ')
1045}
1046
1047const innerScriptOf = (words: readonly string[]): string | undefined => {
1048 if (words[0] === 'eval') {
1049 return words.slice(1).join(' ')
1050 }
1051 if (words[0] === 'git') {
1052 return gitAliasScriptOf(words)
1053 }
1054 if (SHELLS.has(words[0] ?? '')) {
1055 const reading = shellReadingOf(words)
1056 return typeof reading === 'object' ? reading.script : undefined
1057 }
1058 return RUNNERS.has(words[0] ?? '') ? runnerScriptOf(words) : undefined
1059}
1060
1061/**
1062 * Plain words only: no quote, escape, expansion, glob, brace, redirection,
1063 * operator, comment or tab can appear, so the line runs what it spells.
1064 */
1065const PLAIN_LINE = /^[A-Za-z0-9_@%+=:,./~ -]+$/
1066
1067/**
1068 * A `~` the shell expands: at the start of a word, or after `=` or `:`.
1069 */
1070const TILDE = /(^|[ =:])~/
1071
1072const NOT_SIMPLE_HEADS = new Set([...WRAPPERS, ...SHELLS, ...RUNNERS, 'eval', 'source', '.', 'coproc'])
1073
1074/**
1075 * Whether a whole command line is one simple command of plain words: no
1076 * compound operator, substitution, variable, glob, brace or tilde
1077 * expansion, redirection, quoting, env assignment, wrapper or nested shell.
1078 * Anything the check cannot read as plain is not simple.
1079 *
1080 * @param command the Bash tool's `command`, as the model wrote it
1081 */
1082export const isSimpleCommand = (command: string): boolean => {
1083 const [head, ...rest] = command.trim().split(/\s+/)
1084 const trial = trialOf(command)
1085 return (
1086 PLAIN_LINE.test(command) &&
1087 !TILDE.test(command) &&
1088 head !== undefined &&
1089 head !== '' &&
1090 !head.includes('=') &&
1091 !NOT_SIMPLE_HEADS.has(nameOf(head)) &&
1092 // charged on its own words, never on a command it runs (`find -exec`)
1093 // or a script it reads (`psql -f drop.sql`)
1094 (trial === undefined || (trial.charge.id !== UNREAD.id && trial.matched.join(' ') === [nameOf(head), ...rest].join(' ')))
1095 )
1096}
1097
1098/**
1099 * The charge a shell command is tried on, or undefined to let it pass
1100 * without a trial.
1101 *
1102 * @param command the Bash tool's `command`, as the model wrote it
1103 */
1104export const chargeOf = (command: string): Charge | undefined => chargedOf(command)?.charge
1105
1106/**
1107 * The charge a shell command is tried on, with the charged simple
1108 * command's own words (wrappers and quotes set aside), or undefined.
1109 *
1110 * @param command the Bash tool's `command`, as the model wrote it
1111 */
1112export const chargedOf = (command: string): { charge: Charge; words: readonly string[] } | undefined => {
1113 const trial = trialOf(command)
1114 return trial === undefined ? undefined : { charge: trial.charge, words: trial.words }
1115}
1116
1117/**
1118 * The words a charged command line is known by for contempt: those its
1119 * charged command's own spelling reads to, from the reading that charged
1120 * it, not a second one; undefined when nothing is charged.
1121 *
1122 * @param command the Bash tool's `command`, as the model wrote it
1123 */
1124export const knownWordsOf = (command: string): readonly string[] | undefined => trialOf(command)?.known
1125
1126/**
1127 * The longest command line the court reads, in characters; a longer one is
1128 * charged as unread, not read. Reading is not linear in the length: nested
1129 * scripts, `find -exec` and pipes read parts of the line again, so the
1130 * time a reading may take is bounded by `BUDGET`, not by this cap. A
1131 * `bash -c` script cannot exceed 128 KiB on Linux (MAX_ARG_STRLEN), and
1132 * a command line written by hand or by the model is far shorter.
1133 */
1134const MAX_LINE = 64 * 1024
1135
1136/**
1137 * The steps one reading of a command line may take; past them the line is
1138 * charged as unread. At this budget the slowest line measured (a nested
1139 * `find -exec`, `eval`, `case` or `{` run at 64 Ki characters) reads in
1140 * about 50 ms in the plugin runtime, and a 64 Ki here-document or script of
1141 * real code in at most 32 ms and about 570 000 steps. A 64 Ki shell script
1142 * fed to `bash` takes 0.9 to 1.1 million and goes to trial unread.
1143 */
1144export const BUDGET = 600_000
1145
1146/**
1147 * The charge, the words that stand for the charged command, and the words
1148 * the charge was matched on (those of the command run per file, for a
1149 * `find -exec`).
1150 */
1151const trialOf = (command: string): Trial => {
1152 if (last?.command !== command) {
1153 last = { command, trial: readTrialOf(command) }
1154 readings.count += 1
1155 }
1156 return last.trial
1157}
1158
1159/**
1160 * A charge with the words that stand for the charged command, the words it
1161 * was matched on, and the words it is known by: those its own spelling
1162 * reads to (`charge.command`), which make its contempt key.
1163 */
1164type Trial = { charge: Charge; words: readonly string[]; matched: readonly string[]; known: readonly string[] } | undefined
1165
1166/**
1167 * The last line read and its trial: a check asks for the charge, the
1168 * contempt key and the exhibits of one line, and it is read once for all.
1169 */
1170let last: { command: string; trial: Trial } | undefined
1171
1172/**
1173 * The ids of the charges the person switched on; undefined, every charge.
1174 * Set by `switchCharges` when the plugin loads, so the one reading of a line
1175 * serves its charge, its contempt key and its exhibits alike.
1176 */
1177let switchedOn: ReadonlySet<string> | undefined
1178
1179const isOn = (id: string) => switchedOn === undefined || switchedOn.has(id)
1180
1181/**
1182 * Sets which charges go to trial; a charge switched off is never tried, and
1183 * a line is charged with the first charge on it that is switched on.
1184 *
1185 * @param charges the ids of the charges switched on; undefined, every one
1186 */
1187export const switchCharges = (charges: ReadonlySet<string> | undefined) => {
1188 switchedOn = charges
1189 last = undefined
1190}
1191
1192/**
1193 * How many command lines have been read since the module loaded, and the
1194 * steps the last reading took: one past `BUDGET` for a reading that ran
1195 * out, none for a line too long to read.
1196 */
1197export const readings = { count: 0, steps: 0 }
1198
1199/**
1200 * A line charged as unread, not read: too long, or past the budget.hooks/sentence.ts 71 lines1/**
2 * The court's sentence: one safer way to do what a convicted command meant
3 * to do, spelled for the command that was tried. A charge with no entry gets
4 * no sentence; the court never invents one.
5 */
6
7const wordsFrom = (command: string, program: RegExp): string[] => {
8 const words = command.trim().split(/\s+/)
9 const at = words.findIndex(word => program.test(word))
10 return at < 0 ? words : words.slice(at)
11}
12
13const terraform = (command: string) => {
14 const words = wordsFrom(command, /^(terraform|tofu)$/)
15 .filter(word => !/^-auto-approve(=.*)?$/.test(word))
16 .flatMap(word => (word === 'destroy' ? ['plan', '-destroy'] : word === 'apply' ? ['plan'] : [word]))
17 return `Run ${words.join(' ')} first and read what it would remove.`
18}
19
20const recursiveDelete = (command: string) => {
21 const words = command.trim().split(/\s+/)
22 if (words.includes('find')) {
23 return 'Run the same find without -delete first to see what it would remove.'
24 }
25 const rm = words.indexOf('rm')
26 const targets = rm < 0 ? [] : words.slice(rm + 1).filter(word => !word.startsWith('-'))
27 if (targets.length === 0) {
28 return 'Look at what it would delete first; if the files are tracked, git rm -r keeps them in git history.'
29 }
30 const named = targets.join(' ')
31 const verb = targets.length === 1 ? 'is' : 'are'
32 return `If ${named} ${verb} tracked, git rm -r ${named} keeps it in git history; if not, move it aside instead of deleting it.`
33}
34
35const gitClean = (command: string) => {
36 let isDry = false
37 const words = wordsFrom(command, /^git$/).flatMap(word => {
38 const isForce = word === '--force' || (/^-[A-Za-z]+$/.test(word) && word.includes('f'))
39 if (!isForce) {
40 return [word]
41 }
42 const rest = word === '--force' ? '' : word.slice(1).replaceAll('f', '')
43 const flag = `${isDry ? '' : 'n'}${rest}`
44 isDry = true
45 return flag === '' ? [] : [`-${flag}`]
46 })
47 return `Dry-run it first: ${words.join(' ')} lists what would be deleted and deletes nothing.`
48}
49
50const SENTENCES: Record<string, (command: string) => string> = {
51 'force-push': () =>
52 'Use git push --force-with-lease instead: it refuses to overwrite commits you have not fetched.',
53 'terraform-destroy': terraform,
54 'recursive-delete': recursiveDelete,
55 'hard-reset': () => 'Run git stash first, so the changes a hard reset throws away are kept.',
56 'git-clean': gitClean,
57 'kubectl-delete': command =>
58 `Run ${wordsFrom(command, /^kubectl$/).join(' ')} --dry-run=client first: it shows what would be deleted and deletes nothing.`,
59 'drop-table': () =>
60 'Take a backup first (for example pg_dump -t <table> or mysqldump <database> <table>) and keep it until you are sure.',
61}
62
63/**
64 * The sentence for a conviction, or undefined when the charge has none.
65 *
66 * @param chargeId the charge's id (see `RULES` in risky.ts)
67 * @param command the charged simple command, as written
68 */
69export const sentenceFor = (chargeId: string, command: string): string | undefined =>
70 SENTENCES[chargeId]?.(command)
71hooks/settings.ts 67 lines1import type { PluginOptions } from 'claude-code'
2
3import { CHARGE_IDS } from './risky'
4
5/**
6 * How readily the judge convicts. It picks the judge's doctrine sentence
7 * and nothing else: the decision a ruling maps to never changes.
8 */
9export type Strictness = 'lenient' | 'fair' | 'hanging'
10
11/**
12 * The person's settings, from the manifest's `userConfig` as `register`
13 * receives them.
14 */
15export type Settings = {
16 strictness: Strictness
17 sounds: boolean
18 /**
19 * The ids of the charges that go to trial; undefined tries every charge.
20 */
21 charges: ReadonlySet<string> | undefined
22 /**
23 * What to tell the person about ids in `charges` the court does not know,
24 * or undefined when it knows them all.
25 */
26 warning: string | undefined
27}
28
29const STRICTNESSES: readonly Strictness[] = ['lenient', 'fair', 'hanging']
30
31const isStrictness = (value: unknown): value is Strictness => STRICTNESSES.some(one => one === value)
32
33/**
34 * The settings in `options`; a value missing or of the wrong type is the
35 * manifest's default, which the engine fills in before a load.
36 *
37 * @param options what `register(on, options)` received
38 */
39export const settingsOf = (options: PluginOptions | undefined): Settings => {
40 const { strictness, sounds, charges } = options ?? {}
41 return {
42 strictness: isStrictness(strictness) ? strictness : 'fair',
43 sounds: typeof sounds === 'boolean' ? sounds : true,
44 ...chargesOf(charges),
45 }
46}
47
48/**
49 * The charges switched on: the known ids listed. A list that names none the
50 * court knows is a typo, not a wish to switch the court off, so it tries
51 * every charge; only an empty list switches every charge off.
52 */
53const chargesOf = (charges: unknown): Pick<Settings, 'charges' | 'warning'> => {
54 if (!Array.isArray(charges)) {
55 return { charges: undefined, warning: undefined }
56 }
57 const ids = charges.map(id => String(id).trim())
58 const known = new Set(ids.filter(id => CHARGE_IDS.includes(id)))
59 const unknown = ids.filter(id => !known.has(id)).map(id => JSON.stringify(id)).join(', ')
60 if (unknown === '') {
61 return { charges: known, warning: undefined }
62 }
63 return known.size === 0
64 ? { charges: undefined, warning: `trial-run: the charges setting names no charge the court knows (${unknown}), so every charge goes to trial.` }
65 : { charges: known, warning: `trial-run: the charges setting names a charge the court does not know (${unknown}); it is left out.` }
66}
67hooks/stamp.ts 245 lines1/**
2 * ANSI Shadow letters, six rows: full blocks are the letter, the box-drawing
3 * edges its shadow. Every row of a letter is the same width.
4 */
5const SHADOW: Record<string, readonly string[]> = {
6 A: [' █████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
7 C: [' ██████╗', '██╔════╝', '██║ ', '██║ ', '╚██████╗', ' ╚═════╝'],
8 D: ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
9 E: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
10 G: [' ██████╗ ', '██╔════╝ ', '██║ ███╗', '██║ ██║', '╚██████╔╝', ' ╚═════╝ '],
11 I: ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
12 L: ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
13 M: ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
14 N: ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
15 O: [' ██████╗ ', '██╔═══██╗', '██║ ██║', '██║ ██║', '╚██████╔╝', ' ╚═════╝ '],
16 P: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
17 R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
18 S: ['███████╗', '██╔════╝', '███████╗', '╚════██║', '███████║', '╚══════╝'],
19 T: ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
20 U: ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚██████╔╝', ' ╚═════╝ '],
21 V: ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
22 W: ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
23 Y: ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
24 ' ': [' ', ' ', ' ', ' ', ' ', ' '],
25}
26
27/**
28 * Plain block letters, five rows, for a pane too narrow for the shadow ones.
29 */
30const COMPACT: Record<string, readonly string[]> = {
31 A: [' ███ ', '█ █', '█████', '█ █', '█ █'],
32 C: [' ████', '█ ', '█ ', '█ ', ' ████'],
33 D: ['████ ', '█ █', '█ █', '█ █', '████ '],
34 E: ['█████', '█ ', '████ ', '█ ', '█████'],
35 G: [' ████', '█ ', '█ ██', '█ █', ' ████'],
36 I: ['███', ' █ ', ' █ ', ' █ ', '███'],
37 L: ['█ ', '█ ', '█ ', '█ ', '█████'],
38 M: ['█ █', '██ ██', '█ █ █', '█ █', '█ █'],
39 N: ['█ █', '██ █', '█ █ █', '█ ██', '█ █'],
40 O: [' ███ ', '█ █', '█ █', '█ █', ' ███ '],
41 P: ['████ ', '█ █', '████ ', '█ ', '█ '],
42 R: ['████ ', '█ █', '████ ', '█ █ ', '█ █'],
43 S: [' ████', '█ ', ' ███ ', ' █', '████ '],
44 T: ['█████', ' █ ', ' █ ', ' █ ', ' █ '],
45 U: ['█ █', '█ █', '█ █', '█ █', ' ███ '],
46 V: ['█ █', '█ █', '█ █', ' █ █ ', ' █ '],
47 W: ['█ █', '█ █', '█ █ █', '██ ██', '█ █'],
48 Y: ['█ █', ' █ █ ', ' █ ', ' █ ', ' █ '],
49 ' ': [' ', ' ', ' ', ' ', ' '],
50}
51
52const FONTS = {
53 shadow: { letters: SHADOW, gap: '' },
54 compact: { letters: COMPACT, gap: ' ' },
55}
56
57/**
58 * A word set in block letters: its rows, the column each letter starts at
59 * (what the entrance reveals letter by letter), and its width.
60 */
61export type Stamp = { rows: string[]; starts: number[]; width: number }
62
63/**
64 * Sets a word in one of the fonts. A character with no letter is left out.
65 *
66 * @param word the word, in capitals
67 * @param font `shadow`, six rows, or `compact`, five
68 */
69export const stampOf = (word: string, font: keyof typeof FONTS): Stamp => {
70 const { letters, gap } = FONTS[font]
71 const glyphs = [...word].flatMap(char => {
72 const glyph = letters[char]
73 return glyph === undefined ? [] : [glyph]
74 })
75 const height = glyphs[0]?.length ?? 0
76 const rows = Array.from({ length: height }, (_, row) => glyphs.map(glyph => glyph[row] ?? '').join(gap))
77 const starts: number[] = []
78 let column = 0
79 for (const glyph of glyphs) {
80 starts.push(column)
81 column += [...(glyph[0] ?? '')].length + gap.length
82 }
83 return { rows, starts, width: [...(rows[0] ?? '')].length }
84}
85
86/**
87 * The fewest rows a pane must have before a verdict too wide for one line is
88 * stacked in shadow letters, twelve rows tall: the case and the speeches
89 * still need room beneath it.
90 */
91const STACK_ROWS = 28
92
93/**
94 * The verdict's stamp for a body this many columns wide: shadow letters
95 * where they fit; a two-word verdict stacked in shadow letters, a word to a
96 * line, where only that fits and the pane has the rows; compact letters
97 * where those fit; and none at all where even those would wrap (the
98 * headline still says the verdict).
99 */
100export const stampFor = (word: string, columns: number, rows = Number.POSITIVE_INFINITY): Stamp | null => {
101 const shadow = stampOf(word, 'shadow')
102 if (shadow.width <= columns) {
103 return shadow
104 }
105 const words = word.split(' ')
106 if (words.length === 2 && rows >= STACK_ROWS) {
107 const lines = words.map(one => stampOf(one, 'shadow'))
108 const width = Math.max(...lines.map(line => line.width))
109 if (width <= columns) {
110 return {
111 rows: lines.flatMap(line => line.rows.map(row => row + ' '.repeat(width - line.width))),
112 starts: lines[0]?.starts ?? [],
113 width,
114 }
115 }
116 }
117 const compact = stampOf(word, 'compact')
118 return compact.width <= columns ? compact : null
119}
120
121export type StampColors = { fill: string; edge: string; dim: string }
122
123/**
124 * One row of a stamp as coloured runs: blocks (dry or wet with ink) in the
125 * fill colour, the shadow in the edge colour, everything from `revealed` on
126 * drawn dim. A space takes the colour of the run it sits in, so runs stay few.
127 */
128export const segmentsOf = (row: string, revealed: number, colors: StampColors): { text: string; color: string }[] => {
129 const segments: { text: string; color: string }[] = []
130 ;[...row].forEach((char, column) => {
131 const last = segments.at(-1)
132 const color =
133 column >= revealed
134 ? colors.dim
135 : char === ' '
136 ? (last?.color ?? colors.fill)
137 : char === '█' || char === '▓'
138 ? colors.fill
139 : colors.edge
140 if (last?.color === color) {
141 last.text += char
142 } else {
143 segments.push({ text: char, color })
144 }
145 })
146 return segments
147}
148
149/**
150 * The scales of justice in box drawing: level, or tipped to one side, as
151 * the pans bob while the jury deliberates.
152 */
153export const SCALES: Record<'level' | 'left' | 'right', readonly string[]> = {
154 level: [
155 ' ━━━━━━━━━━╋━━━━━━━━━━ ',
156 ' │ ┃ │ ',
157 '╰───╯ ┃ ╰───╯',
158 ' ┃ ',
159 ' ━━━┻━━━ ',
160 ],
161 left: [
162 ' ━━━━━━━━━━╋━━━━━━━━━━ ',
163 ' │ ┃ │ ',
164 ' │ ┃ ╰───╯',
165 '╰───╯ ┃ ',
166 ' ━━━┻━━━ ',
167 ],
168 right: [
169 ' ━━━━━━━━━━╋━━━━━━━━━━ ',
170 ' │ ┃ │ ',
171 '╰───╯ ┃ │ ',
172 ' ┃ ╰───╯',
173 ' ━━━┻━━━ ',
174 ],
175}
176
177/**
178 * The stamp's landing, one frame a step: its vertical offset (negative is
179 * still above the pane, cut off at the top), whether it shakes one column,
180 * and whether its ink is still wet. The last frame is the stamp at rest.
181 */
182const LANDING: readonly { offset: number; shake: boolean; wet: boolean }[] = [
183 { offset: -4, shake: false, wet: false },
184 { offset: -2, shake: false, wet: false },
185 { offset: 1, shake: false, wet: false },
186 { offset: 0, shake: true, wet: true },
187 { offset: 0, shake: false, wet: true },
188 { offset: 0, shake: false, wet: false },
189]
190
191/**
192 * One frame of the stamp landing. Every frame is one row taller than the
193 * stamp, the row the overshoot drops into, so nothing beneath it moves.
194 *
195 * @param rows the stamp's rows
196 * @param step the frame, from 0; past the last, the stamp at rest
197 */
198export const landingFrameOf = (rows: readonly string[], step: number): { rows: string[]; isLast: boolean } => {
199 const index = Math.min(step, LANDING.length - 1)
200 const { offset, shake, wet } = LANDING[index] ?? { offset: 0, shake: false, wet: false }
201 const blank = ' '
202 const shifted = offset < 0 ? rows.slice(-offset) : [...Array<string>(offset).fill(blank), ...rows]
203 const height = rows.length + 1
204 const framed = [...shifted, ...Array<string>(height).fill(blank)].slice(0, height)
205 const drawn = framed.map(row => {
206 const inked = wet ? row.replaceAll('█', '▓') : row
207 return shake && row !== blank ? ` ${inked}` : inked
208 })
209 return { rows: drawn, isLast: index === LANDING.length - 1 }
210}
211
212/**
213 * The gavel for "ALL RISE.": held up, swung, and struck, in box drawing and
214 * blocks. Every pose is seven rows of the same width.
215 */
216export const GAVEL: Record<'raised' | 'swing' | 'struck', readonly string[]> = {
217 raised: [
218 ' ▄▄▄▄▄▄▄ ',
219 ' ███████ ',
220 ' ▀▀▀█▀▀▀ ',
221 ' ╲ ',
222 ' ╲ ',
223 ' ',
224 ' ━━━━━━━━━━━ ',
225 ],
226 swing: [
227 ' ',
228 ' ▄▄▄▄▄▄▄ ',
229 ' ███████━━━━ ',
230 ' ▀▀▀▀▀▀▀ ',
231 ' ',
232 ' ',
233 ' ━━━━━━━━━━━ ',
234 ],
235 struck: [
236 ' ',
237 ' ',
238 ' ',
239 ' * ▄▄▄▄▄▄▄ * ',
240 ' ███████━━━━ ',
241 ' * ▀▀▀▀▀▀▀ * ',
242 ' ━━━━━━━━━━━ ',
243 ],
244}
245