Releases: mariusei/scantool
Release list
v0.30.0
focus now answers the question an edit asks next: what sits right outside the span. An agent that removes or replaces a function needs to know where the blank lines around it go, and until now it read the edges again with sed -n … | cat -A to find out. The answer carries one line under the address:
src/scantool/server.py::list_directories (320-409)
edges: 318-319 blank above, 410-411 blank below, then _focus_answer (412)
It names the blank lines above and below and the sibling that follows them, or the end of the file. JSON carries the same as blank_above, blank_below, next_sibling and end_of_file. Measured with Opus 5.5 in Claude Code, removing a decorated MCP tool from a v0.29.0 clone, with a project CLAUDE.md line asking for sct focus before edits (5 runs per arm): with 0.29.0 every agent spent one to three separate calls reading the edges before deleting; with the edges line none did. Three of ten still printed the edges, as a guard inside the delete command itself rather than a call of its own. Every run deleted the right range.
focus is also shorter. It showed the whole file's outline around the function, so a focus on a 90-line function in an 1800-line file came back as 194 lines. It now keeps the path to the node with its parents' other members (a method still shows its class's methods) and leaves the file's other top-level structures to scan: 99 lines for the same function. The same edit runs cost 38 % less than with 0.29.0 (0.082 against 0.133 USD per run) and are cheaper than reading the span with sed or Read without focus at all. On a reading task ("how does scantool decide which functions are hot?") answers stayed correct in 5 of 5 runs with and without the outline, and no agent went looking for the context that was dropped.
sct search -i works. Agents bring grep's -i along: on that reading task 10 of 16 runs typed sct search . -i 'hot' --names. sct had no -i, the call exited 2 with its message on stderr, and behind 2>/dev/null the agent saw nothing and searched again. -i now makes name matching ignore case (text search always did). In runs that used it the answer took 2.17 calls instead of 3.50 and cost 23 % less (8 runs per arm). Every usage error, for any command, also writes one line to stdout ending in (exit 2), so a hidden stderr no longer reads as an empty answer. No agent in the trials hit a usage error after the fix, so that line is untested by agents.
Contract changes, named. Every focus answer gains an edges: line under the address (also with --body), and loses the file's other top-level structures; the file line names the whole file (basic.py (1-75)) instead of the span of the nodes shown. Focus JSON gains four fields. sct search has -i/--ignore-case. Usage errors print one line on stdout besides argparse's usage on stderr; the exit code stays 2. Goldens for all 23 sample languages are updated.
v0.29.0
A structure's reported range is now safe to cut. In Python, TypeScript and Rust the span started at the def/fn line while the decorators and attributes above it are separate nodes in the grammar, so deleting lines start..end left them behind, bound to the next definition. It happened in practice: an agent removed three handlers by the ranges scan gave it, the route decorator of the fourth stayed above the wrong function, and that endpoint answered 404 through two image tags. Spans, @N rows, focus ranges and JSON start_line now open at the first decorator or attribute, as they always did in Java, C#, PHP and Swift, where the grammar nests annotations inside the definition. The scan excerpt still opens at the definition itself, since the tree lists decorators as rows.
Functions declared inside other functions are visible in TypeScript and JavaScript. React code keeps its logic in handlers, useCallback bodies and helpers inside a useEffect; scan showed only the component and focus MapView loadLayer matched nothing, so agents read the file whole. On two React codebases, 306 named function bindings sat inside function bodies against 423 listed structures. They are now [local] children of the function that declares them, at any depth, and focus reads them bare or qualified (MapView.loadLayer). They stay out of the call graph (a call inside one still belongs to the enclosing function) and out of excerpt selection, so the component keeps its excerpt: on both codebases the output is unchanged apart from the added rows, +7 % and +4 % characters.
What comes next is said at the top. A long answer is read from the top and cut at the bottom, so focus now carries a next: line directly under the address, and search moves its leads from the last line to under the count:
src/scantool/capabilities.py::tool_description (282-286)
next: sct callers tool_description (its call sites) · sct history src/scantool/capabilities.py::tool_description (the commits that changed it)
Each pointer says what it gives and is shell-quoted. A test runs every sample language's pointers and requires them to be true: callers finds the definition, the history address resolves to the same node. It caught three untrue forms before release (a notebook cell inside a callable name, qualified names Java and Ruby do not resolve, and history addresses for a name two structures share). The last one is fixed at the source: history and resolve accept the range suffix focus prints, path::DatabaseManager (15-18), where a Rust struct and its impl share a name. The orientation view's next part recommended scan_file("…") and Read("file", offset=N); its rows are now sct commands with paths written from the caller's directory, so each can be pasted as it stands.
An empty answer says what another reading would find. A bare "No structures found matching the criteria" for a --names search on call names sent an agent to grep, while the same pattern had six text hits. An empty structure search now names its criteria and, when true, how many structures match the name alone and how many text hits the pattern has; an empty text search says how many structure names match. Nothing is added when neither reading finds anything.
The tools can be found by the questions agents ask. With several MCP servers, Claude Code defers tool schemas and an agent finds a tool through ToolSearch, whose ranking is word overlap with the description. "who calls this function", "git blame for a function" and "review the changes in a pull request" returned no scantool tool. Every description now ends with the questions it answers. Measured with blank runs over 43 fixed queries: 7 -> 12 of 13 on the full capability range, 3 -> 4 and 4 -> 5 of 10 on two held-out sets written before the text. Wording helps where its words occur and generalises by about one query in ten; synonym lists added nothing on a fresh set and are not in the release. The instructions block now says what each habit swap gains (grep -rn p -> sct search . p: hits in their function, git log -L -> sct history f::name: through renames).
Contract changes, named. Decorated Python, TypeScript and Rust structures start at their first decorator or attribute (basic.rs User @8 -> @7), and their saliency now includes those lines. focus answers carry a next: line under the address; the frozen format_focus only the callers part, the server layer adds history for files on disk in git. search prints leads under the count. The orientation's next part lists sct commands instead of MCP calls. Empty-search messages are longer; the prefixes the CLI maps to exit code 1 are unchanged. TypeScript/JavaScript trees gain [local] rows. MCP tool descriptions end with Answers: …. The instructions block's command list lost its column padding (1907 characters). tests/golden/hooks.json records two new hooks, ATTACHED_PREFIX_TYPES and ATTACHED_PREFIX_SKIP.
v0.28.0
used by N files is now one number, computed once, and you can ask what it is made of. sct callers <path-to-file> answers with the files that import it, each with the import line. The orientation view's core section uses the same graph, so the count and the list can no longer disagree, and its header says what the number means. Measured on a package of 394 files: one module reported used by 13 and sat at rank 25, outside the top twenty, where it is actually imported by 51 files and belongs at rank 1. The undercount came from import forms the resolver skipped; in an orientation task, three agents each wrote their own AST pass rather than trust the number.
<52 importers in 45 files, 748 files scanned> importers of src/scantool/languages/models.py
- src/scantool/directory_formatter.py:8 from .languages.models import Sweep
- src/scantool/languages/__init__.py:21 from scantool.languages.models import (
Every import form each language actually has is now a test. tests/test_importers.py is a matrix: one target module per language, one importing file per form, and the expected importer set. Forms that resolve today: Python's four shapes plus aliases and relative imports; TypeScript's relative, extensionless, require and export from, and paths aliases from the nearest tsconfig; Go plain, aliased and dot imports; Rust mod, use crate::, self::, super:: and grouped use; Java single, wildcard and static imports; C# using resolved to the files that declare the namespace; PHP use and require __DIR__ . '/…'; Ruby's two require forms; Zig, C, C++, Swift, SCSS and CSS. A form a handler cannot resolve is recorded as a named hole in the test, so the state is visible rather than implied; there are none left.
Two defects that only showed on Windows, both real for anyone with CRLF files. import a.b.c was never extracted from a CRLF file, because the pattern is $-anchored and $ does not consume the carriage return, while the from-import pattern's trailing group absorbed it. Ten resolvers derived the including file's directory with Path(source_file).parent, which is src\dir on Windows, and compared it against a file list keyed with forward slashes, so an import next to its importer resolved to nothing. Both are fixed at the source and guarded by a test that writes CRLF bytes, so neither needs a Windows runner to stay fixed.
Contract changes, named. sct callers takes a file path as well as a name, and answers with importers rather than call sites for it; callers(name=…) on the MCP side does the same. The core section's header now reads CORE FILES (by centrality; used by = files that import it, resolved statically). tests/golden/hooks.json records six more handlers overriding import resolution. Rankings in the orientation view change wherever the old resolver undercounted, which is the point of the release.
v0.27.0
The first line of a multi-part answer now says what the answer contains and how to fetch one part of it. sct <dir> opens with <sct . : core 144, entry 21, structure 10, archetypes 26, architecture 51, deps 14, hot 11, inventory 14, next 11, git 3 — one part: sct . --part core>: every section has a fixed id and its line count, the core files come first, and --part ID (MCP preview_directory(part=…)) renders one or several sections alone. sct overlap and sct surface --against carry the same inventory inside their header (; parts: branches 3, history 3, shared 73, order 1) with --part on both. Measured before building: agents cut long output with | head -40 on their first call, and a preview cut at 40 lines used to show no structure row and nothing that said the other seven sections existed. Measured after: in three blank runs the second sct call was sct . --part entry every time, with no instruction naming the option and no head anywhere.
<sct . : core 144, entry 21, structure 10, … — one part: sct . --part core>
Three filters make the pipes agents reach for lossless. --lines N on scan, focus, search and the preview returns the N most informative lines: structure rows first, then skeletons and body in order, with a trailer … +K lines (--lines N); on commands.py --lines 20 keeps all eleven rows where | head -20 kept four and half a skeleton. sct search --decorator RE with --names lists one row per decorated structure with its decorators on the row, so a FastAPI route table is one call. sct focus --body (MCP body_only=True) prints the header and the numbered lines only, which is what agents extracted with grep "^ +[0-9]+ |".
Sections in documents carry their first sentence. A Markdown or text heading row now reads - What you use it for @35 # Each of these is one command in the agent's shell, or the matching MCP tool., the sentence read across wrapped lines and capped at 120 characters, so a document can be understood from its scan the way a module can from its docstrings. Reported from the field: an agent had to discover focus --body to read any prose at all.
.gitignore follows git's rules. dir/* with !dir/README.md re-includes the file, as git does; dir/ with the same negation does not. Every pattern used to carry an implicit /.* tail, so corpus/* matched the directory itself and pruned it before a negation could apply. Matching is tested against git check-ignore on nine cases, and the walk is faster (ignore decisions over tests/: 17.7 → 6.9 ms).
Contract changes, named. Preview: new first line, core before entry/structure/archetypes, section headers carry ids. Overlap and surface-diff headers: ; parts: … appended inside the brackets, part header lines added. Document section rows gain # gist. MCP search_structures(has_decorator=) matches as a regex, not a substring. Scans of this repository now enter experiments/ because the root ignore file whitelists benchmark/, as git does.
Caveats. Format A/Bs run this week (rows-first, keyword and decorators on the row) were null on correctness with blank agents and are not shipped; both branches stay on origin as records. The instructions block does not mention --lines or --part (no room under its cap); agents found both from --help and from line one.
v0.26.0
File-scope constants are structure nodes in every code language, not only Python. A const MAX_ATTEMPTS = 3 in TypeScript, const MaxRetries = 3 in Go, pub const MAX_ATTEMPTS: u32 = 3 in Rust, a plain const in Zig, #define MAX_RETRIES 3 and constexpr int in C and C++, let defaultAttempts = 3 in Swift, const/define() in PHP and DEFAULT_ATTEMPTS = 3 in Ruby now scan as - NAME = value @line, diff as ~ NAME = 5 [value: 3 → 5], count in overlap, and sit on surface when the language's own visibility rule says they are public. The rule is the one the Python handler has had since 0.23: one named binding at file scope whose value is not a function, class, type or import; members of classes, impls and namespaces stay with their container. Measured before building, on local repositories: library code gains 0.07× (Go) to 0.30× (TypeScript) of the nodes scan listed before, against 0.18× for the Python precedent in scantool's own source; script-style files gain more, and the budget tiers cut them as they cut Python scripts today.
- MAX_ATTEMPTS = 3 @119 # Default number of attempts before giving up.
~ MAX_ATTEMPTS = 5 A:119 → B:119 [value: 3 → 5]
Notebooks have a call graph. The Jupyter handler extracts calls per code cell and addresses them to the .ipynb line, so sct callers, find_divergence, hot functions and the [called by N changed functions here] note on diff rows work on notebooks as on modules.
sct overlap takes --path PREFIX and --kind KIND (the MCP tool takes path= and kind=). The filter is applied once, before anything is counted, so the per-branch counts, the shared rows, the colliding names, the base marks and the merge-order hint all describe the same subset, and the header names the filter. The residual of a stacked pair is listed as path::name rows, not only counted; in JSON sharing_history[].residual is {count, structures[]} where it was a count and a capped string list.
Two format changes an agent will notice. sct callers prints every path prefixed with the directory it was given, so defined: src/scantool/gitref.py::relabel pastes straight into focus, surface and history; with --dir . nothing changes. surface --against binds its labels in the header, surface diff A=@WORKTREE → B=@v0.25.0, where the rows already said A: and B:; JSON direction carries the same string.
C#: the declarations after a file-scoped namespace X; nest under the namespace, as with namespace X { … }, so MyApp.Services.Alpha is the same address in both forms and the surface qualifies it.
The Windows git hang guard now honours its documented bound: the kill step and the final wait share one _KILL_TIMEOUT (before, two ran in sequence, and the test that proved the guard had a deadline below the code's real worst case).
Caveats. Constants do not enter the call graph or the definitions layer, so callers does not resolve a constant's readers. PHP's file-scope $vars are not nodes (request-local state, not a module's contract); Rust associated consts and C++ static data members stay members. A JSDoc directly above export const in TypeScript is not read, an older gap in the JSDoc lookup shared with export function. SQL still has no extract_calls.
v0.25.0
sct history follows one structure through the commits that changed it. Give it path::Qualified.name (with @REF if you like) or path:line, and it walks the file's history backwards: a signature or body change is a row with the same note sct diff prints, a rename pairs by identical body and the earlier name is followed from there, a file move is followed under the earlier path, and the commit that introduced the structure closes the list. Commits that touched the file but not the structure are counted in the coverage line, not listed. It is what git log -L gives for a line range, keyed on the structure instead, and it was the one thing the field study's agents asked for that had been deferred. The MCP tool history prints the same text; the two share one entry.
<4 changes in 6 commits touching the file, 1 rename> history of src/mod.py::alpha@HEAD (1-2)
a1b2c3d 2026-01-06 ~ alpha(y, k=1) [body: 1 code line] alpha uses k
b2c3d4e 2026-01-04 ~ alpha(y, k=1) [signature: (y) → (y, k=1)] alpha takes k
c3d4e5f 2026-01-03 = alpha(y) [renamed from beta] rename beta to alpha (mod.py)
d4e5f6a 2026-01-01 + beta(y) [added] add beta (mod.py)
One description per capability. The server instructions, sct --help, every MCP tool description and the README's usage block are now generated from one table (capabilities.py), and five tests hold them together. Before this they had drifted: the help called surface a Python-package command after every language got one, said --json was for scan and search only, credited scan_diff with a review tail it no longer runs by default, and listed neither divergence nor history. Whatever door an agent reads, it reads the same sentence.
The feature × language golden gains a history cell per language (242 cells). The Python handler no longer lets a scanned file's SyntaxWarning reach stderr.
Caveats. history pairs a rename only by identical body; a rewrite reads as removed then added. It follows a whole-file move, not a structure that moved to another file. The instructions block stays under 2 000 characters with a 140-character interpreter path, which took shortening a few lines.
v0.24.0
The two doors are one reader. Every command answers the same through the shell (sct) and through the MCP tools, and the tools that only the shell had are now on both sides. scan_file, scan_directory and search_structures take ref= and read a file, a directory or a search at any git ref without a checkout, the way sct --ref did; a client without a shell no longer needs git show and scan_file_content to read history. scan_diff runs on the same engine as sct diff: one row per structure with both sides of a signature change, renames paired by identical body, a coverage line for files changed without structural rows, and ref2 for a diff between two refs against their merge-base. Measured on this repository's v0.22.0 → v0.23.0 change, the old scan_diff answered in 137 572 characters over 2 604 lines, 1 149 of its 1 336 structure lines unchanged context; the table is 21 397 characters over 294 lines. Four MCP tools are new, surface, overlap, callers and resolve, byte-identical to their sct commands, and sct divergence is the shell's find_divergence. focus answers as JSON on both doors, with the address, the range, the verbatim body and the pruned skeleton. Under the surface, each command has one entry (commands.py) that both doors call, so the two cannot drift apart again; a test asserts every tool and every runner goes through it and that neither door orchestrates a module function itself.
No language has precedence. The naming and export conventions that only Python overrode are now each language's own, read from the modifiers its handler already recorded: Rust and Zig pub, Go's capitalisation, TypeScript export (including export lists, which the handler now records), Swift private/fileprivate, Java and C# public with interface members implicitly public, C and C++ static and anonymous namespaces, PHP private/protected, Ruby's private/protected sections. Measured on each language's sample, the old rule (a leading underscore) disagreed with the language's own visibility on 4 of 9 Rust definitions, 5 of 9 Zig, 6 of 6 TypeScript; now 0 everywhere. sct surface follows a Rust crate's lib.rs and pub use, a TypeScript package's index.ts exports, a Go package's exported identifiers, and looks through a C#, C++ or PHP namespace or a Ruby module to the names inside, qualified with the container. CODE HEALTH no longer flags Go's TestXxx, Swift's XCTest methods, PHPUnit's test* or Ruby's initialize and to_s as unreferenced; Python's dunder rule was tightened from any __ prefix to ^__.+__$, so a name-mangled private helper is flagged when it is dead. focus reaches dotted names such as MyApp.Services.IConfig and .btn.&--secondary.
Coverage is read off a tree, not asserted. tests/golden/<language>/<command>.txt freezes the answer of every command against every language's sample, from a temporary git repository with the sample in two versions so diff, resolve and overlap have something to say; 199 of the 220 cells answer, and the 21 that do not are named: callers and divergence on documents and data, which have no call graph, surface on YAML and notebooks, whose top level is containers, and focus on one CSS selector. A language gaining or losing a capability shows as a diff in its pull request. The samples grew to carry what every feature needs, and the Python sample's module-level values are the reference for a row every other language still lacks: constants are not nodes outside Python yet, so a changed value is a row in a Python diff and silence elsewhere.
Parsed structures are cached, keyed on the git blob id of the bytes (the id git hash-object gives), the handler, the parameters that shape the answer, the scantool version and the package's own source files, so a file at a ref, the same bytes on stdin and the next sct process all hit. In one process a directory scan at a ref goes from 0.75 s to 0.12 s; across processes sct scan src/scantool --ref v0.23.0 goes from 1.27 s to 0.66 s, against a 0.52 s floor for starting Python. The disk layer lives under the user's cache directory, capped at 200 MB and pruned on a shared schedule; SCANTOOL_CACHE_DIR moves it, SCANTOOL_NO_CACHE=1 turns it off. No locks: atomic replace on write, a torn file is a miss.
Also in this release: sct diff rows say [called by N changed functions here] when other changed functions in the same diff call them; sct overlap carries kind per structure in JSON, marks a structure the base itself changed since the branches forked, reports the residual overlap of branches that share history, and no longer lists a stacked branch under colliding names for a name it inherited; scan --depth deep shows module values whole; YAML and TOML comment blocks are nodes; the Python handler no longer lets a scanned file's SyntaxWarning reach stderr.
Measured against the field study's assignments with a blank opus session, only the MCP registered and no CLAUDE.md: on the seven-question assignment the agent used sct for 24 of its 42 read calls and 9 of the first twelve, every command unprompted, in 10 minutes against the study's 19 to 22, and five of its seven answers checked against the answer key (the other two have no key). On the merge-plan assignment 9 of 35 reads, with the layering-policy file and its module-level tables now visible in diff. On the git-shaped orientation prompt («skaff kjapp oversikt over branches og status …») a null result, three times: the agent answers git questions with git. That is recorded as such.
Contract changes for MCP consumers. scan_diff answers in rows, not labelled skeletons, and its review tail (candidate dead, orphan and drift in the changed files) is opt-in, review=True, on both doors. scan_diff on a bad ref says unknown ref. is_exempt_from_unreferenced takes the definition, not its name, and is_private(node) is the hook surface, overlap and the diff ask; a language qualifier may not contain a colon, since :: belongs to the address form. The disk cache is pickle read from the user's own cache directory, the same trust as their site-packages. A file-scoped C# namespace still yields its declarations as siblings, so their surface names are bare; the client matrix beyond Claude Code and the Windows launchers in CI has not been exercised.
v0.23.0
A blank agent now reaches for sct on its own. Two acceptance runs on 2026-09-12 (opus, auto mode, no CLAUDE.md, only the scantool MCP registered, a seven-question assignment over three repositories, no nudge) put sct in 19 of 57 and 40 of 68 read calls, the first sct call being the fourth tool call in both; with 0.22.0 the same setup had produced 0 of 12 unprompted. What changed is the server instructions. One client cuts that block at about 2 047 characters, silently, and 0.22.0's block was longer than that with the interpreter path counted, so surface, overlap, callers and resolve were never in the model's context at all. The block is now under 2 000 characters with a 140-character interpreter path, opens with an imperative in the shape of the harness's own shell rule (read code through sct in your shell; when a Bash step would cat, head, sed -n, grep, find, ls or git show source, run the sct form; it fits inside && chains and pipes), continues with a per-command substitution table and every command on one line, and leaves the MCP parameter hints to the tool descriptions, which are read when a tool is loaded. One run per variant is not a comparison between wordings, and print mode carries a milder shell rule than an interactive session, so the numbers above are the measurement that was made, not a promise.
The two agents wrote down what they missed, and this release closes all of it. sct search <file> <pattern> searches one file, with --ref too. A name search that finds no structure names the paths that carry the name (1 path matches by name: src/…/discover/), because a Python name that is a module or a package has no structure named after it. The focus ambiguity message listed import statements @84, which focus could not take back since @ is the ref; it now lists Qualified.name (a-b), and a trailing range on the name, alone or in the address form after the ref, picks that node. sct search --ref on macOS printed paths as /privatesrc/…, the temporary directory's /private prefix glued on; paths are whole. sct overlap carries kind per structure in JSON, marks a shared structure base(~) when the base tip itself changed it after the branches forked, reports for each pair sharing history the residual overlap beyond their shared commits, bases its merge-order hint on that residual for stacked pairs, and no longer lists a stacked branch under colliding new names for a name it inherited. sct scan --depth deep shows module-level values whole, a multi-line __all__ or table as numbered lines; the default depth is byte-identical to before, because defaults are too large, not too small. YAML and TOML comment blocks are nodes: a block becomes a comment node named by its first line, with the block verbatim under focus, and a single comment line directly above a key becomes that key's docstring, so a runtimes file whose first 23 lines explain the table no longer scans as three keys. sct surface on a configuration directory lists keys, not comment blocks.
Two invariants hold the line for contributors. A boundary test fails on import ast, tree-sitter, file-extension tests, language keywords or naming rules anywhere outside languages/; the five files exempted are named in the test with their reasons. A golden freezes, per language, which BaseLanguage hooks are overridden and which are inherited, so a language gaining or losing a hook shows as a diff in its pull request. Along the way the directory tag in preview_directory started taking its name from the handler registry: .js, .c and .cpp directories read TypeScript/JavaScript and C/C++ now, and Swift, Zig and TOML directories get a name where they used to show the extension.
Caveats. The instructions block is a design limit of 2 000 characters including the interpreter path; a path longer than about 140 characters would cross it, and the fallback line then belongs in the tool descriptions alone. sct search --names matches structure names; file and directory names appear only in the fallback line of an empty answer. A base-side rename reads as base(-) in overlap. --json --depth deep does not carry the expanded values, as JSON never carried excerpts. The MCP scan_diff still answers in the tree form against the working tree; reconciling it with sct diff's two-sided table follows.
v0.22.0
sct now reads at any git ref and across refs, without a checkout. --ref REF on scan, focus and search answers from the repository's history: a file or one node through git show, a directory or a search through git archive into a temporary directory that is named like the one you typed, so every path in the answer is a path you can pass back. Five commands are new. sct diff <refA> [<refB>] is a structural diff with both sides in view: per file, + added, ~ changed (signature old → new, or body as N code and M doc lines), = renamed (paired by identical body, so a renamed class carries its methods with it), - removed; two refs compare against their merge-base by default, and a note says when the tips differ from that. sct surface <package-dir> lists the public surface of a Python package with each name followed to where it is defined, through __all__, PEP 562 lazy tables, TYPE_CHECKING imports and re-export chains; --against REF prints the surface diff with its direction stated. sct overlap <base> <branch>... diffs N branches against their own merge-bases and reports which structures two or more of them touch, which new names they added independently, which branches share history so their overlap is expected, and which are already in the base by a named criterion. sct callers <name> returns actual call sites with the enclosing function and the line's text, never a mention in a docstring or comment. sct resolve path:line --from REF --to REF carries a line or a name across refs and answers with the enclosing structure's range on both sides, or "gone" with the nearest names. In the field study behind 0.21.0 every one of these was rebuilt by hand from git show, git diff --name-status and an ast walk, the surface listing three times over by three agents who each called it the most useful single call.
Output is valid input. A focus answer opens with the node's address, path::Qualified.name (a-b), and focus accepts that address as one argument, with @REF at the end when it came from a ref. Every answer opens with a coverage line stating what was seen, shown, excluded and by which pattern, unsupported and of which types, and what a budget elided; elided content is marked ⟨…⟩ +N in the tree with N being the lines focus would print, never dropped without a trace. An explicit path wins over a .gitignore that ignores it, with a note naming the file and the pattern; the origin was uv's .venv/.gitignore containing *, which made a scan of an installed package answer "No supported files found". scan and focus read stdin (-, with --as <path> to say what it is). search states its page and offers --offset for the rest, lists line numbers for hits beyond the per-structure cap, reads grep's \| as alternation with a note, and says leads: none and why instead of leaving the section out.
More of a repository is structure. Python scans now include the module docstring and every module-level binding, constants, policy tables and __all__ among them; on 15 branch diffs in the study, 6 had dropped the file carrying the branch's contract for this reason, and 13 of 33 names on one package's surface were module-level values. YAML, JSON and TOML get handlers of their own (keys, tables, lists with their ranges; a Compose file or a pyproject scans to its sections). Jupyter notebooks scan to cells, with the Python and Markdown structure inside each cell.
Two MCP defects from the study are fixed. The delta memory ("unchanged since last scan") was per server process, and a session's sub-agents share that process: 17 agents were told a file was unchanged that they had never seen. Memory is now bound to a caller given by the client, and without one there is no memory at all; the fingerprint is the file's content, so a touch or a checkout restoring identical bytes still counts as unchanged. scan_file, scan_directory, search_structures and scan_file_content take include_metadata=False to leave out size, mtime, churn and the file-info record, which is what sct uses. The git kill step in the scanner has its own time budget and never raises, which ends a flaky hang seen in CI.
Language knowledge has one home. Every naming convention the new commands need lives on BaseLanguage (QUALIFIER, is_private_name, public_surface), with Python's facade logic as an override in languages/python.py and the default for every other language being its top-level definitions not marked private. Two tests hold the line: one fails on import ast, tree-sitter, file-extension tests or naming rules anywhere outside languages/, with the five exemptions named and justified in the test; the other freezes a per-language matrix of which hooks are overridden and which are inherited as a golden, so a language gaining or losing a hook shows as a diff in its pull request. Along the way the directory tag in preview_directory started taking its name from the handler: .js, .c and .cpp directories read TypeScript/JavaScript and C/C++ now, and Swift, Zig and TOML directories get a name where they used to show the extension.
Caveats. sct diff and the MCP scan_diff answer differently: the CLI has the two-sided table, the MCP tool still shows the working tree against a ref in the tree form; reconciling them follows. sct callers matches the bare name and says so when a qualified name was asked; which definition a site binds to is not resolved. sct surface follows Python's export mechanisms; for other languages it lists top-level definitions the language does not mark private, and nothing more. The merge-order line in sct overlap is labelled a hint and is one. Delta output through MCP now requires caller; a client that passes delta=True alone gets full answers. Reading a directory at a ref unpacks it: on this repository sct scan src/scantool/languages --ref v0.21.0, 25 files through git archive, took 1.06 s wall clock on macOS.
v0.21.0
scantool now has a shell entry, sct. Registering the MCP server stays the only install step: when the server starts, it writes a launcher named sct into uv's tool bin directory (uv tool dir --bin, normally ~/.local/bin; on Windows also sct.cmd for cmd.exe and PowerShell) that runs <the server's own interpreter> -m scantool.cli. sct <dir> is the orientation tier, what preview_directory returns; sct scan, sct focus and sct search call the same tool functions as scan_file, scan_directory and search_structures, so the text is the same output contract the MCP tools return, minus the decorations that describe the checkout rather than the code (file size, mtime, churn, delta memory: sct always answers in full, never "unchanged since last scan"). --json on scan and search, --ascii everywhere, UTF-8 and LF on stdout on every platform, exit 0 ok, 1 not found, 2 usage. Every tool description and the server's instructions carry the absolute fallback, "<python>" -m scantool.cli, for shells where the bin directory is not on PATH. SCANTOOL_NO_CLI=1 in the server's environment opts out; nothing edits PATH or shell profiles, a file named sct that scantool did not write is never touched, and one scantool wrote earlier is kept as long as its interpreter still exists and the starting server is not newer, so two installs on one machine do not take turns rewriting it. uv tool install scantool and pipx install scantool give the same command as a regular console script.
The reason is measured. In a 24-day field study of 71 agent sessions and 359 sub-agents (50 308 tool calls, 21 716 of them reads), agents read code through their shell — grep, sed line ranges, git show — and scantool served 4.5 % of reads; the agents rebuilt scan_file by hand (2 525 grep "^def\|^class", 1 145 wc -l) because the harness told them to prefer the shell and that instruction beat the project's own "use scantool". In a Wizard-of-Oz study, the same assignment with a shell command that answered in about a second went from 8 to 21 sct calls, and raw file reading halved. Through the launcher on macOS with a warm cache, each command answers in 0.5–0.9 s.
Two defects fixed on the way. scan_file with output_format="json" returned a JSON string of a JSON string; it now returns the document, as scan_directory and search_structures do. Two docstrings in the SQL handler that mention psql's \i include were not raw strings, which is a SyntaxWarning since Python 3.12; it fired silently under MCP and would have reached stderr on every sct call.
Caveats. Reading at a git ref, ref-to-ref diff and the coverage line are not in this release; they follow. On Windows both launchers are exercised in CI through Git Bash and cmd.exe, from a directory with a space in its path and a PATH without uv; the client matrix beyond that (Codex, Cursor, VS Code, Cline, Windsurf, …) is a release checklist run by hand, not CI. If sct does not appear in your agent's shell after the server has started once, the tool descriptions carry the exact command to run instead, and an issue naming the client and the OS is welcome. The launcher is per user and global by design: one sct, pointing at whichever scantool started first, replaced only when that interpreter is gone or a newer scantool starts.