Skip to main content

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.

Python License Dependencies Tests Model calls Version

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 by client.ts, or sitting next to it in lib/ — 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. init exists 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 28% 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

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.

One more mechanism exists in the source and it ships off, because it does not clear the precision bar. Three others were built, measured, and deleted; the measurements are kept so none gets rebuilt — including the one that would have stopped ② from ever promoting a file, which turned out to cost six points of precision. 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 62–83%. Run unasked bench on your own repo before deciding what this is worth to you.

Five public repos, one per most-used language, 600 synthetic tangled commits, 3,525 labelled files. Pooled: precision 83.9%, recall 70.4%, F1 76.6%.

pooled range across the five repos
precision 83.9% 77–94%
recall 70.4% 62–83%
silent — tangled commits where it flagged nothing 23% 11–48%
false alarms — real commits with ≥1 loud row 15.5% 14–17%

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.

v0.10.0 weights a pathname match by how rare that word is in your repo, the way file contents were already weighted. On seeds held out from the one constant it introduced, that moved recall +7.4, precision +1.6 and the false-alarm rate −1.3 — the first change here to improve all three at once, and the account of the version of it that didn't is the more useful half.

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

393 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

unasked-0.13.0.tar.gz (247.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

unasked-0.13.0-py3-none-any.whl (155.7 kB view details)

Uploaded Python 3

File details

Details for the file unasked-0.13.0.tar.gz.

File metadata

  • Download URL: unasked-0.13.0.tar.gz
  • Upload date:
  • Size: 247.9 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

Hashes for unasked-0.13.0.tar.gz
Algorithm Hash digest
SHA256 966fd32534a391e2ebdeff414bf14b3d72ed5130215fe0146c382252051e1b3b
MD5 f0c834e21d0e76d9d935243878b98827
BLAKE2b-256 a2b38de875bb25a291524e608601f7c164c2144e39b07b156a17f92e0b05ecf4

See more details on using hashes here.

File details

Details for the file unasked-0.13.0-py3-none-any.whl.

File metadata

  • Download URL: unasked-0.13.0-py3-none-any.whl
  • Upload date:
  • Size: 155.7 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

Hashes for unasked-0.13.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c08c20242d3681811c649706f6e681205879b8bc8425f0f3eca871074fec3cd4
MD5 a3d352307841ee0891c5d10f41ba0ff3
BLAKE2b-256 8da5a98cfb9e253bad33af6e6259a476b390b59cfd64f853730d1fb9b980a30d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page