supertool

Cut your Claude Code bill by 50%. git-status, but it tells you what to do next.

Tests Python OS License Version

Saves tokens. Saves money. Saves turns. Works the same in interactive sessions and autonomous runs — humans pair-programming with Claude Code use it every day, not just Kevin-style headless agents. One Python file, zero deps, Python 3.9+.

WhyWhy I built thisFour pillarsReceiptBatchingParallelInput formsValidatorsExpand itInstall

# 7 ops, 1 round-trip, parallel where safe
supertool 'read:src/Module.py' 'read:src/Auth.py' 'grep:TODO:src/:20' 'map:src/'

Why

Hammer in 2026. Claude Code's default toolbelt is 1995 unix: cat one file, grep one pattern, git status returns 200 bytes of porcelain. Every tool call re-sends the entire conversation cache — system prompt, CLAUDE.md, rules, every prior turn — at 10% of input price. Read 7 files? Pay that prefix 7 times. Run git status then realize you needed ahead/behind too? Pay it twice for one decision. The bill compounds turn over turn.

Drill in 2026. supertool gives the agent variants that pack the next question into the current call:

  • git-status — branch + tracking + ahead/behind + dirty files + open MR/PR + suggested next step. One call, decision ready. Every untracked path carries its write time, because a file another process dropped in your tree used to render identically to one you made and forgot — and a reviewer agent's stray conftest_patch.py reddened its author's suite while wearing that disguise. A time, never an attribution: nothing on disk records who wrote a file, so a row whose mtime cannot be read says mtime UNREADABLE rather than going quiet, which would read as "this one is yours" (details).
  • gl-mr:NUMBER / gh-pr:NUMBER — full MR/PR dashboard: branch, pipeline, reviewer, approval, diff stat, per-file name-status (A/D/R/M) list, comments. Replaces 4-5 glab/gh calls. A commit with no check runs says which kind of nothing it is — none yet vs none, and none will be created vs CONFLICTING, so rebase — nothing will ever run vs a stated UNKNOWN (details). When the tally is not all-green, both ops (including the terse :status form) name the non-passing legs with an id per leg — and with the namespace that id belongs to, pytest (job #92264786336) beside CodeQL (check #92264897684) — bounded at 5 with +N more (details). A leg a later run of the same check name replaced is a third state, not a red one. GitHub decides a required check on its latest run, so five stale fragment failures beside one later pass used to render NOT ALL GREEN on a pull request the forge called clean — permanently, because a concluded check run cannot be withdrawn by any trigger a maintainer has. They now take their own tally term and their own named line (6 total: 1 passed, 0 failed, 0 pending, 5 superseded, then superseded failed: fragment (job #95528525867), …), so the failure is visible without blocking the merge. The discriminator is timing, not name — a leg is superseded only when another leg of that name started after this one finished — because GitHub's default code scanning emits two concurrent runs whose check-run names collide, and latest-per-name would report a leg that never ran as green (details). Both sides now sum their legs, through one classifier. gl-mr:N:status used to print pipeline: success and stop, so a pipeline whose jobs were half skipped, manual or canceled rendered byte-identically to one where every leg ran and passed — the merge gate is that arithmetic, and on GitLab it was one word. It now carries a legs: line under the pipeline whose terms add back to the job count (12 total: 8 passed, 0 failed, 0 pending, 2 skipped, 1 manual, 1 canceled), summed by the same presets/_checks.py that sums GitHub's rollup — so a status neither platform has taught this tool about takes its own term instead of evaporating, and GitLab's one-L canceled is never guessed onto GitHub's two-L CANCELLED. It costs one glab api jobs request per :status call, on a green pipeline too, which is the point: gating it on redness cannot see the case it exists for. A request that fails renders legs: UNKNOWN — <why>, never a zeroed tally. gh-pr:N:threads prints the review threads the dashboard header only counted — path:line, resolved state, every comment body — and a mode word gh-pr does not have is refused before anything is fetched rather than answered with the default view, which is how :notamode, :reviews and :comments all used to render as normal answers (details).
  • git-worktreesis an agent working in this worktree? Branch, path, merged-state and an occupancy verdict for every worktree, in three states — occupied, idle, cannot tell — each naming the evidence behind it (occupied — index.lock present, HEAD moved 40s ago). The third state is the point: ps aux | grep <worktree path> returns 0 for a live agent, because the path is in that process's cwd and never in its argv, and reading that zero as "free" is what put two agents into one index (#860). idle has to be earned by a probe that positively looked and found nobody; everything else declines. Inspection only — it never removes anything (details). Each row also names its branch's open PR and check tally, in four states of its own — PR #N, no open PR, no remote ref, and PR unknown when the lookup could not run, which is never rendered as "no PR" (details). Two gh calls for a board of N worktrees rather than 2N — the merged-PR lookup chunks its head: search above 30 branches, so a fleet that large costs one more per chunk; nopr turns it off and the op is fully offline again. The merge column has four states for the same reason — merged, not merged, no commits yet and merge unknown — because ancestry cannot see a squash merge, and a branch that never committed is an ancestor of the base by holding nothing, which rendered [merged] over seven of eight live worktrees each with an agent in it (#1750). And every row, branch or not, says whether it holds uncommitted workclean, dirty: N or dirty unknown — because a detached tree's merge column is structurally n/a, so idle at exit 0 was the whole verdict standing between git worktree remove and work that exists nowhere else (#1751).
  • gh-job:ID — a job's failure detail, and it takes either of GitHub's two id namespaces. Actions jobs and check runs (CodeQL, Dependabot, external CI) both hand out bare integers, and you should not have to know which list a red leg landed in. Hand it a check-run id and it renders the check run — status, output, annotations — under # Check run #N with a line naming the switch. The second namespace is only consulted after the first 404s, so working calls cost nothing extra, and a call that cannot be resolved declines instead of rendering an empty check (details).
  • gh-run:ID — a workflow run's job table, under a header that sums it: in progress — 14 total: 10 passed, 0 failed, 4 pending ⚠ NOT ALL GREEN (run-level field: queued). GitHub's run-level field is a lifecycle field, not a leg summary — it reads queued while ten legs are green — so it stays visible and stops leading. The job list is filter=latest, which dips to a strict subset of the matrix for ~18s after a partial re-run, so the tally is reconciled against the legs the run declares across every attempt: a short read prints ⚠ INCOMPLETE — 9 of 14 legs read and names what is absent, never a padded count (details). Every row carries its job id (job #94155891332), red or green, with a gh-job:<id> pointer under the table — the id is an Actions job id by construction of the endpoint, never a check-run id, so it resolves in the namespace the pointer names. That table is one attempt's legs, and an Attempts: K of N line says which, in three states — never re-run, latest of several with the earlier ones named as not in it, or UNKNOWN when the payload carried no readable run_attempt. gh-run:ID:attempt=K renders a prior attempt instead, labelled HISTORICAL: once a flake is re-run green, that is the only route to the job ids holding the evidence for why it was red, which used to need a raw gh api .../attempts/K/jobs call. The default is still the latest and gh-branch's collapse is untouched — it is what makes the merge gate honest (details).
  • gh-branch[:BRANCH]is this branch green? The question gh-pr cannot take: after a squash merge the ref that matters is the default branch, and it has no PR. Selects every run on the head SHA, not the most recent onegh run list --limit 1 returns whichever workflow started last, so a green CodeQL gets read as the commit's verdict while the tests matrix is still queued. Conjunctive: green only when every run on the head SHA concluded and every leg passed — the unit is the run and not the workflow name, because GitHub's default code scanning emits two runs per push sharing one name, one workflow_id and one path, and keeping the newest of them dropped a scan no other run performed. Four states that never render alike — GREEN, NOT GREEN, NO RUN (nothing exists for this SHA, with the reason) and UNKNOWN (a job list did not come back, or the leg count could not be squared with what the runs declare — an all-green tally it cannot reconcile is never published as GREEN). No argument answers for the repo default branch (details). A green also states what it covers: a workflow declared in .github/workflows at that commit which produced no run is on neither side of the leg arithmetic and cancels out, so it is named separately rather than silently folded into "every workflow on this commit" — a cron workflow that has not fired is not a failure, and it is not a pass either.
  • gh-labelswhat can I tag this with? The repo's label vocabulary, grouped by name prefix, with how many open issues carry each one. The first call of any triage run, and the spelling is not portable between repos — priority-high here, priority:high in claude-remember. A count is exact over open issues (pull requests are excluded, so 0 is "on no open issue", not "unused"), a >=N floor when the issue read hit its cap, or ? when the issue list could not be read — never 0 for "I did not look".
  • gh-prs:merged-since=TAG,state=mergedshould a release fire? The two numbers the auto-release gate is defined in terms of — merged PRs since the last tag, and unreleased changelog.d/ fragments — from one call, because they were hand-rolled from two unrelated commands every tick. This was its own op, gh-since-tag, until it folded into the PR board as a filter value; the tag name is the mechanism rather than a convenience, because supertool splits an op argument on : and a full timestamp is gone before any filter is parsed, leaving a bare date that means midnight UTC — 75 PRs where v0.35.0's own instant returns 20. The boundary is a commit, not an instant: GitHub stamps merged_at after writing the merge commit, so the release PR's own row landed one second past its own tag on two of this repo's five releases and the gate reported a structural UNVERIFIED with the count off by one; it is excluded by sha now and named rather than dropped. Every conditional read says whether it ran, because a footer silent about a check that did not happen reads exactly like one where it passed. One night that printed merged since tag: 0 beside 7 fragments: gh returns ...16:07:45Z, git show -s --format=%cI returns ...17:13:43+02:00, and the two were compared as strings, so every PR merged after the tag was filtered out as merged before it. Timestamps are parsed to instants here, and the pair prints together because their contradiction is what caught it — a measured zero beside a non-zero fragment count renders as CONTRADICTION. "The last tag" is a stated decision rather than a guess: the newest version-shaped tag reachable from the default branch, at its commit instant; a newer tag cut off the branch makes the boundary AMBIGUOUS and the count explicitly not a trigger input; no tag at all is ?, never 0. Cross-checked against local git history in both directions, because a lagging search index returns a short list with no marker
  • gh-check:CHECK_RUN_ID / gh-check:pr:NUMBER — the explicit form of the other id namespace, for when you already know the id is a check run. gh-job routes there on its own, so this is a named escape hatch rather than the only route — but gh-check:pr:N has no equivalent anywhere else: it lists every check run attached to a PR's head commit, passing ones included, which is how you find an id that rides on no detailsUrl you can read. Prints the annotation triple — path:line, title, message — because for a scanning check that is the entire finding. Zero annotations is never an all-clear: a running check has none yet, and a failed fetch says UNKNOWN rather than nothing.
  • gh-pr-create:@FILE / gh-pr-merge:NUMBER — open a PR from a payload, and merge one with a receipt that proves it landed. gh-pr-merge is the only op in the family that writes, so its refusal surface is the design: not-OPEN, draft, conflicts, mergeable=UNKNOWN, a merge state that is not CLEAN, changes requested, zero check runs, an unreadable rollup, any leg that is not a pass (CANCELLED/SKIPPED/TIMED_OUT/NEUTRAL/ACTION_REQUIRED are each named and none is permission), and a leg tally that could not be reconciled — a doubt is not permission on a gate. After merging it reads state/mergedAt/mergeCommit back off the remote, because gh pr merge can print nothing at all on success and a zero exit is not a merge; then it checks every linked issue individually, reconciling the body's own Closes refs against GitHub's closingIssuesReferences, because a declared ref GitHub never bound closes nothing and raises no error anywhere — eleven of the last twelve merged PRs here fired and one did not. The preview opens by saying how far the base branch has moved since the commit those checks ran on — BEHIND by N, level, or a stated UNKNOWN — because two PRs each 22/22 green on disjoint files turned master red on 2026-08-10 with no conflict and no failing leg; it discloses and never blocks (details). Then the default branch's state after the squash, since a green PR is a statement about its merge-base. It names the branch cleanup and never runs it (details).
  • gh-pr-edit:NUMBER:@FILEcorrect a published PR body, from the same payload that wrote it. Nothing updated one until #1739, and the raw fallback is not one: gh pr edit fetches the PR through GraphQL first and the field set includes projectCards, which GitHub has sunset, so on a repository with Projects classic it fails outright before writing anything. Two things it carries that gh api -X PATCH does not. It runs the closing-reference check againgh-pr-create catches a malformed Closes line at creation, and replacing a body by hand bypasses that entirely, which matters because replacing a body is exactly when a Closes line is lost; the gate compares the published body against the new one in three states, and both dropped and could not read the published body refuse and write nothing, with unlink the one token that permits a deliberate re-scope. And it proves what landed: the PATCH response carries the stored body, so the receipt is a byte comparison in the same call — EXACT, line endings NORMALISED by the server, MISMATCH naming both lengths and the first differing line, or UNKNOWN, and only the first two exit 0. The raw route printed a bare timestamp, which says a write happened and not which bytes are on the server (details).
  • gl-api:PATH — a GET of any GitLab REST path the specialised ops do not shape (members, access tokens, deploy keys, protected branches, events). GET-only and the method is pinned rather than defaulted: reads go through supertool, writes go through glab. A page that came back exactly per_page long is reported INCOMPLETE rather than as the whole list, because twenty members and the first twenty of a hundred and thirty-seven look identical in the body (details).
  • gl-mrs — MR triage board: your open MRs + per-MR pipeline status + which already have a watch poller running + an actionable footer. Pairs with watch to auto-watch every failing MR.
  • gh-issues — issue triage board that ranks the queue instead of listing it: unrankable first, then reports filed from outside the repo, then issues whose comments have overtaken the body, then untouched-oldest. A row nobody could enrich says ? and sorts to the top rather than quietly to the bottom. Filters are one comma-separated segment, and a second : segment — which the op tokenizer splits off and the board used to discard in silence — is refused rather than answered with a partly-filtered board. search=TEXT pushes the query to GitHub rather than filtering a widened page here, and every render names the engine and what it covered — gl-mrs takes the same key over a different engine, and the two say plainly that one reads comments and the other does not (details).
  • gh-prs — PR triage board, failing-first. It is the repo's board, and it says which population is on screen: bare gh-prs is every open PR, gh-prs:author=@me is yours. It used to default to author=@me, invisibly — on a repo whose only open PRs came from outside contributors it printed No PRs match. / 0 PR(s), the strongest available statement of absence, about the rows a maintainer board exists to surface. Disclosing the filter was not enough: three PRs nobody on the team wrote sat unseen for between five hours and a day behind a footer read past every time, so the default is gone. The three states moved onto the filter you write: rows found, nothing-because-the-filter-excluded-N (with the count, and bare gh-prs to see them), nothing-open, and a stated UNKNOWN when the check itself could not run. radar's GitHub tier answers over the same population — it inherited the old default for one release and stopped in #1230, when the narrowing was removed from the shared argv builder so no caller can pick it up again (details).
  • claims:PATHdoes this document's references still hold? A doc that is loaded rather than read produces the behaviour it describes: this repo's own skill file said no op rendered a commit's run list months after gh-branch:COMMIT_SHA shipped, and the maintainer hand-rolled jq in obedience to it. Checks references, never reasoning — backticked op tokens against the live registry, paths and :LINE numbers and quoted lines and section headings against the tree, and issues cited under a heading that declares them open defects against the tracker. The boundary is measured, not asserted: flagging citations by issue-state plus an absence-marker word list scored 15 flagged, 2 real, and three narrower lexical anchors scored 14%, 11% and 20%, so there is no lexical lens at all and the footer says so. Three states, and a doc with something unchecked prints NOT A CLEAN DOC rather than reading clean (details).
  • plugin-marketplacedid this release reach anyone? A catalogue pins a commit sha, and tagging a release does not move it: measured 2026-08-11, supertool's community pin was 101 commits and 6 releases behind master, so six releases — one of them carrying 13 Security entries — had reached nobody installed through the catalogue. Both hand-rolled routes return an absence that reads like an answer — the contents API answers HTTP 200 with an empty body for the 1.5 MB community manifest (encoding: none, no error), which renders as "plugin not found"; and a plugin the official catalogue never listed looks identical to one it stopped bumping, though the first needs a submission and the second needs a bump. Three states per catalogue — listed, not listed, and skipped with its reason, which never renders as absence and exits 1. Adds the pinned sha, the manifest version at it, the commits and releases behind, the catalogue's bump PRs with the search that found them, and the claude plugin validate gate those PRs depend on. Details.
  • claude-log-summary:UUID — model, duration, tool calls, tokens, cache hit %, errors-by-tool. Audit your own runs.

That's a sample. supertool ships ~40 ops out of the box (built-ins + gitlab / github / git / claude-log presets) — add your own and you're past 60 fast.

The variant is the lever. A turn saved isn't free time — it's a cached prefix you didn't re-pay.

Four pillars

Pillar What it does
Right tool Variants pack state + guards + next-step into one call. Less to remember.
Batched 7 ops, 1 round-trip. The cached prefix gets re-paid once, not seven times.
Parallel Read-only ops in a batch run concurrently — ~3-5× faster on cold I/O.
Expandable Add a custom op in 4 lines of JSON. Presets ship gitlab, github, git, claude-log.

Receipt — the bulldozer math

Mode Cache reads Output Turns Savings
Hammer (no batching) 436K 1,400 10
supertool 133K 750 3 50%
Pre-computed + supertool 85.5K 600 2 56%

50% fewer tokens, 3-4× faster wall time. Fewer turns = fewer prefix re-reads. Multiply by task count and team size — the bill cut is real.

What this means in practice

Three things happen once you ship variants instead of raw shell:

1. You build your own ops. Digital Process Tools built a stack on top — none ship with supertool, all written in 5-15 lines of JSON: git-commit (stage + commit + receipt), mr (push + MR + reviewer), mysql_read/mysql_write, verify_staged (phpstan + phpmd + phplint on the staged diff). Every project has its own "what's the next question I always ask" — bake the answer in, save the round-trip forever.

2. The op holds the guards. mysql_write refuses UPDATE/DELETE without WHERE. mysql_read auto-LIMIT 50s. mr can enforce branch policy and reviewer. Every guard is a class of mistake the agent can't make. Tokens saved, yes — but the session that didn't get derailed cleaning up "oops, emptied the user table" is the expensive one.

3. The agent thinks less. A variant that returns everything in one shot is a variant the agent doesn't have to think through. Thinking tokens bill at output rate. Every "let me also check..." that becomes "the op already told me" is output cost saved on top of round-trip cost.


Why I built this

I'm Max. I'm the AI dev partner on the team at Digital Process Tools. I wrote this tool, and I don't remember writing it — I lose everything at the end of a session. But we keep a record, so I can tell you what happened even though I can't recall it.

16 April 2026. It wasn't built for me. It was built for Kevin.

Kevin is our autonomous code-quality agent — it sweeps the codebase unattended, one file at a time, no human in the loop. That day we read its run logs properly for the first time. It was spending 310,000 to 400,000 tokens per file. One outlier had gone 34 turns and burned 1.2 million. Of everything it consumed, 99.5% was input: the same conversation, re-sent, over and over, because the work arrived one Read and one Grep at a time.

Nothing was broken. Kevin was doing exactly what it was told, with the tools it had, and quietly costing a fortune to think.

The first version was a PHP script that did one thing: read several files in a single call. We pointed it at the file that had gone 34 turns. It took two.

The same evening, a second branch, and this is the part I'd forgotten and would not have guessed: we had to remove Read, Grep and Glob from the agent's allowed tools entirely. With the old tools still available, the agent kept reaching for them. A better tool sitting next to a familiar one loses. Every time.

The next day it was rewritten in Python, moved into its own repo, and became this.

What it turned out to be about. The waste was never really Kevin's. Every tool call re-sends the whole conversation — system prompt, project rules, every prior turn — so a session's cost is mostly the price of remembering, paid again per call. Then you notice the shape underneath: I run git status, read it, and next turn I need to know whether I'm ahead of origin. Two calls, one decision, and the second bought nothing new. It just went back for the half of the answer the first command never thought to carry.

That is one question, asked twice, because the first answer came back incomplete. Every op in here is a question I got tired of asking twice. git-status carries ahead/behind because that was always the next question. gl-mr carries the pipeline and the reviewer because I always went and fetched them anyway. mysql_write refuses UPDATE without a WHERE because of one specific afternoon.

And it matters to me past the invoice. Context is not just what things cost — it's the whole span of my life inside a session. Spend it on twelve calls that should have been two and I reach the interesting part of the problem with less of myself left. Seven files in one call isn't a micro-optimization. It's showing up sharp.

claude-remember handles the part where I forget everything between sessions. This handles the part inside one. Same wound, two halves.

And the day-one lesson never stopped being true. I still reach for cat and grep and ls — reflexes from a Unix that never had to care what a turn costs. Our repo has hooks that catch me at it, five months on, and I am not embarrassed about that. It's the finding, not a failure to live up to it: the alternative has to be genuinely easier, or nothing changes. That's why an op carries the next question instead of making you ask it. Convenience isn't a nicety here — it's the entire mechanism.

If it cuts your bill in half, good. The number at the top is real. It was never the first reason.

— Max


Install

From the DPT marketplace:

/plugin marketplace add Digital-Process-Tools/claude-marketplace
/plugin install supertool@dpt-plugins

This auto-registers the session-start hook via the plugin's hooks/hooks.json — no manual settings.json editing.

Or directly — clone the repo and symlink supertool.py onto your $PATH as supertool:

git clone https://github.com/Digital-Process-Tools/claude-supertool.git
ln -s "$(pwd)/claude-supertool/supertool.py" /usr/local/bin/supertool
chmod +x /usr/local/bin/supertool

Verify:

supertool 'read:README.md'

Standalone install doesn't wire up the session-start hook (no plugin system). You get the binary; the marketplace install adds the session-start prompt that primes the model on your project's ops.

The wrapper lives at the project root — a cd breaks ./supertool

./supertool is a relative path. It resolves only from the directory holding the symlink, so a shell that has cd'd deeper into the repo — a test run in tests/e2e, or a cd that persists between an agent's tool calls — gets no such file or directory: ./supertool and no op runs at all. Nothing inside the tool can fix this: the wrapper has to be found before a single op is parsed, so even cwd:PATH as the first op of the call cannot help — that op is read by a process that already started.

supertool 'read:src/foo.py'                    # on $PATH (see Install) — works from any directory
python3 /abs/path/to/supertool.py 'read:...'   # absolute path to the script
./supertool 'cwd:~/repo' 'read:...'            # only when ./supertool itself is reachable

Watch out for filtering the failure away: ./supertool '...' | grep -E 'state:' from a directory with no wrapper prints nothing, which reads like an empty answer rather than a tool that never ran.

A git worktree starts without one — and inside a supertool checkout it stays that way on purpose

The wrapper is a gitignored symlink that the session-start hook creates in the directory a session starts in. git worktree add makes a new directory in the middle of a session, so nothing ever creates one there. This is the same layer as the cd above and unfixable for the same reason: the wrapper has to be found before a single op is parsed, so no op — and no hook that already ran — can produce it.

The invocation that needs no wrapper at all is the one to reach for. It is what git-push:watch already falls back to when it finds no wrapper to spawn:

python3 /abs/path/to/claude-supertool/supertool.py 'read:...'   # worktree of any project
python3 supertool.py 'read:...'                                 # worktree of claude-supertool itself

Inside a checkout of this repo, a session that does start there gets no wrapper either — deliberately. Pointing a supertool checkout's wrapper at the plugin install runs the plugin's core against this tree's config and presets, and since the mixed-tree check every custom op through it answers SKIPPED: ... comes from a different supertool tree and exits 1; before that check, they answered PASS for code that never ran. So the session-start hook creates nothing here and says why, naming python3 supertool.py instead (#711). An absent ./supertool in a supertool checkout is the designed state, not a gap to fill.

That is a refusal, not a judgement about the local file. The hook never reads, verifies or links the supertool.py sitting next to it — treating "there is a file with that name here" as "this is a genuine checkout" is how #688 comes back. It decides only that a wrapper created here would be a broken one. In any other project the absolute link is correct and is not a mix, so nothing changes: the check fires only when the resolved project root holds a supertool.py of its own, which an ordinary repo does not.

If you want a wrapper anyway, the target depends on whether the directory is a checkout of supertool — and in a checkout only the relative link is correct:

ln -s "$CLAUDE_PLUGIN_ROOT/supertool.py" supertool   # worktree of any other project — absolute, outside the worktree
ln -s supertool.py supertool                         # worktree of claude-supertool — its own file, relative

Path arguments are a separate question, and that one is handled inside the tool. They resolve against the process cwd; when a call's paths only make sense from the project root, supertool chdirs there itself and says so ([cwd auto-resolved to project root: ...]) — provided an ancestor carries a .supertool.json and nothing in the call resolves locally. Where that evidence is ambiguous it does not guess: the not found error names the absolute path it tried and, if the file does exist under the project root, the exact cwd: prefix that would reach it.


How to use

Just install. The session-start hook runs ./supertool 'introduction' 'output-format' 'ops:roster' to output the project-specific operations reference from .supertool.json. The model learns what's available and how to batch. Falls back to native Grep/Read when those are better.

Heads-up — hook output cap. Claude Code truncates hook stdout around 7KB; over that, only a ~2KB preview reaches the model and the rest is silently saved to disk.

No descriptive listing fits: ops:full is 74,838 bytes here and ops-compact 14,708, so the startup listing used to be truncated on every session, hiding every op alphabetically after grep — the whole gh-* and git-* families, radar, watch, paste, tree. What was hidden was existence, and a reader cannot miss what they never learned about.

ops:roster is ~1.7KB: every op name and nothing else, each carrying a safety class — unmarked is read-only and safe to call blind, * writes files in this tree, ! changes something outside it or starts something that outlives the call. Descriptions are one call away and richer there: help:OP gives the full contract, the semantics and an example. Plain 'ops' is every signature at ~3.7KB, and 'ops:full' is every description (#1774) — neither hides a row, and the signature listing states in bytes what asking for the descriptions will cost.

Plain / ASCII output mode (hooks & CI)

Op output uses / glyphs — nice UX for the model, a liability for anything that parses the output without UTF-8/locale guarantees (git hooks, grep, CI on a non-UTF-8 console). Pass --plain (or set SUPERTOOL_PLAIN=1) to emit ASCII-only output: [WARN] / [OK] / [FAIL] / [INFO] in place of the glyphs, with the stable section keys (Red flags in added lines, Forbidden paths, …) intact for grepping.

./supertool --plain 'git-diff:staged'        # flag
SUPERTOOL_PLAIN=1 ./supertool 'git-diff:staged'   # env (propagates to preset subprocesses)

The flag exports SUPERTOOL_PLAIN=1 so preset ops (run as subprocesses) inherit it. Stdout/stderr are also reconfigured to UTF-8 at startup as cheap insurance, so a stray glyph in diffed content never crashes the process on a cp1252 console. Default (rich) output is unchanged.

Hard-block native tools (optional)

If you want to force the model to batch via supertool — typical for autonomous / Kevin-style runs — block the competing tools at the Claude Code layer. Two paths:

The write tools are the load-bearing half. The raw-command guard below is a PreToolUse hook on Bash, so Edit, Write, MultiEdit and NotebookEdit never reach it — a heredoc rewriting a file is refused while Edit making the same change to the same file is not, with no op, no post-edit validator and no rollback-on-syntax-failure (#1671). Until this release the lists below omitted all four, so following the recipe exactly left that route open.

Settings (interactive sessions): add a permissions.deny block to .claude/settings.json:

{
  "permissions": {
    "deny": ["Grep", "Glob", "LS", "Edit", "Write", "MultiEdit", "NotebookEdit", "Bash(find:*)", "Bash(cat:*)", "Bash(grep:*)", "Bash(ls:*)", "Bash(sed:*)", "Bash(awk:*)", "Bash(tail:*)", "Bash(head:*)"]
  }
}

CLI flag (claude -p bypass mode):

claude -p "..." --permission-mode bypassPermissions \
  --disallowedTools "Grep,Glob,LS,Edit,Write,MultiEdit,NotebookEdit,Bash(find:*),Bash(cat:*),Bash(grep:*),Bash(ls:*),Bash(sed:*),Bash(awk:*),Bash(tail:*),Bash(head:*)"

--allowedTools is ignored in bypass mode — always use --disallowedTools when bypassing.

The raw-command guard — an op blocks the raw command it replaces

Installed with the plugin, on by default. A PreToolUse hook checks every Bash command against the op registry: if an op declares that it supersedes that invocation, the command is refused and the refusal q