Automated multi-agent code review cycle. Spawns reviewers in parallel (plus Codex when available), applies fixes per CLAUDE.md policy, admits a commit only…

Automated multi-agent code review cycle for Claude Code, with a commit gate that admits a commit only when a reviewer saw exactly what it records, and a push or pull request only when you asked for one or set it to go that far.
After you implement changes, review-cycle fans out parallel reviewers, applies fixes per embedded policies, loops until a pass applies no fixes or reaches its round limit, and runs a final cleanup. Agents work as they like between commits; nothing prompts a review on every turn.
The gate asks one question of a commit and one of a push:
git reset, so the agent commits reviewed work without asking you, unless your stop-before setting is commit.fix/x from origin?"), "delete it" answers too. Any of these allows every push until your next message, not only the one you named. Opening a pull request with gh pr create works the same way: "open a PR", "create the pull request" and "ship it" ask for one, and asking for one also allows the push it needs, unless that push would change the default branch or push a tag. Your stop-before setting can let either run without asking. When neither allows it, the gate refuses the command and tells the agent to stop and ask you in its reply, naming what it would push and where ("Push fix/x to origin?"), with the command quoted and any shell aliases expanded. There is no dialog: you answer in your next message, so "yes" allows it, as does a short go-ahead ("ok, lets do that", "sounds good, go ahead"), and anything else, such as "not yet, rename the helper first", is simply your next request. If the command also commits, the refusal says to run the commit on its own. A force push has to be asked for by name: "force push it" (or "yes" to the agent's "Force-push fix/x with a lease?") allows --force-with-lease and --force-if-includes. A bare --force, -f, +refspec or --mirror overwrites whatever the remote holds, so the gate points the agent to --force-with-lease --force-if-includes instead, and allows a bare force only when the request itself names it ("force push it without a lease", "bare force push", "push it with --force"). A bare force mentioned elsewhere in the message, or in the agent's question, grants nothing. Per git's documentation, --force-with-lease alone checks against your remote-tracking ref, which a background fetch can move; --force-if-includes closes that gap. Question dialogs, the agent's own or another plugin's, never grant anything. Messages from other sessions, background-task notifications and subagents never count, and a -p run, with no one to answer, stays refused.Both answers come from what the gate watched in this session: which reviewers the engine spawned, what the working tree held when each started and finished, and which prompts you typed. None of it is a file, so there is nothing to mark, accept, or forge.
Implement changes (no gate while you work)
↓
/review-cycle:review (you ask, or the agent runs it before committing)
↓
┌────┴─────┐
↓ ↓
Codex review-cycle reviewers
(if (parallel subagents; the gate records
installed) the tree each one saw; spec conformance
joins the first round)
└────┬─────┘
↓
Scope wrong for the spec? → stop and ask you
↓
Aggregate findings → apply fixes
↓
Loop until a pass applies no fixes
(at the round limit: hold the last findings, ask you)
↓
Post-loop pass, once: maintainability
(report-only) + cleanup
↓
Coverage check (confirmation pass if cleanup changed anything)
↓
git commit ── the gate checks: every path reviewed?
/review-cycle:initOne-time setup helper. Run after installing the plugin to:
multi_agent = true in ~/.codex/config.toml, and report stored-login state (advisory — auth doesn't gate the leg)git is present and that the commit gate loaded (see Requirements)CLAUDE.mdIdempotent — safe to run multiple times. Replaces the manual setup steps below.
/review-cycle:reviewThe one command for the whole cycle. Fans out reviewers, checks the change against its spec in the first round and stops for your decision when the scope is wrong, auto-applies the safe fixes, loops until a pass applies none, names any file the fixes touched in every round, surfaces structural suggestions for you to act on, runs a final de-slopify pass, and checks that every changed path is covered. When the result is clean, it commits it unless you held off; it pushes only when you asked or your stop-before setting allows it.
The apparatus scales to the diff: light diffs (docs-only, or ~25 changed lines or fewer of anything) get the code reviewer alone, plus spec conformance in the first round when a spec exists; everything else gets the full conditional fan-out. The loop ends on a pass that applies no fixes, since every fix is content no reviewer has seen; a ceiling (2 light, 3 full) stops a cycle that will not converge. The last round applies none of its fixes: it lists them and asks whether to apply them, with one more review of the fixes, or commit with them deferred, which the gate admits because the round that found them covered every path. The Codex leg joins either tier when it's available. Cleanup is a separate, size-only decision — inline under ~150 changed lines, the cleanup agent above that, whatever the tier. Iterations whose fixes were mechanical, or message-only fixes the agent reproduced and verified, get a narrow confirmation pass — the code reviewer alone, on the fixes' delta — instead of a full fan-out; claims local verification can't reach (another OS or shell, a remote service) add the Codex leg to that pass at reduced effort. A reviewer that stalls is nudged once, then dropped and named in the summary rather than holding the cycle hostage. One that keeps working past 30 minutes is capped: the gate keeps each reviewer's clock and asks the session to stop it, and the summary lists it apart from the stalled ones. The budget clears the slowest normal reviewers measured (a 16-minute median for a fan-out's slowest leg, 25 minutes for the slowest reviewer type) and cuts off the 60–70 minute runaways. A plugin reload cancels the clocks of reviewers already running. Every reviewer works in its own directory inside one scratch directory per cycle, which the cycle sweeps at the end: it ends any process still running there, including one a stalled or dropped reviewer left behind, and removes the directory. Every reviewer opens its report with a two-line receipt — the heaviest verification that succeeded, then every verification that did not succeed plus any project check it never attempted — and the summary grades each leg from both lines as executed, partial with the part it couldn't reach, static-analysis-only, or unknown when a line is missing. A leg that could not run the project's checks keeps the findings it saw by reading; claims about an external tool's behavior drawn only from a manifest or config become questions instead of fixes, wherever they come from — the label says whether the leg could have checked, not whether the rule applies. A leg that omitted the receipt is labelled but not demoted for that alone, unless something else in its report shows it could not run the checks — a formatting miss should not cost you a finding, but omitting one should not beat admitting it.
The gate remembers what each reviewer saw for the rest of the session, and the next cycle scopes itself to what changed since: a 20-line follow-up to a converged review gets a small review at the delta's tier, not a full re-run. A review from an earlier session does not count; uncommitted work carried across a restart is reviewed again.
Arguments are natural language — no flags:
/review-cycle:review — review the uncommitted working treeagainst <ref> (e.g. against main) — scope to git diff <ref>..HEADmax <n> — override the iteration ceilingeffort <level> — pin the Codex leg's reasoning effort (none, minimal, low, medium, high, xhigh, max); overrides both the tier cap and your config, raising included/review-cycle:review-prSingle-pass, report-only review of a GitHub pull request, run from your machine. Takes a PR number, URL, or branch (bare invocation reviews the current branch's PR). It fetches the PR head into a disposable detached worktree — your checkout, branch, and index are never touched. The fan-out matches the review cycle's, with the intent brief sourced from the PR's title, body, and commits; on the full tier the report-only pair joins the same pass, since a single pass has no fix loop to shield them from. Findings are reported in the conversation with per-reviewer coverage, so "no findings" is never mistaken for "nobody looked". The Codex leg joins when the CLI is installed, briefed and scoped with --base against the PR's base branch; effort <level> pins its reasoning effort the same way it does in /review-cycle:review.
Nothing is fixed and nothing is posted by default. Say and post (or ask after reading the report) to publish the findings as a single COMMENT review — never an approval — with fingerprint-marked comments, inline and body-level alike, that deduplicate across re-runs. Its reviewers never count toward your own changes: the gate sees the skill start, and the legs it spawns review the PR, not your working tree.
/review-cycle:missesLists the gate's refusals of a push, pull request, merge or release that your latest message, or the offer it replied to, named by its verb, and drafts each as a candidate row for the consent grammar's spec. A refusal you did not expect is how the grammar's misses are found; this keeps them as they happen, instead of from a pasted transcript. A refusal counts only when the step refused is the one named; an offer counts only when your message is a yes or a go-ahead and nothing more. The gate keeps up to 20 in memory until Claude Code restarts, served by the mcp__review-cycle__misses tool; nothing leaves your machine. You decide which rows were requests.
/review-cycle:de-slopifyBundled de-slopify skill — methodology for removing AI writing artifacts from prose, maintained here as part of the plugin (originally imported from oakoss/agent-skills, which no longer carries the canonical copy). The cleanup subagent preloads this skill, so the cycle uses it automatically. Invokable directly for ad-hoc cleanup of prose outside the cycle. Aligned with the standalone prose plugin's rules, so cycle cleanup and the always-on style apply the same standard.
Derived from Anthropic's pr-review-toolkit and modified by Oak OSS (Apache 2.0; see LICENSE-pr-review-toolkit):
review-cycle:code-reviewer — general quality + CLAUDE.md compliancereview-cycle:silent-failure-hunter — error handling, swallowed errorsreview-cycle:type-design-analyzer — type invariants, encapsulationreview-cycle:pr-test-analyzer — test coverage gapsCHANGELOG.md records the changes. The toolkit's code-simplifier is left out because it competes with the cycle's own fix application, and cleanup replaces its comment-analyzer.
New (this plugin):
review-cycle:cleanup — comment policy + de-slopify in one pass, correcting prose whose claims a run contradictsreview-cycle:maintainability-auditor — ambitious structural lens (code-judo moves, file-size sprawl, spaghetti branches, weak seams). Runs in review on substantial-code diffs, report-only — its speculative restructurings are surfaced for you to action, never auto-applied.review-cycle:spec-conformance-analyzer — spec axis: does the diff implement what the originating issue/task/PRD asked for? Runs in the first round of review when a spec source is discoverable, so scope creep against a current spec stops the cycle before any review fix, while a missing or contradicted spec line is fixed like any defect; reported separately from quality findings.The gate is there for a well-meaning agent that commits before a review, or pushes before you asked. It checks the commands agents write, such as git commit, git add … && git commit, a push, or an alias for one, before they run, and refuses one that names a commit or push in a shape it cannot read rather than guess. A command that commits from inside something the gate cannot see into, such as npm version, make release or a project script, is not checked beforehand: a commit it makes without a review, or a push you did not ask for, is reported after the command runs, so the agent tells you, but it is not refused. If the gate cannot read your shell aliases, an aliased commit is not checked, and the first command the gate lets through unchecked carries a note saying so.
It is not a sandbox. An agent set on getting around it can: an obscure shell construct or an environment trick can run git where the gate does not look, and your own shell (!) is never stopped. For containment against an adversarial agent, use an OS-level sandbox; the gate's job is to make the careless path fail loudly, not to make evasion impossible. A gap an ordinary command falls into is a bug; one that needs a deliberately obscure command is out of scope.
The gate is a hooks module (hooks/register.ts), not a shell script: it keeps what it observes in memory for the session, where no tool the agent holds can reach. It watches:
review-cycle:* reviewer spawned from the main session counts once it reports with its two-line receipt. The gate captures the working tree as the reviewer starts and again as it finishes; the review covers a path only if the path was part of the change it was shown and held the same content at both moments. A review that ran but could not be counted is named in the next refusal and in the status tool. review-cycle:cleanup edits code and never counts, and reviewers /review-cycle:review-pr spawns review a PR, not your tree.mktemp -d directory under /tmp. While it runs, the gate refuses its Edit, Write and NotebookEdit calls on a path inside the repository. Its Bash commands are not refused, since the gate cannot tell where a command writes. Instead the gate compares four things before and after each one: HEAD's commit and branch, the staged entries, the working tree, and the local and worktree git config. The reads leave the index, refs and config alone; the working-tree capture adds unreferenced objects to .git/objects, which git gc removes. When something changed, the gate tells the reviewer it may have been another agent, asks it to put back a change it made and to report it either way, and lists the change in the status tool under reviewerChanges. A part the gate could not read is noted there too. Some things are not checked:.git/ beyond HEAD and the config, such as branches, the stash or hooks;node_modules, which never reach a commit;Each comparison costs about two working-tree captures per reviewer command; one measured 48 ms on a 123-file repository.
cd <dir>, any git add …, read-only git commands and steps that commit nothing (fetch, branch, tag, remote, a fast-forward with pull --ff-only when no git commit follows, a statement that only assigns variables with no substitution or redirect; checkout, switch, stash and worktree only when no git commit follows, since they can change what it records), then one git commit … (or one command that makes commits from history: merge, cherry-pick, revert, pull, rebase) and optionally one git push …. A git add is joined to what follows with &&, so a failed add stops the commit; other steps may also use ; or newlines. The whole command may end in a pipe into tail with at most one line or byte count (nonzero, or +N to start from line N), or wc with its counting flags, as in git push 2>&1 | tail -3: those read all of git's output and run nothing. The filter takes no file, redirect or substitution, and the pipeline is not backgrounded with &. Other readers such as head, grep or cat file may stop reading early, which can kill a pre-commit hook still printing, so they are refused. Anything else that commits or pushes — a pipeline, a subshell, a substitution, bash -c, eval, a wrapper like timeout or xargs, a git alias for commit (aliases of aliases included, and one defined in the same command), commit <paths>, an abbreviated long option (--ame), GIT_INDEX_FILE=, a GIT_EDITOR/GIT_PAGER that is more than a program name, and git's commit-writing plumbing (commit-tree, fast-import, send-pack, subtree push) — is refused with the shape to use instead. Your shell's aliases are expanded as bash expands them, so gcam "msg" is judged exactly like git commit -s -a -m "msg", and an alias of several commands is judged like the commands it stands for. An alias for git itself is expanded too: git='git --no-pager' is judged as git with that option, but a command whose expansion no longer runs git as the gate reads it (git='hub', or git wrapped in a runner the gate refuses) is refused, with \git … to run git as itself. A command name or git subcommand the shell would expand as a pattern is refused: *, ?, a closed […] or {…}, zsh's #, ^ and inner ~, and a ( inside a word (/usr/bin/g(i)t, @(git)). The gate reads your aliases when the session starts, the way Claude Code builds its shell snapshot: CLAUDE_CODE_SHELL, else $SHELL, whichever names zsh or bash, else zsh, run as a login shell that sources ~/.zshrc or ~/.bashrc with no input and with CLAUDECODE=1 set, then lists them with the same pipeline the snapshot uses. Claude Code writes its own snapshot only once the session's first command has run, so this read is what covers that first command. If the read fails, takes more than ten seconds, or the rc file never gets to the end of it, the gate falls back to the snapshot (under ~/.claude/shell-snapshots/); if neither can be read, the first command the gate lets through without a check carries a note saying so. Text naming a git commit or push is data only where nothing will run it: an argument to grep, printf or gh, a quoted heredoc into cat. Handed to anything that runs code — a shell, an interpreter like python3, find -exec, a shell reading a pipe or heredoc — it is refused. Commands that make commits from history need neither a review nor your go-ahead, and are not reported afterwards: what they record comes from existing commits, not from the working tree. Their --continue forms record a conflict resolution, which is new content, so the gate refuses them, as it does git am, which applies patches from outside the repository.For an accepted commit, the gate replays the command's git adds against a scratch copy of the index, so it judges exactly the tree the commit would record, then compares each path with the trees reviewers saw. A refusal names every uncovered path as edited after the last review or never reviewed. The real index is never touched, and a refused git add … && git commit … runs neither half.
When a turn you started ends having changed the working tree, and some of what changed has not been seen by any reviewer, the gate sends the agent one prompt telling it to run /review-cycle:review and report back, so it does not stop to ask whether to. The gate sends at most one per message of yours. It sends none while a reviewer is still running, none after an interrupted turn, none once a review has been started, and none for unreviewed work left over from before your message. The agent skips the review when your message said not to review, or when it ended the turn on a question you need to answer first.
Scripts and shell functions are opaque: ./release.sh runs whatever it contains. So after every Bash call, the gate reads what the call added to HEAD's reflog. A commit that slipped past the check — a script that commits, or a pre-commit hook that rewrote a file after the check — is reported back to the agent, which is told to tell you. An entry counts as a commit unless git's own action says it only moved HEAD: a checkout, a reset, a fetch, a fast-forward, a rebase starting or finishing, gh pr merge pulling the merged branch. So a commit, amend, merge commit, cherry-pick, revert or rebase pick counts, and so does an entry git gives no known action, which errs toward a report. That includes a commit made on another branch before HEAD came back. Each unbroken run of new commits is judged from where HEAD stood before it, so a rebase counts its own changes and not the upstream it rebased onto, and a commit on a side branch is judged apart from one on this branch. Not seen: a commit built with plumbing and reached by reset, which logs only the reset; a commit made in another worktree, which has its own HEAD; in a repository that keeps no HEAD reflog (core.logAllRefUpdates off from the start), a commit after which HEAD returns to where it started; and a commit whose reflog entries the same command deleted. The gate says it could not check when HEAD moved and its reflog does not show how, the reflog was expired or rewritten, one command added more than 200 entries, or HEAD is unborn in a reftable repository. A push is seen by the remote-tracking ref it moves or creates, which git logs as update by push: one the gate did not check is reported when your latest message did not ask for a push. Not seen: a push to a URL or a remote with no tracking ref, one that deletes a remote branch, one undone before the command ends, one whose reflog git did not keep, and one buried under more than 50 later moves of the same ref in one command.
Subagents never commit or push in the project, and neither does anything run from another worktree of the same repository. Commits in other repositories (a scratch fixture, git -C /tmp/…) are not the gate's business. When the gate cannot finish judging a command that may commit or push — git failed, or a target directory does not resolve — it refuses rather than letting it through.
The mcp__review-cycle__status tool reports the gate's view: the wo
hooks/register.ts 1850 lines1import type {
2 Caught,
3 CatchHandler,
4 EngineInterface,
5 HookFor,
6 MatchedHook,
7 PromptOrigin,
8 Register,
9 Timer,
10 ToolCallResult,
11} from 'claude-code';
12
13import { added, madeBy, MARK, type Entry } from './attribution';
14import {
15 aliasCommits,
16 classify,
17 ghActions,
18 possibleAliases,
19 shownCommand,
20 type Classification,
21} from './command';
22import {
23 covers,
24 grantOf,
25 holdOf,
26 liftsHold,
27 NO_GRANT,
28 isReply,
29 stepsNamed,
30 type Grant,
31 type HoldStep,
32} from './consent';
33import { containmentReport, insideRepo, repoStateOf, UNREAD, type Capture } from './containment';
34import { editsSkipped, mayWrite, measureEdits } from './edits';
35import {
36 askThem,
37 judgeGh,
38 notAsked,
39 parsePullRequest,
40 permitted,
41 PR_FIELDS,
42 PR_JQ,
43 type GhLookups,
44 type Hold,
45 type InForce,
46 type Lookup,
47 type Unasked as GhUnasked,
48} from './gh-verdict';
49import {
50 coverageOf,
51 firstLine,
52 headOf,
53 parentTree,
54 prospectTree,
55 headLog,
56 headLogCount,
57 messageOf,
58 pushedRefs,
59 remoteRefs,
60 repoAt,
61 reviewablePaths,
62 snapshotOf,
63 treeOf,
64 worktreeTree,
65 type Coverage,
66 type Git,
67 type Refs,
68 type Repo,
69 type Run,
70} from './git';
71import type { PushSpec } from './git-args';
72import { MCP_GITHUB, mcpAction, shownCall } from './github';
73import {
74 configured,
75 STRICTEST,
76 effective,
77 stopBeforeOf,
78 where,
79 type Step,
80 type StopBefore,
81} from './ladder';
82import {
83 KINDS,
84 MAX_LEDGER_BYTES,
85 MAX_REPOS,
86 MAX_TEXT,
87 parseRecord,
88 type Recording,
89} from './ledger';
90import { blobsAt, readLedger, recordInto, type Store } from './ledger-store';
91import { asksUser, nudgeOf, nudgeOn } from './nudge';
92import {
93 askingReason,
94 parseDryRun,
95 pushOutcome,
96 unasked,
97 type DefaultBranch,
98 type Needed,
99} from './push-verdict';
100import { applyEdit, bashTouchesGate, isJsonPath, touchesGate } from './settings';
101import { aliasScript, aliasShell, parseShellAliases, readAliases } from './shell';
102import { MAX_BYTES, skipsPath, slopDirective, slopFindings, type Written } from './slop';
103import { sweep } from './sweep';
104import {
105 EMPTY_TREE,
106 describeUncovered,
107 hasReceipt,
108 isReviewerType,
109 uncoveredOf,
110 type Review,
111 type Row,
112} from './witness';
113
114// Every function that calls `$` lives in this file: `claude plugin validate`
115// follows `$` only into functions declared beside the hook, never across an
116// import. The other modules are pure.
117//
118// The gate watches Bash with a function hook, not the classic PreToolUse
119// bridge. While any `tool.call { tool: "Bash" }` hook is loaded, Bash inside an
120// `isolation: "worktree"` subagent is refused (anthropics/claude-code#92533);
121// the bridge avoids that at the cost of a stub script, should it matter.
122
123type $ = EngineInterface;
124type BashHook = MatchedHook<'tool.call', { tool: 'Bash' }>;
125type GithubHook = MatchedHook<'tool.call', { tool: RegExp }>;
126type SkillHook = MatchedHook<'tool.call', { tool: 'Skill' }>;
127type StatusHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__status' }>;
128type LedgerHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__ledger' }>;
129type RecordHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__ledger_record' }>;
130type ScratchHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__scratch' }>;
131type SweepHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__sweep' }>;
132type ConfigHook = MatchedHook<
133 'config.set',
134 { key: 'review-cycle.enabled' | 'review-cycle.stopBefore' | 'review-cycle.nudge' }
135>;
136type EditHook = MatchedHook<'tool.call', { tool: 'Edit' }>;
137type WriteHook = MatchedHook<'tool.call', { tool: 'Write' }>;
138type NotebookHook = MatchedHook<'tool.call', { tool: 'NotebookEdit' }>;
139type MonitorHook = MatchedHook<'tool.call', { tool: 'Monitor' }>;
140type Input<H> = H extends ($: never, e: infer E, next: never) => unknown ? E : never;
141type NextOf<H> = H extends ($: never, e: never, next: infer N) => unknown ? N : never;
142type Output<H> = H extends (...args: never[]) => infer R ? Awaited<R> : never;
143
144const HUMAN = new Set<PromptOrigin['kind']>(['composer', 'bridge', 'sdk']);
145
146// `spawnTree` is null when the tree could not be read as the leg started; such
147// a leg reviews nothing, but it is still under way until `done`.
148type Leg = {
149 type: string;
150 counts: boolean;
151 spawnTree: string | null;
152 done: boolean;
153 // Fires once the leg outlives its budget; cancelled when it finishes.
154 timer: Timer | null;
155 capped: boolean;
156};
157
158// Measured 2026-09-19 over 138 legs: a fan-out's slowest leg had a median of
159// 16 minutes and the slowest normal leg type 25; runaways ran 60 to 70.
160const LEG_BUDGET_MS = 30 * 60_000;
161
162// What the user's latest message settled: a fresh message replaces it, a
163// prompt queued into the running turn updates it.
164type Message = {
165 grant: Grant;
166 // The working tree when it arrived; null when unreadable.
167 tree: string | null;
168 // Whether the agent was told to review since it arrived.
169 nudged: boolean;
170 // Legs spawned while review-pr runs review a PR worktree, not this tree.
171 prWindow: boolean;
172};
173
174type Miss = { message: string; offer: string; steps: HoldStep[]; refusal: string };
175
176type GateState = {
177 // undefined until resolved; null when the session is not in a repository.
178 root: Repo | null | undefined;
179 reviews: Review[];
180 legs: Map<string, Leg>;
181 message: Message;
182 // How many messages the user has typed, queued ones included.
183 messages: number;
184 lastAnswer: string;
185 // The user's latest message, and the answer it replied to.
186 said: { text: string; before: string };
187 // Refusals of a step the user's message or the offer it answered named:
188 // candidate consent-grammar misses, kept in this session only.
189 misses: Miss[];
190 // The user's shell aliases, which the Bash tool expands.
191 shellAliases: Map<string, string>;
192 aliasError: string | null;
193 // Whether the agent has been told the aliases could not be read.
194 aliasErrorShown: boolean;
195 aliasesLoaded: boolean;
196 // The session-start read of the user's aliases; its error, or null.
197 ownRead: Promise<string | null> | undefined;
198 // Reviews that ran but could not be recorded, and why.
199 dropped: string[];
200 // Legs that outlived their budget this cycle, which the session was asked to stop.
201 capped: string[];
202 // Scratch directories made for review cycles and not yet swept.
203 scratch: string[];
204 // Whether the sweep tool registered; scratch makes nothing without it.
205 sweepServed: boolean;
206 // How many of `dropped` predate the latest recorded review.
207 droppedSince: number;
208 // What reviewers' commands changed in the repository under review.
209 reviewerChanges: string[];
210 // False when the user switched the gate off; the ledger is still served.
211 gateOn: boolean;
212 // From user or managed settings, which alone reach the register options.
213 stopBefore: StopBefore | null;
214 // The user's /config value for the end-of-turn review nudge.
215 nudge: boolean;
216 // "don't push yet" makes every step ask until a message asks for one.
217 held: Hold;
218};
219
220const state: GateState = {
221 root: undefined,
222 reviews: [],
223 legs: new Map(),
224 // No message yet, so nothing to nudge about.
225 message: { grant: NO_GRANT, tree: null, nudged: true, prWindow: false },
226 messages: 0,
227 lastAnswer: '',
228 said: { text: '', before: '' },
229 misses: [],
230 shellAliases: new Map(),
231 aliasError: null,
232 aliasErrorShown: false,
233 aliasesLoaded: false,
234 ownRead: undefined,
235 dropped: [],
236 capped: [],
237 scratch: [],
238 sweepServed: false,
239 droppedSince: 0,
240 reviewerChanges: [],
241 gateOn: true,
242 stopBefore: null,
243 nudge: true,
244 held: null,
245};
246
247// Without `cwd`, $.process.run runs in the Bash tool's current directory,
248// which is what a command's own relative paths resolve against.
249async function run(
250 $: $,
251 argv: string[],
252 opts: { cwd?: string; env?: Record<string, string>; stdin?: string } = {},
253): Promise<Run> {
254 // git's messages are matched in English, so its locale is pinned.
255 const env = { LC_ALL: 'C', LANGUAGE: 'C', ...opts.env };
256 return $.process.run(argv, { timeoutMs: 60_000, ...opts, env });
257}
258
259// The runner git.ts's reads take, bound to this hook's `$`.
260function gitOf($: $): Git {
261 return (argv, opts) => run($, argv, opts);
262}
263
264// Why uncovered content is uncovered, including what the gate itself lost.
265function explain(c: Coverage): string {
266 const parts = [describeUncovered(uncoveredOf(c.rows))];
267 if (c.unread > 0) parts.push(`${c.unread} reviewed tree(s) could not be compared: git failed`);
268 const dropped = state.dropped.slice(state.droppedSince).slice(-3);
269 if (dropped.length > 0) parts.push(`reviews not counted: ${dropped.join('; ')}`);
270 return parts.join('; ');
271}
272
273// Claude Code writes its shell snapshot only once the session's first Bash
274// command runs, after this hook, so the gate reads the aliases itself, the
275// way the snapshot does, when the session starts. Once per module load: an rc
276// file that hangs costs the timeout once, then the snapshot is read instead.
277async function readOwnAliases($: $): Promise<string | null> {
278 try {
279 const tag = Math.random().toString(36).slice(2);
280 const home = await $.env.get('HOME');
281 if (!home) return 'HOME is not set';
282 const shell = aliasShell(await $.env.get('CLAUDE_CODE_SHELL'), await $.env.get('SHELL'));
283 const rc = `${home}/${shell.rc}`;
284 const script = aliasScript((await $.fs.exists(rc)) ? rc : null, tag);
285 // The environment and time limit Claude Code gives its own snapshot: an rc
286 // file can branch on them.
287 const env = { CLAUDECODE: '1', SHELL: shell.path, GIT_EDITOR: 'true' };
288 const argv = [shell.path, '-c', '-l', script];
289 const r = await $.process.run(argv, { stdin: '', timeoutMs: 10_000, env });
290 if (r.exitCode !== 0) {
291 return `the shell exited ${r.exitCode}: ${firstLine(r.stderr) || 'no output'}`;
292 }
293 const aliases = readAliases(r.stdout, tag);
294 if (aliases === null) return 'the shell stopped before printing its aliases';
295 state.shellAliases = aliases;
296 state.aliasesLoaded = true;
297 return null;
298 } catch (error) {
299 return error instanceof Error ? error.message : String(error);
300 }
301}
302
303// Failing both reads leaves the aliases unknown, not empty: the snapshot is
304// retried on the next Bash call, and the first unchecked command says so.
305async function loadShellAliases($: $): Promise<void> {
306 if (state.aliasesLoaded) return;
307 state.ownRead ??= readOwnAliases($);
308 const own = await state.ownRead;
309 if (state.aliasesLoaded) return;
310 try {
311 const home = await $.env.get('HOME');
312 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : null);
313 if (!config) throw new Error('neither CLAUDE_CONFIG_DIR nor HOME is set');
314 const shell = basenameOf((await $.env.get('SHELL')) ?? '');
315 const dir = `${config}/shell-snapshots`;
316 const newest = newestSnapshot(await $.fs.list(dir), shell);
317 if (newest === null) throw new Error(`no shell snapshot in ${dir} yet`);
318 state.shellAliases = parseShellAliases(await $.fs.read(`${dir}/${newest}`), true);
319 state.aliasError = null;
320 state.aliasesLoaded = true;
321 } catch (error) {
322 // Another Bash call's read may have succeeded while this one waited.
323 if (state.aliasesLoaded) return;
324 const snapshot = error instanceof Error ? error.message : String(error);
325 state.aliasError = `reading them from the shell failed (${own}); ${snapshot}`;
326 }
327}
328
329function basenameOf(p: string): string {
330 return p.slice(p.lastIndexOf('/') + 1);
331}
332
333// Snapshot files are named snapshot-<shell>-<milliseconds>-<id>.sh.
334function newestSnapshot(
335 entries: readonly { name: string; kind: string }[],
336 shell: string,
337): string | null {
338 let best: { name: string; at: number; same: boolean } | null = null;
339 for (const e of entries) {
340 const m = /^snapshot-([\w-]+?)-(\d+)-[\w-]+\.sh$/.exec(e.name);
341 if (e.kind !== 'file' || !m) continue;
342 const candidate = { name: e.name, at: Number(m[2]), same: m[1] === shell };
343 if (
344 best === null ||
345 (candidate.same && !best.same) ||
346 (candidate.same === best.same && candidate.at > best.at)
347 ) {
348 best = candidate;
349 }
350 }
351 return best?.name ?? null;
352}
353
354async function ensureRoot($: $): Promise<Repo | null> {
355 if (state.root === undefined) {
356 const repo = await repoAt(gitOf($), null, await $.session.cwd());
357 state.root = repo === 'none' ? null : repo;
358 }
359 return state.root;
360}
361
362function deny(reason: string): { deny: string } {
363 if (reason.includes(ASKS_THEM)) noteMiss(reason);
364 return { deny: `review-cycle: ${reason}` };
365}
366
367// What every refusal asking the user to decide a step says.
368const ASKS_THEM = 'stop and ask them in your reply';
369const MAX_MISSES = 20;
370const MISS_TEXT = 300;
371const clip = (s: string) => s.slice(0, MISS_TEXT);
372
373// The question the answer ended on, which a reply like "yes" answers.
374// The answer's last three lines, which the grammar reads an offer from.
375function closingOf(answer: string): string {
376 return answer.trim().split('\n').filter(Boolean).slice(-3).join('\n');
377}
378
379// The question those lines end on. Sentences split at a mark before a space,
380// so a version in backticks ("Release `v0.25.0`?") stays whole.
381function questionOf(closing: string): string {
382 const sentences = closing.split(/(?<=[.!?])\s+/);
383 return sentences.findLast((s) => s.trim().endsWith('?'))?.trim() ?? '';
384}
385
386// The step a refusal is about, from how notAsked and the merge check word it:
387// "doesn't ask for a merge", "the merge may be a release". Commits and
388// approvals are not consent the grammar misses.
389function refusedStep(refusal: string): HoldStep | null {
390 if (/\bmay be a release\b/.test(refusal)) return 'release';
391 const named =
392 /\bask(?:ed)? for (a (?:force |bare )?push|a bare force|a pull request|a merge|a release)\b/.exec(
393 refusal,
394 )?.[1];
395 if (named === undefined) return null;
396 if (named.endsWith('push') || named === 'a bare force') return 'push';
397 return named === 'a pull request' ? 'pr' : named === 'a merge' ? 'merge' : 'release';
398}
399
400function grants(grant: Grant, step: HoldStep): boolean {
401 if (step === 'push') return covers(grant, 'push');
402 if (step === 'merge') return grant.merge || grant.autoMerge;
403 // A merge the user asked for covers the version pull request too.
404 if (step === 'release') return grant.release || grant.merge;
405 return grant.pr;
406}
407
408function noteMiss(refusal: string): void {
409 const step = refusedStep(refusal);
410 if (step === null) return;
411 const { text, before } = state.said;
412 // The whole closing is kept, so a spec row drafted from it reads as the gate did.
413 const offer = isReply(text) ? closingOf(before) : '';
414 const steps = [...new Set([...stepsNamed(text), ...stepsNamed(questionOf(offer))])];
415 // Merging the version pull request is a release, so naming the merge counts.
416 const named = steps.includes(step) || (step === 'release' && steps.includes('merge'));
417 // A message queued while the gate judged may grant what an older one did not.
418 if (!named || grants(grantOf(text, before), step)) return;
419 const miss = { message: clip(text), offer: clip(offer), steps, refusal: clip(refusal) };
420 const same = (m: Miss) => m.message === miss.message && m.refusal === miss.refusal;
421 state.misses = [...state.misses.filter((m) => !same(m)), miss].slice(-MAX_MISSES);
422}
423
424async function onSessionStart(
425 $: $,
426 e: Input<HookFor<'session.start'>>,
427 next: NextOf<HookFor<'session.start'>>,
428): Promise<Output<HookFor<'session.start'>>> {
429 await registerLedger($);
430 await registerScratch($);
431 if (!state.gateOn) return next(e);
432 try {
433 await ensureRoot($);
434 } catch {
435 // Resolved again on first use; a gated command refuses if it still fails.
436 }
437 // Not awaited: the first Bash call waits on it instead of the session start.
438 state.ownRead ??= readOwnAliases($);
439 try {
440 await $.tool.register({
441 name: 'status',
442 description:
443 "review-cycle's view of the working tree: which changed paths a reviewer has seen, which were edited after the last review or never reviewed, and whether a push or a pull request may run now: asked for in the user's latest message, or below their stop-before setting. Read-only.",
444 inputSchema: { type: 'object', properties: {} },
445 });
446 } catch {
447 // The gate works without its status tool; the skill notes when it is missing.
448 }
449 try {
450 await $.tool.register({
451 name: 'misses',
452 description:
453 "The gate's refusals of a push, pull request, merge or release that the user's latest message, or the offer it answered, named: candidate rows for the consent grammar's spec. Each has the message, the offer (the answer's last lines, when the message replied to it), the steps named and the refusal. Read-only; kept in memory until Claude Code restarts.",
454 inputSchema: { type: 'object', properties: {} },
455 });
456 } catch {
457 // The misses skill says when the tool is missing.
458 }
459 return next(e);
460}
461
462async function onPromptSubmit(
463 $: $,
464 e: Input<HookFor<'prompt.submit'>>,
465 next: NextOf<HookFor<'prompt.submit'>>,
466): Promise<Output<HookFor<'prompt.submit'>>> {
467 if (HUMAN.has(e.origin.kind)) {
468 state.messages++;
469 // What a held message asks for still runs: "push it; don't open a PR yet".
470 state.said = { text: e.text, before: state.lastAnswer };
471 const grant = grantOf(e.text, state.lastAnswer);
472 const hold = holdOf(e.text, state.lastAnswer);
473 if (hold !== null) state.held = hold;
474 else if (liftsHold(grant)) state.held = null;
475 // A prompt queued into a running turn neither ends a review-pr run nor
476 // replaces that turn's starting tree.
477 if (e.turnId !== undefined) {
478 // Updated in place: a pending nudge's rollback holds this record.
479 state.message.grant = grant;
480 state.message.nudged = false;
481 } else {
482 let tree: string | null = null;
483 try {
484 const root = await ensureRoot($);
485 if (root !== null) tree = await worktreeTree(gitOf($), root.top);
486 } catch {
487 // No starting tree means no nudge this message; the commit gate still holds.
488 }
489 state.message = { grant, tree, nudged: false, prWindow: false };
490 }
491 }
492 return next(e);
493}
494
495// The nudge as the settings set it. A file that cannot be read sets nothing,
496// so the others decide: the nudge only prompts a review.
497async function nudgeWanted($: $): Promise<boolean> {
498 const set: Partial<Record<'project' | 'local', boolean | null>> = {};
499 for (const source of ['project', 'local'] as const) {
500 let settings: { pluginConfigs?: unknown } | null;
501 try {
502 settings = await $.settings.read({ source });
503 } catch {
504 continue;
505 }
506 set[source] = nudgeOf(settings?.pluginConfigs);
507 }
508 return nudgeOn(state.nudge, set.project ?? null, set.local ?? null);
509}
510
511// At the end of a main-loop turn that changed the tree, content no reviewer
512// has seen gets one prompt telling the agent to review it, so the agent does
513// not stop to ask the user whether to. Once per user message, and not while a
514// review is under way.
515async function nudgeReview($: $, e: Input<HookFor<'turn.complete'>>): Promise<void> {
516 const message = state.message;
517 if (message.nudged || e.reason !== 'answer' || asksUser(e.answer)) return;
518 const root = state.root;
519 const since = message.tree;
520 if (!root || since === null) return;
521 const pending = [...state.legs].filter(([, leg]) => leg.counts && !leg.done);
522 if (pending.length > 0) {
523 // A leg that ended without a turn.complete would otherwise hold this off for good.
524 const agents = await $.agent.list();
525 const live = new Set(agents.filter((a) => a.status === 'running').map((a) => a.id));
526 for (const [id, leg] of pending) if (!live.has(id)) leg.done = true;
527 if (pending.some(([, leg]) => !leg.done)) return;
528 }
529 const tree = await worktreeTree(gitOf($), root.top);
530 if (tree === since) return;
531 const touched = new Set(await reviewablePaths(gitOf($), root.top, since, tree));
532 const c = await coverageOf(
533 gitOf($),
534 root.top,
535 await headOf(gitOf($), root.top),
536 tree,
537 state.reviews,
538 );
539 if (c === null) return;
540 const rows = c.rows.filter((row) => touched.has(row.path));
541 if (uncoveredOf(rows).length === 0) return;
542 if (!(await nudgeWanted($))) return;
543 message.nudged = true;
544 const text = `review-cycle: this turn left changes no reviewer has seen (${explain({ ...c, rows })}). Invoke /review-cycle:review via the Skill tool now, then report back. Skip it only if the user's latest message said not to review, or your last message asked them something they must answer first.`;
545 // Not awaited: the prompt enters once this turn has ended. A hook's refusal
546 // resolves with `drop` rather than rejecting.
547 Promise.resolve()
548 .then(() => $.prompt.submit({ text }))
549 .then(
550 (r) => {
551 if (r.drop !== undefined) message.nudged = false;
552 },
553 () => {
554 message.nudged = false;
555 },
556 );
557}
558
559function onSkill($: $, e: Input<SkillHook>, next: NextOf<SkillHook>): ReturnType<SkillHook> {
560 if (!e.agentId && e.skill === 'review-cycle:review-pr') state.message.prWindow = true;
561 if (!e.agentId && e.skill === 'review-cycle:review') {
562 state.message.prWindow = false;
563 // A review already under way needs no reminder to start one.
564 state.message.nudged = true;
565 }
566 return next(e);
567}
568
569async function onAgentSpawn(
570 $: $,
571 e: Input<HookFor<'agent.spawn'>>,
572 next: NextOf<HookFor<'agent.spawn'>>,
573): Promise<Output<HookFor<'agent.spawn'>>> {
574 let isLeg = false;
575 let spawnTree: string | null = null;
576 let why = 'the working tree could not be read when it started';
577 try {
578 const root = await ensureRoot($);
579 const inRoot =
580 !e.cwd || (root !== null && (e.cwd === root.top || e.cwd.startsWith(`${root.top}/`)));
581 isLeg = root !== null && !e.parentAgentId && isReviewerType(e.subagentType) && inRoot;
582 // Captured before the leg starts, so it is the tree the leg is given.
583 if (isLeg && root !== null) spawnTree = await worktreeTree(gitOf($), root.top);
584 } catch (error) {
585 isLeg = isReviewerType(e.subagentType) && !e.parentAgentId;
586 why = `${why} (${error instanceof Error ? error.message : String(error)})`;
587 }
588 const r = await next(e);
589 if (isLeg && 'agentId' in r && r.agentId) {
590 if (spawnTree === null) state.dropped.push(`${e.subagentType}: ${why}`);
591 const counts = !state.message.prWindow;
592 const leg: Leg = {
593 type: e.subagentType,
594 counts,
595 spawnTree,
596 done: false,
597 timer: null,
598 capped: false,
599 };
600 state.legs.set(r.agentId, leg);
601 const id = r.agentId;
602 leg.timer = $.clock.after(LEG_BUDGET_MS, () => overBudget($, id, leg));
603 }
604 return r;
605}
606
607// The orchestrator waits on notifications, and a leg that keeps working sends
608// none, so the gate keeps the clock and asks the session to stop the leg.
609function overBudget($: $, agentId: string, leg: Leg): void {
610 if (leg.done || leg.capped) return;
611 leg.capped = true;
612 const minutes = LEG_BUDGET_MS / 60_000;
613 state.capped.push(`${leg.type} (agent ${agentId}): still running after ${minutes} minutes`);
614 const text = `review-cycle: reviewer leg ${leg.type} (agent ${agentId}) has run past its ${minutes}-minute budget. Stop it with the TaskStop tool, continue the cycle without it, and list it under "Reviewers capped (over budget)" in the summary.`;
615 Promise.resolve()
616 .then(() => $.prompt.submit({ text }))
617 .then(
618 (r) => {
619 if (r.drop !== undefined)
620 state.capped.push(
621 `${leg.type} (agent ${agentId}): the request to stop it was refused (${r.drop})`,
622 );
623 },
624 (error: unknown) => {
625 state.capped.push(
626 `${leg.type} (agent ${agentId}): the request to stop it failed (${error instanceof Error ? error.message : String(error)})`,
627 );
628 },
629 );
630}
631
632// A leg's completion is the review event: the tree it saw is the working tree
633// now. Nothing edited after this moment is reviewed until a leg sees it.
634async function onTurnComplete(
635 $: $,
636 e: Input<HookFor<'turn.complete'>>,
637 next: NextOf<HookFor<'turn.complete'>>,
638): Promise<Output<HookFor<'turn.complete'>>> {
639 if (!e.agentId) {
640 state.lastAnswer = e.answer;
641 const r = await next(e);
642 try {
643 await nudgeReview($, e);
644 } catch {
645 // A missed nudge leaves the commit gate to refuse the unreviewed commit.
646 }
647 return r;
648 }
649 const leg = state.legs.get(e.agentId);
650 if (leg) {
651 leg.done = true;
652 leg.timer?.cancel();
653 }
654 const root = state.root;
655 if (!leg?.counts || leg.spawnTree === null || !root) return next(e);
656 // A capped leg is already reported as capped, not as a dropped review.
657 if (leg.capped && (e.isAborted || e.reason !== 'answer')) return next(e);
658 if (e.isAborted || e.reason !== 'answer') {
659 state.dropped.push(`${leg.type}: it did not finish (${e.isAborted ? 'aborted' : e.reason})`);
660 return next(e);
661 }
662 if (!hasReceipt(e.answer)) {
663 state.dropped.push(`${leg.type}: its report did not open with the execution receipt`);
664 return next(e);
665 }
666 try {
667 const tree = await worktreeTree(gitOf($), root.top);
668 const head = await headOf(gitOf($), root.top);
669 const reviewedPaths = await reviewablePaths(gitOf($), root.top, head, tree);
670 if (reviewedPaths === null) throw new Error('the paths it reviewed could not be listed');
671 state.reviews.push({ type: leg.type, trees: [leg.spawnTree, tree], reviewedPaths });
672 // Refusals explain the current state; older failures stay in the status tool.
673 state.droppedSince = state.dropped.length;
674 } catch (error) {
675 state.dropped.push(`${leg.type}: ${error instanceof Error ? error.message : String(error)}`);
676 }
677 return next(e);
678}
679
680const SWITCHED_BY_USER =
681 'review-cycle is switched on and off only by the user, in /config. Ask them; to read a settings file, use the Read tool.';
682
683async function currentText($: $, path: string): Promise<string | null> {
684 return (await $.fs.exists(path)) ? $.fs.read(path) : null;
685}
686
687function runningLeg(agentId: string | undefined): Leg | null {
688 const leg = agentId === undefined ? undefined : state.legs.get(agentId);
689 return leg && !leg.done ? leg : null;
690}
691
692// A reviewer changing the tree it reviews voids its own review and can land in
693// the user's commit; scratch work belongs outside the repository.
694async function containedEdit(
695 $: $,
696 agentId: string | undefined,
697 path: string,
698): Promise<{ deny: string } | null> {
699 if (runningLeg(agentId) === null) return null;
700 let root: Repo | null;
701 try {
702 root = await ensureRoot($);
703 } catch (error) {
704 const why = error instanceof Error ? error.message : String(error);
705 return deny(
706 `the gate could not find the repository under review (${why}), so it refuses a reviewer's edits. Report back instead.`,
707 );
708 }
709 if (root === null || !insideRepo(path, root.top)) return null;
710 return deny(
711 `reviewers do not edit the repository under review (${root.top}). Copy what you need into a private directory from mktemp -d and change the copy.`,
712 );
713}
714
715async function onEditContained(
716 $: $,
717 e: Input<EditHook>,
718 next: NextOf<EditHook>,
719): Promise<Output<EditHook>> {
720 return (await containedEdit($, e.agentId, e.file_path)) ?? next(e);
721}
722
723async function onWriteContained(
724 $: $,
725 e: Input<WriteHook>,
726 next: NextOf<WriteHook>,
727): Promise<Output<WriteHook>> {
728 return (await containedEdit($, e.agentId, e.file_path)) ?? next(e);
729}
730
731async function onNotebookEdit(
732 $: $,
733 e: Input<NotebookHook>,
734 next: NextOf<NotebookHook>,
735): Promise<Output<NotebookHook>> {
736 return (await containedEdit($, e.agentId, e.notebook_path)) ?? next(e);
737}
738
739async function onEdit($: $, e: Input<EditHook>, next: NextOf<EditHook>): Promise<Output<EditHook>> {
740 if (!isJsonPath(e.file_path)) return next(e);
741 const before = await currentText($, e.file_path);
742 // An edit to a missing file creates it with the new text.
743 if (before === null) return touchesGate(null, e.new_string) ? deny(SWITCHED_BY_USER) : next(e);
744 const after = applyEdit(before, e.old_string, e.new_string, e.replace_all === true);
745 // An edit the gate cannot replay passes only when neither the file nor the
746 // edit names a switch.
747 const touches =
748 after === null
749 ? touchesGate(before, '') || touchesGate(e.old_string, e.new_string)
750 : touchesGate(before, after);
751 return touches ? deny(SWITCHED_BY_USER) : next(e);
752}
753
754async function onWrite(
755 $: $,
756 e: Input<WriteHook>,
757 next: NextOf<WriteHook>,
758): Promise<Output<WriteHook>> {
759 if (!isJsonPath(e.file_path)) return next(e);
760 if (touchesGate(await currentText($, e.file_path), e.content)) return deny(SWITCHED_BY_USER);
761 return next(e);
762}
763
764// The comment-slop scan runs after the tool, beside the gate's own Edit and
765// Write hooks and also when the gate is switched off.
766async function onEditSlop(
767 $: $,
768 e: Input<EditHook>,
769 next: NextOf<EditHook>,
770): Promise<Output<EditHook>> {
771 const r = await next(e);
772 return withSlop($, r, { path: e.file_path, text: e.new_string, replaced: e.old_string });
773}
774
775async function onWriteSlop(
776 $: $,
777 e: Input<WriteHook>,
778 next: NextOf<WriteHook>,
779): Promise<Output<WriteHook>> {
780 const r = await next(e);
781 return withSlop($, r, { path: e.file_path, text: e.content, replaced: null });
782}
783
784// A refused or failed call wrote nothing, so it is not scanned.
785async function withSlop<N extends string>(
786 $: $,
787 r: ToolCallResult<N>,
788 w: Omit<Written, 'file'>,
789): Promise<ToolCallResult<N>> {
790 if (r.deny !== undefined || r.isError === true) return r;
791 const note = await slopNote($, w);
792 return note === null ? r : { ...r, context: [...(r.context ?? []), note] };
793}
794
795// Never refuses: the write has already happened. Files outside a git
796// repository are not scanned.
797async function slopNote($: $, w: Omit<Written, 'file'>): Promise<string | null> {
798 const { path } = w;
799 if (skipsPath(path)) return null;
800 try {
801 const slash = path.lastIndexOf('/');
802 const dir = slash === -1 ? '.' : path.slice(0, slash) || '/';
803 const repo = await run($, ['git', '-C', dir, 'rev-parse', '--show-toplevel']);
804 if (repo.exitCode !== 0) {
805 if (repo.stderr.includes('not a git repository')) return null;
806 const why = firstLine(repo.stderr) || `exit ${repo.exitCode}`;
807 return `review-cycle: git failed (${why}); comment-slop scan skipped.`;
808 }
809 if (!(await $.fs.exists(path))) return null;
810 const { kind, size } = await $.fs.stat(path);
811 if (kind !== 'file' || size > MAX_BYTES) return null;
812 const findings = slopFindings({ ...w, file: await $.fs.read(path) });
813 return findings.length > 0 ? slopDirective(path, findings) : null;
814 } catch (error) {
815 const why = error instanceof Error ? error.message : String(error);
816 return `review-cycle: comment-slop scan skipped (${why}).`;
817 }
818}
819
820// A settings file the gate could not read may be switching it off.
821function fileCheckFailed(path: string, next: Caught): { deny: string } | null {
822 if (next.called || !isJsonPath(path)) return null;
823 return deny(
824 `the gate could not check whether this changes its own switch (${next.error.message ?? next.error.kind}), so it is refused.`,
825 );
826}
827
828function onEditError(
829 $: $,
830 e: Input<EditHook>,
831 next: NextOf<EditHook> & Caught,
832): ReturnType<CatchHandler<EditHook>> {
833 return fileCheckFailed(e.file_path, next) ?? next(e);
834}
835
836function onWriteError(
837 $: $,
838 e: Input<WriteHook>,
839 next: NextOf<WriteHook> & Caught,
840): ReturnType<CatchHandler<WriteHook>> {
841 return fileCheckFailed(e.file_path, next) ?? next(e);
842}
843
844// Monitor runs a shell command the Bash hooks never see, so one that the gate
845// would judge, or that touches its switch, goes through Bash instead.
846async function onMonitor(
847 $: $,
848 e: Input<MonitorHook>,
849 next: NextOf<MonitorHook>,
850): Promise<Output<MonitorHook>> {
851 if (e.command === undefined) return next(e);
852 await loadShellAliases($);
853 const judged =
854 bashTouchesGate(e.command) ||
855 classify(e.command, state.shellAliases).kind !== 'none' ||
856 ghActions(e.command, state.shellAliases).length > 0 ||
857 possibleAliases(e.command, state.shellAliases).length > 0;
858 if (!judged) return next(e);
859 return deny(
860 'Monitor runs commands the gate does not check. Run a command that commits, pushes, opens, merges, approves or comments on a pull request, releases, calls a git alias or writes settings with the Bash tool.',
861 );
862}
863
864function onMonitorError(
865 $: $,
866 e: Input<MonitorHook>,
867 next: NextOf<MonitorHook> & Caught,
868): ReturnType<CatchHandler<MonitorHook>> {
869 if (next.called || e.command === undefined) return next(e);
870 return deny(
871 `the gate could not check this command (${next.error.message ?? next.error.kind}), so it is refused.`,
872 );
873}
874
875// A GitHub MCP tool is judged as the gh command it stands for would be.
876async function onGithubTool(
877 $: $,
878 e: Input<GithubHook>,
879 next: NextOf<GithubHook>,
880): Promise<Output<GithubHook>> {
881 const action = mcpAction(e.tool, e);
882 if (action === null) return next(e);
883 if (e.agentId) return deny(SUBAGENT);
884 const message = state.messages;
885 const shown = shownCall(e.tool, e);
886 const gh = await judgeGh(shown, [action], state.message.grant, state.held, ghLookups($));
887 if ('deny' in gh) return deny(gh.deny);
888 const stale = overtaken(message, next.signal, 'call');
889 if (stale !== null) return stale;
890 const r = await next(e);
891 if (r.deny !== undefined) return r;
892 const notes = unaskedNotes($, gh.ran);
893 return notes.length === 0 ? r : { ...r, context: [...(r.context ?? []), ...notes] };
894}
895
896// Why a judged call does not run after all: a newer message than the one its
897// grant was read at (`message`), or an abort, past which nothing would report
898// what ran.
899function overtaken(message: number, signal: AbortSignal, what: string): { deny: string } | null {
900 if (state.messages !== message) {
901 return deny(
902 `the user sent a new message while the gate was checking this ${what}, so it did not run. Act on their message.`,
903 );
904 }
905 if (signal.aborted) {
906 return deny('the call was interrupted while the gate was checking it, so it did not run.');
907 }
908 return null;
909}
910
911// The notes for the steps a call ran unasked; a push a requested pull request
912// needs is not noted.
913function unaskedNotes($: $, ran: readonly Unasked[]): string[] {
914 return ran.filter((step) => !step.forPr).map((step) => ranUnasked($, step));
915}
916
917function onGithubToolError(
918 $: $,
919 e: Input<GithubHook>,
920 next: NextOf<GithubHook> & Caught,
921): ReturnType<CatchHandler<GithubHook>> {
922 if (next.called) return next(e);
923 const why =
924 next.error.message ??
925 (next.error.kind === 'timeout'
926 ? 'it ran out of time; calling it again may work'
927 : next.error.kind);
928 return deny(
929 `the gate could not check this call (${why}), so it is refused. Tell the user; they can make the change on GitHub themselves.`,
930 );
931}
932
933// A reviewer's Bash is not refused, since it cannot be read for where it
934// writes; the repository is compared around it instead, and what changed is
935// put to the reviewer and kept for the status tool.
936async function onBash($: $, e: Input<BashHook>, next: NextOf<BashHook>): Promise<Output<BashHook>> {
937 const leg = runningLeg(e.agentId);
938 if (!leg) return judgeBash($, e, next);
939 let root: Repo | null = null;
940 let lookup: string | null = null;
941 try {
942 root = await ensureRoot($);
943 } catch (error) {
944 lookup = error instanceof Error ? error.message : String(error);
945 }
946 if (root === null && lookup === null) return judgeBash($, e, next);
947 const capture = async (): Promise<Capture> => {
948 if (root === null) return { state: UNREAD, why: lookup };
949 try {
950 return await repoStateOf(gitOf($), root.top);
951 } catch (error) {
952 return { state: UNREAD, why: error instanceof Error ? error.message : String(error) };
953 }
954 };
955 const before = await capture();
956 const report = async (): Promise<string[]> => {
957 const { notes, records } = containmentReport({
958 type: leg.type,
959 command: e.command,
960 before,
961 after: await capture(),
962 background: e.run_in_background === true,
963 });
964 state.reviewerChanges.push(...records);
965 return notes;
966 };
967 let r: Output<BashHook>;
968 try {
969 r = await judgeBash($, e, next);
970 } catch (error) {
971 // The gate's failure handler answers a call that threw, so no note can
972 // ride along; the record still reaches the status tool. A separate hook
973 // would never see this case, which is why the comparison wraps the gate.
974 await report();
975 throw error;
976 }
977 if (r.deny !== undefined) return r;
978 const notes = await report();
979 return notes.length === 0 ? r : { ...r, context: [...(r.context ?? []), ...notes] };
980}
981
982// A note, never a refusal: the command has already run. Reviewer legs are
983// compared by the gate already, and a background command is still running
984// when the call returns. Never throws, so the gate's own notes always arrive.
985async function onBashEdits(
986 $: $,
987 e: Input<BashHook>,
988 next: NextOf<BashHook>,
989): Promise<Output<BashHook>> {
990 const skip =
991 runningLeg(e.agentId) !== null ||
992 e.run_in_background === true ||
993 !mayWrite(e.command, state.shellAliases);
994 if (skip) return next(e);
995 let root: Repo | null;
996 // onBash, registered first, has already looked the root up; this guards a
997 // registration order where it has not.
998 try {
999 root = await ensureRoot($);
1000 } catch (error) {
1001 return withNote(next(e), editsSkipped(messageOf(error)));
1002 }
1003 if (root === null) return next(e);
1004 const { top } = root;
1005 const git = gitOf($);
1006 const { result, note } = await measureEdits(
1007 () => worktreeTree(git, top),
1008 (before, after) => reviewablePaths(git, top, before, after),
1009 () => next(e),
1010 );
1011 return note === null ? result : withNote(result, note);
1012}
1013
1014async function judgeBash(
1015 $: $,
1016 e: Input<BashHook>,
1017 next: NextOf<BashHook>,
1018): Promise<Output<BashHook>> {
1019 // A newer message overtakes anything decided from this one.
1020 const message = state.messages;
1021 const granted = state.message.grant;
1022 if (bashTouchesGate(e.command)) return deny(SWITCHED_BY_USER);
1023 await loadShellAliases($);
1024 const cls = classify(e.command, state.shellAliases);
1025 if (cls.kind === 'refuse') return deny(cls.reason);
1026 // A gh command beside a commit or push is refused by classify.
1027 if (cls.kind === 'none') {
1028 const actions = ghActions(e.command, state.shellAliases);
1029 if (actions.length > 0 && e.agentId) return deny(SUBAGENT);
1030 const gh = await judgeGh(quote(e.command), actions, granted, state.held, ghLookups($));
1031 if ('deny' in gh) return deny(gh.deny);
1032 // Only a GitHub step is stopped by a newer message; other commands run.
1033 const since = actions.length > 0 ? message : null;
1034 const candidates = possibleAliases(e.command, state.shellAliases);
1035 if (candidates.length > 0) {
1036 const configured = await run($, ['git', 'config', '--get-regexp', String.raw`^alias\.`]);
1037 if (configured.exitCode > 1)
1038 throw new Error(`git config failed: ${firstLine(configured.stderr)}`);
1039 const aliases = new Map<string, string>();
1040 for (const line of configured.stdout.split('\n')) {
1041 const m = /^alias\.(\S+)\s(.*)$/.exec(line);
1042 if (m?.[1] && m[2] !== undefined) aliases.set(m[1], m[2]);
1043 }
1044 for (const a of candidates) {
1045 if (a.inline !== null) aliases.set(a.sub, a.inline);
1046 }
1047 for (const a of candidates) {
1048 if (aliasCommits(a.sub, (name) => aliases.get(name) ?? null)) {
1049 return deny(
1050 `\`git ${a.sub}\` is an alias that commits or pushes. Run the git command directly so the gate can check it.`,
1051 );
1052 }
1053 }
1054 }
1055 const r = await watch($, await ensureRoot($), e, next, 'unchecked', granted, since, gh.ran);
1056 if (state.aliasError === null || state.aliasErrorShown || r.deny !== undefined) return r;
1057 state.aliasErrorShown = true;
1058 return {
1059 ...r,
1060 context: [
1061 ...(r.context ?? []),
1062 `review-cycle could not read the user's shell aliases (${state.aliasError}), so an aliased git commit or push is not checked. Tell the user.`,
1063 ],
1064 };
1065 }
1066
1067 const root = await ensureRoot($);
1068 if (root === null) return next(e);
1069 const target = await repoAt(gitOf($), cls.dir);
1070 if (target === 'none' || target.common !== root.common) return next(e);
1071 if (target.top !== root.top) {
1072 return deny(
1073 `this commits or pushes from another worktree of this repository (${target.top}). Do it from ${root.top}, where the gate can check it.`,
1074 );
1075 }
1076 if (e.agentId) return deny(SUBAGENT);
1077
1078 // A commit needs only a review: it stays local, and undoing one costs a reset.
1079 const unreviewed = await reviewed($, root.top, cls);
1080 if (unreviewed !== null) return deny(unreviewed);
1081
1082 if (cls.commit && !cls.commit.dryRun && !granted.commit) {
1083 const ladder = await ladderOf($);
1084 if (!permitted(granted, ladder, state.held !== null).commit) {
1085 return deny(commitRefusal(e.command, ladder));
1086 }
1087 }
1088 const ran: Unasked[] = [];
1089 if (cls.push) {
1090 const verdict = await judgePush($, root.top, e.command, cls.commit !== null, cls.push, granted);
1091 if ('deny' in verdict) return deny(verdict.deny);
1092 if (verdict.ran !== null) ran.push(verdict.ran);
1093 }
1094 const checked = cls.commit ? 'commit' : cls.history !== null ? 'history' : 'push';
1095 return watch($, root, e, next, checked, granted, message, ran);
1096}
1097
1098// The refusal when a reviewer has not seen all of what a real commit would
1099// record; null otherwise.
1100async function reviewed(
1101 $: $,
1102 top: string,
1103 cls: Extract<Classification, { kind: 'gated' }>,
1104): Promise<string | null> {
1105 if (!cls.commit || cls.commit.dryRun) return null;
1106 const head = await headOf(gitOf($), top);
1107 // An amend replaces HEAD, so what it records is judged against HEAD's parent.
1108 const base = cls.commit.amend && head !== EMPTY_TREE ? await parentTree(gitOf($), top) : head;
1109 if (base === null)
1110 throw new Error("could not read the tree of HEAD's parent, which an amend replaces");
1111 const prospect = await prospectTree(gitOf($), top, cls);
1112 const c = await coverageOf(gitOf($), top, base, prospect, state.reviews);
1113 if (c === null) {
1114 const against =
1115 base === EMPTY_TREE ? 'the empty tree' : cls.commit.amend ? "HEAD's parent" : 'HEAD';
1116 throw new Error(`could not compare the tree this commit would record with ${against}`);
1117 }
1118 if (uncoveredOf(c.rows).length > 0) {
1119 return `no reviewer has seen what this commit records (${explain(c)}). Invoke /review-cycle:review via the Skill tool so a reviewer sees the current tree, then commit.`;
1120 }
1121 return null;
1122}
1123
1124// Longer than this, the refusal quotes only the start of the command.
1125const MAX_SHOWN = 300;
1126
1127const LEASE = '`--force-with-lease --force-if-includes`';
1128
1129const SUBAGENT =
1130 'subagents do not commit, push, open, merge, approve or comment on pull requests, or release, in this repository. Report back to the main session instead.';
1131
1132// A push a requested pull request needs (`forPr`) runs unasked, not noted.
1133type Unasked = GhUnasked & { forPr?: true };
1134
1135// The rung in force: the local file's, else the user's, made earlier by the
1136// project file's. Settings that cannot be read stop before every step.
1137async function ladderOf($: $): Promise<InForce> {
1138 const rungs: Partial<Record<'project' | 'local', StopBefore | null>> = {};
1139 for (const source of ['project', 'local'] as const) {
1140 try {
1141 const settings = await $.settings.read({ source });
1142 rungs[source] = configured(settings.pluginConfigs);
1143 } catch (error) {
1144 const unreadable = `${where(source)}: ${messageOf(error)}`;
1145 return { stopBefore: STRICTEST, source: 'default', unreadable };
1146 }
1147 }
1148 return effective(state.stopBefore, rungs.project ?? null, rungs.local ?? null);
1149}
1150
1151// Shown to the user as a dim line, and to the agent, so the step is never
1152// silent. Worded as having run, not succeeded: the command may still fail.
1153const STEP_NAME: Record<Step, string> = {
1154 commit: 'a commit',
1155 push: 'a push',
1156 pr: 'a pull request',
1157 merge: 'a merge',
1158 approve: 'an approval',
1159 release: 'a release',
1160};
1161
1162function ranUnasked($: $, { step, ladder }: Unasked): string {
1163 const line = `review-cycle: ran ${STEP_NAME[step]} without asking, since the stop-before setting is ${ladder.stopBefore} (${where(ladder.source)}).`;
1164 $.ui.log(line);
1165 return line;
1166}
1167
1168// Git's own credential prompt fails instead of waiting, so the push asks.
1169const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0' };
1170// Also after the push's options, to beat a -q or --verify; the leading
1171// --dry-run is the one no option value can swallow. Measured on git 2.56.
1172// A submodule push runs that submodule's hooks, which --no-verify misses.
1173const DRY_RUN = [
1174 '--dry-run',
1175 '--porcelain',
1176 '--no-verify',
1177 '--no-quiet',
1178 '--recurse-submodules=no',
1179];
1180
1181const DELETES = 'it deletes a remote branch';
1182
1183// Why a push asks whatever the setting, or null. Off the ladder: a push that
1184// changes a remote's default branch, pushes a tag, deletes or force-updates a
1185// ref, and one the gate cannot see the targets of. Git itself names the refs
1186// a push updates, with its own push config applied, through a dry run.
1187async function alwaysAsks($: $, top: string, spec: PushSpec): Promise<string | null> {
1188 if (spec.deletes) return DELETES;
1189 if (spec.every) return 'it pushes every branch';
1190 if (spec.tags) return 'it pushes tags';
1191 if (spec.argv === null) return 'its remote or branch is built at run time';
1192 if (spec.after !== null) {
1193 return `\`git ${spec.after}\` runs before it in the same command, which can change where it goes (run that step on its own first, and the push is judged after it)`;
1194 }
1195 const { config, args, end } = spec.argv;
1196 const probe = ['--dry-run', ...args.slice(0, end), ...DRY_RUN, ...args.slice(end)];
1197 const dry = await run($, ['git', ...config, 'push', ...probe], { cwd: top, env: NO_PROMPT });
1198 if (dry.exitCode !== 0) {
1199 return `a dry run of it, which shows what it would push, failed (${firstLine(dry.stderr) || `exit ${dry.exitCode}`})`;
1200 }hooks/attribution.ts 86 lines1// Which commits a Bash command made, read from HEAD's reflog. Pure.
2//
3// Every command that moves HEAD logs why: `commit: …`, `checkout: moving …`,
4// `merge x: Fast-forward`. An entry counts as recording a commit unless git's
5// own action prefix says it only moved HEAD, so an unknown or empty message
6// counts too. A commit built with plumbing and then reached by `reset` logs
7// only the reset, so it is not seen.
8
9// One HEAD reflog entry: the commit HEAD moved to, and the line git printed,
10// which identifies the entry.
11export type Entry = { id: string; line: string; message: string };
12
13// id, date, identity and message, NUL-separated: an identity may hold a tab.
14export const REFLOG_FORMAT = '%H%x00%gd%x00%gn <%ge>%x00%gs';
15
16// How many of HEAD's newest entries mark where a command began. More than one,
17// since a repeated operation within one second logs an identical line.
18export const MARK = 3;
19
20export function parseReflog(stdout: string): Entry[] {
21 const entries: Entry[] = [];
22 for (const line of stdout.split('\n')) {
23 const fields = line.split('\0');
24 const id = fields[0] ?? '';
25 if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(id)) continue;
26 entries.push({ id, line, message: fields.slice(3).join('\0') });
27 }
28 return entries;
29}
30
31// The `count` newest entries of `after`, the ones the command added, provided
32// the entries below them are the ones recorded before it (`before`, newest
33// first). Null when they are not: the log was expired, rewritten, or read too
34// short.
35export function added(
36 after: readonly Entry[],
37 before: readonly Entry[],
38 count: number,
39): Entry[] | null {
40 if (count < 0 || count > after.length) return null;
41 if (!before.every((b, j) => after[count + j]?.line === b.line)) return null;
42 return after.slice(0, count);
43}
44
45// Whether an entry records a commit rather than only moving HEAD, judged by
46// the action git writes before `: `, never by the commit subject after it. A
47// cherry-pick whose subject is `fast-forward` reads as git's own fast-forward
48// message would, so a cherry-pick fast-forward counts as a commit.
49export function recordsCommit(message: string): boolean {
50 const colon = message.indexOf(': ');
51 const action = colon === -1 ? message : message.slice(0, colon);
52 const rest = colon === -1 ? '' : message.slice(colon + 2);
53 // A fetch never records a commit; with --update-head-ok it can move HEAD.
54 if (/^(checkout|reset|clone|branch|fetch)\b/i.test(action)) return false;
55 if (message === 'am --abort' || message === 'initial pull') return false;
56 if (/^(rebase|pull)\b.*\((start|finish|abort|reset)\)$/.test(action)) return false;
57 if (/^(merge|pull)\b/.test(action) && /^fast-forward\b/i.test(rest)) return false;
58 return !(action === 'rebase' && (rest === 'fast-forward' || rest.startsWith('checkout ')));
59}
60
61// A run of commits recorded one on top of another: `base` is HEAD before the
62// first, `tip` the last.
63export type Lineage = { base: string; tip: string };
64
65// The commits `entries` (newest first) recorded, grouped into lineages: a
66// move between two commits starts a new one, so a commit on another branch is
67// judged apart from one on this. `start` is HEAD before the command.
68export function madeBy(entries: readonly Entry[], start: string): Lineage[] {
69 const oldestFirst = entries.toReversed();
70 const lineages: Lineage[] = [];
71 let current: Lineage | null = null;
72 for (const [i, entry] of oldestFirst.entries()) {
73 if (!recordsCommit(entry.message)) {
74 current = null;
75 continue;
76 }
77 if (current === null) {
78 current = { base: oldestFirst[i - 1]?.id ?? start, tip: entry.id };
79 lineages.push(current);
80 } else {
81 current.tip = entry.id;
82 }
83 }
84 return lineages;
85}
86hooks/command.ts 1254 lines1// Classifies a Bash command for the commit gate. Pure: no `$`, no I/O.
2//
3// The accepted shape is deliberately narrow: an optional leading `cd <dir>`,
4// then any of `git add …`, read-only git commands and steps such as
5// `git fetch` that commit nothing, then at most one `git commit …` (or one
6// command that makes commits from history, such as `git merge`) and at most
7// one `git push …`, joined by `&&`, `;` or newlines.
8// Every other command that would commit or push is refused. Text naming git
9// is data only where nothing will run it: an argument to `grep` or `printf`,
10// a quoted heredoc into `cat`. Handed to anything that runs code — a shell,
11// an interpreter, `xargs`, `find -exec` — it is refused.
12
13import { addArgv, commitSpec, pushSpec, type CommitSpec, type PushSpec } from './git-args';
14import { apiActions, asking, type GhAction, type GhContext, type Unlookable } from './github';
15import { PUBLISH_TEXT, publishActionsOf } from './publish';
16import {
17 assignmentName,
18 parse,
19 QUALIFIER,
20 type ShellAliases,
21 type Statement,
22 type Word,
23} from './shell';
24
25// The mode must come before the name: `git config <name> <value> --get` still
26// writes (measured on git 2.56).
27function readsConfig(args: Word[]): boolean {
28 const named = args.findIndex((w) => !w.text.startsWith('-'));
29 const before = named === -1 ? args : args.slice(0, named);
30 return before.some((w) => /^(--get(-all|-regexp)?|--list|-l)$/.test(w.text));
31}
32
33export const BUILTIN_RUNNERS: ReadonlySet<string> = new Set(['.', 'eval', 'source']);
34// Commands that run their arguments, or their standard input, as code.
35export const INTERPRETERS: ReadonlySet<string> = new Set([
36 'bash',
37 'sh',
38 'zsh',
39 'dash',
40 'ksh',
41 'fish',
42 'eval',
43 'source',
44 '.',
45 'python',
46 'python3',
47 'node',
48 'deno',
49 'bun',
50 'tsx',
51 'ts-node',
52 'lua',
53 'perl',
54 'ruby',
55 'php',
56 'find',
57 'parallel',
58 'watch',
59 'script',
60 'expect',
61 'osascript',
62 'uv',
63 'mise',
64 'nix-shell',
65 'sudo',
66 'doas',
67]);
68export const KEYWORDS: ReadonlySet<string> = new Set([
69 '!',
70 '{',
71 '}',
72 'if',
73 'then',
74 'else',
75 'elif',
76 'fi',
77 'do',
78 'done',
79 'while',
80 'until',
81 'for',
82 'case',
83 'esac',
84 'select',
85 'function',
86 'coproc',
87 '[[',
88 ']]',
89 'repeat',
90 'always',
91 'nocorrect',
92 'noglob',
93]);
94// Commands that record existing commits. Their `--continue` forms record a
95// conflict resolution instead, which is new content, so those are refused.
96const HISTORY = new Set(['merge', 'cherry-pick', 'revert', 'pull', 'rebase']);
97// A mention of a git command that commits or pushes.
98// `git` and the subcommand as separate words, so `pre-commit`, `.git/hooks/`,
99// `fix/merge-conflicts` and helpers like `git merge-base` do not count.
100const MENTION =
101 /(^|[\s;&|('"`=/])git(\s+-\S+(\s+[^\s-]\S*)?)*\s+(commit|push|merge(?!-(base|tree|file)\b)|cherry-pick|revert|am|pull|rebase)\b|(^|[\s;&|('"`=/])git-(commit|push|merge(?!-(base|tree|file)\b)|cherry-pick|revert|am|pull|rebase)\b/;
102// sed's `e` command and s///e, and awk's system() and pipes, run shell code.
103const RUNS_SHELL: Record<string, RegExp> = {
104 sed: /\/[gipIwmM0-9]*e[gipIwmM0-9]*(['"\s;}]|$)|(^|[\s;'"{])e(\s|$)/,
105 awk: /system\s*\(|\|\s*getline|["']\s*\|\s*"|\|\s*"/,
106 gawk: /system\s*\(|\|\s*getline|["']\s*\|\s*"|\|\s*"/,
107};
108// Commands whose arguments are only ever data, never a command to run.
109const DATA = new Set([
110 'echo',
111 'printf',
112 'rg',
113 'grep',
114 'egrep',
115 'fgrep',
116 'ag',
117 'cat',
118 'head',
119 'tail',
120 'less',
121 'more',
122 'wc',
123 'sort',
124 'uniq',
125 'jq',
126 'tr',
127 'cut',
128 'diff',
129 'gh',
130 'bd',
131 'column',
132]);
133// A committing verb as a word of its own, as in `$GIT commit`, not `commit-gate`.
134const WORD_VERB = /(^|[\s'"`])(commit|push|merge|cherry-pick|revert|am|pull|rebase)([\s'"`]|$)/;
135// Shells that run the script on their standard input when given no script.
136const STDIN_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish']);
137// Git commands that write commits or push other than commit and push
138// themselves; none is needed for everyday work, so none is judged.
139const REFUSED = new Set([
140 'send-pack',
141 'http-push',
142 'fast-import',
143 'commit-tree',
144 'filter-branch',
145 'am',
146]);
147// Steps that commit nothing and leave the index alone, allowed before the one
148// command that commits or pushes.
149const NEUTRAL = new Set([
150 'fetch',
151 'checkout',
152 'switch',
153 'branch',
154 'tag',
155 'stash',
156 'remote',
157 'worktree',
158]);
159// Of those, the ones that can change HEAD or the index: fine before a push or
160// a history command, but not before a commit the gate judges from the index.
161const MOVES_INDEX = new Set(['checkout', 'switch', 'stash', 'worktree']);
162// Steps that change the index; run them on their own before the commit.
163const RESTAGE = new Set(['rm', 'mv', 'restore', 'reset', 'apply', 'update-index']);
164const READ_ONLY = new Set([
165 'grep',
166 'status',
167 'log',
168 'diff',
169 'show',
170 'rev-parse',
171 'describe',
172 'ls-files',
173 'shortlog',
174 'blame',
175 'whatchanged',
176 'cat-file',
177 'rev-list',
178 'name-rev',
179 'merge-base',
180]);
181// Author and committer identity, and GIT_TERMINAL_PROMPT; any other GIT_*
182// variable can point git at a different index, directory or object store than
183// the one checked.
184const ALLOWED_GIT_ENV = /^GIT_((AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|TERMINAL_PROMPT)$/;
185// These name a program git runs, so only a bare program name (`cat`, `true`)
186// is allowed: anything longer could run a commit of its own.
187const GIT_PROGRAM_ENV = /^GIT_(PAGER|EDITOR|SEQUENCE_EDITOR)$/;
188// Git's own options before the subcommand that take a separate value.
189const GIT_VALUE_OPTIONS = new Set(['--attr-source', '--list-cmds']);
190
191export type GitCall = {
192 // Options before the subcommand that `add` replays (`-c key=value` pairs).
193 config: string[];
194 // `-C` directories, in order, relative to the statement's directory.
195 dirs: string[];
196 sub: string;
197 args: Word[];
198};
199
200type Kind =
201 // `dir` is null when the directory is not a literal path.
202 | { kind: 'cd'; dir: string | null }
203 // `via` is a reserved word the command runs under, such as `if` or `!`.
204 | { kind: 'git'; git: GitCall; via: string | null }
205 // Literal variable assignments with no substitution or redirect: they run nothing.
206 | { kind: 'assign' }
207 // `words` are the command's words after assignments and reserved words.
208 | { kind: 'other'; head: string; words: Word[] }
209 // `always` refusals stand whatever the command's text mentions.
210 | { kind: 'refuse'; reason: string; always?: boolean };
211
212export function basename(p: string): string {
213 return p.slice(p.lastIndexOf('/') + 1);
214}
215
216// Git's global options before the subcommand. Only `-C` and `-c` are kept;
217// options that point git at a different repository are refused. A `-C` built
218// at run time is allowed only for a subcommand that neither commits nor could
219// be an alias, which git would look up in that directory.
220function gitCall(words: Word[], first: string | null): GitCall | { refuse: string } | null {
221 const config: string[] = [];
222 const dirs: string[] = [];
223 if (first !== null) return { config, dirs, sub: first, args: words.slice(1) };
224 let dynamicDir = false;
225 let i = 1;
226 for (let w = words[i]; w !== undefined; w = words[i]) {
227 if (w.dynamic)
228 return {
229 refuse: 'git run with an option or subcommand built from a variable or substitution',
230 };
231 const t = w.text;
232 if (t === '-C' || t === '-c') {
233 const v = words[i + 1];
234 if (!v || (v.dynamic && t === '-c'))
235 return { refuse: `git ${t} with a value built from a variable or substitution` };
236 dynamicDir ||= v.dynamic;
237 if (t === '-C') dirs.push(v.text);
238 else config.push('-c', v.text);
239 i += 2;
240 } else if (/^-C./.test(t)) {
241 dirs.push(t.slice(2));
242 i++;
243 } else if (
244 /^--(git-dir|work-tree|namespace|bare|config-env|exec-path|super-prefix)(=|$)/.test(t)
245 ) {
246 return { refuse: `git ${t.split('=')[0]}, which points git at another repository` };
247 } else if (GIT_VALUE_OPTIONS.has(t)) {
248 i += 2;
249 } else if (t.startsWith('-')) {
250 i++;
251 } else if (w.pattern) {
252 return { refuse: `git ${t}, a subcommand the shell would expand` };
253 } else {
254 const args = words.slice(i + 1);
255 if (dynamicDir && !reads(t, args) && !NEUTRAL.has(t))
256 return { refuse: 'git -C with a directory built from a variable or substitution' };
257 return { config, dirs, sub: t, args };
258 }
259 }
260 return null;
261}
262
263function reads(sub: string, args: Word[]): boolean {
264 return READ_ONLY.has(sub) || (sub === 'config' && readsConfig(args));
265}
266
267function kindOf(st: Statement): Kind {
268 let words = st.words;
269 let assigned = false;
270 let via: string | null = null;
271 for (let first = words[0]; first !== undefined; first = words[0]) {
272 const name = assignmentName(first);
273 if (name) {
274 const value = first.text.slice(name.length + 1);
275 const program = GIT_PROGRAM_ENV.test(name) && !first.dynamic && /^[\w.-]+$/.test(value);
276 if (name.startsWith('GIT_') && !ALLOWED_GIT_ENV.test(name) && !program) {
277 return { kind: 'refuse', reason: `${name}, which changes what git commits` };
278 }
279 assigned = true;
280 } else if (!first.dynamic && KEYWORDS.has(first.text)) {
281 via ??= first.text;
282 } else {
283 break;
284 }
285 words = words.slice(1);
286 }
287 const [head, arg] = words;
288 if (head === undefined) {
289 // A substitution or redirect runs between the gate's check and the commit.
290 const inert = st.inner.length === 0 && !st.redirected;
291 if (assigned && via === null && inert) return { kind: 'assign' };
292 return { kind: 'other', head: via ?? '', words };
293 }
294 if (head.dynamic) {
295 return { kind: 'refuse', reason: 'a command name built from a variable or substitution' };
296 }
297 if (head.pattern) return { kind: 'refuse', reason: 'a command name the shell would expand' };
298 if (head.text === 'cd' && !assigned && via === null) {
299 if (words.length !== 2 || !arg || arg.dynamic) return { kind: 'cd', dir: null };
300 return { kind: 'cd', dir: arg.text };
301 }
302 const name = basename(head.text.replace(/^=/, ''));
303 const plumbing = /^git-(.+)$/.exec(name)?.[1] ?? null;
304 if (name !== 'git' && plumbing === null) return { kind: 'other', head: name, words };
305 const g = gitCall(words, plumbing);
306 if (g === null) return { kind: 'other', head: name, words };
307 if ('refuse' in g) return { kind: 'refuse', reason: g.refuse, always: true };
308 return { kind: 'git', git: g, via };
309}
310
311function commits(sub: string): boolean {
312 return sub === 'commit' || sub === 'push' || HISTORY.has(sub);
313}
314
315// A fast-forward pull records no commit. The last of `--ff`, `--no-ff` and
316// `--ff-only` wins, and a run-time word could be any; `merge --ff-only -s ours`
317// still records a merge, so merge is not included.
318function fastForwardOnly(g: GitCall): boolean {
319 if (g.sub !== 'pull' || g.args.some((w) => w.dynamic)) return false;
320 return g.args.findLast((w) => /^--(no-)?ff(-only)?$/.test(w.text))?.text === '--ff-only';
321}
322
323function records(g: GitCall): boolean {
324 return commits(g.sub) && !fastForwardOnly(g);
325}
326
327function textOf(st: Statement): string {
328 return [...st.words.map((w) => w.text), ...st.heredocs.map((h) => h.body)].join(' ');
329}
330
331export function every(
332 list: Statement[],
333 visit: (st: Statement, nested: boolean) => string | null,
334 nested = false,
335): string | null {
336 for (const st of list) {
337 const found = visit(st, nested);
338 if (found) return found;
339 for (const inner of st.inner) {
340 const deeper = every(inner, visit, true);
341 if (deeper) return deeper;
342 }
343 }
344 return null;
345}
346
347// The git calls a command's words make after the first, as `arch -arm64 git
348// push` or `flock /tmp/l git commit` would run them. A data command's words
349// are never run.
350function gitCallsIn(words: Word[]): (GitCall | { refuse: string })[] {
351 if (DATA.has(basename(words[0]?.text ?? ''))) return [];
352 const calls: (GitCall | { refuse: string })[] = [];
353 for (const [i, w] of words.entries()) {
354 if (i === 0 || w.dynamic) continue;
355 const name = basename(w.text);
356 const plumbing = /^git-(.+)$/.exec(name)?.[1] ?? null;
357 if (name !== 'git' && plumbing === null) continue;
358 const g = gitCall(words.slice(i), plumbing);
359 if (g !== null) calls.push(g);
360 }
361 return calls;
362}
363
364// Whether any word after the first starts a git command that commits or
365// pushes, or a variable stands in for git before a committing subcommand.
366function runsGit(words: Word[]): string | null {
367 if (DATA.has(basename(words[0]?.text ?? ''))) return null;
368 for (const [i, w] of words.entries()) {
369 if (i === 0 || !w.dynamic) continue;
370 const next = words.slice(i + 1).find((x) => !x.text.startsWith('-'));
371 if (next && (commits(next.text) || REFUSED.has(next.text))) {
372 return `\`${w.text} ${next.text}\``;
373 }
374 }
375 for (const g of gitCallsIn(words)) {
376 if ('refuse' in g) return 'git with options the gate cannot read';
377 // Run through another program, a fast-forward's arguments go unread.
378 if (commits(g.sub) || REFUSED.has(g.sub)) return `git ${g.sub}`;
379 }
380 return null;
381}
382
383// A program among the words that runs code it is given: `bash -c`, or
384// `timeout 5 sh` reading a script from a pipe. Every word is checked, since
385// anything can run the command after its own options.
386function runsCode(
387 words: Word[],
388 own: string,
389 mentions: boolean,
390 aliases: ShellAliases,
391): string | null {
392 if (DATA.has(basename(words[0]?.text ?? ''))) return null;
393 for (const [i, w] of words.entries()) {
394 if (w.dynamic) continue;
395 const name = basename(w.text);
396 const runs = Object.hasOwn(RUNS_SHELL, name) ? RUNS_SHELL[name] : undefined;
397 if (runs && runs.test(own) && MENTION.test(own)) {
398 return `\`${name}\` running a git commit or push`;
399 }
400 // xargs builds its command from its input, which anything in the command
401 // may feed it; one running a data command runs nothing else.
402 if (name === 'xargs') {
403 const target = words.slice(i + 1).find((x) => !x.text.startsWith('-'));
404 if (mentions && !DATA.has(basename(target?.text ?? ''))) {
405 return '`xargs` given a git commit or push to run';
406 }
407 continue;
408 }
409 if (!INTERPRETERS.has(name)) continue;
410 // Builtins run code only as the command itself: `fd x .` searches `.`.
411 if (i > 0 && BUILTIN_RUNNERS.has(name)) continue;
412 // A shell with no script reads one from its standard input: a pipe or
413 // heredoc anywhere in the command can feed it.
414 const scriptless =
415 STDIN_SHELLS.has(name) &&
416 words.slice(i + 1).every((x) => x.text.startsWith('-') && x.text !== '-c');
417 // A shell's `-c` script is a command like any other; judge it as one.
418 const c = words.findIndex((x, j) => j > i && x.text === '-c');
419 const script = c === -1 ? undefined : words[c + 1];
420 if (
421 STDIN_SHELLS.has(name) &&
422 script &&
423 !script.dynamic &&
424 classify(script.text, aliases).kind !== 'none'
425 ) {
426 return `\`${name} -c\` running a git commit or push`;
427 }
428 if (MENTION.test(own) || (scriptless && mentions)) {
429 return `\`${name}\` given a git commit or push to run`;
430 }
431 }
432 return null;
433}
434
435// Whether an alias's value, with the aliases inside it expanded and the
436// words that follow it in `rest`, names a git command that commits or pushes:
437// `g push` with `g=git` does.
438function aliasMentionsGit(name: string, aliases: ShellAliases, rest = ''): boolean {
439 const value = aliases.get(name);
440 if (value === undefined) return false;
441 return MENTION.test(` ${parse(value, aliases).text} ${rest}`);
442}
443
444// A reason to refuse found anywhere in the command, or null; the shape of the
445// top-level commit or push is checked in classify(). `text` is the command
446// with its aliases expanded.
447function hidden(statements: Statement[], text: string, aliases: ShellAliases): string | null {
448 const mentions = MENTION.test(text);
449 return every(statements, (st, nested) => {
450 const k = kindOf(st);
451 // Where the reader simplifies the shell's grammar — a case arm, a
452 // function body, eval's argument — an alias it left unexpanded may run.
453 const structured =
454 st.group ||
455 st.words.some((w) => !w.dynamic && KEYWORDS.has(w.text)) ||
456 (k.kind === 'other' && BUILTIN_RUNNERS.has(k.head));
457 const words = st.words.map((w) => w.text);
458 const unread = !structured
459 ? undefined
460 : st.aliases.find((a) =>
461 aliasMentionsGit(a, aliases, words.slice(words.indexOf(a) + 1).join(' ')),
462 );
463 if (unread !== undefined) {
464 return `the shell alias \`${unread}\`, which runs a git commit or push where the gate cannot read it`;
465 }
466 const own = ` ${textOf(st)}`;
467 const splits = st.words.some((w) => /^(-S|--split-string)/.test(w.text));
468 if (splits && st.words.some((w) => basename(w.text) === 'env') && MENTION.test(own)) {
469 return 'env -S running a git commit or push';
470 }
471 if (k.kind === 'refuse') {
472 return k.always || mentions || WORD_VERB.test(text) ? k.reason : null;
473 }
474 if (k.kind === 'git') {
475 const sub = k.git.sub;
476 if (REFUSED.has(sub)) return `git ${sub}, which writes commits the gate cannot judge`;
477 if (
478 sub === 'config' &&
479 !readsConfig(k.git.args) &&
480 k.git.args.some((w) => /^alias\./i.test(w.text)) &&
481 statements.length > 1
482 ) {
483 return 'a git alias defined alongside other commands, which the gate reads only beforehand';
484 }
485 if (sub === 'subtree' && k.git.args.some((w) => /^(push|add|merge|pull)$/.test(w.text))) {
486 return 'git subtree, which commits or pushes where the gate cannot judge it';
487 }
488 if (records(k.git) || sub === 'add') {
489 if (nested) return `git ${sub} inside a substitution, group or heredoc`;
490 if (k.via) return `git ${sub} run through ${k.via}`;
491 return null;
492 }
493 // A fast-forward's own `git pull` is not a mention; its arguments may be.
494 const judged = fastForwardOnly(k.git)
495 ? ` ${[...k.git.args.map((w) => w.text), ...st.heredocs.map((h) => h.body)].join(' ')}`
496 : own;
497 if (!READ_ONLY.has(sub) && MENTION.test(judged)) {
498 return `git ${sub} running a git command that commits or pushes`;
499 }
500 return null;
501 }
502 if (k.kind === 'other') {
503 const found = runsGit(k.words);
504 if (found) return `${found} run through \`${k.head}\``;
505 // `echo push | xargs git`: git's subcommand arrives at run time.
506 const bare = k.words.some(
507 (w, i) =>
508 i > 0 &&
509 !w.dynamic &&
510 basename(w.text) === 'git' &&
511 gitCall(k.words.slice(i), null) === null,
512 );
513 if (bare && WORD_VERB.test(text)) {
514 return `git run through \`${k.head}\` with its subcommand supplied at run time`;
515 }
516 // eval reads its arguments as a command, aliases and all.
517 const run =
518 k.head === 'eval'
519 ? ` ${
520 parse(
521 k.words
522 .slice(1)
523 .map((w) => w.text)
524 .join(' '),
525 aliases,
526 ).text
527 }`
528 : own;
529 const code = runsCode(k.words, run, mentions, aliases);
530 if (code) return code;
531 }
532 return null;
533 });
534}
535
536type Gated = {
537 kind: 'gated';
538 // Directory the git statements run in, relative to the shell's cwd.
539 dir: string;
540 // Each `git add` to replay: the words after `git`, config options first.
541 adds: string[][];
542} & (
543 | { commit: CommitSpec; history: null; push: PushSpec | null }
544 | { commit: null; history: string; push: PushSpec | null }
545 | { commit: null; history: null; push: PushSpec }
546);
547
548export type Classification = { kind: 'none' } | { kind: 'refuse'; reason: string } | Gated;
549
550const SHAPE =
551 'Run the commit as its own command, optionally after `git add …` and read-only git commands, joined with && (for example `git add -A && git commit -m …`).';
552
553function joinDir(base: string, dirs: string[]): string {
554 let d = base;
555 for (const x of dirs) d = x.startsWith('/') ? x : d === '.' ? x : `${d}/${x}`;
556 return d;
557}
558
559// Git calls anywhere in the command whose subcommand git does not ship under
560// that name, so it may be an alias. `inline` is a `-c alias.<sub>=…` value.
561export function possibleAliases(
562 command: string,
563 aliases: ShellAliases = new Map(),
564): { sub: string; inline: string | null }[] {
565 // Read twice: with git as itself, so `git ci` is looked up even under
566 // `git='hub'`, and through the `git` alias, which can add its own `-c alias.<sub>=…`.
567 const out = aliasCallsIn(command, withoutGit(aliases));
568 if (!aliases.has('git')) return out;
569 for (const a of aliasCallsIn(command, aliases)) {
570 if (!out.some((o) => o.sub === a.sub && o.inline === a.inline)) out.push(a);
571 }
572 return out;
573}
574
575function aliasCallsIn(
576 command: string,
577 aliases: ShellAliases,
578): { sub: string; inline: string | null }[] {
579 const parsed = parse(command, aliases);
580 if ('error' in parsed) return [];
581 const out: { sub: string; inline: string | null }[] = [];
582 every(parsed.statements, (st) => {
583 const k = kindOf(st);
584 const calls = k.kind === 'git' ? [k.git] : k.kind === 'other' ? gitCallsIn(k.words) : [];
585 for (const g of calls) {
586 if ('refuse' in g || reads(g.sub, g.args) || commits(g.sub) || g.sub === 'add') continue;
587 const prefix = `alias.${g.sub}=`;
588 const inline = g.config.find((c) => c.startsWith(prefix))?.slice(prefix.length) ?? null;
589 out.push({ sub: g.sub, inline });
590 }
591 return null;
592 });
593 return out;
594}
595
596// A `!` git alias runs its text in a shell, with the alias's arguments
597// appended: one that ends in a bare `git`, or passes them on, runs whatever
598// it is given.
599function shellAliasCommits(script: string): boolean {
600 if (classify(script).kind !== 'none' || /\$[@*1-9]/.test(script)) return true;
601 const parsed = parse(script);
602 if ('error' in parsed) return true;
603 // An inline `-c alias.x=…` defines a git alias the gate cannot look up.
604 if (possibleAliases(script).some((a) => a.inline !== null)) return true;
605 const words = parsed.statements.at(-1)?.words ?? [];
606 return words.some(
607 (w, i) => !w.dynamic && basename(w.text) === 'git' && gitCall(words.slice(i), null) === null,
608 );
609}
610
611// Whether alias `sub` ends up committing or pushing, following aliases of
612// aliases through `lookup` (the expansion of a name, or null for none). An
613// expansion may lead with git's own options (`-c x=y commit`). Too deep a
614// chain counts as committing: it cannot be followed to the end.
615export function aliasCommits(sub: string, lookup: (name: string) => string | null): boolean {
616 let name = sub;
617 for (let depth = 0; depth < 8; depth++) {
618 const expansion = lookup(name)?.trim();
619 if (!expansion) return false;
620 if (expansion.startsWith('!')) return shellAliasCommits(expansion.slice(1));
621 // Git splits an alias as a shell would, quotes included.
622 const parsed = parse(expansion);
623 if ('error' in parsed) return true;
624 const words = (parsed.statements[0]?.words ?? []).map((w) => w.text);
625 let i = 0;
626 while (i < words.length && (words[i] ?? '').startsWith('-')) {
627 i += words[i] === '-c' || words[i] === '-C' || GIT_VALUE_OPTIONS.has(words[i] ?? '') ? 2 : 1;
628 }
629 const next = words[i] ?? '';
630 if (commits(next) || REFUSED.has(next)) return true;
631 name = next;
632 }
633 return true;
634}
635
636function execs(w: Word): boolean {
637 return /^(--exec(=|$)|-[a-zA-Z]*x[a-zA-Z]*$)/.test(w.text);
638}
639
640function withoutGit(aliases: ShellAliases): ShellAliases {
641 if (!aliases.has('git')) return aliases;
642 const own = new Map(aliases);
643 own.delete('git');
644 return own;
645}
646
647// A shell alias for git itself can run anything in git's place, or more than
648// git. A command that reaches it is judged on its expansion, which is what
649// runs; one whose expansion does not read as git is refused.
650export function classify(command: string, aliases: ShellAliases = new Map()): Classification {
651 const git = aliases.get('git');
652 const own = withoutGit(aliases);
653 const plain = judge(command, own);
654 // Read both ways: `eval` reparses its text, so whether the alias is reached
655 // cannot be told from the outer command.
656 if (git === undefined || plain.kind === 'refuse') return plain;
657 const expanded = judge(command, aliases);
658 if (expanded.kind === 'gated' || (plain.kind === 'none' && expanded.kind === 'none')) {
659 return expanded;
660 }
661 return {
662 kind: 'refuse',
663 reason: `\`git\` is a shell alias here (for \`${git}\`), so git would not run as the gate reads it. Run \`\\git …\` so git runs as itself`,
664 };
665}
666
667// A filter that reads the pipe to its end and runs nothing: `tail` with a
668// count, or `wc` with its counting flags. Anything that may stop reading early
669// (`head`, `grep -q`, a file operand) can kill a hook still printing, so git
670// aborts while the pipeline reports success.
671// One count: from the start (`+N`), or a nonzero last-N. GNU `tail` exits at
672// once on a zero count and BSD `tail` on a second count, neither reading.
673const COUNT = String.raw`(\+\d{1,9}|0*[1-9]\d{0,8})`;
674const TAIL_ONE = new RegExp(`^(-0*[1-9]\\d{0,8}|-[nc]${COUNT}|--(lines|bytes)=${COUNT})$`);
675const TAIL_COUNT = new RegExp(`^${COUNT}$`);
676
677function readsToEnd(head: string, args: string[]): boolean {
678 if (head === 'wc') return args.every((a) => /^-[lcwm]+$/.test(a));
679 if (head !== 'tail' || args.length > 2) return false;
680 const [a, b] = args;
681 if (a === undefined) return true;
682 if (b === undefined) return TAIL_ONE.test(a);
683 return (a === '-n' || a === '-c') && TAIL_COUNT.test(b);
684}
685
686// `op` is the operator after a statement, so a filter fed by a pipe is one
687// whose predecessor ends in `|`; one ending in `&` would run the whole
688// pipeline in the background, past the gate's check after the command.
689function isOutputFilter({ st, k }: { st: Statement; k: Kind }, fed: Statement): boolean {
690 return (
691 fed.op === '|' &&
692 st.op !== '&' &&
693 k.kind === 'other' &&
694 !st.redirected &&
695 st.inner.length === 0 &&
696 readsToEnd(
697 k.head,
698 k.words.slice(1).map((w) => w.text),
699 )
700 );
701}
702
703function judge(command: string, aliases: ShellAliases): Classification {
704 const parsed = parse(command, aliases);
705 if ('error' in parsed) {
706 // The qualifier's code can spell a commit any way, so nothing around it is read.
707 if (parsed.error === QUALIFIER) {
708 return {
709 kind: 'refuse',
710 reason: `${QUALIFIER}, which the gate does not read. Run the command without it`,
711 };
712 }
713 // The shell joins a backslash-newline before it reads anything.
714 const flat = command.replaceAll('\\\n', '');
715 const tokens = flat.split(/[^\w.:@+-]+/);
716 const aliased = tokens.some((w, i) =>
717 aliasMentionsGit(w, aliases, tokens.slice(i + 1).join(' ')),
718 );
719 return MENTION.test(flat) || /\bgit\b/.test(flat) || /\bgit\b/.test(parsed.text) || aliased
720 ? { kind: 'refuse', reason: `the command could not be read (${parsed.error})` }
721 : { kind: 'none' };
722 }
723 const sts = parsed.statements;
724
725 const reason = hidden(sts, parsed.text, aliases);
726 if (reason) return { kind: 'refuse', reason: `${reason}. ${SHAPE}` };
727
728 const all = sts.map((st) => ({ st, k: kindOf(st) }));
729 const sensitive = all.some(({ k }) => k.kind === 'git' && records(k.git));
730 if (!sensitive) return { kind: 'none' };
731 // A pipe into a plain output filter at the very end reads what the commit or
732 // push printed and runs nothing, so it is set aside before judging.
733 let end = all.length;
734 while (end > 1 && isOutputFilter(all[end - 1]!, all[end - 2]!.st)) end--;
735 const pairs = all.slice(0, end);
736 if (end < all.length) {
737 const tail = pairs[end - 1]!;
738 pairs[end - 1] = { ...tail, st: { ...tail.st, op: '' } };
739 }
740
741 let base = '.';
742 let dir: string | null = null;
743 const adds: string[][] = [];
744 let commit: CommitSpec | null = null;
745 let history: string | null = null;
746 let push: PushSpec | null = null;
747 let movedIndex: string | null = null;
748 let retargeted: string | null = null;
749 for (const [i, { st, k }] of pairs.entries()) {
750 if (st.op !== '' && st.op !== '&&' && st.op !== ';' && st.op !== '\n') {
751 return {
752 kind: 'refuse',
753 reason: `a commit or push in a command joined with \`${st.op}\`. ${SHAPE}`,
754 };
755 }
756 if (st.group)
757 return { kind: 'refuse', reason: `a group or function alongside a commit or push. ${SHAPE}` };
758 if (k.kind === 'refuse') return { kind: 'refuse', reason: `${k.reason}. ${SHAPE}` };
759 if (k.kind === 'assign') continue;
760 if (k.kind === 'cd') {
761 if (i !== 0) return { kind: 'refuse', reason: `a cd after the first command. ${SHAPE}` };
762 if (k.dir === null) {
763 return {
764 kind: 'refuse',
765 reason: `a cd whose directory is not a literal path, before a commit or push. ${SHAPE}`,
766 };
767 }
768 base = k.dir;
769 continue;
770 }
771 if (k.kind === 'other')
772 return {
773 kind: 'refuse',
774 reason: `${k.head ? `\`${k.head}\`` : 'an assignment with a substitution or redirect'} alongside a commit or push. ${SHAPE}`,
775 };
776 const g = k.git;
777 const d = joinDir(base, g.dirs);
778 if (reads(g.sub, g.args)) continue;
779 if ((NEUTRAL.has(g.sub) || fastForwardOnly(g)) && !commit && history === null && !push) {
780 // A pull moves HEAD, and `--squash` stages what it brings in.
781 if (MOVES_INDEX.has(g.sub) || g.sub === 'pull') movedIndex = g.sub;
782 // Any of them can move HEAD, a branch, an upstream or a remote URL.
783 retargeted ??= g.sub;
784 continue;
785 }
786 if (RESTAGE.has(g.sub)) {
787 return {
788 kind: 'refuse',
789 reason: `git ${g.sub} alongside a commit. Run it as its own command first, then commit`,
790 };
791 }
792 if (dir !== null && d !== dir)
793 return { kind: 'refuse', reason: `git commands in different directories. ${SHAPE}` };
794 dir = d;
795 if (g.sub === 'add') {
796 if (commit || history || push)
797 return { kind: 'refuse', reason: `git add after the commit. ${SHAPE}` };
798 // With `;`, a failed add still lets the commit run, on an index the
799 // gate never judged.
800 if (st.op !== '&&') {
801 return {
802 kind: 'refuse',
803 reason: `git add joined with \`${st.op === '\n' ? 'a newline' : st.op}\`. ${SHAPE}`,
804 };
805 }
806 const argv = addArgv(g.config, g.args);
807 if ('refuse' in argv) return { kind: 'refuse', reason: `${argv.refuse}. ${SHAPE}` };
808 adds.push(argv);
809 } else if (g.sub === 'push') {
810 if (push) return { kind: 'refuse', reason: `more than one push. ${SHAPE}` };
811 const spec = pushSpec(g.args);
812 if ('refuse' in spec) return { kind: 'refuse', reason: spec.refuse };
813 push = {
814 ...spec,
815 argv: spec.argv === null ? null : { ...spec.argv, config: g.config },
816 // `git rebase <upstream> <branch>` checks the branch out first.
817 after: retargeted ?? history,
818 };
819 } else {
820 if (commit || history || push)
821 return {
822 kind: 'refuse',
823 reason: `more than one command that commits, or one after a push. ${SHAPE}`,
824 };
825 if (g.sub === 'commit') {
826 if (movedIndex !== null) {
827 return {
828 kind: 'refuse',
829 reason: `git ${movedIndex} before a commit can change what it records. Run it as its own command first, then commit`,
830 };
831 }
832 const spec = commitSpec(g.args);
833 if ('refuse' in spec) return { kind: 'refuse', reason: spec.refuse };
834 commit = { ...spec, config: g.config };
835 } else if (HISTORY.has(g.sub)) {
836 if (adds.length > 0)
837 return { kind: 'refuse', reason: `git add before git ${g.sub}. ${SHAPE}` };
838 if (g.sub === 'rebase' && g.args.some(execs)) {
839 return {
840 kind: 'refuse',
841 reason: `git ${g.sub} --exec, which runs commands the gate cannot see. The user can run it from their terminal`,
842 };
843 }
844 if (g.args.some((w) => w.text === '--continue')) {
845 return {
846 kind: 'refuse',
847 reason: `git ${g.sub} --continue records the conflict resolution, content no reviewer has seen. Review the resolution first; the user can finish it from their terminal`,
848 };
849 }
850 history = g.sub;
851 } else {
852 return { kind: 'refuse', reason: `git ${g.sub} alongside a commit or push. ${SHAPE}` };
853 }
854 }
855 }
856 const at = dir ?? base;
857 if (commit) return { kind: 'gated', dir: at, adds, commit, history: null, push };
858 if (history !== null) return { kind: 'gated', dir: at, adds, commit: null, history, push };
859 if (push) return { kind: 'gated', dir: at, adds, commit: null, history: null, push };
860 throw new Error('a command that commits was classified with nothing to gate');
861}
862
863function tame(text: string): string {
864 return text
865 .trim()
866 .replaceAll(/[\p{Zs}\t]*\r?\n\s*/gu, ' ⏎ ')
867 .replaceAll(/[\p{Zs}\t]+/gu, ' ')
868 .replaceAll(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '\u{FFFD}');
869}
870
871// gh's own commands (gh 2.102.0's `gh --help`). Any other word is a gh alias
872// or an extension, which the gate does not read.
873const GH_COMMANDS = new Set([
874 'agent-task',
875 'alias',
876 'api',
877 'attestation',
878 'auth',
879 'browse',
880 'cache',
881 'codespace',
882 'cs',
883 'completion',
884 'config',
885 'copilot',
886 'discussion',
887 'extension',
888 'ext',
889 'extensions',
890 'gist',
891 'gpg-key',
892 'help',
893 'issue',
894 'label',
895 'licenses',
896 'org',
897 'pr',
898 'preview',
899 'project',
900 'release',
901 'repo',
902 'ruleset',
903 'run',
904 'search',
905 'secret',
906 'skill',
907 'ssh-key',
908 'status',
909 'variable',
910 'version',
911 'workflow',
912]);
913// The verbs of the groups the ladder reads; any other word there is an alias
914// (`pr m`), so it asks.
915const GH_VERBS: Record<string, ReadonlySet<string>> = {
916 pr: new Set([
917 'create',
918 'new',
919 'list',
920 'ls',
921 'status',
922 'checkout',
923 'co',
924 'checks',
925 'close',
926 'comment',
927 'diff',
928 'edit',
929 'lock',
930 'merge',
931 'ready',
932 'reopen',
933 'revert',
934 'review',
935 'unlock',
936 'update-branch',
937 'view',
938 ]),
939 issue: new Set([
940 'create',
941 'new',
942 'list',
943 'ls',
944 'status',
945 'close',
946 'comment',
947 'delete',
948 'develop',
949 'edit',
950 'lock',
951 'pin',
952 'reopen',
953 'transfer',
954 'unlock',
955 'unpin',
956 'view',
957 ]),
958 release: new Set([
959 'create',
960 'new',
961 'list',
962 'ls',
963 'delete',
964 'delete-asset',
965 'download',
966 'edit',
967 'upload',
968 'verify',
969 'verify-asset',
970 'view',
971 ]),
972};
973
974// gh's options that take a value: `gh -R o/r pr create`, `gh pr -R o/r create`.
975const GH_VALUE = new Set(['-R', '--repo', '--hostname']);
976// `gh pr merge`'s options that take a value.
977const MERGE_VALUE = new Set([
978 '-b',
979 '--body',
980 '-F',
981 '--body-file',
982 '-t',
983 '--subject',
984 '--match-head-commit',
985 '-A',
986 '--author-email',
987]);
988const RELEASE_WRITES = new Set(['create', 'new', 'edit', 'delete', 'delete-asset', 'upload']);
989// Read as text when the command does not parse: opening a pull request, and
990// any other gh write, which then cannot be read further.
991const GH_PR = /\bgh\b[^|;&\n]*\bpr\s+(?:[^\s;&|]+\s+)*?(create|new|ready)\b/;
992const GH_WRITE =
993 /\bgh\b[^|;&\n]*\b((pr\s+(?:[^\s;&|]+\s+)*?(merge|review|comment)|issue\s+(?:[^\s;&|]+\s+)*?comment|release\s+(?:[^\s;&|]+\s+)*?(create|new|edit|delete|upload))\b|api\s(?:[^|;&\n]*\s)?(-X|--method|-[fF]|--field|--raw-field|--input))/;
994
995const isRepo = (t: string) => t === '-R' || t === '--repo';
996const namesRepo = (t: string) => isRepo(t) || t.startsWith('--repo=') || /^-R./.test(t);
997
998// The repository an option names: `-R o/r`, `-Ro/r`, `--repo o/r`, `--repo=o/r`.
999function repoAt(words: Word[], i: number): Word | null {
1000 const w = words[i];
1001 const t = w?.text ?? '';
1002 if (w && t.startsWith('--repo=')) return { ...w, text: t.slice('--repo='.length) };
1003 if (w && /^-R./.test(t)) return { ...w, text: t.slice(2) };
1004 return words[i + 1] ?? null;
1005}
1006
1007// Skips options from `i`, noting the repository one names.
1008function pastOptions(words: Word[], from: number, into: { repo: Word | null }): number {
1009 let i = from;
1010 while (words[i]?.text.startsWith('-')) {
1011 const t = words[i]?.text ?? '';
1012 if (namesRepo(t)) into.repo = repoAt(words, i);
1013 i += GH_VALUE.has(t) ? 2 : 1;
1014 }
1015 return i;
1016}
1017
1018// Go's strconv.ParseBool, which gh's flags use: `--approve=0` is unset.
1019const FALSE = new Set(['0', 'f', 'F', 'false', 'False', 'FALSE']);
1020
1021// Whether a gh boolean flag is set, as gh's flag parser reads it: `--name`,
1022// `--name=<true>`, or its letter in a short cluster before a letter that takes
1023// the rest as its value (`-ab LGTM` approves, `-a=false` does not).
1024function flagSet(words: Word[], long: string, short: string | null, valueLetters: string): boolean {
1025 return words.some(({ text: t }) => {
1026 if (t === `--${long}`) return true;
1027 if (t.startsWith(`--${long}=`)) return !FALSE.has(t.slice(long.length + 3));
1028 if (short === null || !/^-[^-]/.test(t)) return false;
1029 for (let i = 1; i < t.length; i++) {
1030 const letter = t.charAt(i);
1031 if (letter === short) return t[i + 1] === '=' ? !FALSE.has(t.slice(i + 2)) : true;
1032 if (valueLetters.includes(letter)) return false;
1033 }
1034 return false;
1035 });
1036}
1037
1038// Whether a word built at run time may be a flag or a selector rather than
1039// the value of an option that takes one (`-b "$BODY"`, `--subject="$S"`,
1040// `-b"$BODY"`). Unquoted, a value may split into words that are flags.
1041function builtArgs(rest: Word[], values: ReadonlySet<string>): boolean {
1042 return rest.some((w, i) => {
1043 if (!w.dynamic) return false;
1044 if (w.splits) return true;
1045 if (values.has(rest[i - 1]?.text ?? '')) return false;
1046 const option = /^(--[^=]+)=/.exec(w.text)?.[1] ?? /^(-[^-])./.exec(w.text)?.[1];
1047 return option === undefined || !values.has(option);
1048 });
1049}
1050
1051const REVIEW_VALUE = new Set(['-b', '--body', '-F', '--body-file', '-R', '--repo']);
1052
1053// What a `gh pr` command names for `gh pr view` to look up, or why it cannot be.
1054function lookupOf(
1055 rest: Word[],
1056 repo: Word | null,
1057 context: GhContext,
1058): readonly string[] | Unlookable {
1059 let selector: Word | null = null;
1060 let target = repo;
1061 for (let i = 0; i < rest.length; i++) {
1062 const t = rest[i]?.text ?? '';
1063 if (namesRepo(t)) {
1064 target = repoAt(rest, i);
1065 if (isRepo(t)) i++;
1066 } else if (MERGE_VALUE.has(t)) i++;
1067 else if (!t.startsWith('-') && selector === null) selector = rest[i] ?? null;
1068 }
1069 if (context === 'beside') return { cannot: 'beside' };
1070 if (context === 'elsewhere' || target?.dynamic) return { cannot: 'elsewhere' };
1071 return [...(selector ? [selector.text] : []), ...(target ? ['--repo', target.text] : [])];
1072}
1073
1074// Whether `--auto` is set as a flag, not taken as an option's value: in
1075// `--body --auto` it is the body, and gh merges at once.
1076function autoSet(rest: Word[]): boolean {
1077 const flags = rest.filter((_, i) => {
1078 const before = rest[i - 1]?.text ?? '';
1079 return !MERGE_VALUE.has(before) && before !== '-R' && before !== '--repo';
1080 });
1081 return flagSet(flags, 'auto', null, '');
1082}
1083
1084function mergeOf(words: Word[], from: number, repo: Word | null, context: GhContext): GhAction {
1085 const rest = words.slice(from);
1086 return {
1087 kind: 'merge',
1088 admin: flagSet(rest, 'admin', null, ''),
1089 auto: autoSet(rest),
1090 lookup: lookupOf(rest, repo, context),
1091 };
1092}
1093
1094// gh prints help and stops, unless an option takes the word as its value:
1095// `-b --help` merges. After any option it counts as a value, so `--admin
1096// --help` still asks, and `--help=false` or xargs input can turn it off.
1097function asksHelp(words: Word[], fed: boolean): boolean {
1098 if (fed) return false;
1099 const end = words.findIndex((w) => w.text === '--');
1100 const args = end === -1 ? words : words.slice(0, end);
1101 if (args.some((w) => w.text.startsWith('--help='))) return false;
1102 return args.some((w, i) => {
1103 if (w.text !== '-h' && w.text !== '--help') return false;
1104 const before = args[i - 1];
1105 return before === undefined || (!before.dynamic && !before.text.startsWith('-'));
1106 });
1107}
1108
1109// The gh command at `at`. An alias or extension is not read, so the agent
1110// writes gh's own words; `fed` is xargs or parallel supplying the rest.
1111function ghAt(
1112 words: Word[],
1113 at: number,
1114 context: GhContext,
1115 fed: boolean,
1116): GhAction | GhAction[] | null {
1117 const seen = { repo: null as Word | null };
1118 const sub = pastOptions(words, at + 1, seen);
1119 const group = words[sub]?.text;
1120 const fromInput = { kind: 'unread', why: 'its gh command comes from its input' } as const;
1121 if (group === undefined) return fed ? fromInput : null;
1122 if (words[sub]?.dynamic) return { kind: 'unread', why: 'its gh command is built at run time' };
1123 if (!GH_COMMANDS.has(group)) {
1124 return {
1125 kind: 'unread',
1126 why: `\`gh ${group}\` is a gh alias or extension, which the gate does not read`,
1127 };
1128 }
1129 if (asksHelp(words.slice(at + 1), fed)) return null;
1130 if (group === 'api') {
1131 // `gh --hostname h api …` sends it to another host.
1132 const host = words.slice(at + 1, sub).some((w) => /^--hostname(=|$)/.test(w.text));
1133 return apiActions(words.slice(sub + 1), host ? 'elsewhere' : context, fed);
1134 }
1135 const verbs = GH_VERBS[group];
1136 if (verbs === undefined) return null;
1137 const verbAt = pastOptions(words, sub + 1, seen);
1138 const verb = words[verbAt]?.text;
1139 if (verb === undefined) return fed ? fromInput : null;
1140 if (words[verbAt]?.dynamic) return { kind: 'unread', why: 'its gh command is built at run time' };
1141 if (!verbs.has(verb)) {
1142 return {
1143 kind: 'unread',
1144 why: `\`gh ${group} ${verb}\` is a gh alias, which the gate does not read`,
1145 };
1146 }
1147 const w = words;
1148 const rest = w.slice(verbAt + 1);
1149 // A flag built at run time or fed by xargs could approve, merge with
1150 // --admin or point --repo elsewhere, which the words do not show.
1151 const unreadArgs = {
1152 kind: 'unread',
1153 why: `the arguments of \`gh pr ${verb}\` are built at run time or come from its input`,
1154 } as const;
1155 if (group === 'pr') {
1156 if (verb === 'create' || verb === 'new' || verb === 'revert') return { kind: 'pr' };
1157 if (verb === 'merge') {
1158 if (fed || builtArgs(rest, new Set([...MERGE_VALUE, '-R', '--repo']))) return unreadArgs;
1159 return mergeOf(w, verbAt + 1, seen.repo, context);
1160 }
1161 if (verb === 'review') {
1162 if (fed || builtArgs(rest, REVIEW_VALUE)) return unreadArgs;
1163 return { kind: flagSet(rest, 'approve', 'a', 'bFR') ? 'approve' : 'comment' };
1164 }
1165 if (verb === 'comment') return { kind: 'comment' };
1166 // `--undo` turns it back into a draft, which asks nobody to review it.
1167 if (verb === 'ready') {
1168 // A value built at run time may be `false`, which marks it ready.
1169 const built = rest.some((w) => w.dynamic && w.text.startsWith('--undo='));
1170 return flagSet(rest, 'undo', null, '') && !built ? null : { kind: 'pr' };
1171 }
1172 // Merges or rebases the base into the pull request's own branch.
1173 if (verb === 'update-branch') {
1174 if (fed || builtArgs(rest, new Set(['-R', '--repo']))) return unreadArgs;
1175 if (flagSet(rest, 'rebase', null, '')) {
1176 return { kind: 'push', ref: asking("it rebases the pull request's branch", true) };
1177 }
1178 const lookup = lookupOf(rest, seen.repo, context);
1179 return 'cannot' in lookup
1180 ? { kind: 'push', ref: asking('the pull request it updates cannot be looked up', false) }
1181 : { kind: 'push', ref: { head: lookup } };
1182 }
1183 }
1184 if (group === 'issue' && verb === 'comment') return { kind: 'comment' };
1185 if (group === 'release' && RELEASE_WRITES.has(verb)) return { kind: 'release' };
1186 return null;
1187}
1188
1189// Commands that hand their input to the command they run.
1190const FROM_INPUT = new Set(['xargs', 'parallel']);
1191
1192// As for git, `gh` or a publisher at any word runs unless the first word is a data command:
1193// `timeout 60 gh pr create`, `sudo -u bot gh …`. Quoted text is not read, so
1194// `bash -c "gh pr create"` goes unseen: a well-meaning agent writes it plainly.
1195function ghActionsOf(st: Statement, alone: boolean): GhAction[] {
1196 const first = basename(st.words[0]?.text ?? '');
1197 // `gh` is a data command to the git check: what it is given never runs git.
1198 if (first !== 'gh' && DATA.has(first)) return [];
1199 const found: GhAction[] = [];
1200 const published = publishActionsOf(st.words);hooks/consent.ts 1058 lines1// Decides whether a human prompt asks for a push. Pure.
2//
3// A grant needs the verb in a request the grammar below recognises: only
4// request words before it ("ok, please commit", "can you push?", "I want you
5// to commit") and only an object, a destination or a courtesy after it
6// ("commit the changes", "push to main now"). Anything else grants nothing:
7// "the commit gate", "agents would commit", "I'll push later", "commit to this
8// approach", "push the button". Missing a request costs one question; reading
9// one that was not made costs a push nobody asked for.
10//
11// A bare affirmative ("yes", "go ahead") grants what the previous answer's
12// closing question offered to do.
13
14// How far the user's message lets a push go; each level covers those before
15// it.
16const PUSH_LEVELS = ['none', 'push', 'lease', 'bare'] as const;
17export type PushLevel = (typeof PUSH_LEVELS)[number];
18export const pushRank = (level: PushLevel): number => PUSH_LEVELS.indexOf(level);
19
20// What the user's message asked for. `push` is only what they asked to push:
21// the push a pull request needs is judged by the gate, which still asks
22// before a tag or default-branch push it would carry. `comment` is a reply on
23// a pull request, asked for by addressing its review. `autoMerge` is a merge
24// asked for once the pull request is ready ("merge it when CI passes"), which
25// only `gh pr merge --auto` leaves to GitHub. `mergeNamed` holds the pull
26// request numbers a merge request named ("merge 131"), empty for "merge it".
27export type Grant = Readonly<{
28 commit: boolean;
29 push: PushLevel;
30 pr: boolean;
31 merge: boolean;
32 mergeNamed: readonly string[];
33 autoMerge: boolean;
34 approve: boolean;
35 release: boolean;
36 comment: boolean;
37}>;
38
39export const NO_GRANT: Grant = Object.freeze({
40 commit: false,
41 push: 'none',
42 pr: false,
43 merge: false,
44 mergeNamed: [],
45 autoMerge: false,
46 approve: false,
47 release: false,
48 comment: false,
49});
50
51// Compared by rank, so a level added between two others keeps its meaning.
52export function covers(grant: Grant, level: PushLevel): boolean {
53 return pushRank(grant.push) >= pushRank(level);
54}
55
56type Verb = 'commit' | 'push' | 'force' | 'pr' | 'merge' | 'approve' | 'release' | 'comment';
57type MutableGrant = { -readonly [K in keyof Grant]: Grant[K] };
58
59const fresh = (): MutableGrant => ({ ...NO_GRANT });
60
61function raise(g: MutableGrant, level: PushLevel): void {
62 if (pushRank(level) > pushRank(g.push)) g.push = level;
63}
64
65// A verb family: the word a request uses and the one an offer uses ("push",
66// "pushing"), what it grants, and what may follow it.
67type Family = Readonly<
68 {
69 forms: readonly [request: string, offer: string];
70 grants: readonly Verb[];
71 // What the tail must name for the verb to ask.
72 object?: (tail: readonly string[]) => boolean;
73 } &
74 // A merge or approval names a pull request, and nothing else.
75 (
76 | { tail: 'pull-request'; numbered?: never; packaged?: never; from?: never }
77 // A publish names a version or a registry, never git work.
78 | { tail: 'publish'; numbered: true; packaged: true; from?: never }
79 | {
80 tail?: never;
81 // A number or "on": "Release 0.25.0?", "Reply to the review on #116?".
82 numbered?: true;
83 // A package or registry: "release the crate", "cut a release to npm".
84 packaged?: true;
85 // "from" names a pull request's head; after a push it names a source.
86 from?: true;
87 }
88 )
89>;
90
91// Replying names the review it answers, so "address the TODO comments" asks
92// for nothing on GitHub.
93const REVIEW = new Set(['review', 'reviews', 'reviewer', 'reviewers', 'feedback', 'pr']);
94const reply = (forms: Family['forms']): Family => ({
95 forms,
96 grants: ['comment'],
97 numbered: true,
98 object: (tail) => tail.some((x) => REVIEW.has(x)),
99});
100const releasing = (forms: Family['forms']): Family => ({
101 forms,
102 grants: ['release'],
103 numbered: true,
104 packaged: true,
105});
106
107const FAMILIES: readonly Family[] = [
108 { forms: ['commit', 'committing'], grants: ['commit'] },
109 { forms: ['push', 'pushing'], grants: ['push'] },
110 { forms: ['ship', 'shipping'], grants: ['commit', 'push', 'pr'] },
111 // Deleting a remote branch is a push: the branch is what goes, so "delete it
112 // from the branch" (a commit) and "delete the branch comments" ask for none.
113 {
114 forms: ['delete', 'deleting'],
115 grants: ['push'],
116 object: (tail) => {
117 const at = tail.lastIndexOf('branch');
118 if (at === -1 || tail.slice(0, at).some((x) => TOWARD.has(x))) return false;
119 const rest = tail.slice(at + 1).filter((x) => !COURTESY.has(x));
120 const [toward, ...where] = rest;
121 return toward === undefined || (TOWARD.has(toward) && where.every((x) => REMOTE.has(x)));
122 },
123 from: true,
124 },
125 // "force push" and "force-push", read as one word by `forcePhrase`.
126 { forms: ['forcepush', 'forcepushing'], grants: ['push', 'force'] },
127 // "open a PR", read as one word by `prPhrase`.
128 { forms: ['openpr', 'openingpr'], grants: ['pr'], from: true },
129 { forms: ['merge', 'merging'], grants: ['merge'], tail: 'pull-request' },
130 { forms: ['approve', 'approving'], grants: ['approve'], tail: 'pull-request' },
131 releasing(['release', 'releasing']),
132 // "cut a release", read as one word by `prPhrase`.
133 releasing(['cutrelease', 'cuttingrelease']),
134 {
135 forms: ['publish', 'publishing'],
136 grants: ['release'],
137 tail: 'publish',
138 numbered: true,
139 packaged: true,
140 },
141 // "mark it ready for review", read as one word by `prPhrase`.
142 { forms: ['markready', 'markingready'], grants: ['pr'] },
143 reply(['address', 'addressing']),
144 reply(['reply', 'replying']),
145 reply(['respond', 'responding']),
146];
147
148// Every verb form, request and offer, as a copy the table never reads.
149export const VERB_FORMS: readonly string[] = FAMILIES.flatMap((f) => f.forms);
150
151const REQUESTED: Readonly<Record<string, Family>> = Object.fromEntries(
152 FAMILIES.map((f) => [f.forms[0], f]),
153);
154const OFFERED: Readonly<Record<string, Family>> = Object.fromEntries(
155 FAMILIES.flatMap((f) => f.forms.map((form) => [form, f])),
156);
157
158// "open a PR", "create the pull request", "opening a new draft PR"; "cut a
159// release", "publish the new release"; "mark it ready for review", "ready for
160// review".
161function prPhrase(text: string): string {
162 return (
163 text
164 .replaceAll(
165 /\b(?:(mark)|(marking))\s+(?:(?:it|this|the|pr|pull[\s-]request|draft|#?\d+)\s+)*(?:as\s+)?ready(?:\s+for\s+review)?\b/gi,
166 (_m: string, verb?: string) => (verb ? 'markready' : 'markingready'),
167 )
168 .replaceAll(/\bready for review\b/gi, 'markready')
169 .replaceAll(
170 /\b(?:(open|create|make|raise|submit)|(opening|creating|making|raising|submitting))\s+(?:(?:a|an|the|new)\s+)*(?:draft\s+)?(?:pr|pull[\s-]request)\b/gi,
171 (_m: string, verb?: string) => (verb ? 'openpr' : 'openingpr'),
172 )
173 .replaceAll(
174 /\b(?:(cut|create|make|publish|do)|(cutting|creating|making|publishing|doing))\s+(?:(?:a|an|the|new)\s+)*release\b/gi,
175 (_m: string, verb?: string) => (verb ? 'cutrelease' : 'cuttingrelease'),
176 )
177 // Merging the version pull request is the release, but only where the noun
178 // ends the clause: "merge the release fixes" names other work.
179 .replaceAll(
180 /\b(?:(merge)|(merging))\s+(?:(?:the|this|that|our|new)\s+)*(?:release|version(?:[\s-]packages)?)(?:\s+(?:pr|pull[\s-]request))?(?:\s+#?\d+)?(?=\s*(?:$|[.,;:!?)](?!\d))|\s+(?:now|please|then|and|too|yet|first|next)\b)/gi,
181 (_m: string, verb?: string) => (verb ? 'cutrelease' : 'cuttingrelease'),
182 )
183 );
184}
185
186// A bare --force named apart from a lease ("`--force`", "bare force push",
187// "without a lease") becomes the word `nolease`, which counts only in the
188// tail of a push the grammar grants. Run before quotes are read, since
189// `--force` is usually backticked; "without" would make the clause conditional.
190function forcePhrase(text: string): string {
191 return text
192 .replaceAll(/`--force`|(?<![\w-])--force(?![\w-])/g, 'nolease')
193 .replaceAll(/\bbare force[\s-]?push(ing)?\b/gi, (_m: string, ing?: string) =>
194 ing ? 'forcepushing nolease' : 'forcepush nolease',
195 )
196 .replaceAll(/\s*[,-]?\s*\bwithout (?:a |the )?lease\b/gi, ' nolease')
197 .replaceAll(/\bforce[\s-]?push(ing)?\b/gi, (_m: string, ing?: string) =>
198 ing ? 'forcepushing' : 'forcepush',
199 );
200}
201
202// Words that may come before the verb in a request.
203const REQUEST_LEAD = new Set([
204 'ok',
205 'okay',
206 'alright',
207 'great',
208 'cool',
209 'nice',
210 'perfect',
211 'awesome',
212 'good',
213 'fine',
214 'thanks',
215 'yes',
216 'yeah',
217 'sure',
218 'lgtm',
219 'now',
220 'then',
221 'also',
222 'just',
223 'please',
224 'go',
225 'ahead',
226 'lets',
227 "let's",
228 'let',
229 'us',
230 'can',
231 'could',
232 'would',
233 'will',
234 'you',
235 'feel',
236 'free',
237 'to',
238 'i',
239 "i'd",
240 'want',
241 'need',
242 'like',
243 'we',
244]);
245
246// Words that may come before the verb in the agent's offer.
247const OFFER_LEAD = new Set([
248 'ok',
249 'okay',
250 'so',
251 'now',
252 'then',
253 'want',
254 'me',
255 'to',
256 'should',
257 'shall',
258 'can',
259 'may',
260 'i',
261 "i'll",
262 'we',
263 'do',
264 'you',
265 'would',
266 'like',
267 'go',
268 'ahead',
269 'with',
270 'the',
271 'ready',
272 'proceed',
273 'and',
274]);
275
276// Words that may follow the verb: its object, where it goes, and courtesies.
277const TAIL = new Set([
278 'it',
279 'this',
280 'that',
281 'them',
282 'these',
283 'those',
284 'everything',
285 'all',
286 'of',
287 'the',
288 'my',
289 'your',
290 'our',
291 'changes',
292 'change',
293 'code',
294 'commits',
295 'fix',
296 'fixes',
297 'work',
298 'files',
299 'file',
300 'edits',
301 'diff',
302 'stuff',
303 'branch',
304 'tag',
305 'tags',
306 'up',
307 'to',
308 'into',
309 'against',
310 'from',
311 'main',
312 'master',
313 'origin',
314 'remote',
315 'upstream',
316 'github',
317 'pr',
318 'now',
319 'please',
320 'thanks',
321 'too',
322 'again',
323 'as',
324 'well',
325 'right',
326 'away',
327 'me',
328 'with',
329 'a',
330 'message',
331 'msg',
332 'quoted',
333 'lease',
334 'nolease',
335 'version',
336 'review',
337 'reviews',
338 'reviewer',
339 'reviewers',
340 'feedback',
341 'comments',
342 'comment',
343]);
344
345// What a publish or release may also name: "publish the package to npm". Kept
346// out of TAIL, so "push to npm" asks for no git push.
347const PACKAGE = new Set(['package', 'packages', 'crate', 'crates', 'npm', 'registry']);
348const PUBLISH_TAIL = new Set([
349 'it',
350 'this',
351 'that',
352 'them',
353 'these',
354 'those',
355 'all',
356 'of',
357 'my',
358 'our',
359 'your',
360 'me',
361 'as',
362 'well',
363 'the',
364 'a',
365 'version',
366 // A backticked name: "Publish `review-cycle@0.25.0`?".
367 'quoted',
368 'to',
369 'now',
370 'please',
371 'thanks',
372 'too',
373 'again',
374 'right',
375 'away',
376]);
377
378// After these, only a destination: "push to main", not "commit to this approach".
379const TOWARD = new Set(['to', 'into', 'against', 'from']);
380const COURTESY = new Set(['now', 'please', 'thanks', 'too', 'again']);
381// Where a deleted branch goes from.
382const REMOTE = new Set(['origin', 'upstream', 'remote', 'github', 'the']);
383const DESTINATION = new Set([
384 'main',
385 'master',
386 'origin',
387 'remote',
388 'upstream',
389 'github',
390 'the',
391 'it',
392 'pr',
393 'branch',
394 'review',
395 'reviewer',
396 'reviewers',
397 'feedback',
398 'comments',
399]);
400
401// A merge or approval names a pull request: "merge it", "merge the PR",
402// "approve 116". A tail naming branches or a destination ("merge main into
403// it") asks for a local `git merge`, which the push checks judge.
404const PULL_REQUEST = new Set([
405 'it',
406 'this',
407 'that',
408 'the',
409 'pr',
410 'now',
411 'please',
412 'thanks',
413 'too',
414 'again',
415 'right',
416 'away',
417]);
418// A pull request first, then at most the base it goes into: "merge 116 into
419// main", but not "merge main into it" or "merge this into the PR", which
420// name branches to merge locally.
421const BASE = new Set(['main', 'master', 'base', 'default']);
422function namesPullRequest(tail: string[]): boolean {
423 const into = tail.findIndex((x) => TOWARD.has(x) || x === 'on');
424 const named = into === -1 ? tail : tail.slice(0, into);
425 if (!named.every((x) => PULL_REQUEST.has(x) || /^\d+$/.test(x))) return false;
426 if (into === -1) return true;
427 const where = tail.slice(into + 1);
428 const pr = named.some((x) => /^\d+$/.test(x) || x === 'pr' || x === 'it' || x === 'this');
429 return (
430 pr &&
431 /^(into|to|against)$/.test(tail[into] ?? '') &&
432 where.some((x) => BASE.has(x)) &&
433 where.every((x) => BASE.has(x) || x === 'the' || x === 'branch')
434 );
435}
436
437// Opening a part joined by "and" or "then", these carry over to the parts
438// after it: "I didn't ask you to review and commit", "they review then commit".
439// A SUBORDINATE word withholds its clause before any part is read.
440const MOOD =
441 /^(don'?t|not|never|no|didn'?t|won'?t|can'?t|cannot|shouldn'?t|wouldn'?t|doesn'?t|i'll|i'm|i've|we'll|we're|they|he|she|agents?|claude|who|which|might|should)$/;
442
443// Opening a part, these describe something rather than ask for it: "the flow
444// is review, then commit". Later parts and continuing clauses grant nothing.
445const DESCRIBES = new Set([
446 'the',
447 'a',
448 'an',
449 'this',
450 'that',
451 'these',
452 'those',
453 'our',
454 'my',
455 'their',
456 'its',
457 "it's",
458 'it',
459 'there',
460 'here',
461]);
462
463// Anywhere in a part, these make it a statement about how work goes ("usually
464// I review", "the flow is review"), so the parts after it are description too.
465const STATEMENT = new Set([
466 'is',
467 'are',
468 'was',
469 'were',
470 'i',
471 'we',
472 'they',
473 'he',
474 'she',
475 'agents',
476 'agent',
477 'claude',
478 'usually',
479 'normally',
480 'typically',
481 'always',
482 'often',
483 'sometimes',
484 'generally',
485]);
486
487// Anywhere in the clause, these make the verb conditional, not a request.
488const SUBORDINATE = new Set([
489 'whether',
490 'if',
491 'unless',
492 'when',
493 'once',
494 'until',
495 'before',
496 'after',
497 'without',
498]);
499
500// A clause that only agrees: "ok", "sounds good".
501const AGREE =
502 /^\s*(ok|okay|alright|yes|yeah|yep|sure|great|cool|perfect|lgtm|sounds good|looks good)\s*$/i;
503const AGREE_WORD = new Set([
504 'ok',
505 'okay',
506 'alright',
507 'yes',
508 'yeah',
509 'yep',
510 'sure',
511 'great',
512 'cool',
513 'perfect',
514 'lgtm',
515]);
516// A clause with no verb that holds off: "not yet, commit later".
517const HOLD = /^\s*(not yet|not now|hold off|hold on|wait|don'?t yet)\b/i;
518const RETRACT = /^\s*(no|nope|wait|never ?mind|scratch that|hold on|actually,? (no|don'?t))\b/i;
519// A clause opening with one of these makes the whole prompt conditional.
520const CONDITION = /^\s*(if|when|once|after|unless|until|as soon as)\b/i;
521// A sentence opening with one of these asks something rather than asking for it.
522const QUESTION = new Set([
523 'why',
524 'how',
525 'what',
526 'when',
527 'where',
528 'which',
529 'who',
530 'is',
531 'are',
532 'was',
533 'does',
534 'do',
535 'did',
536 'has',
537 'have',
538 'should',
539 'explain',
540 'whether',
541]);
542// Questions that hand the action back to the user, or offer to skip it.
543const HANDBACK = new Set(['yourself', "you'd", 'rather', 'skip', 'leave', 'instead', 'terminal']);
544
545// "yes", "Ok, lets do that", "great, go ahead": a yes, a go-ahead, or both,
546// and nothing more, since a longer reply may say something else. "great" or
547// "looks good" alone may praise the work, so it answers only with a go-ahead.
548const YES = String.raw`(?:yes|yep|yeah|yup|y|ok|okay|sure|sounds good|lgtm)`;
549const AGREEMENT = String.raw`(?:${YES}|alright|great|cool|perfect|looks good)`;
550const GO_AHEAD = String.raw`(?:(?:let['’]?s|let us)\s+(?:do (?:it|that|this)|go(?: ahead| for it)?)|do (?:it|that|this)|go ahead|go for it|please do)`;
551const AFFIRMATIVE = new RegExp(
552 String.raw`^(?:${YES}|(?:${AGREEMENT}[\s,.!]+)+${GO_AHEAD}|${GO_AHEAD})\b[\s.!,]*(please|thanks|thank you)?[\s.!]*$`,
553 'i',
554);
555
556// Quoted text is a commit message or a name, never part of the request, even
557// when it runs over several lines.
558function unquote(text: string): string {
559 return text
560 .replaceAll(/(^|[\s(:=])(["'`])[\s\S]*?\2(?=$|[\s.,;:!?)])/g, '$1 quoted ')
561 .replaceAll(/“[^”]*”|‘[^’]*’/g, ' quoted ');
562}
563
564function words(text: string): string[] {
565 return text
566 .toLowerCase()
567 .replaceAll(/[’‘]/g, "'")
568 .split(/[^a-z0-9'-]+/)
569 .filter(Boolean);
570}
571
572// "commit it", "push to main now": nothing but tail words, with "to" naming a
573// destination and a message only in "with a message".
574function isTail(tail: string[], family: Family): boolean {
575 if (family.tail === 'pull-request') return namesPullRequest(tail);
576 const publish = family.tail === 'publish';
577 for (const [i, word] of tail.entries()) {
578 // A publish goes to or on a registry: "publish to npm", not "publish to it".
579 const where = word === 'to' || word === 'on';
580 const after = tail.slice(i + 1).find((x) => !/^(the|a|my|our|your)$/.test(x)) ?? '';
581 if (publish && where && !PACKAGE.has(after)) return false;
582 if (family.numbered && (word === 'on' || /^\d+$/.test(word))) continue;
583 if (family.packaged && PACKAGE.has(word)) continue;
584 // "Publish branch" is a first push in editors, so a publish names no git work.
585 if (publish && !PUBLISH_TAIL.has(word)) return false;
586 if (!TAIL.has(word)) return false;
587 if (word === 'from' && !family.from) return false;
588 const next = tail[i + 1] ?? '';
589 if (TOWARD.has(word) && !DESTINATION.has(next) && !(family.packaged && PACKAGE.has(after))) {
590 return false;
591 }
592 if ((word === 'message' || word === 'msg') && !tail.slice(0, i).includes('with')) return false;
593 }
594 return true;
595}
596
597// "we can push" after an agreement ("ok, we can push") answers, rather than
598// describes ("CI is green, we can push").
599function isLead(lead: string[], allowed: Set<string>, agreed: boolean): boolean {
600 if (!lead.every((x) => allowed.has(x) || x === 'to')) return false;
601 // The user's own plan unless addressed to the agent: "I want you to push".
602 if (allowed === REQUEST_LEAD) {
603 if (lead.some((x) => x === 'i' || x === "i'd") && !lead.includes('you')) return false;
604 const we = lead.indexOf('we');
605 const agreeing =
606 (agreed || (we > 0 && lead.slice(0, we).every((x) => AGREE_WORD.has(x)))) &&
607 /^(can|could)$/.test(lead[we + 1] ?? '');
608 if (we !== -1 && !agreeing && !/^(can|could|let'?s?)$/.test(lead[we - 1] ?? '')) return false;
609 }
610 // "Should we push?" offers; "Do we push to main?" asks how the repo works.
611 if (allowed === OFFER_LEAD) {
612 const we = lead.indexOf('we');
613 if (we !== -1 && !/^(should|shall)$/.test(lead[we - 1] ?? '')) return false;
614 }
615 return true;
616}
617
618// A condition GitHub's auto-merge waits on itself: "when it's ready", "once
619// CI passes", "after the checks are green".
620const READY =
621 /^(?:(?:it|it's|its|this|that|(?:the )?pr(?: \d+)?|(?:the )?ci|everything|(?:(?:the|its|all) )?(?:checks|tests))(?: is| are)? )?(?:ready(?: to merge)?|green|passing|passes|pass|succeeds|succeed|goes green|go green|turns green|turn green)(?: please| thanks| now)?$/;
622
623const asSoonAs = (w: readonly string[]): number =>
624 w.findIndex((x, i) => x === 'as' && w[i + 1] === 'soon' && w[i + 2] === 'as');
625
626// "merge it when it's ready": one merge request, then a condition only
627// auto-merge waits on. Anything else conditional asks for nothing.
628function isReadyMerge(
629 w: string[],
630 verbs: Readonly<Record<string, Family>>,
631 lead: Set<string>,
632 agreed: boolean,
633): boolean {
634 const soon = asSoonAs(w);
635 const k = soon === -1 ? w.findIndex((x) => SUBORDINATE.has(x)) : soon;
636 const after = soon === -1 ? k + 1 : k + 3;
637 if (k === -1 || (soon === -1 && !/^(when|once|after|if)$/.test(w[k] ?? ''))) return false;
638 if (!READY.test(w.slice(after).join(' '))) return false;
639 // "go ahead and merge it": only request words before the last "and".
640 const joined = w.slice(0, k).findLastIndex((x) => x === 'and' || x === 'then');
641 const before = w.slice(0, joined + 1).filter((x) => x !== 'and' && x !== 'then');
642 const request = w.slice(joined + 1, k);
643 const at = request.findIndex((x) => Object.hasOwn(verbs, x));
644 const family = verbs[request[at] ?? ''];
645 return (
646 family?.forms[0] === 'merge' &&
647 isLead([...before, ...request.slice(0, at)], lead, agreed) &&
648 namesPullRequest(request.slice(at + 1)) &&
649 samePullRequest(w.slice(after).join(' '), request.join(' '))
650 );
651}
652
653// "once it's ready, merge it" read as "merge it once it's ready", the form
654// isReadyMerge knows; null unless a readiness condition leads into a merge.
655function leadingReady(sentence: string): string | null {
656 const lead =
657 /^(\s*(?:(?:ok|okay|yes|yeah|sure|alright|great|cool|perfect)[\s,]+)*)(when|once|after|if|as soon as)\s+(.*?)([.!?;]*)$/is.exec(
658 sentence,
659 );
660 if (lead === null) return null;
661 const [, agreed = '', when = '', rest = '', end = ''] = lead;
662 const tokens = rest.split(/\s+/);
663 // The longest condition: "it is ready to merge, merge it" waits on all of it.
664 for (let i = tokens.length; i >= 1; i--) {
665 const condition = tokens.slice(0, i).join(' ').replace(/,$/, '');
666 if (!READY.test(words(condition).join(' '))) continue;
667 const request = tokens
668 .slice(i)
669 .join(' ')
670 .replace(/^then\s+/i, '');
671 if (!MERGE_WORD.test(request) || !samePullRequest(condition, request)) return null;
672 return `${agreed}${request} ${when} ${condition}${end}`;
673 }
674 return null;
675}
676
677// "merge 134 once PR 133 is ready" waits on #133, which `--auto` on #134
678// never does, so a condition naming a pull request names the one merged.
679function samePullRequest(condition: string, request: string): boolean {
680 const waits = /\bpr\s+#?(\d+)\b/i.exec(condition)?.[1];
681 const merges = /#?\b(\d+)\b/.exec(request)?.[1];
682 return waits === undefined || merges === undefined || waits === merges;
683}
684
685// Only a merge once it is ready: anything else the sentence asks for may wait
686// on the same condition.
687function onlyAutoMerge(g: MutableGrant): boolean {
688 return g.autoMerge && g.push === 'none' && STEPS.every((s) => s === 'autoMerge' || !g[s]);
689}
690
691// What a clause leaves for the clauses after it in the sentence: a mood
692// withholds all of them, a description those that continue it with "and" or
693// "then". `ready` withholds the rest, which may wait on its condition too.
694type Carry = 'none' | 'mood' | 'described' | 'ready';
695
696// The verbs a clause asks for, reading each part joined by "and" or "then" as
697// its own request: "fix the parser and commit it".
698function grammarGrant(
699 clause: string,
700 verbs: Readonly<Record<string, Family>>,
701 lead: Set<string>,
702 into: MutableGrant,
703 agreed = false,
704 heard?: Set<Family>,
705): Carry {
706 const w = words(clause);
707 if (isReadyMerge(w, verbs, lead, agreed)) {
708 into.autoMerge = true;
709 return 'ready';
710 }
711 if (w.some((x) => SUBORDINATE.has(x)) || asSoonAs(w) !== -1) return 'mood';
712 const parts: string[][] = [[]];
713 for (const word of w) {
714 if (word === 'and' || word === 'then') parts.push([]);
715 else parts.at(-1)?.push(word);
716 }
717 let described = false;
718 let merging = false;
719 for (const part of parts) {
720 // "merge 131 and 130": a list of numbers after a merge names them too.
721 if (merging && part.every((x) => /^\d+$/.test(x) || x === 'pr')) {
722 into.mergeNamed = [...into.mergeNamed, ...part.filter((x) => /^\d+$/.test(x))];
723 continue;
724 }
725 merging = false;
726 const at = part.findIndex((x) => Object.hasOwn(verbs, x));
727 const family = verbs[part[at] ?? ''];
728 const tail = part.slice(at + 1);
729 const asks =
730 family !== undefined &&
731 !described &&
732 isLead(part.slice(0, at), lead, agreed) &&
733 isTail(tail, family) &&
734 (family.object?.(tail) ?? true);
735 if (asks) {
736 heard?.add(family);
737 const granted = family.grants;
738 if (granted.includes('commit')) into.commit = true;
739 if (granted.includes('push')) raise(into, 'push');
740 if (granted.includes('force')) raise(into, 'lease');
741 if (granted.includes('pr')) into.pr = true;
742 if (granted.includes('merge')) {
743 into.merge = true;
744 into.mergeNamed = [...into.mergeNamed, ...tail.filter((x) => /^\d+$/.test(x))];
745 merging = true;
746 }
747 if (granted.includes('approve')) into.approve = true;
748 if (granted.includes('release')) into.release = true;
749 if (granted.includes('comment')) into.comment = true;
750 // "push it with `--force`", "force push it without a lease"; not a
751 // `--force` given as the commit message.
752 const bare = tail.indexOf('nolease');
753 const message = tail.findIndex((x) => x === 'message' || x === 'msg');
754 if (bare !== -1 && (message === -1 || message > bare) && granted.includes('push')) {
755 raise(into, 'bare');
756 }
757 }
758 const opening = part.find((x) => !lead.has(x));
759 if (opening !== undefined && MOOD.test(opening)) return 'mood';
760 if (opening !== undefined && DESCRIBES.has(opening) && opening === part[0]) described = true;
761 // A part shaped as a request is not a description, even when its tail
762 // is not one the grammar reads: "I want you to commit separately and push".
763 const requestShaped = at !== -1 && isLead(part.slice(0, at), lead, agreed);
764 if (!requestShaped && part.some((x) => STATEMENT.has(x))) described = true;
765 }
766 return described ? 'described' : 'none';
767}
768
769function continues(clause: string): boolean {
770 return /^\s*(and|then)\b/i.test(clause);
771}
772
773// Sentences keep their closing mark, so a question can be told from a request.
774function sentences(text: string): string[] {
775 return text.match(/[^.!?;\n]+[.!?;]*/g) ?? [];
776}
777
778// Clauses split on commas and "but", so "don't push, but commit it" withholds
779// the push and grants the commit.
780// A clause that opens with "but" contrasts with what came before, so it keeps
781// its leading "but" for the caller to see and strip.
782function clauses(sentence: string): string[] {
783 return sentence.split(/,|\s+(?=but\s)/i);
784}
785
786const CONTRAST = /^\s*but\s+/i;
787
788function isQuestion(sentence: string): boolean {
789 const first = words(sentence)[0] ?? '';
790 if (QUESTION.has(first)) return true;
791 if (!sentence.trim().endsWith('?')) return false;
792 return !/^\s*(can|could|would|will) (you|we)\b|^\s*(please|mind)\b/i.test(sentence);
793}
794
795function settled(g: MutableGrant): Grant {
796 return Object.freeze({ ...g });
797}
798
799// Each step a grant can name besides the push level.
800const STEPS = ['commit', 'pr', 'merge', 'autoMerge', 'approve', 'release', 'comment'] as const;
801
802function merged(into: MutableGrant, from: MutableGrant): void {
803 for (const step of STEPS) into[step] ||= from[step];
804 into.mergeNamed = [...into.mergeNamed, ...from.mergeNamed];
805 raise(into, from.push);
806}
807
808// Any step beyond the push the grant names.
809function asksBeyondPush(g: Grant): boolean {
810 return g.pr || g.merge || g.autoMerge || g.approve || g.release || g.comment;
811}
812
813// The verbs the previous answer's closing questions offered to do.
814function asked(answer: string, heard?: Set<Family>): Grant {
815 const g = fresh();
816 // A semicolon before "then" or "and" joins clauses as a comma does: "Do we commit; then push?".
817 // Elsewhere it ends a sentence: "I'll leave the docs alone; should I push?".
818 // A name the offer gives, as in "Push fix/x to `origin`?", reads as "it": an
819 // object or destination a push may take; after "delete" it is the branch.
820 // Only here, in the agent's offer: a user's "push it to `later`" defers. A
821 // ref ends on a word character, so a sentence's closing period stays, and
822 // one joining hand-back words ("rather/prefer") stays words.
823 const named = forcePhrase(answer.trim().split('\n').filter(Boolean).slice(-3).join('\n'))
824 // A path is deleted too, so a backticked or slashed name is a branch only
825 // beside "branch" or a remote; a plain word is never read as one.
826 .replaceAll(
827 /\b(delete|deleting)\s+(?:the\s+)?(?:remote\s+)?(?:branch\s+(?:`[\w./-]+`|[\w.-]+\/[\w./-]*[\w-])|(?:`[\w./-]+`|[\w.-]+\/[\w./-]*[\w-])(?=\s+from\s+(?:`[\w-]+`|(?:origin|upstream|remote|github)\b)))/gi,
828 '$1 branch',
829 )
830 // A backticked remote a delete names: `fork` is one, `main` or a file is not.
831 .replaceAll(
832 /(\bdelet(?:e|ing)\b[^?.!\n]*?\bfrom\s+)`([\w./-]+)`/gi,
833 (_m: string, lead: string, name: string) =>
834 /^[\w-]+$/.test(name) && !/^(main|master)$/i.test(name)
835 ? `${lead}remote`
836 : `${lead}\`${name}\``,
837 )
838 // A backticked hand-back stays a word: "or would you `rather` do it?".
839 .replaceAll(/`([\w./-]+)`/g, (_m: string, name: string) =>
840 words(name).some((x) => HANDBACK.has(x)) ? name.replaceAll('/', ' ') : 'it',
841 );
842 const tail = prPhrase(unquote(named))
843 .replaceAll(/[\w.-]+\/[\w./-]*[\w-]/g, (m) =>
844 words(m).some((x) => HANDBACK.has(x)) ? m.replaceAll('/', ' ') : 'it',
845 )
846 .replaceAll(/;(?=\s*(and|then)\b)/gi, ',');
847 for (const q of sentences(tail)) {
848 if (!q.trim().endsWith('?') || words(q).some((x) => HANDBACK.has(x))) continue;
849 // "Do we commit, then push?" asks how work goes; its "then push" offers nothing.
850 let carry: Carry = 'none';
851 for (const part of clauses(q)) {
852 const c = part.replace(CONTRAST, '');
853 if (carry !== 'none' && continues(c)) continue;
854 const left = grammarGrant(c, OFFERED, OFFER_LEAD, g, false, heard);
855 // "Should I fix it, then push?" offers the push; only a commit or push clause carries.
856 if (left !== 'none' && words(c).some((x) => Object.hasOwn(OFFERED, x))) carry = left;
857 }
858 }
859 return settled(g);
860}
861
862const DELETE_IT = new RegExp(
863 String.raw`^(?:${AGREEMENT}[\s,.!]+)*(?:(?:let['’]?s|let us|please|go ahead and)\s+)?delete (?:it|that|them)(?:\s+from\s+\x60?[\w.-]+\x60?)?(?:\s+(?:now|please|too))?\b[\s.!,]*(?:please|thanks|thank you)?[\s.!]*$`,
864 'i',
865);
866
867function offersDelete(answer: string): boolean {
868 const heard = new Set<Family>();
869 asked(answer, heard);
870 return [...heard].some((f) => f.forms[0] === 'delete');
871}
872
873const PUSH_WORD =
874 /\b(push|pushes|pushing|pushed|ship|ships|shipping|shipped|forcepush|forcepushing)\b/i;
875const PR_WORD = /\b(openpr|openingpr|markready|markingready)\b/i;
876const MERGE_WORD = /\b(merge|merges|merging|merged)\b/i;
877const APPROVE_WORD = /\b(approve|approves|approving|approved)\b/i;
878const RELEASE_WORD =
879 /\b(release|releases|releasing|released|cutrelease|cuttingrelease|publish|publishes|publishing|published)\b/i;
880
881// A step mentioned without being asked for, or an offer of one held off ("not yet").
882export type HoldStep = 'push' | 'pr' | 'merge' | 'approve' | 'release' | 'comment';
883
884// Deleting a remote branch is a push.
885const STEP_WORDS: readonly (readonly [HoldStep, RegExp])[] = [
886 ['push', PUSH_WORD],
887 ['push', /\b(delete|deletes|deleting|deleted)\b/i],
888 ['pr', PR_WORD],
889 ['merge', MERGE_WORD],
890 ['approve', APPROVE_WORD],
891 ['release', RELEASE_WORD],
892];
893
894// A message that is a yes or a go-ahead and nothing more, as the grammar
895// reads one: it answers the offer before it.
896export function isReply(text: string): boolean {
897 return AFFIRMATIVE.test(text.trim().replaceAll(/\bno (problem|worries)\b/gi, 'ok'));
898}
899
900// The steps a message names by their verbs, asked for or not.
901export function stepsNamed(text: string): HoldStep[] {
902 const read = prPhrase(unquote(forcePhrase(text)));
903 return [...new Set(STEP_WORDS.filter(([, word]) => word.test(read)).map(([step]) => step))];
904}
905export type HoldReason = Readonly<{ step: HoldStep; how: 'mentioned' | 'declined' }>;
906
907// A step mentioned without being asked for holds every step but a commit, read
908// by mention rather than grammar since a hold only makes the agent ask.
909export function holdOf(prompt: string, previousAnswer = ''): HoldReason | null {
910 const grant = grantOf(prompt, previousAnswer);
911 const text = prPhrase(unquote(forcePhrase(prompt)));
912 const mentioned = (step: HoldStep) => ({ step, how: 'mentioned' }) as const;
913 if (PUSH_WORD.test(text) && !covers(grant, 'push')) return mentioned('push');
914 if (PR_WORD.test(text) && !grant.pr) return mentioned('pr');
915 if (MERGE_WORD.test(text) && !grant.merge && !grant.autoMerge) return mentioned('merge');
916 if (APPROVE_WORD.test(text) && !grant.approve) return mentioned('approve');
917 if (RELEASE_WORD.test(text) && !grant.release) return mentioned('release');
918 if (!HOLD.test(text) || covers(grant, 'push') || asksBeyondPush(grant)) return null;
919 // The furthest step offered: "merge #116 and delete the branch?" offers a
920 // merge, though deleting the branch is a push too.
921 const offered = asked(previousAnswer);
922 const step: HoldStep | null = offered.release
923 ? 'release'
924 : offered.merge || offered.autoMerge
925 ? 'merge'
926 : offered.approve
927 ? 'approve'
928 : offered.pr
929 ? 'pr'
930 : covers(offered, 'push')
931 ? 'push'
932 : offered.comment
933 ? 'comment'
934 : null;
935 return step === null ? null : { step, how: 'declined' };
936}
937
938export function holdsOf(prompt: string, previousAnswer = ''): boolean {
939 return holdOf(prompt, previousAnswer) !== null;
940}
941
942// Whether a message asks for a step on the way out: a push, a pull request, a
943// merge or a release. An approval or a reply asks for none of them.
944export function liftsHold(grant: Grant): boolean {
945 return covers(grant, 'push') || grant.pr || grant.merge || grant.autoMerge || grant.release;
946}
947
948export function grantOf(prompt: string, previousAnswer = ''): Grant {
949 // "No problem" and "no worries" agree; they retract nothing.
950 const text = prompt.trim().replaceAll(/\bno (problem|worries)\b/gi, 'ok');
951 if (AFFIRMATIVE.test(text)) {
952 const offer = asked(previousAnswer);
953 if (!offer.merge || offer.mergeNamed.length > 0) return offer;
954 return settled({ ...offer, mergeNamed: unnamedMerge('', previousAnswer) });
955 }
956 // "delete it", answering an offer to delete a branch, names that branch;
957 // only as the whole reply, since "delete it, meaning the TODO" names another.
958 const answering = DELETE_IT.test(text) && offersDelete(previousAnswer);
959 // Pasted shell sessions and quoted output are not requests.
960 const typed = (answering ? text.replace(/\b(delete\s+(?:it|that|them))\b/i, '$1 branch') : text)
961 .split('\n')
962 .filter((line) => !/^\s*([$>+#]|PS\s|\w+@[\w.-]+[:$])/.test(line))
963 // A list item or an emphasised label names a step; it does not ask for it.
964 .filter((line) => !/^\s*(\d+[.)]|[-*•])\s|^\s*[*_]+[^*_]+[*_]+\s*$/.test(line))
965 .join('\n');
966 let g = fresh();
967 // What the previous sentence left: "we commit; then push" describes across
968 // the semicolon as "we commit, then push" does across the comma.
969 let prev: Carry = 'none';
970 for (const raw of sentences(prPhrase(unquote(forcePhrase(typed))))) {
971 const flipped = leadingReady(raw);
972 const sentence = flipped ?? raw;
973 // "once it's ready, do not merge it" reads as a question once reordered.
974 if (flipped !== null && (RETRACT.test(sentence) || isQuestion(sentence))) return NO_GRANT;
975 // A retraction ("no wait", "never mind") cancels what came before it.
976 if (RETRACT.test(sentence)) {
977 g = fresh();
978 prev = 'none';
979 continue;
980 }
981 if (isQuestion(sentence)) {
982 prev = 'none';
983 continue;
984 }
985 if (prev !== 'none' && continues(sentence)) {
986 if (!sentence.trim().endsWith(';')) prev = 'none';
987 continue;
988 }
989 // One clause that withholds withholds its whole sentence: "push it, but
990 // not until CI passes" grants nothing.
991 const mine = fresh();
992 let carry: Carry = 'none';
993 let withheld = false;
994 let agreed = false;
995 for (const part of clauses(sentence)) {
996 const c = part.replace(CONTRAST, '');
997 if (CONDITION.test(c)) return NO_GRANT;
998 if (HOLD.test(c)) withheld = true;
999 if (withheld) break;
1000 if (carry === 'described' && continues(c)) continue;
1001 // After a ready-merge a clause can still withhold, but grants nothing.
1002 const into = carry === 'ready' ? fresh() : mine;
1003 const left = grammarGrant(c, REQUESTED, REQUEST_LEAD, into, agreed);
1004 agreed = AGREE.test(c);
1005 if (left === 'mood') withheld = true;
1006 else if (left !== 'none' && carry !== 'ready') carry = left;
1007 }
1008 // Any other condition leading a sentence withholds the whole prompt.
1009 if (flipped !== null && (withheld || !onlyAutoMerge(mine))) return NO_GRANT;
1010 if (!withheld) merged(g, mine);
1011 // Only a semicolon ties two sentences together, and only around the verbs:
1012 // "I fixed it. Then push it." asks.
1013 const tied =
1014 sentence.trim().endsWith(';') && words(sentence).some((x) => Object.hasOwn(REQUESTED, x));
1015 prev = tied ? (withheld ? 'mood' : carry) : 'none';
1016 }
1017 if (g.merge && g.mergeNamed.length === 0) g.mergeNamed = unnamedMerge(typed, previousAnswer);
1018 return settled(g);
1019}
1020
1021// Never a pull request number: a merge naming it covers no pull request by name.
1022export const NAMED_ELSEWHERE = '?';
1023
1024// The pull request numbers a text names: #131, PR 131, /pull/131.
1025function numbers(text: string): Set<string> {
1026 return new Set(
1027 [...text.matchAll(/(?:#|\b(?:pr|pull request)\s+#?|\/pull\/)(\d+)\b/gi)].map((m) => m[1] ?? ''),
1028 );
1029}
1030
1031// What "merge it" names: the pull requests the end of the previous answer
1032// names (#131, PR 131, /pull/131), none meaning any. Several, or a message
1033// naming numbers or the version PR itself ("merge it; do not merge 130"),
1034// name none by name, so the version PR is looked up and asks.
1035function unnamedMerge(typed: string, previousAnswer: string): readonly string[] {
1036 const text = unquote(typed);
1037 if (/#?\b\d+\b/.test(text) || /\b(version|release)\s+(pr|pull\s+request)\b/i.test(text)) {
1038 return [NAMED_ELSEWHERE];
1039 }
1040 const closing = previousAnswer.trim().split('\n').filter(Boolean).slice(-3).join('\n');
1041 // The question it answers decides first: "Release by merging #130?" after a
1042 // summary that names #134 too.
1043 const questions = sentences(closing)
1044 .filter((q) => q.trim().endsWith('?'))
1045 .join(' ');
1046 const asked = numbers(questions);
1047 // "#134 merged. Merge the version PR?" offers the version PR, not #134.
1048 if (
1049 asked.size === 0 &&
1050 /\b(version|release)\s+(pr|pull\s+request)\b|\bmerge\s+it\s+to\s+release\b/i.test(questions)
1051 ) {
1052 return [];
1053 }
1054 const offered = asked.size > 0 ? asked : numbers(closing);
1055 if (offered.size > 1) return [NAMED_ELSEWHERE];
1056 return [...offered];
1057}
1058hooks/containment.ts 123 lines1// What a running reviewer is to leave as it found it, and what the gate tells
2// it and the status tool when something moved. The reads take a `Git` the hook
3// supplies, as git.ts does; the rest is pure.
4
5import { firstLine, sha, worktreeTree, type Git } from './git';
6
7// HEAD's commit and branch, the staged entries, the working tree and the local
8// and worktree config. null marks a part git could not read.
9export type RepoState = {
10 head: string | null;
11 index: string | null;
12 work: string | null;
13 config: string | null;
14};
15
16export const UNREAD: RepoState = { head: null, index: null, work: null, config: null };
17
18// A state and, when a read threw, why.
19export type Capture = { state: RepoState; why: string | null };
20
21const PARTS: Record<keyof RepoState, string> = {
22 head: 'HEAD',
23 index: 'the staged content',
24 work: 'the working tree',
25 config: 'the local git config',
26};
27
28// Neither read takes the index lock that `write-tree` would. The listing is
29// hashed inside git, so a large index is not cut at the output limit.
30const INDEX_DIGEST =
31 'list=$(git ls-files -s) || exit 2; printf %s "$list" | git hash-object --stdin';
32
33// A working tree git could not build is unreadable alone, so the other parts
34// are still compared, and its reason is kept.
35export async function repoStateOf(git: Git, root: string): Promise<Capture> {
36 const opts = { cwd: root };
37 // rev-parse exits 1 on an unborn branch, symbolic-ref on a detached HEAD;
38 // both exiting 1 is a read that failed, since a killed child reads as 1.
39 const commit = await git(['git', 'rev-parse', '--verify', '-q', 'HEAD'], opts);
40 const branch = await git(['git', 'symbolic-ref', '-q', 'HEAD'], opts);
41 const index = await git(['sh', '-c', INDEX_DIGEST], opts);
42 const config = await git(['git', 'config', '--list', '--show-scope', '-z'], opts);
43 const headRead =
44 (commit.exitCode === 0 && branch.exitCode <= 1) ||
45 (commit.exitCode === 1 && branch.exitCode === 0);
46 let why: string | null = null;
47 const work = await worktreeTree(git, root).catch((error: unknown) => {
48 why = error instanceof Error ? error.message : String(error);
49 return null;
50 });
51 const state: RepoState = {
52 head: headRead ? `${commit.stdout.trim()} ${branch.stdout.trim()}` : null,
53 index: index.exitCode === 0 ? sha(index.stdout) : null,
54 work,
55 config: config.exitCode === 0 ? ownConfig(config.stdout) : null,
56 };
57 return { state, why };
58}
59
60// `-z` prints scope, then `key\nvalue`, each ended by NUL, so a value that
61// spans lines stays whole.
62export function ownConfig(z: string): string {
63 const fields = z.split('\0');
64 const own: string[] = [];
65 for (let i = 0; i + 1 < fields.length; i += 2) {
66 if (fields[i] === 'local' || fields[i] === 'worktree')
67 own.push(`${fields[i]}\0${fields[i + 1]}`);
68 }
69 return own.join('\0');
70}
71
72export function repoChanges(
73 a: RepoState,
74 b: RepoState,
75): { changed: string[]; unreadable: string[] } {
76 const changed: string[] = [];
77 const unreadable: string[] = [];
78 for (const [k, name] of Object.entries(PARTS) as [keyof RepoState, string][]) {
79 if (a[k] === null || b[k] === null) unreadable.push(name);
80 else if (a[k] !== b[k]) changed.push(name);
81 }
82 return { changed, unreadable };
83}
84
85export function insideRepo(path: string, top: string): boolean {
86 return path === top || path.startsWith(`${top}/`);
87}
88
89// The notes for the reviewer and the records for the status tool.
90export function containmentReport(r: {
91 type: string;
92 command: string;
93 before: Capture;
94 after: Capture;
95 background: boolean;
96}): { notes: string[]; records: string[] } {
97 const shown = firstLine(r.command).slice(0, 80);
98 const { changed, unreadable } = repoChanges(r.before.state, r.after.state);
99 const notes: string[] = [];
100 const records: string[] = [];
101 if (changed.length > 0) {
102 const what = changed.join(', ');
103 records.push(`${r.type}: ${what} changed while \`${shown}\` ran`);
104 notes.push(
105 `review-cycle: ${what} of the repository under review changed while this command ran, by it or by another agent. If this command made the change, put it back; either way, say so in your report, and keep scratch work in a private directory from mktemp -d.`,
106 );
107 }
108 if (unreadable.length > 0) {
109 const whys = [...new Set([r.before.why, r.after.why])].filter((w) => w !== null);
110 const what = `${unreadable.join(', ')}${whys.length === 0 ? '' : ` (${whys.join('; ')})`}`;
111 records.push(`${r.type}: could not check ${what} while \`${shown}\` ran`);
112 notes.push(
113 `review-cycle could not check whether this command changed ${what} of the repository under review.`,
114 );
115 }
116 if (r.background) {
117 notes.push(
118 'review-cycle: this command runs in the background, so what it changes in the repository under review after it returns is not checked.',
119 );
120 }
121 return { notes, records };
122}
123hooks/edits.ts 125 lines1// Notes the files a Bash command changed, so its edits go through Edit and
2// Write, where the comment-slop and settings checks run. No `$`: the snapshot
3// and the comparison are passed in, as git.ts takes its runner.
4//
5// The working tree is compared around the command; `mayWrite` only picks the
6// commands worth measuring, so a false yes costs time, not accuracy.
7
8import { basename, BUILTIN_RUNNERS, every, INTERPRETERS, KEYWORDS } from './command';
9import { messageOf } from './git';
10import { assignmentName, parse, type ShellAliases, type Statement, type Word } from './shell';
11
12// Writers and runners the gate does not treat as running code.
13const WRITERS = new Set([
14 'tee',
15 'sed',
16 'gsed',
17 'awk',
18 'gawk',
19 'cp',
20 'mv',
21 'install',
22 'truncate',
23 'dd',
24 'rsync',
25 'sponge',
26 'patch',
27 'xargs',
28]);
29const PYTHON = /^python[0-9.]*$/;
30// Wrappers that run the command after them, besides the reserved words.
31const WRAPPERS = new Set(['time', 'env', 'exec', 'command', 'nohup', 'nice']);
32// The paths a note lists before it counts the rest.
33const SHOWN = 10;
34
35// A redirect whose target the shell computes may land anywhere.
36const writesFile = (w: Word): boolean => w.dynamic || !w.text.startsWith('/dev/');
37
38const nameOf = (w: Word): string => basename(w.text).replace(/\.exe$/i, '');
39
40// Runners whose names are also ordinary words and paths (`git add .`,
41// `rg expect`, `pnpm run watch`): they count only as the command itself,
42// which runsScript checks.
43const COMMAND_ONLY = new Set([
44 ...BUILTIN_RUNNERS,
45 'expect',
46 'watch',
47 'script',
48 'sudo',
49 'doas',
50 'uv',
51 'mise',
52 'nix-shell',
53]);
54
55function names(w: Word): boolean {
56 const name = nameOf(w);
57 if (COMMAND_ONLY.has(name)) return false;
58 return INTERPRETERS.has(name) || WRITERS.has(name) || PYTHON.test(name);
59}
60
61const beforeCommand = (w: Word): boolean =>
62 assignmentName(w) !== null ||
63 KEYWORDS.has(w.text) ||
64 WRAPPERS.has(w.text) ||
65 w.text.startsWith('-');
66
67// A script run by its path, or a command-only runner, behind any
68// assignments, flags, reserved words and wrappers.
69function runsScript(st: Statement): boolean {
70 const command = st.words.find((w) => !beforeCommand(w));
71 if (command === undefined) return false;
72 return command.text.includes('/') || COMMAND_ONLY.has(nameOf(command));
73}
74
75// Any word counts, not only the first, so a writer behind `if`, `time`,
76// `env`, `sudo`, `xargs` or a wrapper still counts. A command it cannot read
77// counts.
78export function mayWrite(command: string, aliases: ShellAliases = new Map()): boolean {
79 const parsed = parse(command, aliases);
80 if ('error' in parsed) return true;
81 if (parsed.bareWrites.some(writesFile)) return true;
82 const writes = (st: Statement): boolean =>
83 st.writes.some(writesFile) || st.words.some(names) || runsScript(st);
84 return every(parsed.statements, (st) => (writes(st) ? 'writes' : null)) !== null;
85}
86
87// The comparison sees every change made while the command ran, a parallel
88// Edit or the user's own save included, so the note cannot claim the command
89// made them.
90export function editNote(paths: readonly string[]): string {
91 const shown = paths.slice(0, SHOWN).join(', ');
92 const more = paths.length > SHOWN ? ` and ${paths.length - SHOWN} more` : '';
93 return `review-cycle: files changed while this command ran: ${shown}${more}. If the command made those edits, make file changes with Edit or Write instead: the comment-slop and settings checks run there, not on Bash.`;
94}
95
96export function editsSkipped(why: string): string {
97 return `review-cycle: could not check which files this command changed (${why}).`;
98}
99
100// Runs the command between two snapshots and names what changed. Never
101// rejects for a failed measurement: the command runs, and a failure becomes
102// the note. `diff` returns null when it cannot compare.
103export async function measureEdits<R>(
104 snapshot: () => Promise<string>,
105 diff: (before: string, after: string) => Promise<string[] | null>,
106 run: () => Promise<R>,
107): Promise<{ result: R; note: string | null }> {
108 let before: string;
109 try {
110 before = await snapshot();
111 } catch (error) {
112 return { result: await run(), note: editsSkipped(messageOf(error)) };
113 }
114 const result = await run();
115 try {
116 const after = await snapshot();
117 if (after === before) return { result, note: null };
118 const changed = await diff(before, after);
119 if (changed === null) return { result, note: editsSkipped('git could not compare the trees') };
120 return { result, note: changed.length === 0 ? null : editNote(changed) };
121 } catch (error) {
122 return { result, note: editsSkipped(messageOf(error)) };
123 }
124}
125hooks/gh-verdict.ts 302 lines1// Whether the GitHub writes in a call may run on the stop-before ladder, and
2// the refusals when they may not. Pure: what it needs from gh or the settings
3// comes through `GhLookups`, which register.ts builds.
4
5import { covers, type Grant, type HoldReason, type HoldStep } from './consent';
6import type { GhAction, PushRef } from './github';
7import { asks, type Ladder, type Step } from './ladder';
8import { askingReason, pushOutcome } from './push-verdict';
9
10// `unreadable` names the settings file that could not be read, and why.
11export type InForce = Ladder & { unreadable?: string };
12// A step that runs without the user asking for it directly, by the ladder.
13export type Unasked = { step: Step; ladder: Ladder };
14
15// What a gh read printed, or why the step asks instead.
16export type Lookup = { out: string; asks?: never } | { asks: string; out?: never };
17
18export type PullRequest = { cross: boolean; repo: string; branch: string };
19
20export const PR_FIELDS = 'url,isCrossRepository,headRefName';
21export const PR_JQ = String.raw`"\(.isCrossRepository) \(.url) \(.headRefName)"`;
22
23export function parsePullRequest(out: string): PullRequest | { asks: string } {
24 const read = /^(true|false) https:\/\/([^/\s]+\/[^/\s]+\/[^/\s]+)\/pull\/\d+ (\S+)$/.exec(out);
25 if (read === null) return { asks: `looking up the pull request it updates printed \`${out}\`` };
26 const [, cross = '', repo = '', branch = ''] = read;
27 // The repository from the pull request's URL, so an Enterprise host survives.
28 return { cross: cross === 'true', repo, branch };
29}
30
31export type GhLookups = {
32 ladder: () => Promise<InForce>;
33 // The head branch of the pull request `gh pr view <lookup>` names.
34 mergeHead: (lookup: readonly string[]) => Promise<Lookup>;
35 pullRequest: (head: readonly string[]) => Promise<PullRequest | { asks: string }>;
36 // The default branch of `repo` (HOST/OWNER/NAME or OWNER/NAME), or of the
37 // repository gh picks when it is null.
38 defaultBranch: (repo: string | null) => Promise<Lookup>;
39};
40
41// A read's text without surrounding space; one that printed nothing names
42// nothing, so the step asks.
43function read(r: Lookup, what: string): Lookup {
44 if (r.asks !== undefined) return r;
45 const out = r.out.trim();
46 return out === '' ? { asks: `looking up ${what} printed nothing` } : { out };
47}
48
49// What may run: what the user asked for, and what the ladder lets through.
50// A hold stops every step but a commit.
51type Allowed = Readonly<Record<Step, boolean>>;
52export function permitted(granted: Grant, ladder: Ladder, held: boolean): Allowed {
53 const free = (step: Step) => !asks(ladder, step);
54 return {
55 commit: granted.commit || free('commit'),
56 push: covers(granted, 'push') || (!held && free('push')),
57 pr: granted.pr || (!held && free('pr')),
58 merge: granted.merge || (!held && free('merge')),
59 approve: granted.approve || (!held && free('approve')),
60 release: granted.release || (!held && free('release')),
61 };
62}
63
64// The agent asks in its reply, naming the step and its target, and ends its turn.
65// `shown` is the call as the refusal quotes it.
66export function askThem(naming: string, example: string, shown: string): string {
67 return `stop and ask them in your reply, naming ${naming} with names in backticks (for example ${example}), and end your turn; their answer decides. The command: ${shown}`;
68}
69
70// Why the steps are held, or null when nothing holds them.
71export type Hold = HoldReason | null;
72
73const STEP_NAME: Record<HoldStep, string> = {
74 push: 'a push',
75 pr: 'a pull request',
76 merge: 'a merge',
77 approve: 'an approval',
78 release: 'a release',
79 comment: 'a reply on GitHub',
80};
81
82// What held the steps, so the agent can tell the user.
83function heldWhy(held: HoldReason): string {
84 const step = STEP_NAME[held.step];
85 return held.how === 'mentioned'
86 ? `their message mentioned ${step} without asking for one`
87 : `they put off ${step} the agent offered`;
88}
89
90// Why the user's latest message does not cover the step, the setting
91// included when its files could not be read.
92export function notAsked(what: string, ladder: InForce, holdable: boolean, held: Hold): string {
93 const why =
94 holdable && held !== null
95 ? `the user held off (${heldWhy(held)}) and hasn't asked for ${what} since`
96 : `the user's latest message doesn't ask for ${what}`;
97 return ladder.unreadable === undefined
98 ? why
99 : `could not read ${ladder.unreadable}, so the gate stops before every step, and ${why}`;
100}
101
102function prRefusal(shown: string, ladder: InForce, held: Hold): string {
103 return `${notAsked('a pull request', ladder, true, held)}, so nothing ran. To open one, ${askThem('the branch and the base it targets', '"Open a PR from `fix/x` into `main`?"', shown)}`;
104}
105
106// The branch oakum's version pull request comes from: merging it is the release.
107const VERSION_BRANCH = 'oakum/version-packages';
108
109const CANNOT: Record<'elsewhere' | 'unnamed', string> = {
110 elsewhere:
111 'something outside the words the gate looks up picks where it merges (a `--repo` built at run time, `GH_REPO=`, `GIT_DIR=`, `env -C`, `--hostname`, a URL on another host), so the gate cannot tell a release from a merge',
112 unnamed:
113 'it does not name the pull request by number and repository, so the gate cannot tell a release from a merge',
114};
115
116// The pull request number a merge names (`116`, or a URL ending in it), or
117// null when it names none.
118function pullNumberOf(lookup: readonly string[] | { cannot: string }): string | null {
119 if ('cannot' in lookup) return null;
120 const selector = lookup[0];
121 if (selector === undefined || selector.startsWith('-')) return null;
122 return /(\d+)\/?$/.exec(selector)?.[1] ?? null;
123}
124
125// Which step a merge is: a release when it merges the version pull request.
126// A lookup that fails says why, and the merge asks.
127async function mergeStep(
128 lookups: GhLookups,
129 lookup: readonly string[],
130): Promise<{ step: 'merge' | 'release' } | { asks: string }> {
131 const r = read(await lookups.mergeHead(lookup), 'the pull request it merges');
132 if (r.asks !== undefined) return { asks: r.asks };
133 return { step: r.out === VERSION_BRANCH ? 'release' : 'merge' };
134}
135
136// Why a push the ladder lets through asks anyway, or null: one to the
137// default branch, or one whose branch the gate cannot tell.
138async function pushAsks(lookups: GhLookups, ref: PushRef): Promise<string | null> {
139 if ('asks' in ref) return ref.asks;
140 let target: { branch: string; repo: string | null } = { branch: '', repo: null };
141 if ('head' in ref) {
142 const head = await lookups.pullRequest(ref.head);
143 if ('asks' in head) return head.asks;
144 if (head.cross) return "it updates a branch in the pull request's fork";
145 if (head.branch === '') return 'the pull request it updates names no branch';
146 target = head;
147 } else target = ref;
148 const r = read(await lookups.defaultBranch(target.repo), 'the default branch');
149 if (r.asks !== undefined) return r.asks;
150 const url = target.repo ?? '';
151 return askingReason(
152 [{ flag: ' ', to: `refs/heads/${target.branch}`, url }],
153 new Map([[url, { branch: r.out }]]),
154 );
155}
156
157// Each example is one the consent grammar grants on a yes (consent.spec.ts).
158const GH_ASK: Record<
159 'merge' | 'approve' | 'release' | 'comment' | 'push',
160 [string, string, string]
161> = {
162 merge: ['a merge', 'the pull request', '"Merge #116?"'],
163 approve: ['an approval', 'the pull request', '"Approve #116?"'],
164 release: ['a release', 'the version it releases', '"Release `v0.25.0`?"'],
165 comment: [
166 'a comment on GitHub',
167 'where it goes and what it says',
168 '"Reply to the review on #116?"',
169 ],
170 push: ['a push', 'what it pushes and where', '"Push `fix/x` to `origin`?"'],
171};
172
173function ghRefusal(
174 shown: string,
175 kind: keyof typeof GH_ASK,
176 ladder: InForce,
177 always: string | null,
178 held: Hold,
179): string {
180 const [what, naming, example] = GH_ASK[kind];
181 const asksAnyway = always === null ? '' : ` This asks whatever the setting: ${always}.`;
182 const unreviewed =
183 kind === 'push' ? ' It writes to GitHub directly, so no review covers it.' : '';
184 return `${notAsked(what, ladder, true, held)}, so nothing ran.${asksAnyway}${unreviewed} To go ahead, ${askThem(naming, example, shown)}`;
185}
186
187// What the GitHub writes in a call may run, or the refusal of the first one
188// that may not. A comment is off the ladder: it needs the user's request. A
189// requested pull request lets no push through GitHub's API, which no review
190// covers.
191export async function judgeGh(
192 shown: string,
193 actions: readonly GhAction[],
194 granted: Grant,
195 held: Hold,
196 lookups: GhLookups,
197): Promise<{ deny: string } | { ran: Unasked[] }> {
198 const ran: Unasked[] = [];
199 if (actions.length === 0) return { ran };
200 const ladder = await lookups.ladder();
201 const may = permitted(granted, ladder, held !== null);
202 const refuse = (kind: keyof typeof GH_ASK, always: string | null) => ({
203 deny: ghRefusal(shown, kind, ladder, always, held),
204 });
205 for (const action of actions) {
206 switch (action.kind) {
207 case 'pr': {
208 if (!may.pr) return { deny: prRefusal(shown, ladder, held) };
209 if (!granted.pr) ran.push({ step: 'pr', ladder });
210 break;
211 }
212 case 'unread': {
213 const remedy = action.remedy ?? 'Run the gh command itself, written out.';
214 return {
215 deny: `${action.why}, so the gate cannot tell which step it takes, and nothing ran. ${remedy} The command: ${shown}`,
216 };
217 }
218 case 'comment': {
219 if (!granted.comment) return refuse('comment', null);
220 break;
221 }
222 case 'approve': {
223 if (!may.approve) return refuse('approve', null);
224 if (!granted.approve) ran.push({ step: 'approve', ladder });
225 break;
226 }
227 case 'release': {
228 if (!may.release) return refuse('release', null);
229 if (!granted.release) ran.push({ step: 'release', ladder });
230 break;
231 }
232 case 'push': {
233 const { ref } = action;
234 const needed = 'asks' in ref && ref.force ? 'bare' : 'push';
235 const outcome = await pushOutcome(covers(granted, needed) ? null : needed, may.push, () =>
236 pushAsks(lookups, ref),
237 );
238 if (outcome.kind === 'unasked') ran.push({ step: 'push', ladder });
239 if (outcome.kind !== 'refused') break;
240 if (outcome.missing === 'bare') {
241 return {
242 deny: `a forced ref update overwrites whatever the branch holds, with no lease, and the user's latest message doesn't ask for a bare force, so nothing ran. If they want one, ${askThem('what it overwrites and where', '"Force-push `fix/x` to `origin` without a lease?"', shown)}`,
243 };
244 }
245 return refuse('push', outcome.always);
246 }
247 case 'merge': {
248 if (action.admin) {
249 return {
250 deny: `--admin merges past branch protection, which the gate never lets an agent do, so nothing ran. Give the user the command to run themselves, and say why it needs --admin. The command: ${shown}`,
251 };
252 }
253 // A merge asked for once the pull request is ready is what `--auto`
254 // does; a merge now goes against that request, whatever the setting.
255 if (granted.autoMerge && !granted.merge && !action.auto) {
256 return {
257 deny: `the user asked to merge only once the pull request is ready, which \`gh pr merge --auto\` leaves to GitHub, so nothing ran. Run \`gh pr merge <number> --auto\`, naming the pull request by number, or ${askThem('the pull request', '"Merge #116 now?"', shown)}`,
258 };
259 }
260 const asked = { ...granted, merge: granted.merge || (action.auto && granted.autoMerge) };
261 const mayHere = permitted(asked, ladder, held !== null);
262 // A merge the user asked for covers oakum's version pull request too:
263 // merging it is the only way to release, so "merge it" asks for that,
264 // unless the same message held off a release ("merge 131. don't release yet")
265 // or named other pull requests ("merge 131" never merges #130).
266 const named = granted.mergeNamed;
267 const target = pullNumberOf(action.lookup);
268 const covered = named.length === 0 || (target !== null && named.includes(target));
269 if (granted.merge && held?.step !== 'release' && covered) break;
270 // Allowed either way, a merge needs no lookup to tell which it is.
271 if (mayHere.merge && mayHere.release) {
272 if (!asked.merge && !asked.release) ran.push({ step: 'merge', ladder });
273 break;
274 }
275 const { lookup } = action;
276 let which: Awaited<ReturnType<typeof mergeStep>>;
277 if (!('cannot' in lookup)) which = await mergeStep(lookups, lookup);
278 else if (lookup.cannot === 'beside') {
279 return {
280 deny: `other steps in the same command can change which pull request the merge reaches, so the gate cannot tell a release from a merge, and nothing ran. Run the merge as its own command. The command: ${shown}`,
281 };
282 } else which = { asks: CANNOT[lookup.cannot] };
283 if ('asks' in which && mayHere.merge) {
284 return {
285 deny: `the merge may be a release, which the user has not allowed: ${which.asks}. Nothing ran. To merge it, ${askThem('the pull request', '"Merge and release #62?"', shown)}`,
286 };
287 }
288 if ('asks' in which) return refuse('merge', which.asks);
289 const step = which.step;
290 if (!mayHere[step]) return refuse(step, null);
291 if (!asked[step]) ran.push({ step, ladder });
292 break;
293 }
294 default: {
295 const unhandled: never = action;
296 throw new Error(`no judgment for the gh action ${JSON.stringify(unhandled)}`);
297 }
298 }
299 }
300 return { ran };
301}
302hooks/git.ts 370 lines1// Git reads the gate makes, through a `Git` the hook supplies: `claude plugin
2// validate` follows `$` only into functions declared in register.ts, so the
3// runner is passed in rather than `$`.
4
5import { parseReflog, REFLOG_FORMAT, type Entry } from './attribution';
6import type { Classification } from './command';
7import {
8 CONFIG_PATH,
9 EMPTY_TREE,
10 EXCLUDES,
11 coverage,
12 ignoresOf,
13 type Review,
14 type Row,
15} from './witness';
16
17export type Run = { exitCode: number; stdout: string; stderr: string };
18export type Git = (
19 argv: string[],
20 opts?: { cwd?: string; env?: Record<string, string>; stdin?: string },
21) => Promise<Run>;
22
23// A repository by its working tree and by the object store its worktrees share.
24export type Repo = { top: string; common: string };
25
26export function sha(s: string): string | null {
27 const t = s.trim();
28 return /^[a-f0-9]{40}$/.test(t) ? t : null;
29}
30
31export function firstLine(s: string): string {
32 return s.trim().split('\n')[0] ?? '';
33}
34
35export function messageOf(error: unknown): string {
36 return error instanceof Error ? error.message : String(error);
37}
38
39// The repository at `dir` (or the given cwd), 'none' when there is none, and
40// a throw when git could not say.
41export async function repoAt(git: Git, dir: string | null, cwd?: string): Promise<Repo | 'none'> {
42 const argv = ['git', ...(dir === null ? [] : ['-C', dir])];
43 const r = await git(
44 [...argv, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
45 cwd === undefined ? {} : { cwd },
46 );
47 if (r.exitCode === 0) {
48 const [top, common] = r.stdout.trim().split('\n');
49 if (top && common) return { top, common };
50 }
51 if (/not a git repository/i.test(r.stderr)) return 'none';
52 throw new Error(`git rev-parse failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
53}
54
55// HEAD's commit, or EMPTY_TREE on an unborn branch or when HEAD names a
56// missing object; a throw when git failed.
57export async function headOf(git: Git, root: string): Promise<string> {
58 const r = await git(['git', 'rev-parse', '--verify', '-q', 'HEAD^{commit}'], { cwd: root });
59 if (r.exitCode === 0) {
60 const head = sha(r.stdout);
61 if (head) return head;
62 }
63 if (r.exitCode === 1 && r.stdout.trim() === '') return EMPTY_TREE;
64 throw new Error(`git rev-parse HEAD failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
65}
66
67export async function treeOf(git: Git, root: string, commit: string): Promise<string | null> {
68 if (commit === EMPTY_TREE) return EMPTY_TREE;
69 const r = await git(['git', 'rev-parse', `${commit}^{tree}`], { cwd: root });
70 return r.exitCode === 0 ? sha(r.stdout) : null;
71}
72
73// The config is read from the tree being judged, not the working tree: an
74// `ignore` entry counts only once it is part of what gets reviewed.
75async function pathspecs(git: Git, root: string, tree: string): Promise<string[]> {
76 const r = await git(['git', 'show', `${tree}:${CONFIG_PATH}`], { cwd: root });
77 return ['.', ...EXCLUDES, ...ignoresOf(r.exitCode === 0 ? r.stdout : null)];
78}
79
80function nulList(out: string): string[] {
81 return out.split('\0').filter(Boolean);
82}
83
84// Paths that differ between two trees and need review: the excludes and
85// `ignore` patterns applied, the config always included.
86export async function reviewablePaths(
87 git: Git,
88 root: string,
89 from: string,
90 to: string,
91): Promise<string[] | null> {
92 const base = ['git', 'diff-tree', '-r', '-z', '--no-renames', '--name-only', from, to, '--'];
93 const main = await git([...base, ...(await pathspecs(git, root, to))], { cwd: root });
94 const cfg = await git([...base, CONFIG_PATH], { cwd: root });
95 if (main.exitCode !== 0 || cfg.exitCode !== 0) return null;
96 return [...new Set([...nulList(main.stdout), ...nulList(cfg.stdout)])];
97}
98
99// Runs one step and names it in any failure: git's own first error line, or
100// the runner's reason when it could not run the step at all (a timeout).
101async function must(name: string, run: Promise<Run>): Promise<Run> {
102 let r: Run;
103 try {
104 r = await run;
105 } catch (error) {
106 throw new Error(`${name} failed: ${error instanceof Error ? error.message : String(error)}`, {
107 cause: error,
108 });
109 }
110 if (r.exitCode !== 0)
111 throw new Error(`${name} failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
112 return r;
113}
114
115// A tree built in a scratch copy of the index, so the real index is never
116// touched. `prepare` stages into it; its commands see GIT_INDEX_FILE.
117async function scratchTree(
118 git: Git,
119 root: string,
120 prepare: (env: Record<string, string>) => Promise<void>,
121): Promise<string> {
122 const mk = await must('mktemp', git(['mktemp'], { cwd: root }));
123 const scratch = mk.stdout.trim();
124 if (!scratch) throw new Error('mktemp printed no path');
125 const env = { GIT_INDEX_FILE: scratch };
126 try {
127 const idx = await must(
128 'git rev-parse --git-path index',
129 git(['git', 'rev-parse', '--path-format=absolute', '--git-path', 'index'], { cwd: root }),
130 );
131 await must(
132 'copying the index',
133 git(
134 [
135 'sh',
136 '-c',
137 'if [ -f "$1" ]; then cp "$1" "$2"; else rm -f "$2"; fi',
138 'sh',
139 idx.stdout.trim(),
140 scratch,
141 ],
142 { cwd: root },
143 ),
144 );
145 await prepare(env);
146 const wt = await must('git write-tree', git(['git', 'write-tree'], { cwd: root, env }));
147 const tree = sha(wt.stdout);
148 if (tree === null) {
149 throw new Error(`git write-tree printed no tree id: ${firstLine(wt.stdout) || '(nothing)'}`);
150 }
151 return tree;
152 } finally {
153 try {
154 await git(['rm', '-f', scratch], { cwd: root });
155 } catch {
156 // A leftover temp file does not change the verdict.
157 }
158 }
159}
160
161export async function worktreeTree(git: Git, root: string): Promise<string> {
162 return scratchTree(git, root, async (env) => {
163 await must('git add -A', git(['git', 'add', '-A'], { cwd: root, env }));
164 });
165}
166
167// The tree the command's commit would record: the index after replaying its
168// `git add`s, plus `add -u` for `commit -a`.
169export async function prospectTree(
170 git: Git,
171 root: string,
172 cls: Extract<Classification, { kind: 'gated' }>,
173): Promise<string> {
174 return scratchTree(git, root, async (env) => {
175 for (const argv of cls.adds) {
176 await must('git add', git(['git', '-C', cls.dir, ...argv], { env }));
177 }
178 if (!cls.commit?.all) return;
179 await must('git add -u', git(['git', ...cls.commit.config, 'add', '-u'], { cwd: root, env }));
180 });
181}
182
183export type Coverage = { rows: Row[]; unread: number };
184
185// Coverage of the paths `to` changes against `from`. `unread` counts reviewed
186// trees git could not compare, which then cover nothing.
187export async function coverageOf(
188 git: Git,
189 root: string,
190 from: string,
191 to: string,
192 reviews: readonly Review[],
193): Promise<Coverage | null> {
194 const changed = await reviewablePaths(git, root, from, to);
195 if (changed === null) return null;
196 if (changed.length === 0) return { rows: [], unread: 0 };
197 let unread = 0;
198 const differing = new Map<string, Set<string>>();
199 for (const tree of new Set(reviews.flatMap((r) => r.trees))) {
200 // Changed paths are literal file names here, never pathspec magic.
201 const d = await git(
202 [
203 'git',
204 '--literal-pathspecs',
205 'diff-tree',
206 '-r',
207 '-z',
208 '--no-renames',
209 '--name-only',
210 tree,
211 to,
212 '--',
213 ...changed,
214 ],
215 { cwd: root },
216 );
217 if (d.exitCode === 0) differing.set(tree, new Set(nulList(d.stdout)));
218 else unread++;
219 }
220 return { rows: coverage(changed, reviews, differing), unread };
221}
222
223// HEAD's parent's tree; the caller has already resolved HEAD to a commit.
224export async function parentTree(git: Git, root: string): Promise<string | null> {
225 const p = await git(['git', 'rev-parse', '--verify', '-q', 'HEAD^'], { cwd: root });
226 return p.exitCode === 0 ? treeOf(git, root, p.stdout.trim()) : EMPTY_TREE;
227}
228
229export type Refs = Map<string, string>;
230
231// Remote-tracking refs by name. Symbolic refs are left out: their reflog does
232// not follow the ref they point at.
233export async function remoteRefs(git: Git, root: string): Promise<Refs> {
234 const r = await git(
235 ['git', 'for-each-ref', '--format=%(objectname)%09%(symref)%09%(refname)', 'refs/remotes'],
236 { cwd: root },
237 );
238 if (r.exitCode !== 0) {
239 throw new Error(`git for-each-ref failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
240 }
241 const refs: Refs = new Map();
242 for (const line of r.stdout.split('\n')) {
243 const [id, symref, name] = line.split('\t');
244 if (id && name && !symref) refs.set(name, id);
245 }
246 return refs;
247}
248
249// The reflog messages the command added to `ref`, newest first: the entries
250// after the one that set its starting value `was`. A fresh clone logs nothing
251// before a ref's first move, so a log that never reaches `was` is all new. A
252// ref the command created stops at `remote: renamed`: a renamed remote keeps
253// the old remote's entries below that line. At most 50 are read.
254async function reflogSince(
255 git: Git,
256 root: string,
257 ref: string,
258 was: string | undefined,
259): Promise<string[]> {
260 const r = await git(['git', 'log', '-g', '-n', '50', '--format=%H %gs', ref, '--'], {
261 cwd: root,
262 });
263 if (r.exitCode !== 0) {
264 throw new Error(`git log -g ${ref} failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
265 }
266 const messages: string[] = [];
267 for (const line of r.stdout.split('\n')) {
268 const space = line.indexOf(' ');
269 if (space === -1) break;
270 const message = line.slice(space + 1);
271 if (line.slice(0, space) === was) break;
272 if (was === undefined && message.startsWith('remote: renamed ')) break;
273 messages.push(message);
274 }
275 return messages;
276}
277
278// Remote-tracking refs a push moved, by name: git logs those moves as
279// `update by push`. A push that updates no remote-tracking ref, deletes one,
280// or leaves no reflog is not seen.
281export async function pushedRefs(
282 git: Git,
283 root: string,
284 before: Refs,
285 after: Refs,
286): Promise<string[]> {
287 const pushed: string[] = [];
288 for (const [ref, id] of after) {
289 if (before.get(ref) === id) continue;
290 const log = await reflogSince(git, root, ref, before.get(ref));
291 if (log.some((m) => m.startsWith('update by push'))) {
292 pushed.push(ref.slice('refs/remotes/'.length));
293 }
294 }
295 return pushed;
296}
297
298// HEAD's newest `n` reflog entries, newest first. git cannot read it while
299// HEAD is unborn, so callers skip it then.
300export async function headLog(git: Git, root: string, n: number): Promise<Entry[]> {
301 const r = await git(
302 ['git', 'log', '-g', '-n', String(n), '--date=raw', `--format=${REFLOG_FORMAT}`, 'HEAD', '--'],
303 { cwd: root },
304 );
305 if (r.exitCode !== 0) {
306 throw new Error(`git log -g HEAD failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
307 }
308 return parseReflog(r.stdout);
309}
310
311// How many entries HEAD's reflog holds. While HEAD is unborn git will not
312// read it, so the log file's entries are counted instead, skipping the ones
313// that record a deletion (a null new id), as `rev-list -g` does. A reftable
314// repository keeps no such file, so an unborn HEAD there cannot be counted.
315export async function headLogCount(git: Git, root: string, unborn: boolean): Promise<number> {
316 if (!unborn) {
317 const r = await git(['git', 'rev-list', '-g', '--count', 'HEAD'], { cwd: root });
318 const n = Number(r.stdout.trim());
319 if (r.exitCode !== 0 || !Number.isInteger(n)) {
320 throw new Error(`git rev-list -g failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
321 }
322 return n;
323 }
324 const p = await git(['git', 'rev-parse', '--path-format=absolute', '--git-path', 'logs/HEAD'], {
325 cwd: root,
326 });
327 const path = p.stdout.trim();
328 if (p.exitCode !== 0 || !path) {
329 throw new Error(`git rev-parse failed: ${firstLine(p.stderr) || `exit ${p.exitCode}`}`);
330 }
331 const count = await git([
332 'sh',
333 '-c',
334 // awk counts, so a file it cannot read fails the call rather than counting 0.
335 'if [ -f "$1" ]; then awk \'$2 ~ /^[0-9a-f]+$/ && $2 !~ /^0+$/ { n++ } END { print n + 0 }\' "$1"; else echo none; fi',
336 'sh',
337 path,
338 ]);
339 const out = count.stdout.trim();
340 if (out === 'none') {
341 const exists = await git(['git', 'reflog', 'exists', 'HEAD'], { cwd: root });
342 if (exists.exitCode === 0) throw new Error("HEAD's reflog cannot be read while HEAD is unborn");
343 return 0;
344 }
345 const n = Number(out);
346 if (count.exitCode !== 0 || !Number.isInteger(n)) {
347 throw new Error(
348 `counting ${path} failed: ${firstLine(count.stderr) || `exit ${count.exitCode}`}`,
349 );
350 }
351 return n;
352}
353
354// A digest of HEAD and every reviewable change against it, blob ids included:
355// equal digests mean nothing a reviewer should see has moved.
356export async function snapshotOf(
357 git: Git,
358 root: string,
359 head: string,
360 tree: string,
361): Promise<string | null> {
362 const base = ['git', 'diff-tree', '-r', '--no-renames', '--raw', head, tree, '--'];
363 const main = await git([...base, ...(await pathspecs(git, root, tree))], { cwd: root });
364 const cfg = await git([...base, CONFIG_PATH], { cwd: root });
365 if (main.exitCode !== 0 || cfg.exitCode !== 0) return null;
366 const bytes = new TextEncoder().encode(`${head}\n${main.stdout}${cfg.stdout}`);
367 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
368 return [...digest].map((b) => b.toString(16).padStart(2, '0')).join('');
369}
370hooks/git-args.ts 367 lines1import type { Word } from './shell';
2
3export type Refusal = { refuse: string };
4
5export type CommitSpec = {
6 all: boolean;
7 amend: boolean;
8 dryRun: boolean;
9 // The commit's own `-c` options, which `-a` staging must see too.
10 config: string[];
11};
12
13export type PushSpec = {
14 // `lease` is --force-with-lease or --force-if-includes; `bare` is --force,
15 // -f, --mirror or a `+refspec`, which overwrite whatever the remote holds.
16 force: 'none' | 'lease' | 'bare';
17 // --tags, --follow-tags, a refs/tags/ refspec or `tag <name>`.
18 tags: boolean;
19 // --delete, -d, --prune, --mirror or a `:ref` refspec.
20 deletes: boolean;
21 // --all, --branches or --mirror.
22 every: boolean;
23 // `default` when none is given, so git's configured default applies.
24 remote: 'default' | 'dynamic' | { name: string };
25 // The refspecs after the remote; null when one is built at run time.
26 refspecs: string[] | null;
27 // The push as git is given it, `-c` options then the words after `push`,
28 // for the gate to repeat as a dry run; null when a word is built at run time.
29 // `end` is the index of the `--` that ends its options, else the length.
30 argv: { config: string[]; args: string[]; end: number } | null;
31 // An earlier step that can retarget the push, unseen by a dry run run first.
32 after: string | null;
33};
34
35// `value` takes the rest of the word or else the next word (`--x=v` or
36// `--x v` for a long one); `attached` takes only the rest of the word, so
37// `-uno` and `--signed=if-asked` but never the next word; a refusal stops
38// the read with its reason.
39type Kind = 'flag' | 'value' | 'attached' | Refusal;
40
41type Table<L extends string, S extends string> = {
42 sub: string;
43 long: Readonly<Record<L, Kind>>;
44 short: Readonly<Record<S, Kind>>;
45 // Whether a short letter the table does not name is refused or let pass.
46 otherShort: 'refuse' | 'accept';
47 // Refuse an option whose value would be the next word when there is none.
48 needsValue: boolean;
49};
50
51// Tables are object literals, so a name listed twice does not compile, and
52// the option names a callback compares against are their keys, so a
53// misspelled one does not compile either.
54const table = <L extends string, S extends string>(t: Table<L, S>): Table<L, S> => t;
55
56// A known option as read. `value` is the attached text, or the next word
57// for a `value` kind (null when the words ran out).
58type Option<L extends string, S extends string> =
59 | { long: true; name: L; value: Word | null }
60 | { long: false; name: S; value: Word | null };
61
62type Read = { positionals: Word[]; end: number };
63
64function kindOf<K extends string>(names: Readonly<Record<K, Kind>>, name: string): Kind | null {
65 return Object.hasOwn(names, name) ? names[name as K] : null;
66}
67
68// Git accepts any unambiguous prefix of a long option (`--ame` amends), so
69// only exact names are read and any other is refused.
70function readOptions<L extends string, S extends string>(
71 args: Word[],
72 options: Table<L, S>,
73 option: (o: Option<L, S>) => void,
74 positional: (w: Word, afterOptions: boolean) => Refusal | null = () => null,
75): Read | Refusal {
76 const positionals: Word[] = [];
77 const unknown = (name: string) => ({
78 refuse: `git ${options.sub} ${name}, an option the gate does not know. Spell it out in full`,
79 });
80 const valueless = (name: string) => ({
81 refuse: `git ${options.sub} ${name} without a value. Give it one, or leave it out`,
82 });
83 let end = args.length;
84 for (let i = 0; i < args.length; i++) {
85 const arg = args[i];
86 if (arg === undefined) break;
87 // An option is read by its name even when its value is built at run
88 // time: `--force-with-lease=main:$expect`.
89 const t = arg.text;
90 if (i > end || !t.startsWith('-') || t === '-') {
91 const refused = positional(arg, i > end);
92 if (refused) return refused;
93 positionals.push(arg);
94 continue;
95 }
96 if (t === '--') {
97 end = i;
98 continue;
99 }
100 if (t.startsWith('--')) {
101 const name = t.slice(2).split('=')[0] ?? '';
102 const kind = kindOf(options.long, name);
103 if (kind === null) return unknown(`--${name}`);
104 if (typeof kind === 'object') return kind;
105 const eq = t.indexOf('=');
106 let value: Word | null = eq === -1 ? null : { ...arg, text: t.slice(eq + 1) };
107 if (kind === 'value' && eq === -1) {
108 value = args[i + 1] ?? null;
109 if (++i >= args.length && options.needsValue) return valueless(`--${name}`);
110 }
111 option({ long: true, name: name as L, value });
112 continue;
113 }
114 for (let j = 1; j < t.length; j++) {
115 const letter = t.charAt(j);
116 const kind = kindOf(options.short, letter);
117 if (kind === null) {
118 if (options.otherShort === 'refuse') return unknown(`-${letter}`);
119 continue;
120 }
121 if (typeof kind === 'object') return kind;
122 const rest = j < t.length - 1 ? { ...arg, text: t.slice(j + 1) } : null;
123 let value: Word | null = kind === 'flag' ? null : rest;
124 if (kind === 'value' && rest === null) {
125 value = args[i + 1] ?? null;
126 if (++i >= args.length && options.needsValue) return valueless(`-${letter}`);
127 }
128 option({ long: false, name: letter as S, value });
129 if (kind !== 'flag') break;
130 }
131 }
132 return { positionals, end };
133}
134
135const PUSH = table({
136 sub: 'push',
137 long: {
138 repo: 'value',
139 'push-option': 'value',
140 'receive-pack': 'value',
141 exec: 'value',
142 signed: 'attached',
143 'recurse-submodules': 'attached',
144 force: 'flag',
145 'force-with-lease': 'flag',
146 'force-if-includes': 'flag',
147 tags: 'flag',
148 'follow-tags': 'flag',
149 delete: 'flag',
150 prune: 'flag',
151 all: 'flag',
152 branches: 'flag',
153 mirror: 'flag',
154 'set-upstream': 'flag',
155 verbose: 'flag',
156 quiet: 'flag',
157 progress: 'flag',
158 'no-progress': 'flag',
159 verify: 'flag',
160 'no-verify': 'flag',
161 'dry-run': 'flag',
162 porcelain: 'flag',
163 atomic: 'flag',
164 'no-atomic': 'flag',
165 // Accepted but not read: a tag flag anywhere counts as a tag push, which
166 // errs toward asking (git keeps --tags and --follow-tags apart).
167 'no-tags': 'flag',
168 'no-follow-tags': 'flag',
169 thin: 'flag',
170 'no-thin': 'flag',
171 ipv4: 'flag',
172 ipv6: 'flag',
173 'no-force-with-lease': 'flag',
174 'no-force-if-includes': 'flag',
175 'no-recurse-submodules': 'flag',
176 'no-signed': 'flag',
177 },
178 short: {
179 o: 'value',
180 f: 'flag',
181 d: 'flag',
182 u: 'flag',
183 v: 'flag',
184 q: 'flag',
185 n: 'flag',
186 '4': 'flag',
187 '6': 'flag',
188 },
189 otherShort: 'refuse',
190 needsValue: true,
191});
192
193// What a refspec says about the push, read from the text as written so
194// `"+$B"` still shows its `+`. `next` is the refspec after it, if any.
195function refspecOf(
196 r: string,
197 next: boolean,
198): Pick<PushSpec, 'deletes' | 'tags'> & { bare: boolean } {
199 return {
200 bare: r.startsWith('+'),
201 deletes: r.startsWith(':'),
202 // `git push origin tag v1` pushes refs/tags/v1.
203 tags: /(^|:)refs\/tags\//.test(r.replace(/^\+/, '')) || (r === 'tag' && next),
204 };
205}
206
207export function pushSpec(args: Word[]): PushSpec | Refusal {
208 const spec: PushSpec = {
209 force: 'none',
210 tags: false,
211 deletes: false,
212 every: false,
213 remote: 'default',
214 refspecs: [],
215 argv: null,
216 after: null,
217 };
218 const bare = () => (spec.force = 'bare');
219 const lease = () => (spec.force = spec.force === 'bare' ? 'bare' : 'lease');
220 let repo: Word | null = null;
221 const read = readOptions(args, PUSH, (o) => {
222 if (!o.long) {
223 if (o.name === 'f') bare();
224 else if (o.name === 'd') spec.deletes = true;
225 } else if (o.name === 'force') bare();
226 else if (o.name === 'force-with-lease' || o.name === 'force-if-includes') lease();
227 else if (o.name === 'tags' || o.name === 'follow-tags') spec.tags = true;
228 else if (o.name === 'delete' || o.name === 'prune') spec.deletes = true;
229 else if (o.name === 'all' || o.name === 'branches') spec.every = true;
230 else if (o.name === 'mirror') {
231 // git help push: refs are "force updated" and missing ones "removed".
232 bare();
233 spec.deletes = true;
234 spec.every = true;
235 } else if (o.name === 'repo') repo = o.value;
236 });
237 if ('refuse' in read) return read;
238 if (!args.some((w) => w.dynamic)) {
239 spec.argv = { config: [], args: args.map((w) => w.text), end: read.end };
240 }
241 // `--repo` stands in for the remote argument, which wins when both are given.
242 const [remote = repo ?? undefined, ...refspecs] = read.positionals;
243 if (remote !== undefined) spec.remote = remote.dynamic ? 'dynamic' : { name: remote.text };
244 spec.refspecs = refspecs.some((w) => w.dynamic) ? null : refspecs.map((w) => w.text);
245 for (const [k, { text }] of refspecs.entries()) {
246 const r = refspecOf(text, k < refspecs.length - 1);
247 if (r.bare) bare();
248 if (r.deletes) spec.deletes = true;
249 if (r.tags) spec.tags = true;
250 }
251 return spec;
252}
253
254const interactive = (flag: string): Refusal => ({
255 refuse: `git commit ${flag}, which stages interactively`,
256});
257const stageFirst = (flag: string): Refusal => ({
258 refuse: `git commit ${flag}. Stage with \`git add\`, then run \`git commit\` alone`,
259});
260
261const COMMIT = table({
262 sub: 'commit',
263 long: {
264 patch: interactive('--patch'),
265 interactive: interactive('--interactive'),
266 include: stageFirst('--include'),
267 only: stageFirst('--only'),
268 'pathspec-from-file': {
269 refuse: 'git commit --pathspec-from-file. Stage with `git add`, then commit',
270 },
271 message: 'value',
272 file: 'value',
273 'reuse-message': 'value',
274 'reedit-message': 'value',
275 template: 'value',
276 author: 'value',
277 date: 'value',
278 cleanup: 'value',
279 fixup: 'value',
280 squash: 'value',
281 trailer: 'value',
282 all: 'flag',
283 amend: 'flag',
284 'dry-run': 'flag',
285 'no-edit': 'flag',
286 edit: 'flag',
287 'no-verify': 'flag',
288 verify: 'flag',
289 signoff: 'flag',
290 'no-signoff': 'flag',
291 'no-gpg-sign': 'flag',
292 'allow-empty': 'flag',
293 'allow-empty-message': 'flag',
294 quiet: 'flag',
295 verbose: 'flag',
296 status: 'flag',
297 'no-status': 'flag',
298 'reset-author': 'flag',
299 short: 'flag',
300 branch: 'flag',
301 porcelain: 'flag',
302 long: 'flag',
303 null: 'flag',
304 'no-post-rewrite': 'flag',
305 'gpg-sign': 'flag',
306 'untracked-files': 'flag',
307 },
308 short: {
309 p: interactive('-p'),
310 i: stageFirst('-i'),
311 o: stageFirst('-o'),
312 m: 'value',
313 F: 'value',
314 C: 'value',
315 c: 'value',
316 t: 'value',
317 S: 'attached',
318 u: 'attached',
319 a: 'flag',
320 },
321 otherShort: 'accept',
322 needsValue: false,
323});
324
325const PATHSPECS: Refusal = {
326 refuse: 'git commit with pathspecs. Stage them with `git add`, then run `git commit` alone',
327};
328
329export function commitSpec(args: Word[]): CommitSpec | Refusal {
330 const spec: CommitSpec = { all: false, amend: false, dryRun: false, config: [] };
331 const read = readOptions(
332 args,
333 COMMIT,
334 (o) => {
335 if (o.long ? o.name === 'all' : o.name === 'a') spec.all = true;
336 else if (o.long && o.name === 'amend') spec.amend = true;
337 else if (o.long && o.name === 'dry-run') spec.dryRun = true;
338 },
339 (w, afterOptions) =>
340 w.dynamic && !afterOptions
341 ? {
342 refuse:
343 'git commit with a pathspec built from a variable or substitution. Stage with `git add`, then commit',
344 }
345 : PATHSPECS,
346 );
347 return 'refuse' in read ? read : spec;
348}
349
350export function addArgv(config: string[], args: Word[]): string[] | Refusal {
351 for (const w of args) {
352 if (w.dynamic)
353 return { refuse: 'git add with an argument built from a variable or substitution' };
354 if (w.text.includes('{') && w.pattern)
355 return { refuse: 'git add with a brace expansion; list the paths' };
356 if (
357 /^(-p|-i|-e|--patch|--interactive|--edit)$/.test(w.text) ||
358 /^-[a-zA-Z]*[pie][a-zA-Z]*$/.test(w.text)
359 ) {
360 return { refuse: `git add ${w.text}, which stages interactively` };
361 }
362 if (w.text.startsWith('--pathspec-from-file'))
363 return { refuse: 'git add --pathspec-from-file' };
364 }
365 return [...config, 'add', ...args.map((w) => w.text)];
366}
367hooks/github.ts 539 lines1// What a GitHub write is on the stop-before ladder, read from a `gh api` call
2// or a GitHub MCP tool call. Pure: no `$`, no I/O.
3
4import type { Word } from './shell';
5
6// Where a gh write runs: `elsewhere` when something outside its words picks
7// the repository, `beside` when another step in the command may.
8export type GhContext = 'fixed' | 'elsewhere' | 'beside';
9
10// `asks` says why the push asks whatever the setting; a `force` one needs a
11// request for a bare force, since the API has no lease.
12export type Asking = { asks: string; force: boolean };
13export const asking = (asks: string, force: boolean): Asking => ({ asks, force });
14
15// `head` is a pull request's own branch, looked up by `gh pr view <head>`.
16export type PushRef =
17 | { branch: string; repo: string | null; force?: never }
18 | { head: readonly string[]; force?: never }
19 | Asking;
20
21export type Unlookable = { cannot: Exclude<GhContext, 'fixed'> | 'unnamed' };
22
23export type GhAction =
24 | { kind: 'pr' }
25 // `auto` waits for what GitHub requires to merge: `gh pr merge --auto`.
26 | { kind: 'merge'; admin: boolean; auto: boolean; lookup: readonly string[] | Unlookable }
27 | { kind: 'approve' }
28 | { kind: 'comment' }
29 | { kind: 'release' }
30 | { kind: 'push'; ref: PushRef }
31 | { kind: 'unread'; why: string; remedy?: string };
32
33// `gh api`'s options that take a value (gh 2.102.0's `gh api --help`), and
34// the short ones among them, which take the rest of a cluster (`-XPOST`).
35const API_VALUE = new Set([
36 '-F',
37 '--field',
38 '-f',
39 '--raw-field',
40 '-H',
41 '--header',
42 '-q',
43 '--jq',
44 '-X',
45 '--method',
46 '-p',
47 '--preview',
48 '-t',
49 '--template',
50 '--cache',
51 '--hostname',
52 '--input',
53]);
54const API_SHORT_VALUE = 'FfHqXpt';
55const FIELD = new Set(['-F', '--field', '-f', '--raw-field']);
56
57// A field's value, or null when it is built at run time or read from a file
58// (`-F body=@notes.md`).
59type Fields = Map<string, string | null>;
60
61type ApiCall = {
62 // The method `-X` names, upper-cased; `?` when it is built at run time.
63 method: string | null;
64 endpoint: Word | null;
65 // A word built at run time that may be options (`"$ARGS"`, `$X`), not a
66 // value or an endpoint with a written-out start.
67 opaque: boolean;
68 fields: Fields;
69 // A field whose name is built at run time, so any field may hide in it.
70 unnamed: boolean;
71 input: boolean;
72 hostname: boolean;
73};
74
75// The option a word is and its value, when it takes one: `-X POST`, `-XPOST`,
76// `-X=POST`, `--method=POST`, `-iXPOST`.
77function optionAt(words: Word[], i: number): { name: string; value: Word | null; next: number } {
78 const w = words[i]!;
79 const t = w.text;
80 const long = /^(--[^=]+)=([\s\S]*)$/.exec(t);
81 if (long?.[1] !== undefined) {
82 return { name: long[1], value: { ...w, text: long[2] ?? '' }, next: i + 1 };
83 }
84 if (t.startsWith('--')) {
85 return API_VALUE.has(t)
86 ? { name: t, value: words[i + 1] ?? null, next: i + 2 }
87 : { name: t, value: null, next: i + 1 };
88 }
89 for (let at = 1; at < t.length; at++) {
90 const letter = t.charAt(at);
91 if (!API_SHORT_VALUE.includes(letter)) continue;
92 const rest = t.slice(at + 1).replace(/^=/, '');
93 return rest === ''
94 ? { name: `-${letter}`, value: words[i + 1] ?? null, next: i + 2 }
95 : { name: `-${letter}`, value: { ...w, text: rest }, next: i + 1 };
96 }
97 return { name: t, value: null, next: i + 1 };
98}
99
100// The words after `gh api`.
101function readApi(words: Word[]): ApiCall {
102 const call: ApiCall = {
103 method: null,
104 endpoint: null,
105 opaque: false,
106 fields: new Map(),
107 unnamed: false,
108 input: false,
109 hostname: false,
110 };
111 let i = 0;
112 while (i < words.length) {
113 const w = words[i]!;
114 // A written-out start (`repos/…/$PR`) makes the word the endpoint.
115 if (w.dynamic && /^[$`]/.test(w.text)) {
116 call.opaque = true;
117 i++;
118 continue;
119 }
120 if (!w.text.startsWith('-') || w.text === '-') {
121 call.endpoint ??= w;
122 i++;
123 continue;
124 }
125 const { name, value, next } = optionAt(words, i);
126 i = next;
127 if (name === '-X' || name === '--method') {
128 call.method = value === null || value.dynamic ? '?' : value.text.toUpperCase();
129 } else if (FIELD.has(name) && value !== null) {
130 const eq = value.text.indexOf('=');
131 const key = eq === -1 ? value.text : value.text.slice(0, eq);
132 if (/[$`]/.test(key)) call.unnamed = true;
133 const raw = eq === -1 ? '' : value.text.slice(eq + 1);
134 // A typed field reads a file (`@f`) or fills in `{branch}` and the like.
135 const typed = (name === '-F' || name === '--field') && /^@|\{\w+\}/.test(raw);
136 call.fields.set(key, value.dynamic || typed ? null : raw);
137 } else if (name === '--input') call.input = true;
138 else if (name === '--hostname') call.hostname = true;
139 }
140 return call;
141}
142
143// A branch as GitHub's ref APIs take it, or why the push it names asks: `null`
144// is a name read at run time, `undefined` none at all.
145function branchOf(name: string | null | undefined, missing: string): string | Asking {
146 if (name === null) {
147 return asking('the branch it names is built at run time or read from a file', false);
148 }
149 if (name?.startsWith('refs/tags/')) return asking('it pushes a tag', false);
150 if (name?.startsWith('refs/') && !name.startsWith('refs/heads/')) {
151 return asking(`it writes \`${name}\`, which is no branch`, false);
152 }
153 const branch = name?.replace(/^refs\/heads\//, '');
154 return branch === undefined || branch === '' ? asking(missing, false) : branch;
155}
156
157function pushRef(
158 name: string | null | undefined,
159 missing: string,
160 repo: string | null,
161 where: GhContext,
162): PushRef {
163 if (where === 'elsewhere') return asking('where it pushes is picked outside its words', false);
164 const branch = branchOf(name, missing);
165 if (typeof branch !== 'string') return branch;
166 if (where === 'beside' && repo === null) {
167 return asking('another step in the command can change which repository it reaches', false);
168 }
169 return { branch, repo };
170}
171
172// What a REST write to `repos/<owner>/<repo>/<path>` is on the ladder.
173function restAction(
174 method: string,
175 path: string,
176 repo: string | null,
177 call: ApiCall,
178 where: GhContext,
179): GhAction | null {
180 const field = (key: string) => (call.unnamed || call.input ? null : call.fields.get(key));
181 const unknown = (key: string) => call.input || call.unnamed || call.fields.get(key) === null;
182 const push = (name: string | null | undefined, missing: string): GhAction => ({
183 kind: 'push',
184 ref: pushRef(name, missing, repo, where),
185 });
186 if (path === 'pulls') return method === 'POST' ? { kind: 'pr' } : null;
187 if (path === 'releases' || path.startsWith('releases/')) return { kind: 'release' };
188 const pull = /^pulls\/([^/]+)\/(.+)$/.exec(path);
189 if (pull?.[2] === 'merge') {
190 const number = pull[1] ?? '';
191 const lookup: readonly string[] | Unlookable =
192 where !== 'fixed'
193 ? { cannot: where }
194 : /^\d+$/.test(number)
195 ? [number, ...(repo === null ? [] : ['--repo', repo])]
196 : { cannot: 'unnamed' };
197 return { kind: 'merge', admin: false, auto: false, lookup };
198 }
199 if (pull?.[2] === 'update-branch') {
200 const number = pull[1] ?? '';
201 const unlookable = where === 'elsewhere' || (where === 'beside' && repo === null);
202 if (unlookable || !/^\d+$/.test(number)) {
203 return {
204 kind: 'push',
205 ref: asking('the pull request it updates cannot be looked up', false),
206 };
207 }
208 return { kind: 'push', ref: { head: [number, ...(repo === null ? [] : ['--repo', repo])] } };
209 }
210 if (pull?.[2] === 'reviews' || pull?.[2]?.startsWith('reviews/')) {
211 if (unknown('event')) {
212 return {
213 kind: 'unread',
214 why: 'the review event of this `gh api` call is built at run time or read from a file',
215 remedy: 'Write the event out with `-f event=…`.',
216 };
217 }
218 return { kind: field('event')?.toUpperCase() === 'APPROVE' ? 'approve' : 'comment' };
219 }
220 if (/^(pulls|issues)\/[^/]+\/comments(\/|$)|^(pulls|issues)\/comments(\/|$)/.test(path)) {
221 return { kind: 'comment' };
222 }
223 if (path.startsWith('contents/')) {
224 return push(field('branch'), 'it names no branch, so it commits to the default branch');
225 }
226 if (path === 'merges') return push(field('base'), 'its base branch is not written out');
227 if (path === 'merge-upstream') {
228 return push(field('branch'), 'the branch it updates is not written out');
229 }
230 if (/^branches\/.+\/rename$/.test(path)) {
231 return { kind: 'push', ref: asking('it renames a branch', false) };
232 }
233 if (path === 'git/refs' && method === 'POST') {
234 const ref = field('ref');
235 const named = ref === null || ref?.startsWith('refs/') ? ref : undefined;
236 return push(named, 'the ref it creates is not written out');
237 }
238 const ref = /^git\/refs\/(heads|tags)\/(.+)$/.exec(path);
239 if (ref) {
240 const name = ref[2] ?? '';
241 if (ref[1] === 'tags') return { kind: 'push', ref: asking('it pushes a tag', false) };
242 if (method === 'DELETE') return { kind: 'push', ref: asking(`it deletes \`${name}\``, false) };
243 const force = field('force');
244 if (force === null || (force !== undefined && force !== 'false')) {
245 return { kind: 'push', ref: asking(`it force-updates \`${name}\``, true) };
246 }
247 return push(name, '');
248 }
249 return null;
250}
251
252// GraphQL mutations that are ladder steps (GitHub's schema, read 2026-10-03);
253// GitHub has none that writes a release. A review approves when its event
254// says APPROVE.
255const BY_ID = {
256 kind: 'merge',
257 admin: false,
258 auto: false,
259 lookup: { cannot: 'unnamed' },
260} as const satisfies GhAction;
261const MUTATIONS: Record<string, GhAction | 'review' | 'ref' | 'update'> = {
262 createPullRequest: { kind: 'pr' },
263 markPullRequestReadyForReview: { kind: 'pr' },
264 revertPullRequest: { kind: 'pr' },
265 mergePullRequest: BY_ID,
266 enablePullRequestAutoMerge: { ...BY_ID, auto: true },
267 enqueuePullRequest: BY_ID,
268 addPullRequestReview: 'review',
269 submitPullRequestReview: 'review',
270 updatePullRequestBranch: 'update',
271 ...Object.fromEntries(
272 [
273 'addComment',
274 'addPullRequestReviewComment',
275 'addPullRequestReviewThread',
276 'addPullRequestReviewThreadReply',
277 'updateIssueComment',
278 'updatePullRequestReview',
279 'updatePullRequestReviewComment',
280 'deleteIssueComment',
281 'deletePullRequestReview',
282 'deletePullRequestReviewComment',
283 'dismissPullRequestReview',
284 'resolveReviewThread',
285 'unresolveReviewThread',
286 'minimizeComment',
287 'unminimizeComment',
288 ].map((name) => [name, { kind: 'comment' } as const]),
289 ),
290 ...Object.fromEntries(
291 [
292 'createCommitOnBranch',
293 'createLinkedBranch',
294 'createRef',
295 'updateRef',
296 'updateRefs',
297 'deleteRef',
298 'mergeBranch',
299 ].map((name) => [name, 'ref' as const]),
300 ),
301};
302
303const unreadEvent: GhAction = {
304 kind: 'unread',
305 why: 'the review event of this GraphQL mutation is built at run time or read from a file',
306 remedy: 'Write the event out.',
307};
308
309const approves = (v: string | null | undefined) => v?.toUpperCase() === 'APPROVE';
310
311// A review approves only by its `event` argument, written in the query or
312// passed in a variable (`-f event=…`, `-f input[event]=…`).
313function reviewOf(query: string, call: ApiCall): GhAction {
314 // Every review in the query counts, so one that approves is never read as a
315 // comment. `(?<!\$)` skips a variable's definition (`$event: …Event`).
316 const args = [...query.matchAll(/(?<!\$)\bevent\s*:\s*(\$?\w+)/g)].map((m) => m[1] ?? '');
317 // A variable no field sets takes its default; a declared one with neither
318 // sends no event (a pending review), and an undeclared one is unread.
319 const values = args.map((arg) => {
320 if (!arg.startsWith('$')) return arg;
321 const name = arg.slice(1);
322 if (call.fields.has(name)) return call.fields.get(name);
323 const declared = new RegExp(String.raw`\$${name}\s*:\s*[^,)=$]*(?:=\s*(\w+))?`).exec(query);
324 return declared === null ? null : (declared[1] ?? '');
325 });
326 const keys = [...call.fields.keys()].filter((k) => /(^|\[)event\]?$/.test(k));
327 if (values.some((v) => approves(v)) || keys.some((k) => approves(call.fields.get(k)))) {
328 return { kind: 'approve' };
329 }
330 if (values.includes(null)) return unreadEvent;
331 // A review given a whole input object (`input: $input`) carries its event in
332 // that variable or its `[event]` key, either of which may be read at run time.
333 const objects = [...query.matchAll(/\binput\s*:\s*\$(\w+)/g)].map((m) => m[1]);
334 const hidden = [...call.fields].some(
335 ([k, v]) => v === null && objects.some((o) => k === o || k === `${o}[event]`),
336 );
337 return hidden ? unreadEvent : { kind: 'comment' };
338}
339
340// The query with its strings and comments blanked, so text inside them names
341// no operation or mutation.
342const bare = (query: string) =>
343 query.replaceAll(/"""(?:\\"""|[\s\S])*?"""|"(?:\\.|[^"\\])*"|#[^\n]*/g, ' ');
344
345function graphqlActions(call: ApiCall): GhAction[] {
346 const query = call.unnamed || call.input ? null : call.fields.get('query');
347 if (query === undefined) return [];
348 if (query === null) {
349 return [
350 {
351 kind: 'unread',
352 why: 'the GraphQL query of this `gh api` call is built at run time or read from a file',
353 remedy: 'Write the query out with `-f query=…`.',
354 },
355 ];
356 }
357 const ops = bare(query);
358 if (!/(^|[\s}])mutation\b/.test(ops)) return [];
359 const found: GhAction[] = [];
360 for (const [name, action] of Object.entries(MUTATIONS)) {
361 if (!new RegExp(String.raw`\b${name}\s*\(`).test(ops)) continue;
362 if (action === 'review') found.push(reviewOf(ops, call));
363 else if (action === 'ref') {
364 // `force: $f` reads the field that sets `$f`.
365 const forced = (value: string | undefined) => {
366 const name = /^\$(\w+)$/.exec(value ?? '')?.[1];
367 const set = name === undefined ? value : call.fields.get(name);
368 return set !== 'false';
369 };
370 const force =
371 [...ops.matchAll(/\bforce\s*:\s*(\$?\w+)/g)].some((m) => forced(m[1])) ||
372 [...call.fields].some(([k, v]) => /(^|\[)force\]?$/.test(k) && v !== 'false');
373 const asks = 'the gate does not read which branch a GraphQL mutation writes';
374 found.push({ kind: 'push', ref: asking(asks, force) });
375 } else if (action === 'update') {
376 // A rebase rewrites the branch, as `gh pr update-branch --rebase` does.
377 const method = (value: string | undefined) => {
378 const name = /^\$(\w+)$/.exec(value ?? '')?.[1];
379 return name === undefined ? value : call.fields.get(name);
380 };
381 const rebases =
382 [...ops.matchAll(/\bupdateMethod\s*:\s*(\$?\w+)/g)].some((m) => method(m[1]) !== 'MERGE') ||
383 [...call.fields].some(([k, v]) => /(^|\[)updateMethod\]?$/.test(k) && v !== 'MERGE');
384 const asks = 'it names the pull request whose branch it updates by id';
385 found.push({ kind: 'push', ref: asking(asks, rebases) });
386 } else found.push(action);
387 }
388 return found;
389}
390
391// What a `gh api` call writes on the ladder, from the words after `api`. A
392// read, or a write to an endpoint no ladder step covers, is nothing.
393export function apiActions(words: Word[], context: GhContext, fed: boolean): GhAction[] {
394 const call = readApi(words);
395 if (call.method === 'GET' || call.method === 'HEAD') return [];
396 if (call.opaque || call.method === '?') {
397 return [
398 {
399 kind: 'unread',
400 why: 'the arguments of `gh api` are built at run time',
401 remedy: 'Write its options out; a read can name `--method GET`.',
402 },
403 ];
404 }
405 if (call.endpoint === null) {
406 return fed ? [{ kind: 'unread', why: 'its `gh api` endpoint comes from its input' }] : [];
407 }
408 // As gh's api.go decides it: POST once fields or --input are given.
409 const written = call.fields.size > 0 || call.input || call.unnamed;
410 const method = call.method ?? (written ? 'POST' : 'GET');
411 if (method === 'GET' || method === 'HEAD') return [];
412 if (fed) return [{ kind: 'unread', why: 'the arguments of `gh api` come from its input' }];
413 if (call.endpoint.dynamic) {
414 return [
415 {
416 kind: 'unread',
417 why: 'the endpoint of this `gh api` write is built at run time',
418 remedy: 'Write the endpoint out.',
419 },
420 ];
421 }
422 // gh sends a full URL as written: another host is another repository.
423 // GitHub Enterprise serves REST under /api/v3/ and GraphQL at /api/graphql.
424 const url = /^https?:\/\/([^/]+)\/(?:api\/v3\/|api\/(?=graphql\b))?/i.exec(call.endpoint.text);
425 const host = url !== null && url[1]?.toLowerCase() !== 'api.github.com';
426 const endpoint = call.endpoint.text
427 .slice(url?.[0].length ?? 0)
428 .replace(/^\//, '')
429 .replace(/\?.*$/, '');
430 if (endpoint === 'graphql') return graphqlActions(call);
431 const m = /^repos\/([^/]+)\/([^/]+)\/(.+)$/.exec(endpoint);
432 if (!m) return [];
433 const [owner = '', name = '', path = ''] = m.slice(1);
434 const placeholder = owner === '{owner}' && name === '{repo}';
435 const elsewhere =
436 context === 'elsewhere' || call.hostname || host || (!placeholder && /[{}]/.test(owner + name));
437 const where = elsewhere ? 'elsewhere' : context;
438 const action = restAction(method, path, placeholder ? null : `${owner}/${name}`, call, where);
439 return action === null ? [] : [action];
440}
441
442// The GitHub MCP server's write tools that are ladder steps (its README,
443// read 2026-10-03), matched by name under any server name. It has no tool
444// that writes a release.
445const MCP_COMMENT = new Set([
446 'add_comment_to_pending_review',
447 'add_reply_to_pull_request_comment',
448 'add_issue_comment',
449 'update_issue_comment',
450]);
451const MCP_PUSH = new Set(['push_files', 'create_or_update_file', 'delete_file', 'create_branch']);
452const MCP_TOOLS = [
453 'create_pull_request',
454 'create_pull_request_with_copilot',
455 'merge_pull_request',
456 'pull_request_review_write',
457 'update_pull_request_branch',
458 'update_pull_request',
459 ...MCP_COMMENT,
460 ...MCP_PUSH,
461];
462
463// Matches the tool names mcpAction reads.
464export const MCP_GITHUB = new RegExp(String.raw`^mcp__.+__(${MCP_TOOLS.join('|')})$`);
465
466// The arguments a refusal quotes: those naming where the write goes.
467const SHOWN = new Set([
468 'owner',
469 'repo',
470 'pullNumber',
471 'issue_number',
472 'comment_id',
473 'commentId',
474 'branch',
475 'from_branch',
476 'base',
477 'head',
478 'path',
479 'method',
480 'event',
481 'merge_method',
482]);
483
484// The tool call as a refusal quotes it: its name and the arguments naming
485// where it writes, with characters that could hide or reorder text replaced.
486export function shownCall(tool: string, input: Record<string, unknown>): string {
487 const args = Object.entries(input).filter(
488 ([key, v]) => SHOWN.has(key) && ['string', 'number', 'boolean'].includes(typeof v),
489 );
490 const shown = `${tool} ${JSON.stringify(Object.fromEntries(args))}`;
491 return shown.replaceAll(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '\u{FFFD}');
492}
493
494const text = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : null);
495// An owner or repository name gh reads as one, never as an option.
496const named = (s: string | null): s is string => s !== null && /^\w[\w.-]*$/.test(s);
497
498// What a GitHub MCP tool call is on the ladder, or null for any other tool.
499export function mcpAction(tool: string, input: Record<string, unknown>): GhAction | null {
500 const name = MCP_TOOLS.find((t) => tool.endsWith(`__${t}`));
501 if (name === undefined) return null;
502 const owner = text(input.owner);
503 const repoName = text(input.repo);
504 const repo = named(owner) && named(repoName) ? `${owner}/${repoName}` : null;
505 if (name === 'create_pull_request' || name === 'create_pull_request_with_copilot') {
506 return { kind: 'pr' };
507 }
508 if (name === 'merge_pull_request') {
509 const number = text(input.pullNumber);
510 const lookup =
511 number !== null && /^\d+$/.test(number) && repo !== null
512 ? [number, '--repo', repo]
513 : { cannot: 'unnamed' as const };
514 return { kind: 'merge', admin: false, auto: false, lookup };
515 }
516 if (name === 'pull_request_review_write') {
517 return { kind: text(input.event)?.toUpperCase() === 'APPROVE' ? 'approve' : 'comment' };
518 }
519 if (name === 'update_pull_request_branch') {
520 const number = text(input.pullNumber);
521 if (number === null || !/^\d+$/.test(number) || repo === null) {
522 return {
523 kind: 'push',
524 ref: asking('the pull request it updates cannot be looked up', false),
525 };
526 }
527 return { kind: 'push', ref: { head: [number, '--repo', repo] } };
528 }
529 // Marking a draft ready for review is the pull request step; other edits are not.
530 if (name === 'update_pull_request') {
531 return input.draft === false || input.draft === 'false' ? { kind: 'pr' } : null;
532 }
533 if (MCP_COMMENT.has(name)) return { kind: 'comment' };
534 // gh's lookup of the default branch needs the repository spelled out.
535 if (repo === null) return { kind: 'push', ref: asking('it does not name its repository', false) };
536 const branch = branchOf(text(input.branch) ?? undefined, 'it names no branch');
537 return { kind: 'push', ref: typeof branch === 'string' ? { branch, repo } : branch };
538}
539hooks/ladder.ts 87 lines1// Where the agent stops to ask the user before an outward step. Pure.
2//
3// One setting, review-cycle's `stopBefore`: a step below the chosen rung runs
4// without asking, one at or above it needs the user's request. Claude Code
5// passes the register options only from user and managed settings, so the
6// gate reads the project's and the local file's pluginConfigs itself.
7
8export const RUNGS = ['commit', 'push', 'open PR', 'merge', 'release', 'never stop'] as const;
9export type StopBefore = (typeof RUNGS)[number];
10export const DEFAULT_STOP: StopBefore = 'push';
11// What a setting the gate cannot trust falls back to.
12export const STRICTEST: StopBefore = RUNGS[0];
13
14// The steps the gate judges, in rung order.
15export type Step = 'commit' | 'push' | 'pr' | 'merge' | 'approve' | 'release';
16const STEP_RUNG: Record<Step, StopBefore> = {
17 commit: 'commit',
18 push: 'push',
19 pr: 'open PR',
20 merge: 'merge',
21 // An approval can be all a merge waits for.
22 approve: 'merge',
23 release: 'release',
24};
25
26export type Source = 'default' | 'user' | 'project' | 'local';
27export type Ladder = Readonly<{ stopBefore: StopBefore; source: Source }>;
28
29const PLUGIN = /^review-cycle(@|$)/;
30
31const rank = (r: StopBefore): number => RUNGS.indexOf(r);
32
33// A value outside the options counts as unset, as Claude Code documents for
34// the options it passes.
35export function stopBeforeOf(value: unknown): StopBefore | null {
36 return RUNGS.find((r) => r === value) ?? null;
37}
38
39// The value a settings file's pluginConfigs gives review-cycle, under its bare
40// name or a marketplace-qualified one (`review-cycle@oakoss`). A value outside
41// the options stops before every step: a typo meant to stop earlier must not
42// leave a looser rung in force.
43export function configured(pluginConfigs: unknown): StopBefore | null {
44 if (pluginConfigs === null || typeof pluginConfigs !== 'object') return null;
45 let found: StopBefore | null = null;
46 for (const [name, entry] of Object.entries(pluginConfigs)) {
47 if (!PLUGIN.test(name) || entry === null || typeof entry !== 'object') continue;
48 const options: unknown = (entry as { options?: unknown }).options;
49 if (options === null || typeof options !== 'object' || !('stopBefore' in options)) continue;
50 const value = stopBeforeOf(options.stopBefore) ?? STRICTEST;
51 if (found === null || rank(value) < rank(found)) found = value;
52 }
53 return found;
54}
55
56// The local file is the user's own and gitignored, so it may set any rung. A
57// committed project file only stops earlier than the user's own value.
58export function effective(
59 user: StopBefore | null,
60 project: StopBefore | null,
61 local: StopBefore | null,
62): Ladder {
63 if (local !== null) return { stopBefore: local, source: 'local' };
64 // Claude Code fills in the option's default, so a user value equal to it
65 // cannot be told from no value at all.
66 const mine: Ladder =
67 user === null || user === DEFAULT_STOP
68 ? { stopBefore: DEFAULT_STOP, source: 'default' }
69 : { stopBefore: user, source: 'user' };
70 if (project !== null && rank(project) < rank(mine.stopBefore)) {
71 return { stopBefore: project, source: 'project' };
72 }
73 return mine;
74}
75
76// Whether the step needs the user's request under this ladder.
77export function asks(ladder: Ladder, step: Step): boolean {
78 return rank(STEP_RUNG[step]) >= rank(ladder.stopBefore);
79}
80
81export function where(source: Source): string {
82 if (source === 'default') return 'the default';
83 if (source === 'project') return '.claude/settings.json';
84 if (source === 'local') return '.claude/settings.local.json';
85 return '/config';
86}
87hooks/ledger.ts 343 lines1// The findings ledger: what earlier review cycles in a repository settled
2// without fixing, carried into the next cycle's briefs so reviewers do not
3// re-litigate it. Pure.
4//
5// A fixed finding is never an entry: the code changed, so raising it again
6// means it came back. An entry leaves the ledger when a cycle resolves it, when
7// its file is gone, or when newer entries push it past the cap. One settled
8// longer ago than EXPIRY_DAYS reads as stale, so a cycle judges it again.
9
10export const KINDS = ['deferred', 'rebutted', 'left-alone', 'question'] as const;
11export type Kind = (typeof KINDS)[number];
12
13export type Entry = {
14 id: string;
15 path: string;
16 line: number | null;
17 kind: Kind;
18 finding: string;
19 reason: string;
20 source: string;
21 // The path's blob in the working tree the latest cycle to carry it reviewed,
22 // so a reader can tell whether the file changed since.
23 blob: string;
24 // When a cycle last settled it; carrying it forward does not move this.
25 date: string;
26};
27
28export type NewEntry = Omit<Entry, 'id' | 'blob' | 'date'>;
29
30// One repository's ledger as read. `unreadable` counts what this version could
31// not write back unchanged, such as entries a newer version wrote; the next
32// record drops them and says how many.
33export type Stored = { entries: Entry[]; unreadable: number; updated: number };
34
35// The store holds MAX_REPOS ledgers in 4 MiB of JSON. A byte budget, not an
36// entry count: entries run near 470 bytes but can reach about 10 KB.
37export const MAX_LEDGER_BYTES = 384 * 1024;
38export const MAX_TEXT = 400;
39export const MAX_PATH = 1024;
40export const MAX_REPOS = 10;
41export const EXPIRY_DAYS = 90;
42export const PREFIX = 'ledger:';
43
44const ENTRY_KEYS = [
45 'id',
46 'path',
47 'line',
48 'kind',
49 'finding',
50 'reason',
51 'source',
52 'blob',
53 'date',
54] as const;
55const BLOB = /^([0-9a-f]{40}|[0-9a-f]{64})$/;
56const DATE = /^\d{4}-\d{2}-\d{2}$/;
57
58// The store refuses a key over 256 characters.
59export const MAX_KEY = 256;
60
61// FNV-1a from `basis`, as 8 hex digits.
62function fnv(text: string, basis: number): string {
63 let h = basis;
64 for (const ch of text) {
65 h ^= ch.codePointAt(0) ?? 0;
66 h = Math.imul(h, 0x01_00_01_93) >>> 0;
67 }
68 return h.toString(16).padStart(8, '0');
69}
70
71// A path too long for the store is keyed by its hash. `common` is absolute, so
72// a hashed key (`#`) never equals a path's own.
73export function keyOf(common: string): string {
74 const key = `${PREFIX}${common}`;
75 if (key.length <= MAX_KEY) return key;
76 return `${PREFIX}#${fnv(common, 0x81_1c_9d_c5)}${fnv(common, 0x05_0c_5d_1f)}`;
77}
78
79// The same finding at the same path gets the same id in every cycle.
80export function entryId(path: string, finding: string): string {
81 return fnv(`${path}\0${finding}`, 0x81_1c_9d_c5);
82}
83
84// Never cuts a surrogate pair in half.
85function clip(s: string): string {
86 const t = s.trim().replaceAll(/\s+/g, ' ');
87 if (t.length <= MAX_TEXT) return t;
88 const head = t.slice(0, MAX_TEXT - 1);
89 return `${/[\uD800-\uDBFF]$/.test(head) ? head.slice(0, -1) : head}…`;
90}
91
92function isKind(k: unknown): k is Kind {
93 return typeof k === 'string' && (KINDS as readonly string[]).includes(k);
94}
95
96function text(v: unknown): string | null {
97 return typeof v === 'string' && v.trim() !== '' ? v : null;
98}
99
100function normalPath(p: string): string {
101 return p.trim().replace(/^(\.\/)+/, '');
102}
103
104// A path as git's changed-path list spells it, or why it is not one: Phase 3
105// selects entries by that list, so any other spelling is never read back.
106function pathOf(v: unknown): string | { error: string } {
107 if (typeof v !== 'string') return { error: 'path is required' };
108 if (/\p{Cc}/u.test(v)) return { error: 'path holds a control character' };
109 const p = normalPath(v);
110 if (p === '') return { error: 'path is empty' };
111 if (p.length > MAX_PATH) return { error: `path is over ${MAX_PATH} characters` };
112 // Normalizing again must change nothing, or the stored path reads back as
113 // another one.
114 if (normalPath(p) !== p) return { error: `path starts or ends with whitespace: ${p}` };
115 const segments = p.split(/[/\\]/);
116 if (/^[A-Za-z]:/.test(p) || segments.some((s) => s === '' || s === '.' || s === '..')) {
117 return { error: `path must be repository-relative, as git spells it: ${p}` };
118 }
119 return p;
120}
121
122// One finding as the record tool takes it, or its first fault.
123function newEntryOf(raw: unknown): NewEntry | { error: string } {
124 const e = (raw ?? {}) as Record<string, unknown>;
125 if (e.kind === 'fixed') {
126 return {
127 error:
128 'a fixed finding is not recorded; pass the id of the entry it settled in `resolve` instead',
129 };
130 }
131 if (!isKind(e.kind)) return { error: `kind must be one of ${KINDS.join(', ')}` };
132 const path = pathOf(e.path);
133 if (typeof path !== 'string') return path;
134 const finding = text(e.finding);
135 const reason = text(e.reason);
136 const source = text(e.source);
137 if (!finding || !reason || !source) {
138 return { error: 'finding, reason and source are all required' };
139 }
140 const line = e.line ?? null;
141 if (line !== null && !(Number.isSafeInteger(line) && (line as number) > 0)) {
142 return { error: 'line must be a positive integer' };
143 }
144 return {
145 path,
146 line: line as number | null,
147 kind: e.kind,
148 finding: clip(finding),
149 reason: clip(reason),
150 source: clip(source),
151 };
152}
153
154// A stored entry, or null unless this version would write it back exactly as
155// stored: every key known, every value one the record tool produces.
156function storedEntryOf(raw: unknown): Entry | null {
157 if (typeof raw !== 'object' || raw === null) return null;
158 const r = raw as Record<string, unknown>;
159 if (Object.keys(r).some((k) => !(ENTRY_KEYS as readonly string[]).includes(k))) return null;
160 const n = newEntryOf(r);
161 if ('error' in n) return null;
162 const { blob, date } = r;
163 if (typeof blob !== 'string' || !BLOB.test(blob)) return null;
164 if (typeof date !== 'string' || !DATE.test(date)) return null;
165 const e: Entry = { id: entryId(n.path, n.finding), ...n, blob, date };
166 return ENTRY_KEYS.every((k) => e[k] === r[k]) ? e : null;
167}
168
169// The `updated` stamp of any stored value, ledger or not, so pruning orders a
170// ledger this version cannot read by when it was written.
171export function updatedOf(value: unknown): number {
172 const u = (value as { updated?: unknown } | null | undefined)?.updated;
173 return typeof u === 'number' ? u : 0;
174}
175
176// Whatever the store holds under one repository's key.
177export function storedOf(value?: unknown): Stored {
178 if (value === undefined) return { entries: [], unreadable: 0, updated: 0 };
179 const updated = updatedOf(value);
180 const v = (value ?? {}) as { entries?: unknown; updated?: unknown };
181 const known = new Set(['entries', 'updated']);
182 if (
183 typeof value !== 'object' ||
184 value === null ||
185 !Array.isArray(v.entries) ||
186 typeof v.updated !== 'number' ||
187 Object.keys(value).some((k) => !known.has(k))
188 ) {
189 // Every entry is lost with the ledger around it, so each one counts.
190 const lost = Array.isArray(v.entries) ? v.entries.length : 0;
191 return { entries: [], unreadable: Math.max(1, lost), updated };
192 }
193 const entries: Entry[] = [];
194 const ids = new Set<string>();
195 let unreadable = 0;
196 for (const raw of v.entries) {
197 const e = storedEntryOf(raw);
198 if (e && !ids.has(e.id)) {
199 ids.add(e.id);
200 entries.push(e);
201 } else {
202 unreadable++;
203 }
204 }
205 return { entries, unreadable, updated };
206}
207
208// `keep` carries entries a cycle settled again unchanged: each is stamped with
209// its file as that cycle reviewed it, and its date stays.
210export type Recording = { add: NewEntry[]; resolve: string[]; keep: string[] };
211
212function ids(v: unknown): v is string[] {
213 return Array.isArray(v) && v.every((r) => typeof r === 'string' && /^[0-9a-f]{8}$/.test(r));
214}
215
216// The record tool's input, whole or not at all: a batch half-written is
217// harder to read back than one refused with its first fault named.
218export function parseRecord(input: unknown): Recording | { error: string } {
219 const i = (input ?? {}) as { entries?: unknown; resolve?: unknown; keep?: unknown };
220 const rawEntries = i.entries ?? [];
221 const rawResolve = i.resolve ?? [];
222 const rawKeep = i.keep ?? [];
223 if (!Array.isArray(rawEntries)) return { error: '`entries` must be an array' };
224 if (!ids(rawResolve)) return { error: '`resolve` must be an array of entry ids' };
225 if (!ids(rawKeep)) return { error: '`keep` must be an array of entry ids' };
226 const add: NewEntry[] = [];
227 for (const [n, raw] of rawEntries.entries()) {
228 const e = newEntryOf(raw);
229 if ('error' in e) return { error: `entries[${n}]: ${e.error}` };
230 add.push(e);
231 }
232 if (add.length === 0 && rawResolve.length === 0 && rawKeep.length === 0) {
233 return { error: 'nothing to record: pass `entries`, `keep` or `resolve`' };
234 }
235 return { add, resolve: rawResolve, keep: rawKeep };
236}
237
238export type Merged = {
239 entries: Entry[];
240 added: number;
241 updated: number;
242 resolved: number;
243 kept: number;
244 // Kept entries whose file is gone from the working tree, and so dropped.
245 gone: number;
246 // Ids in `resolve` or `keep` that named no entry.
247 unknown: string[];
248 evicted: number;
249};
250
251// Resolves first, then keeps, then adds, so a batch that resolves an entry and
252// records the same finding again keeps the new one. A kept or re-recorded
253// entry moves to the newest end, so eviction takes what no cycle has touched
254// longest. `blobs` must hold every added path; a kept entry whose path it
255// lacks is gone.
256export function merge(
257 existing: Entry[],
258 rec: Recording,
259 blobs: ReadonlyMap<string, string>,
260 date: string,
261): Merged {
262 const byId = new Map(existing.map((e) => [e.id, e]));
263 const unknown: string[] = [];
264 let resolved = 0;
265 for (const id of rec.resolve) {
266 if (byId.delete(id)) resolved++;
267 else unknown.push(id);
268 }
269 let kept = 0;
270 let gone = 0;
271 for (const id of rec.keep) {
272 const e = byId.get(id);
273 if (!e) {
274 unknown.push(id);
275 continue;
276 }
277 byId.delete(id);
278 const blob = blobs.get(e.path);
279 if (blob === undefined) {
280 gone++;
281 } else {
282 byId.set(id, { ...e, blob });
283 kept++;
284 }
285 }
286 let added = 0;
287 let updated = 0;
288 for (const n of rec.add) {
289 const blob = blobs.get(n.path);
290 if (blob === undefined) throw new Error(`no blob for ${n.path}`);
291 const id = entryId(n.path, n.finding);
292 if (byId.delete(id)) updated++;
293 else added++;
294 byId.set(id, { id, ...n, blob, date });
295 }
296 const all = [...byId.values()];
297 const sizes = all.map((e) => utf8Bytes(JSON.stringify(e)) + 1);
298 let size = sizes.reduce((n, s) => n + s, 0);
299 let evicted = 0;
300 while (evicted < all.length && size > MAX_LEDGER_BYTES) size -= sizes[evicted++] ?? 0;
301 return { entries: all.slice(evicted), added, updated, resolved, kept, gone, unknown, evicted };
302}
303
304export function utf8Bytes(s: string): number {
305 let n = 0;
306 for (const ch of s) {
307 const c = ch.codePointAt(0) ?? 0;
308 n += c < 0x80 ? 1 : c < 0x8_00 ? 2 : c < 0x1_00_00 ? 3 : 4;
309 }
310 return n;
311}
312
313export function isStale(date: string, now: number): boolean {
314 const [y, m, d] = date.split('-').map(Number);
315 return now - Date.UTC(y ?? 0, (m ?? 1) - 1, d ?? 1) > EXPIRY_DAYS * 86_400_000;
316}
317
318export function select(entries: Entry[], paths?: readonly string[]): Entry[] {
319 if (paths === undefined) return entries;
320 const want = new Set(paths.map((p) => normalPath(p)));
321 return entries.filter((e) => want.has(e.path));
322}
323
324// `git ls-tree -r -z` output as path to blob id.
325export function blobsOf(lsTree: string): Map<string, string> {
326 const blobs = new Map<string, string>();
327 for (const rec of lsTree.split('\0')) {
328 const m = /^\d+ blob ([0-9a-f]+)\t(.+)$/s.exec(rec);
329 if (m?.[1] && m[2]) blobs.set(m[2], m[1]);
330 }
331 return blobs;
332}
333
334// The other repositories' ledgers to drop so that, with this one, the store
335// holds at most MAX_REPOS: the least recently updated go first.
336export function staleKeys(
337 ledgers: readonly { key: string; updated: number }[],
338 keep: string,
339): string[] {
340 const others = ledgers.filter((l) => l.key !== keep).toSorted((a, b) => a.updated - b.updated);
341 return others.slice(0, Math.max(0, others.length - (MAX_REPOS - 1))).map((l) => l.key);
342}
343