Twelve everyday commands: commit and ship under your own name, review a diff with evidence, hand a chat off before its context runs out, collect bugs until a…

Two Claude Code plugins from one marketplace.
makarasty runs one job from one chat first (several sessions at once only on opt-in): a test sweep of a live app, a fix run over its backlog, a refactor across a hundred files, a research sweep, a design pass. You plan once and answer up to four questions. By default the run stays in one chat: the coordinator works the queue and spawns a fresh subagent per task, which reports by writing files (docs/ONECHAT.md). Separate worker chats, one chip each, are opt-in. Between what a run finds and what it changes there is a gate: no fix without a reproduction that failed first.makarasty-tools is twelve everyday commands that have nothing to do with fleets: commit under your own name, ship a branch, review a diff with evidence, hand a chat off before its context runs out, get a phone message when a chat finishes, tune the machine for a game, find what is worth updating, clean up after chats, and a few more.Install one or both. They are versioned separately.
An agent that cannot see what it is inspecting still reports findings, with full confidence, and they look exactly like real ones. A browser pane that stopped compositing is the sharpest case: it still navigates, still returns DOM, and every visual observation through it is false. A green suite that skipped your file and a docs page that never loaded produce the same confident nothing.
So every path through this plugin measures before it trusts. A worker counts frames before it looks at a page, a finding without evidence is refused by a script rather than by a request in prose, and a run that had a blind worker says so by name instead of reporting its area as clean.
The second reason is the one most multi-agent setups learn late: a session runs only while something invokes it. In one measured run, three of six workers ended a turn right after claiming their next task and sat dead for about three hours each, holding claims nobody else could take. The protocol here is built so that this can't happen silently. See The other way a run dies.
/plugin marketplace add makarasty/makarasty-claude
/plugin install makarasty@makarasty
/plugin install makarasty-tools@makarasty
Restart Claude Code afterwards; plugins load at session start. To install from a local checkout instead, pass its path to marketplace add.
bash and dash (FLEET_SELFTEST_SH=dash node scripts/fleet-selftest.mjs runs it under dash).fleet.sh itself: its commands run in scripts/fleet.mjs, where a call that took seconds of shell forks on Git Bash takes a tenth of one [M37]. The plugin ships that JavaScript compiled; nothing is installed or built on your machine.rg, sg, jq and similar tools are offered by fleet-init and never required.node scripts/fleet-selftest.mjs
From an install rather than a checkout, the plugin's path is installPath for makarasty in ~/.claude/plugins/installed_plugins.json. The self-test runs the whole protocol (claims, lanes, the schema gate, the clocks, the markers, the landing check) against temporary directories in about 75 seconds on a 16-thread Windows machine, with no sessions, no browser and no tokens. It should end with N passed, 0 failed. --jobs N (or FLEET_SELFTEST_JOBS) sets how many parts run at once, 3 by default, and the run prints the free RAM and the live node.exe count before it starts. To run one part, use node --test scripts/selftest/<part>.test.mjs.
The scripts and hooks are written in TypeScript under src/ (and tools/src/ for the tools plugin) and compiled to the .mjs files beside them, which are committed and are what runs. The two browser probes are the exception: src/probes/*.ts compiles to scripts/*.js. Edit the .mts or .ts, never the .mjs or the probe's .js:
npm install --prefix src
npm run build --prefix src
The first installs the pinned compiler and Node's types into src/node_modules, once. The self-test then also type-checks the sources and fails when a committed .mjs is not what its source compiles to.
In the project you want to work on:
/makarasty:fleet-init
It finds the app's origin and services, sets up a login path an agent can use on its own, writes FLEET.md, and tells you how many workers this machine will carry. Then:
/makarasty:fleet-plan <what you want done>
The planner interviews you (at most four questions, asked at once), splits the job into a queue, and runs it in this chat. Only if you chose separate chats does it offer one chip per worker for you to click. When the run lands, /makarasty:fleet-collect <run-id> merges their findings into one ranked backlog.
docs/WALKTHROUGH.md is a first fleet in fifteen minutes, with two workers and no browser.
| Command | Started by | What it does |
|---|---|---|
/makarasty:fleet | you | Lists the other commands and when to use each |
/makarasty:fleet-init | you or Claude | Prepares a project: origin, services, agent login, FLEET.md, machine sizing |
/makarasty:fleet-plan <mission> [fast] | you or Claude, when asked | Interviews you into a plan, splits it into a queue or briefs, runs it in one chat; one chip per worker only on opt-in |
/makarasty:fleet-run <brief or run dir> | the worker (an opt-in chat, after you click its chip) | Runs one brief, or works a queue until it is drained |
/makarasty:fleet-login | you or Claude | Opens and logs in to the project's local app (or at an origin you pass it), with credentials from the runbook, never from the chat |
/makarasty:fleet-wait <run-id> [n] | you or Claude | Waits without spending model turns, then collects |
/makarasty:fleet-collect <run-id> | you or Claude | Merges, enforces the evidence contract, dedupes, ranks |
/makarasty:fleet-analyze <run-id> | you or Claude | How a run went, from its transcripts: where task time went, one-tool turns, re-reads, cache misses, tokens and an estimated cost per worker and task, the slowest tasks, and what the numbers say to change |
/makarasty:fleet-pause <run-id> [off] [reason] | you or Claude | Really stops every worker (a hook refuses their edits, browser and shell calls after a 30 s grace; they commit, stop their subagents and wait), or lifts the pause. "Pause the fleet" in plain words fires it |
/makarasty:fleet-resume <run-id> | you or Claude | After a crash: reopens the workers whose context survived, respawns the rest |
/makarasty:fleet-design <screens> [fast] | you | Captures the app's screens as artboards on disk and assembles them into a Claude Design canvas |
/makarasty:fleet-redesign <screens and direction> [fast] | you | Proposes redesigns beside the captured screens, loading and error states included |
/makarasty:fleet-call <who and what about> [fast] | you | Digs the facts for a vendor call out of the project, with evidence, and builds a bilingual page to read aloud; live <run-id> answers beside you during the call |
fleet, fleet-design, fleet-redesign and fleet-call carry disable-model-invocation: true: they start paid work and wait on your clicks, so only you can start them. fleet-pause is the other one an agent can start. fleet-plan can also be started by Claude when you ask it to launch a fleet or hand a run to a new chat; in one chat it starts spawning subagents after Round 0, and offers chips only on opt-in, so it costs nothing until you answer Round 0. fleet-run is the exception on purpose. The worker's own model invokes it in the new session, and the flag would stop every worker the moment it tried to begin.
fleet-coder does one code task in the shared checkout, touching only the paths its task owns:, and commits nothing. One fresh subagent per task.fleet-reviewer reviews one task's diff and edits nothing.fleet-scenario walks a multi-step browser scenario and returns bounded JSON. Screenshots and DOM reads stay in its context; about eighty tokens come back to the parent.fleet-profiler measures load, interaction and stability, with the spread of each reading and the machine load beside it.fleet-triage merges and ranks a run's findings, on Haiku.fleet-design-eye reviews one screen for design defects: geometry probes first, a zoomed screenshot of each candidate second, and every finding carries the rectangles behind it.| Command | What it does | ||
|---|---|---|---|
/makarasty-tools:commit | Commits this chat's files under your own name, short message, no AI trailer. Commits by path, so a file another chat staged stays out | ||
| `/makarasty-tools:ship [branches\ | all] [mine] [tag <name>]` | Merges branches in, splits the tree into your own commits, leaves files a parallel chat is still editing, pushes, tags with notes | |
| `/makarasty-tools:review [--fix\ | --loop]` | Reports only findings that name an input that breaks the code, each one checked by a pass that tries to disprove it. --loop fixes and re-reviews until a round finds nothing | |
| `/makarasty-tools:handoff [focus] \ | from <chat>` | Writes a handoff file and offers the next chat as a chip that can read or ask this one; from takes over another chat's work after checking its claims | |
/makarasty-tools:hold [codeword] | Collects dictated bugs one line each without acting, then on the codeword fixes them grouped by shared cause | ||
/makarasty-tools:explain [topic] | What happened, why, what was done, what is left, whether it can ship and what was not checked, in plain words | ||
| `/makarasty-tools:unslop [on\ | off\ | text]` | Turns humanised replies on or off, or rewrites a given text without assistant tics |
/makarasty-tools:say <what to say> | Turns what you mean into simple English to say on a call or send to a vendor, with a gist in your language beside each line | ||
/makarasty-tools:notify [what you are waiting for] | Sends one message to your phone when this chat, another chat or a fleet run finishes: Telegram, Discord, ntfy or a webhook | ||
/makarasty-tools:game <game> | Reads the hardware, disks and the game's install, proposes changes with sources, has a red team try to disprove each, and reports what survived. Applies nothing without a yes per item | ||
/makarasty-tools:cleanup [area] | Finds processes ended chats left running, runaway logs, stale worktrees and caches, has a red team defend each, ends processes on a yes and gives the commands for files. "почисти за собой" stops what this chat started | ||
/makarasty-tools:update [area] | Finds what is out of date (apps, runtimes, Claude Code and plugins, other agents, drivers), ranks it, has a red team challenge each pick, and updates only on a yes |
All twelve can be started by you or by Claude, and most trigger on plain phrasing in any language: "commit as me", "закоммить от меня", "ping me when the tests are done".
Context hook. Once a chat's context passes 700k tokens, and again every 150k above that, the chat is told to offer a handoff in one line (a chat that coordinates or works a fleet run gets none: the run's own marks watch it, below). MAKARASTY_HANDOFF_AT and MAKARASTY_HANDOFF_STEP move the levels; MAKARASTY_HANDOFF_AT=0 turns it off.
Model router. A subagent that Opus or Fable spawns without naming a model runs on Sonnet when its description starts with [exec] and on Haiku with [mech]; an Explore agent runs on Haiku. fleet-coder is routed by tag like general-purpose; fleet-reviewer never is (a tag on it is ignored), because it is the check that makes a tag safe. Untagged work keeps the parent's model, and a named model is never changed. /router shows what it changed; MAKARASTY_ROUTER=off turns it off. Why it waits for a tag: docs/MODELS.md, "The router".
Git guard. A shell call that would throw away uncommitted work (git stash, reset --hard, clean -f, checkout -- <path>, restore without --staged, a forced switch) is refused with the reason and a safe way round it, because several chats often share one working tree. Commits, plain switches, stash list and clean -n run. It is a function hook, so no shell call pays a process for it; MAKARASTY_GIT_GUARD=off turns it off.
Reads through tools. One line in the system prompt sends file reads and searches to Read, Grep and Glob instead of cat, grep or sed through the shell: 0.02-0.07 s a call against 0.1-1 s [M40]. MAKARASTY_READS=off turns it off.
Relaunch instead of a chain of handoffs. A queue worker on 1.5.14 or later that nears the context limit (worker_relaunch_k in calibration.json, 700K as shipped) is replaced by the coordinator without asking: it finishes its task and leaves at its next claim, and you click the replacement chip. A fleet coordinator, or a brief worker or an older one, that nears its mark (coordinator_handoff_k and worker_relaunch_k, both 700K, with a second ask at 800K; auto-compaction starts a little past 900K) makes the coordinator ask you once, with a notification you can see from another chat. On "Relaunch now" it pauses the run, writes the state, hands the open work of the over-full workers back (a worker's uncommitted work is committed for it as wip: handed back), and offers one coordinator chip and the fresh worker chips in one go. Click the coordinator first, then the workers; the run stays paused until the new coordinator resumes it. When only workers are over, "Replace workers NN only" offers just their chips and the coordinator carries on.
Notifications. Say "ping me when it's done" in the chat doing the work. It arms a marker for that session, and a hook sends one message when the turn ends, with the last 500 characters of the reply under the verdict. A chat that ends on a question is reported as waiting for you; one that leaves a background job running is not finished and keeps its marker; one that dies on a rate limit says so. Setup is one command in your terminal: a wizard asks for the channel, takes one value (a bot token, a webhook URL or an ntfy topic), and saves it to ~/.claude/makarasty/notify.json only after a test message reaches your phone. The secret never passes through a chat. While no chat is armed the hooks cost one ls per event and start no Node process.
Assigned (opt-in separate chats only; one chat always files a queue): the planner writes one brief per worker, and the run ends when the briefs do.
Pull: the planner writes a queue, workers claim tasks when free, and the planner keeps adding tasks while they run. A claim is a directory, because mkdir fails atomically on one that exists; with eight concurrent claimers, exactly one won. Use pull when the job is bigger than the plan, which is most of the time. Details in docs/PULL.md.
A fleet queues for whatever the machine has exactly one of. Each task declares a lane: pane for the browser, verify for the test suite and typechecker, repo for work that only reads and writes files. The lanes are capped separately. The pane lane is 1 in one chat; with opt-in chats it is capped by your display, ten at most. The repo lane is capped by the machine. Capping file work at the width of a monitor is how a run ends up eight browsers wide and two files wide.
That one field is what makes the plugin more than a browser tool. A run with no pane tasks is a refactor, a migration or a research sweep, and the gate changes its evidence instead of disappearing: a reproduction that fails before a fix and passes after, a verbatim quote with its locator from each source, a test count.
A pane worker spends about 20 of its 23 minutes waiting on one scenario subagent, so it claims one repo task and works it during the wait. Details in docs/LANES.md.
Each task declares its kind, which sets the working style and the axis the job splits along.
| Kind | Splits by | Isolation |
|---|---|---|
verify | screen ownership | none |
fix | file cluster, re-verifying each entry before repairing | worktree |
investigate | hypothesis | worktree when instrumenting |
implement | seam | worktree |
research | source | none |
design | one screen, in three waves: recon, primitives, screens | worktree |
critique | one screen; the evidence is a rectangle or a ratio, never a screenshot alone | none |
canvas | one screen per artboard: recon, primitives sheet, screens, compare, assemble | none |
redesign | one screen; proposals beside the captured ones, directions sketched first | none |
call | source: the facts with their evidence first, then the page you read aloud | none |
Splitting along the wrong axis is what makes a fleet run worthless. Two workers on one slice cost twice as much and then agree with each other, which reads as corroboration and isn't.
The last four kinds form a loop: critique what runs, capture it as a canvas, propose beside it, then implement the approved artboards with the design kind. See docs/DESIGN.md and docs/MISSIONS.md.
A browser pane that is not on screen stops compositing, and nothing tells you:
| Symptom | Cause |
|---|---|
| screenshot times out after 5s | pane not displayed |
requestAnimationFrame never fires | nothing is scheduled without compositing |
| transitions frozen at their start value | transitionend never fires |
| virtualized rows read as empty text | they need layout that never runs |
| in-page requests hang to their timeout | measured: an axios POST sat 180s while curl answered in 4s |
preview_start returns navOk with the right title | navigation and titles survive; only frames don't |
So every pane worker counts frames first:
new Promise(res => { let f = 0; requestAnimationFrame(function t(){ f++; requestAnimationFrame(t); }); setTimeout(() => res(f), 1000); })
Ten or more is live, read twice a second apart; under ten is blind. From ten to fifty-nine the pane is live on a busy machine: the worker carries on, reports the number, and asks you nothing. A blind worker asks you to open its pane and measures again, because the reading is the proof, not your reply. A worker that stays blind writes .blocked and no findings, and collection lists blocked workers by name. Two panes in separate sessions were measured live at the same time, with the second chat unfocused: what matters is that the pane is displayed, not which chat has focus. More in docs/BROWSER.md.
A blind pane is loud once you know the symptom. This one is silent. When a turn ends with no subagent running and no background command pending, the session stops, and nothing in a fleet types into a worker's chat to restart it. Its last message usually says what it was about to do next.
Measured on 2026-08-27: three of six workers ended a turn right after claiming their next task and sat dead for 169, 171 and 176 minutes. The planner slept through it, because its watch reported new files and there were none. One cross-session status check brought all three back within seconds.
So a worker claims and starts work in the same turn, arms sleep 120; echo wake in the background when it has to stop anywhere else, and the planner's watch reports silence as well as progress. A Stop hook catches the case of a fresh claim whose heartbeat never moved. Full rules in docs/PROTOCOL.md.
About 15 findings in 100 were refuted once somebody tried to fix them. So a fix task is refused by finish unless a reproduction ran through fleet-gate.mjs prove, failing before the change and passing after. A cause several findings share is ruled on once, before any of them is patched. An edit that drops a name something outside the repository reads (a route, a config key) is blocked by a hook until a question or a recorded decision names it. See docs/GATE.md.
A fleet runs unattended across a dozen sessions, so what it may remove is a short closed list, not each worker's judgement:
.fleet/<run-id>/..claude/worktrees/, and only through fleet.sh clean.Nothing else. There is no git reset --hard, no git clean, no git checkout -- anywhere in the plugin, and no recursive force-delete of a path it did not create.
fleet.sh clean is a dry run unless given --remove. It touches only worktrees this run registered, keeps any tree with uncommitted changes or with commits neither merged nor pushed, and deletes a branch only with git branch -d, which refuses unmerged work. Every deletion passes a path gate: absolute, no .. or . segments, at least four levels below the root, inside .claude/worktrees/ with something after it, and never a directory that contains the shell's working directory.
One step here is not obvious, and it was measured [M32]: a worktree usually has a node_modules junction into the main checkout, and git worktree remove follows that junction and deletes what it points at. Eight runs out of eight on Windows, at the top level and nested, --force included, exit code 0 every time. So clean unlinks every junction and symlink inside a worktree before anything recursive touches it. The same applies to the harness's own ExitWorktree and to any hand-written rm -rf. See docs/WORKTREES.md and docs/SAFETY.md.
A selection of real runs, not all of them. They come from two different projects, a large private TypeScript web app and the public Kotlin plugin Essentials, and cover different scenarios: browser sweeps of a running app, audits, fix runs, refactors and performance measurement. Each one is written up in docs/RUNS.md with how it was set up, what it produced, what went wrong, and the cost.
| Run | Workers | Wall clock | Result | Worker cost |
|---|---|---|---|---|
| First eight-worker browser sweep | 8 pane | 104 min | 94 findings, 3 blockers; all 8 panes opened blind and the gate caught every one | ~$140 |
| Pull run, 34 tasks | 6 pane | 4 h 57 min | 254 findings; three workers sat dead for about 3 h each | not separable |
| Audit, then fix | 14, then 8 | 132 + 80 min | 246 findings, then 38 fixed and merged, 3 refuted | ~$560 + ~$430 |
| Eighty tasks across three lanes | 13 | 4 h 6 min | 80 tasks; third verify round green at about 37,000 tests | ~$1,600 |
| Read-only sweep, then a fix run | 12, then 4 | ~90 min active, then 3 h 51 min | 1,500 findings, then 175 tasks worked, 93 fixed | ~$740 + ~$1,110 |
| Refactor, then shrink | 9, then 10 | 71 + 107 min | 96 fixed, 32 refuted; then about 27,000 lines removed (−17.6% against a −25% target) | ~$480 + ~$740 |
| Essentials, audit to green | 4-10 per run | ~50 h calendar | 436 findings; 251 tests with 20 failing to 383 with 0, ten green runs of ten | ~$1,400, plus ~
hooks/index.ts 12 lines1import type { Register } from 'claude-code'
2import { register as router } from './router.ts'
3import { register as gitGuard } from './git-guard.ts'
4import { register as reads } from './reads.ts'
5
6// The engine loads one hooks module per plugin: this is it.
7export const register: Register = (on, options) => {
8 router(on, options)
9 gitGuard(on, options)
10 reads(on, options)
11}
12hooks/router.ts 84 lines1import type { Register } from 'claude-code'
2
3// Picks a cheaper model for a subagent only where the caller says it is safe.
4// Untagged work keeps the parent's model: on three replayed ports Sonnet tied twice
5// and once closed a task the shell left open, which its own tests did not catch [M39].
6export type Route = { to?: 'sonnet' | 'haiku'; why: string }
7
8export type SpawnFacts = {
9 model?: string
10 parentModel: string
11 subagentType: string
12 description: string
13 plugin: string
14 builtIn: boolean
15 workflow: boolean
16 fork: boolean
17}
18
19const TOP = /opus|fable/i
20// The fleet's own coder and reviewer: their prompt carries the task file, so a tag is as safe as on general-purpose
21// for the coder; the reviewer is the check, so it is never routed down.
22const FLEET = /^(makarasty:)?fleet-(coder|reviewer)$/
23
24export function route(f: SpawnFacts): Route {
25 if (f.model) return { why: 'model named' }
26 // provider.plugin may carry the marketplace (makarasty@...): only the name before it is checked.
27 const fleet = /^makarasty(@|$)/.test(f.plugin) && FLEET.test(f.subagentType)
28 if ((!f.builtIn && !fleet) || f.workflow || f.fork) return { why: 'not a plain built-in spawn' }
29 if (!TOP.test(f.parentModel)) return { why: 'parent below the top tier' }
30 if (f.subagentType === 'Explore') return { to: 'haiku', why: 'locator' }
31 if (f.subagentType !== 'general-purpose' && !fleet) return { why: 'other agent' }
32 // The reviewer is the independent check that makes a tag safe, so it keeps the parent's model [M41].
33 if (fleet && /fleet-reviewer$/.test(f.subagentType)) return { why: 'reviewer is the check' }
34 if (/^\s*\[mech\]/i.test(f.description)) return { to: 'haiku', why: 'tagged mech' }
35 if (/^\s*\[exec\]/i.test(f.description)) return { to: 'sonnet', why: 'tagged exec' }
36 return { why: 'untagged' }
37}
38
39type Stats = { routed: Record<string, number>; kept: Record<string, number>; last: string[] }
40
41export const register: Register = on => {
42 // Parallel spawns would each read the old stats and overwrite each other: one writer at a time.
43 let writes = Promise.resolve()
44 // A user's own Explore.md or general-purpose.md also reports provider "engine"; only the offer says where it came from.
45 const ownNames = new Set<string>()
46 on('agent.offer', ($, e, next) => {
47 if (e.source !== 'built-in') ownNames.add(e.agent)
48 return next(e)
49 })
50 on('agent.spawn', async ($, e, next) => {
51 if ((await $.env.get('MAKARASTY_ROUTER')) === 'off') return next(e)
52 const r = route({
53 model: e.model,
54 parentModel: e.parentModel,
55 subagentType: e.subagentType,
56 description: e.description,
57 plugin: e.provider.plugin,
58 builtIn: e.provider.plugin === 'engine' && !ownNames.has(e.subagentType),
59 workflow: e.workflow !== undefined,
60 fork: e.fork,
61 })
62 const line = `${new Date().toISOString().slice(0, 16)} ${r.to ?? 'keep'} (${r.why}) ${e.subagentType}: ${e.description}`
63 writes = writes.then(async () => {
64 const s = ((await $.store.get('stats')) as Stats | undefined) ?? { routed: {}, kept: {}, last: [] }
65 if (r.to) s.routed[r.to] = (s.routed[r.to] ?? 0) + 1
66 else s.kept[r.why] = (s.kept[r.why] ?? 0) + 1
67 s.last = [line, ...s.last].slice(0, 50)
68 await $.store.set('stats', s)
69 }).catch(() => {})
70 return next(r.to ? { ...e, model: r.to } : e)
71 }).catch(($, e, next) => next(e))
72
73 on('session.start', async ($, e, next) => {
74 await $.command.register({ name: 'router', description: 'What the model router changed: counts and the last 50 spawns' })
75 return next(e)
76 })
77
78 on('command.run', { command: 'router' }, async $ => {
79 const s = (await $.store.get('stats')) as Stats | undefined
80 if (!s) return { text: 'The router has not seen a spawn yet.' }
81 return { text: `routed ${JSON.stringify(s.routed)}, kept ${JSON.stringify(s.kept)}\n${s.last.join('\n')}` }
82 })
83}
84hooks/git-guard.ts 211 lines1import type { Register } from 'claude-code'
2
3// Refuses git commands that throw away uncommitted work. Several sessions often share one working tree, so
4// "my changes" is never all the changes: a stash once wiped 171 files of other sessions' work. A function
5// hook, so the check is a few regexes inside the engine and no shell call pays a process for it.
6// Allowed on purpose: commit, branch switch without force, reset (mixed/soft), stash list/show, clean -n,
7// restore --staged, push --force-with-lease. The person can still run anything themselves with `!`.
8type Rule = [RegExp | ((s: string) => boolean), string]
9const RULES: Rule[] = [
10 // Read-only stash subcommands pass, and create, which only writes a commit object; branch applies and
11 // drops the stash, so it is refused too.
12 [(s) => /^stash(\s|$)/.test(s) && !/^stash\s+(list|show|create)(\s|$)/.test(s), "git stash pulls every uncommitted change out of the tree, other sessions' included"],
13 [/^reset\b.*\s--(hard|merge|keep)\b/, 'git reset --hard/--merge/--keep overwrites uncommitted changes'],
14 [(s) => /^clean\b/.test(s) && /\s(-\w*f|--force)/.test(s) && !/\s(-[a-z]*n[a-z]*|--dry-run)\b/.test(s), "git clean -f deletes untracked files, other sessions' new files included"],
15 // A trailing `--` with no path after it (`checkout main --`) only ends the options.
16 [/^checkout\b.*\s(--\s+\S|\.(\s|$))/, 'git checkout -- <path> / . discards uncommitted changes'],
17 [/^checkout\b.*\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(?=\s|$)/, 'git checkout -f discards uncommitted changes'],
18 // A tree-ish then a path (`checkout HEAD a.ts`) overwrites the file. One bare name could be a branch or a
19 // path and stays allowed; -b/-B/--orphan take a start point as their second word.
20 [(s) => /^checkout\b/.test(s) && !/\s(-[bB]|--orphan)(\s|$)/.test(s) && s.split(' ').slice(1).filter((w) => !w.startsWith('-')).length >= 2,
21 'git checkout <tree-ish> <path> discards uncommitted changes'],
22 [/^switch\b.*\s(-[a-zA-Z]*f[a-zA-Z]*|--force|--discard-changes)(?=\s|$)/, 'git switch --discard-changes discards uncommitted changes'],
23 [(s) => /^restore\b/.test(s) && !(/\s(--staged|-S)\b/.test(s) && !/\s(--worktree|-W)\b/.test(s)), 'git restore without --staged discards uncommitted changes'],
24 // A reversed patch (`git diff | git apply -R`) undoes every uncommitted change it lists in the tree;
25 // only --cached keeps it to the index. --check, --stat, --numstat and --summary apply nothing, unless --apply.
26 [(s) => /^apply\b/.test(s) && /\s(-[a-zA-Z0-9]*R[a-zA-Z0-9]*|--reverse)(\s|$)/.test(s) && !/\s--cached(\s|$)/.test(s)
27 && (!/\s--(check|stat|numstat|summary)(\s|$)/.test(s) || /\s--apply(\s|$)/.test(s)),
28 'git apply -R reverses uncommitted changes in the tree (git apply -R --cached, --check or --stat is allowed)'],
29 [/^worktree\s+remove\b/, 'git worktree remove follows a node_modules junction and empties the main checkout: unlink reparse points first and clean only through fleet.sh clean'],
30 [(s) => /^push\b/.test(s) && (/\s--force(\s|$)/.test(s) || /\s-[a-zA-Z]*f[a-zA-Z]*(\s|$)/.test(s) || /\s\+\S/.test(s)), 'git push --force overwrites commits on the remote (--force-with-lease is allowed)'],
31 [(s) => /^branch\b/.test(s) && (/\s-[a-zA-Z]*D/.test(s) || (/\s(-[a-zA-Z]*d[a-zA-Z]*|--delete)(\s|$)/.test(s) && /\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(\s|$)/.test(s))), 'git branch -D deletes a branch with commits that may be unmerged'],
32 // From dcg (Dicklesworthstone/destructive_command_guard): history and object store rewrites.
33 [/^filter-branch\b/, 'git filter-branch rewrites every commit of the branch and checks the rewritten tree out'],
34 [/^reflog\s+expire\b/, 'git reflog expire drops the reflog entries that are the only way back to a lost commit'],
35 [/^update-ref\b.*\s(-d|--delete)(\s|$)/, 'git update-ref -d deletes a ref, and the commits only it reaches'],
36 [/^read-tree\b.*\s--reset\b/, 'git read-tree --reset overwrites the index, and with -u the files too'],
37 // Plain `git rm` refuses a file with local changes; -f removes it anyway.
38 [(s) => /^rm\b/.test(s) && !/\s--cached(\s|$)/.test(s) && /\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(\s|$)/.test(s), 'git rm -f deletes files with uncommitted changes (plain git rm and git rm --cached are allowed)'],
39 [/^gc\b.*\s--prune=now\b/, 'git gc --prune=now deletes unreachable objects at once, the last copy of a dropped stash or reset commit'],
40]
41// In place of git or its subcommand, a variable hides what runs; refused rather than guessed.
42const VARIABLE = 'the command or its subcommand is a variable, so what it runs cannot be read before it runs: write git and the subcommand out'
43// Global options that sit between `git` and the subcommand; these take a value.
44const GLOBAL_WITH_VALUE = /^(-C|-c|--git-dir|--work-tree|--namespace)$/
45const SHELL = /(^|[\\/])((ba|z|da|k)?sh|pwsh|powershell|cmd)(\.exe)?$/i
46const WRAPPER = /^(env|command|sudo|nohup|time|exec|builtin|nice|xargs|timeout|watch|coproc|ionice|stdbuf|setsid)$/
47// Subcommands a variable head is refused on; `rm` stays out so `"$DOCKER" rm -f web` runs.
48const VIA_GUARDED = /^(stash|reset|checkout|switch|restore|clean|worktree|push|branch|filter-branch|reflog|update-ref|read-tree|gc|apply|submodule)$/
49const GIT = /(^|[\\/])git(\.exe)?$/i
50// The flag after which a shell takes its command line: bash -c / -lc, powershell -Command, cmd /c.
51const SHELL_CMD = /^-\w*c$|^-command$|^\/c$/i
52const MARK = /^\u0001(\d+)\u0001$/
53// Redirections (2>&1, > /dev/null, &>f, *> $null, < f) are not part of the command.
54const REDIR = /[\d*]*(?:>>?|<)&?\s*[^\s;&|()<>]+|&>>?\s*[^\s;&|()<>]+/g
55
56// Index of the `)` that closes a paren whose body starts at `from`. `esc` is the shell's escape character.
57function closing(s: string, from: number, esc: string): number {
58 for (let d = 1, i = from; i < s.length; i++) {
59 const c = s[i]
60 if (c === esc) i++
61 else if (c === "'") { const e = s.indexOf("'", i + 1); i = e < 0 ? s.length : e }
62 else if (c === '(') d++
63 else if (c === ')' && --d === 0) return i
64 }
65 return s.length
66}
67
68// Code hidden inside a double-quoted string runs too: $(...), and in Bash `...`. In PowerShell a backtick is
69// the escape character, never a substitution.
70function substs(body: string, into: string[], ps: boolean): void {
71 const esc = ps ? '`' : '\\'
72 for (let i = 0; i < body.length; i++) {
73 if (body[i] === esc) i++
74 else if (body[i] === '$' && body[i + 1] === '(') { const e = closing(body, i + 2, esc); into.push(body.slice(i + 2, e)); i = e }
75 else if (!ps && body[i] === '`') { const e = body.indexOf('`', i + 1); if (e < 0) break; into.push(body.slice(i + 1, e)); i = e }
76 }
77}
78
79// Replaces every quoted string with a numbered mark (kept in `lits`), pulls $(...) and backticks into `nested`,
80// and turns subshell parens into separators.
81// shortcut: quotes inside a $( ) that hold parens can mis-pair; upgrade to a real shell parser if that bites.
82function lex(src: string, lits: string[], nested: string[], ps: boolean): string {
83 const esc = ps ? '`' : '\\'
84 let out = ''
85 for (let i = 0; i < src.length; i++) {
86 const c = src[i]!
87 if (c === esc) { out += src.slice(i, i + 2); i++ }
88 else if (c === "'") {
89 const e = src.indexOf("'", i + 1), end = e < 0 ? src.length : e
90 out += `\u0001${lits.push(src.slice(i + 1, end)) - 1}\u0001`; i = end
91 } else if (c === '"') {
92 let end = i + 1
93 while (end < src.length && src[end] !== '"') end += src[end] === esc ? 2 : 1
94 const body = src.slice(i + 1, end)
95 substs(body, nested, ps)
96 out += `\u0001${lits.push(ps ? body.replace(/`(.)/g, '$1') : body.replace(/\\(["\\$`])/g, '$1')) - 1}\u0001`; i = end
97 } else if (c === '$' && src[i + 1] === '(') { const e = closing(src, i + 2, esc); nested.push(src.slice(i + 2, e)); out += ' ; '; i = e }
98 else if (!ps && c === '`') { const e = src.indexOf('`', i + 1), end = e < 0 ? src.length : e; nested.push(src.slice(i + 1, end)); out += ' ; '; i = end }
99 else out += c === '(' || c === ')' ? ';' : c
100 }
101 return out
102}
103
104// A git subcommand line, and the variable that stood in for git when one did.
105type Sub = { sub: string; via?: string }
106function subcommands(cmd: string, ps: boolean, depth = 0): Sub[] {
107 if (depth > 4) return []
108 // A line continuation (PowerShell backtick, Bash backslash before the newline) is one command, not two.
109 cmd = cmd.replace(ps ? /`\r?\n/g : /\\\r?\n/g, ' ')
110 const lits: string[] = [], nested: string[] = []
111 // A heredoc on a shell's own stdin (`bash <<EOF`, flags only) is code; one fed to a script (`sh x.sh <<EOF`)
112 // or any other command (commit messages) is that command's text.
113 for (const m of cmd.matchAll(/(?:^|[\s;&|(/\\])(?:ba|z|da|k)?sh(?:\.exe)?(?:[ \t]+-[\w-]+)*[ \t]*<<-?\s*['"]?(\w+)['"]?\n([\s\S]*?)\n\s*\1\b/g)) nested.push(m[2]!)
114 const cleaned = lex(cmd
115 .replace(/<<-?\s*['"]?(\w+)['"]?[\s\S]*?\n\s*\1\b/g, ' ') // heredoc bodies (commit messages)
116 .replace(/@'[\s\S]*?'@|@"[\s\S]*?"@/g, ' '), lits, nested, ps) // PowerShell here-strings
117 .replace(REDIR, ' ')
118 const out: Sub[] = []
119 // A quoted word as the shell hands it on: `"git"`, `& "C:\Program Files\Git\cmd\git.exe"`.
120 const unq = (w: string): string => { const m = MARK.exec(w); return m ? lits[+m[1]!]! : w }
121 // Words another command runs as a command line: one quoted word is a command line of its own; unquoted,
122 // the words are, their quoted words put back in quotes.
123 const handOn = (rest: string[]): void => {
124 if (rest[0] && !MARK.test(rest[0])) nested.push(rest.map((w) => { const m = MARK.exec(w); return m ? `'${lits[+m[1]!]}'` : w }).join(' '))
125 else for (const w of rest) { const m = MARK.exec(w); if (m) nested.push(lits[+m[1]!]!) }
126 }
127 // A brace standing alone opens or closes a block (`% { git stash }`, `try { ... }`); `@{u}` and `-I{}` stay words.
128 for (const seg of cleaned.split(/&&|\|\||[;|&\n]|(?:^|\s)[{}](?=\s|$)/)) {
129 const words = seg.trim().split(/\s+/)
130 // A closing brace glued to the last word (`{git stash}`) or standing alone ends a block, not an argument.
131 // `{}` (find -exec's file, xargs -I{}) is a word, not a block.
132 const last = words.pop()!.replace(/(?<!\{)\}$/, '')
133 if (last) words.push(last)
134 let at = 0, wrapped = false
135 // PS call operator, VAR=x prefixes, shell keywords before a command, env/command/sudo and their flags
136 while (at < words.length) {
137 const w = words[at]!
138 if (/^(&|\{|!|if|then|do|else|elif|while|until|\w+=\S*)$/.test(w) || (wrapped && w.startsWith('-'))) at++
139 // An opening brace glued to the command (`{git stash}`) opens a block.
140 else if (w.length > 1 && w.startsWith('{')) words[at] = w.slice(1)
141 // PowerShell captures output with `$x = git stash`, which still runs git.
142 else if (/^\$[\w:]+$/.test(w) && words[at + 1] === '=') at += 2
143 // A wrapper's own arguments (`timeout 60`, `sudo -u bob`, `nice -n 10`) end where git or a shell starts.
144 else if (WRAPPER.test(w)) {
145 const g = words.findIndex((x, k) => k > at && (GIT.test(unq(x)) || SHELL.test(unq(x))))
146 if (g > 0) { at = g; break }
147 wrapped = true; at++
148 }
149 else break
150 }
151 const head = unq(words[at] ?? '')
152 // bash -c "..." / eval "..." / iex "...": the string is a command line of its own.
153 // Unquoted (`cmd /c git stash`), the rest of the line is that command, its quoted words put back in quotes.
154 const flag = /^(eval|iex|invoke-expression)$/i.test(head) ? at : SHELL.test(head) ? words.findIndex((w, k) => k > at && SHELL_CMD.test(w)) : -1
155 if (flag >= 0) handOn(words.slice(flag + 1))
156 // find -exec / -execdir runs the words after it, up to `\;` or `+`, once per file.
157 const exec = words.findIndex((w, k) => k > at && /^-exec(dir)?$/.test(w))
158 if (exec >= 0) handOn(words.slice(exec + 1))
159 // A bare variable head (`$g`, `"$DOCKER"`) may stand for git: only the very next word is read, as a subcommand,
160 // so `$_ -match 'x'` or `"$d" rm -f web` pass. A path to a tool (`$env:X\dotnet.exe`) is that tool, not git.
161 const via = head.startsWith('$') && !GIT.test(head) ? head : undefined
162 if (via ? !/^\$\{?[\w:]+\}?$/.test(via) : !GIT.test(head)) continue
163 let i = at + 1
164 if (via) { if (!VIA_GUARDED.test(unq(words[i] ?? ''))) continue }
165 else while (i < words.length && words[i]!.startsWith('-')) i += GLOBAL_WITH_VALUE.test(words[i]!) ? 2 : 1
166 if (i < words.length) out.push({ sub: [unq(words[i]!), ...words.slice(i + 1)].join(' '), via })
167 // `git submodule foreach [--recursive] <command>` runs that command in every submodule.
168 if (unq(words[i] ?? '') === 'submodule') {
169 let f = words.findIndex((w, k) => k > i && unq(w) === 'foreach')
170 if (f > 0) { f++; while (f < words.length && words[f]!.startsWith('-')) f++; handOn(words.slice(f)) }
171 }
172 }
173 // shortcut: a string one shell hands another (`powershell -Command` from Bash) is lexed by the outer shell's
174 // escapes; lex nested by the inner shell if that ever hides a command.
175 for (const n of nested) out.push(...subcommands(n, ps, depth + 1))
176 return out
177}
178
179// null = let it run, otherwise why it would destroy work. `tool` is the tool the command runs in.
180// shortcut: with no tool the command is read as Bash, so a PowerShell backtick counts as a substitution
181// (over-blocks) and a backtick continuation is not joined; the hook always passes it, guess only for a new caller.
182export function check(cmd: string, tool = 'Bash'): string | null {
183 for (const { sub, via } of subcommands(cmd, tool === 'PowerShell')) {
184 const name = sub.split(' ')[0]!
185 if (!via && name.startsWith('$')) return `git ${name}: ${VARIABLE}`
186 // shortcut: a bare `$var` before a guarded subcommand word is read as git, so `$pkg restore` is refused too.
187 for (const [rule, why] of RULES) if (typeof rule === 'function' ? rule(sub) : rule.test(sub)) return via ? `${via} ${name}: ${VARIABLE}` : `git ${name}: ${why}`
188 }
189 return null
190}
191// Worth lexing: names git, or holds a variable that may stand in for it.
192const MAYBE = /\bgit\b|\$/i
193
194// What a shell call is told, or null to let it run.
195export function denial(command: string, off: boolean, tool = 'Bash'): string | null {
196 if (off || !MAYBE.test(command)) return null
197 const why = check(command, tool)
198 return why && `Blocked by makarasty-tools git-guard: ${why}. Other sessions share this tree. ` +
199 'Revert only your own edits with Edit/Write, save work with a commit or `git diff > file`; ' +
200 'to see pre-fix code, `git show HEAD:<path>` into the scratchpad or use a worktree; ' +
201 'or ask the user to run the command themselves with `!` if it is truly needed. MAKARASTY_GIT_GUARD=off turns this off.'
202}
203
204export const register: Register = on => {
205 on('tool.call', { tool: /^(Bash|PowerShell)$/ }, async ($, e, next) => {
206 const command = e.tool === 'Bash' || e.tool === 'PowerShell' ? e.command : ''
207 const deny = MAYBE.test(command) && denial(command, (await $.env.get('MAKARASTY_GIT_GUARD')) === 'off', e.tool)
208 return deny ? { deny } : next(e)
209 }).catch(($, e, next) => next(e))
210}
211hooks/reads.ts 21 lines1import type { Register } from 'claude-code'
2
3// Agents read files through the shell when a dedicated tool would answer in a twentieth of the time: on the
4// operator's machine 84 K of 115 K shell calls in four weeks were grep, sed, cat, ls and the like, each one
5// 0.1-1 s of harness before the command ran, against 0.02-0.07 s for Read and Grep [M40]. One line, every turn.
6export const READS = 'Read, search and list files with the Read, Grep and Glob tools, not with cat, head, tail, sed -n, grep, rg, find or ls through Bash or PowerShell: a dedicated tool answers in about 0.02-0.07 s, a shell call costs 0.1-1 s before its command even runs, and every agent pays that on every call. Use the shell for what only a shell does: running programs, git, builds and tests.'
7
8type Section = { id: string; text: string; scope: 'shared' | 'session' }
9
10// The prompt's sections with the line added last, or as they were.
11export function withReads<S extends Section>(sections: readonly S[], tools: readonly string[], off: boolean): readonly (S | Section)[] {
12 return off || !tools.includes('Read') ? sections : [...sections, { id: 'makarasty-tools:reads', text: READS, scope: 'session' }]
13}
14
15export const register: Register = on => {
16 on('prompt.compose', async ($, e, next) => {
17 const composed = await next(e)
18 return { ...composed, sections: withReads(composed.sections, e.tools, (await $.env.get('MAKARASTY_READS')) === 'off') }
19 }).catch(($, e, next) => next(e))
20}
21