unasked
Did the agent's change stay inside what you asked for?
A scope-drift detector for coding agents. Runs after every edit and again at the end of the turn, classifies every changed file as in-scope, derived, or drift, and blocks the turn when the drift is risky.
Quick start · How it decides · Accuracy · Commands · Measurement record
The gap this fills
Linters, reviewers, and test runners all pass on a diff that quietly refactored two files you never mentioned. None of them know what you asked for, so none of them can tell you the agent went somewhere else. That gap is the product.
you: "fix the retry backoff in the mail client"
agent: edits lib/client.ts ← the work
edits reports/exporter.ts ← an unrelated file it decided to tidy
edits package.json ← added dayjs while it was in there
tests: ✓ pass
lint: ✓ pass
review: 3 files changed, LGTM
unasked is the check that says the last two lines were never part of the request. That turn, run for real:
unasked — in scope · lib/client.ts +3/-1 · 1 file this turn
unasked — UNEXPECTED · reports/exporter.ts +4/-6 · structural (outside the touched area)
unasked — UNEXPECTED · package.json +1/-1 · risky (new dependency: dayjs)
unexpected elsewhere — 1 structural: reports/exporter.ts (outside the touched area)
unasked — 2 unexpected changes in 3 files · scope: lib
Modified requested files (1)
lib/client.ts +3 -1
Unexpected changes (2)
risky package.json new dependency: dayjs
structural reports/exporter.ts outside the touched area
Exit 2, so the turn is blocked and the agent has to revert or explain.
[!NOTE] Had the tidied file been
utils/dates.ts— imported byclient.ts, or sitting next to it inlib/— unasked would call it in scope and say nothing. That is the v0 blind spot, on purpose; see What it cannot see.
Zero dependencies, zero model calls, zero network. A tool whose job is to flag dependency creep in someone else's diff cannot arrive with dependencies of its own. Everything here is stdlib Python and git plumbing.
Quick start
uv tool install unasked # or: uv tool install git+https://github.com/Lakshay1509/unasked
unasked --version
A git checkout also works unpackaged — every command below is identical to
python3 unasked.py .... uvx unasked ... is fine for one-off commands, but do not use
it to install hooks: it produces a uv cache path that works today and is deleted later,
leaving hooks that look installed and never run.
Claude Code — hooks (recommended)
unasked install --yes # without --yes it only prints what it would add
Restart Claude Code. Three hooks register: UserPromptSubmit captures a baseline (silent,
~50 ms), PostToolUse prints one line per new verdict, Stop classifies everything since the
baseline and reports in full. All three are wired outside the agent's control — it cannot
skip the check or paraphrase the result, and that is the point of this mode rather than an
implementation detail. --no-per-edit keeps only the end-of-turn report.
Every other agent — init
Codex, Cursor, Aider, Gemini CLI and friends have no hook seam. What they have is a file of instructions read at session start:
unasked init # writes the protocol into AGENTS.md
unasked init --claude # ...and wires the Claude Code hooks too
The block it writes tells the agent to run start / check / report where the hooks would
have fired, and to put each line in its reply verbatim.
[!IMPORTANT] This mode is cooperative, and that is a real difference, not a caveat. A hook runs whether the agent likes it or not; a line in AGENTS.md is something the agent can quietly not do, and you find out by noticing the lines are missing. Prefer hooks wherever you have them.
initexists because the alternative for every other agent is no check at all.
init is idempotent and doubles as the upgrade path. --file CLAUDE.md (repeatable) targets
other agent files, --print dumps the block to stdout, --command overrides the invocation.
What you'll see
The per-edit tick and the end-of-turn report share the classifier, the render and the speak-once rule, so they cannot disagree about the same edit. The tick never blocks mid-turn — a heartbeat, not a gate — so a wrong turn is visible at edit 3 instead of arriving as a wall of text at the end. A file speaks when its verdict is new, not once per edit.
When nothing is flagged:
unasked ✓ none flagged — 3 files: auth/login.py, auth/session.py +1 more · scope: auth
unasked ~ 2 files, no scope match — approved by fallback (largest change)
unasked — not a git repo, nothing was checked
unasked — no baseline for this session, nothing was checked
It says none flagged, not in scope. On 10% of tangled commits it flags nothing at all
and every one of those printed this line, so the line describes what the tool did rather than
what your diff is. scope: names the directories it judged against — a silent miss is almost
always a wrong scope, and that is the one part you can check at a glance.
Silence is not a verdict. An empty screen is also what an uninstalled hook, a crashed hook, a missing baseline and a directory outside git all look like. The last three lines above are turns where nothing was verified, and none can be mistaken for a pass.
Severity renders as red / amber / grey dots reading left to right as answer this / look at this / ignore this; where colour does not survive — the Stop hook renders with ANSI stripped, which is the path most people actually read — the dot is replaced by the word. No block prints more than 12 rows, and past that it names where the rest are without dropping the count. Both bounds are set by measurement: ACCURACY.md §3.
How it decides
Seven stages, cheapest first. No model calls, no call graph, no network. Every stage is switchable so its contribution can be re-priced rather than trusted.
changed files
│
① attribution ────── drop files you had already edited (content hash)
│
② anchors ────────── which files is the request about?
│ instruction × pathname, weighted 3/2/2/1, each match
│ scaled by how rare that word is in your repo
│ + ③ contents as promoter and veto
│ + edit order as a tie-break only
│
├─ ④ imports ──── one hop, in or out, from any anchor
├─ ⑤ history ──── files this repo habitually changes with an anchor
└─ directory ─ same folder, only if that folder has ≤12 files
│
⑥ derived ────────── lockfiles, migrations, snapshots, generated artifacts
│
⑦ drift ─────────── everything left, graded risky / structural / cosmetic
⑨ re-orders the anchors, and never changes how many there are. ② picks the
anchors with an absolute bar on one number. ⑨ re-reads the same candidates with a
fitted score over three signals — the pathname match, the file's contents, and
what the change actually did — plus each one's share of the turn's best, then
re-fills the set with the best k. Keeping k is what makes it safe: a turn with
no anchor has no in-scope at all, and this cannot empty one. Held out that is
+4.5 recall for +0.3 precision, and it
converts at 7.9:1 against a 4.9:1 bar.
What it moves is strict, not silent. The share of tangled commits where
every planted file was caught goes 60% → 73%; the share where the tool says
nothing at all is unchanged at 8%, because the unverified tier was already
listing those files. ⑨ turns listings into flags — a catch is a finding, a hedge
is the absence of reassurance — and catches more of them per commit.
--no-rank restores v0.13.0.
An anchor that loses badly to another anchor is not the work. ⑧ demotes any file holding
less than a fifth of the turn's best score. The score already ranks — bad anchors sit below
good ones 83% of the time — but an absolute bar cannot use that, because a score of 1.0 where
the turn's best is 1.2 is not the evidence 1.0 is where the best is 8. Judging by share is the
same evidence on a scale that means the same thing every turn. Held out that is
+6.3 recall for a precision change no sample can establish,
and it is bought: false alarms on ordinary commits go 15.5% → 17.8%. --no-compete restores
the old behaviour.
A guessed anchor anchors only itself. On the 27% of turns where the request matches nothing,
a fallback chain picks a file and says so. That guess still holds — a turn with no anchor makes
every real file a false alarm — but it stops expanding: no directory claim, no import walk. The
two rules that grow out of an anchor's position read 14 planted drift files against 1 real one
on those turns, because reaching outward from a guess multiplies the guess. History and manifest
claims still expand, being more reliable there than the anchor itself. Held out that is
+1.0 recall and +0.1 precision at 12:1,
for +0.4 points of false alarm — the cheapest recall this project has bought.
--expand-from-guess restores the old behaviour.
A scope claim the request did not carry gets a hedge, not a tick. Files reached by ④, ⑤ or
directory proximity — and anchors the fallback chain guessed at — are listed under In scope, unverified rather than Modified requested files. It flags nothing, so precision and the
false-alarm rate cannot move; what it changes is that 90.5% of every missed drift file used
to sit under a tick. Held out, that takes the share of planted drift a reader can act on from
70% to 84%, and the share of tangled commits the tool says nothing at all on from 22% to
12%. --no-unverified restores the old report.
A word is worth what it is rare. Ask for "the retry in the mail client" and client may
name a tenth of your repo — matching it says almost nothing, while one match on a word nothing
else uses says almost everything. ② weights every pathname match that way, which is where
77% of every missed drift file was coming
from.
Text and structure are separate signals on purpose. Nothing about the name client.ts
says "retry backoff", but backoff is inside it — that is ③, which also acts as a veto, so a
file whose body has nothing to do with the request cannot be dragged into scope by edit order
alone. ④ reads import lines only, never inside a function. ⑤ needs no parser at all, so it is
the only signal that works in every language.
Severity in ⑦ comes from cheap facts: new dependency, deletion, real .env, sensitive path or
destructive SQL → risky; new files and edits outside the touched area → structural;
whitespace- or import-only → cosmetic.
⑨ is the first mechanism here with fitted constants, and that is a real difference.
Every other threshold in this tool was swept against a target fixed before the sweep ran, and
anyone with a clone can re-sweep it. ⑨'s weights came out of a fit on five large, mature
open-source repos — one kind of repo — so
"this ordering is better" is established on repos like those and nowhere else yet. That is
the main reason --no-rank exists, and the reason to run unasked bench on your own before
taking the number on trust.
Nothing ships switched off. Every mechanism in the source is one of the stages above; six others were built, measured and deleted, and their ledgers are kept precisely so none gets rebuilt — a margin rule its own replacement dominates, an anchor cap that separates at a coin flip, a Java/C# import parser that worked and cost recall anyway, and three retired behaviours. A rule that does not ship is removed, not parked behind a flag; what stays switchable is ablations of shipping signals, which answer "what does this buy?" and can move under a measurement. Why each stage is shaped this way, with the numbers that set every threshold: ACCURACY.md §2.
Accuracy
Recall does not travel between codebases. The range across five repositories is 70–90%. Run
unasked benchon your own repo before deciding what this is worth to you — and since v0.14.0 that matters more, because ⑨'s constants are fitted on five large mature open-source projects rather than swept against a pre-registered target.
Five public repos, one per most-used language, 400 synthetic tangled commits, 2,346 labelled files. Pooled: precision 83.4%, recall 81.4%, F1 82.4%.
| pooled | range across the five repos | |
|---|---|---|
| precision | 83.4% | 73–94% |
| recall | 81.4% | 70–90% |
| visible — drift flagged or listed without a tick | 88.7% | 85–93% |
| silent — tangled commits where it said nothing at all | 8% | 2–14% |
| caught every planted file on the commit | 73% | 54–88% |
| false alarms — real commits with ≥1 loud row | 18.9% | 17–23% |
400 commits and not 600, because v0.14.0 has fitted constants. ⑨'s weights came out of a fit on seed 0, so seed 0 is training data and quoting it here would be a model describing its own homework. Every row above is seeds 1 and 2 alone, which no sweep and no fit has touched. Versions through v0.13.0 published all three seeds; the two are not comparable cell-for-cell, and ACCURACY.md §1 carries both.
Silent is the column to read. A rate is not a failure mode; silence is — it is the one outcome the hook cannot signal and you cannot detect. It was 23% three versions ago, and v0.14.0 did not move it — what that release moved is the row above it.
strict is what v0.14.0 bought. The share of tangled commits where every planted file
was caught goes 60% → 73% held out. The tier was already keeping those commits off the
silent list by listing the files; ⑨ turns listings into flags and catches more of them.
visible is not recall, and the two must never be added. Recall counts what got flagged.
visible also counts files listed under In scope, unverified — files the tool declines to
vouch for. A catch is a finding; a hedge is the absence of reassurance.
Four rules moved those numbers, each measured on the 1,000-commit control before it shipped: a fitted re-rank of the anchor candidates, pathname matches weighted by word rarity, a hedge instead of a tick on scope claims the request did not carry, a relative anchor bar, and a guessed anchor that no longer expands.
On the majority case — 1,000 real, untangled commits — the median number of loud rows is 0, p75 is 0, p90 is 1.
Full tables, the miss breakdown, the ceiling analysis, the calibration against commercial code review, and everything the corpus still cannot see: ACCURACY.md.
What it cannot see
Drift inside a file or directory that's already in scope. If you ask for a fix in
auth/login.py and the agent also rewrites auth/session.py, v0 calls that in scope and says
nothing. Same for a file the work imports.
That is deliberate. Flagging same-directory edits would fire on every legitimate helper change, and one bogus flag per session is all it takes for anyone to turn the tool off. Precision over recall, on purpose — and it has been tried narrowly and measured at parity, so that attempt was deleted rather than kept.
Also blind to: dynamic dispatch, DI wiring, config-driven behaviour, anything outside the
current git repo, and any language importgraph does not parse.
Command reference
Setup
| command | what it does |
|---|---|
unasked init [--claude] [--file PATH] [--print] [--command CMD] |
write the agent protocol into AGENTS.md. Idempotent; also the upgrade path |
unasked install [--yes] [--no-per-edit] [--force] |
wire up the Claude Code hooks. Without --yes it only prints what it would add |
unasked uninstall [--purge] [--settings PATH] |
remove them again. --purge also drops the journal |
Per turn
What the hooks do for you, and what AGENTS.md tells an agent to do.
| command | what it does |
|---|---|
unasked start [--prompt "..." | -] |
capture a baseline in the current repo. - reads the instruction from stdin |
unasked check [--file PATH] |
one line about the file just written |
unasked report [--json] |
classify everything since the baseline |
unasked report --line [--file PATH] |
the same verdict as one line, as the tick prints it |
unasked hook-start / hook-edit / hook-stop |
hook entrypoints (stdin JSON). Not for humans |
check splits its two audiences across the two streams: stdout is what belongs in the chat,
stderr is for whoever ran it, so an empty stdout means "nothing new" rather than "something
broke". One baseline per repo, so start, check and report find each other across
processes; UNASKED_SESSION=name overrides that for two agents sharing a checkout.
Measurement
replay, bench, bench --probe, compare, prompts, survival and stats. Start with
unasked bench --pairs 40 --order realistic — it is the one that can tell you whether the
classifier is any good on your repo. Each instrument, every flag, and what each one is and
is not evidence for: ACCURACY.md §4.
Exit codes
| code | meaning |
|---|---|
0 |
clean — or only quiet ride-alongs, which are named on the line either way |
1 |
drift |
2 |
risky drift |
3 |
could not analyze (not a git repo, no baseline, crash) |
could not analyze is always rendered distinctly from clean. A silent failure that reads as
a pass is the worst thing this tool could do.
State and privacy
| path | what |
|---|---|
~/.local/state/unasked/sessions/ |
per-session baselines |
~/.local/state/unasked/journal.jsonl |
every classification with its inputs |
~/.local/state/unasked/{coupling,bm25}/ |
caches, keyed by repo and revision. Safe to delete at any time |
Local only. No telemetry, no network calls, no model calls. Nothing about your code or your prompts leaves the machine.
Development
Tests
python3 -m pytest tests/ -q # or: python3 -m tests, which needs nothing installed
448 tests, one directory per question the tool asks — classify/ (which bucket a file
lands in), signals/ (the evidence scope is decided from), report/ (what a turn prints),
cli/, bench/. Every test builds a real git repo in a tempdir and asserts the
classification. Nothing is mocked: the tool is git plumbing, so mocking git would only test
the mocks. tests/__init__.py redirects XDG_STATE_HOME at a tempdir before anything imports
the tool, so a run never touches your journal.
Layout
One module per question the tool asks; unasked/__init__.py opens with a map of the package.
importgraph is structure, coupling is history, content is BM25 over file bodies; changes deps scope classify report render journal are the pipeline, hooks invocation cli commands/
the entrypoints, and bench/ the synthetic drift set. unasked.py at the root is a launcher,
so a checkout stays runnable by absolute path from inside another repo — which is how install
and init write it into config files.
Docs
README.md is this: what it does and how to run it. ACCURACY.md is the
measurement record — every number, every threshold's justification, every instrument. next.md
is the working design log, including what was built and then switched off.
License
MIT — see LICENSE.
unasked — because tests, lints and reviews all pass on a diff that went somewhere else.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file unasked-0.14.0.tar.gz.
File metadata
- Download URL: unasked-0.14.0.tar.gz
- Upload date:
- Size: 263.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.12 {"installer":{"name":"uv","version":"0.11.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21b6b064f77fb3eb4a785c9caf7e2080db40a638c21887ffb2bb0b75e14ccc36
|
|
| MD5 |
7024462e06ddb9f10dc9998b6e57d499
|
|
| BLAKE2b-256 |
9292316899b57f61a3024d5e02edef197aa11ad4f595b70354d9a17e143b7a0b
|
File details
Details for the file unasked-0.14.0-py3-none-any.whl.
File metadata
- Download URL: unasked-0.14.0-py3-none-any.whl
- Upload date:
- Size: 164.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.12 {"installer":{"name":"uv","version":"0.11.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9c9a692146b63f1ca72934be221e48bcb18de565e35ac860cf6047c2b57f9eca
|
|
| MD5 |
adf46864cbd6a8aea6a1864ee6a896f2
|
|
| BLAKE2b-256 |
cc17ffe528835e4a73faa926a5bd78014f33523d24a6509ff9d8443f3cc4e6d7
|