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, bar = 2
        │                 + ③ 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

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.

Three more mechanisms exist in the source and all three ship off, because none clears the precision bar. Why each stage is shaped this way, with the measurements that set every threshold: ACCURACY.md §2.


Accuracy

Recall does not travel between codebases. The range across five repositories is 45–82%. 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 82.4%, recall 62.9%, F1 71.3%.

pooled range across the five repos
precision 82.4% 77–91%
recall 62.9% 45–82%
silent — tangled commits where it flagged nothing 28% 10–57%
false alarms — real commits with ≥1 loud row 17% 14–20%

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. 73% of every miss is the anchor stage deciding the drifting file was the work, and a perfect anchor stage would still only reach 89.8% recall.

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 it ships off.

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,imports}/ 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

367 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.9.5.tar.gz (210.0 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.9.5-py3-none-any.whl (142.9 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: unasked-0.9.5.tar.gz
  • Upload date:
  • Size: 210.0 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.9.5.tar.gz
Algorithm Hash digest
SHA256 e89147abe65c90618660d056b5e18bd3b16e8af153ee02ff87623f79b816c1a7
MD5 781103484a0d28c736aec268f59fb5ca
BLAKE2b-256 3489ec8e11c6667ec81e92df6cdceba26281e9e57cc686a827460e3f899e7bb3

See more details on using hashes here.

File details

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

File metadata

  • Download URL: unasked-0.9.5-py3-none-any.whl
  • Upload date:
  • Size: 142.9 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.9.5-py3-none-any.whl
Algorithm Hash digest
SHA256 7de999815195873659885a9e7d2dc456c790ce7db7510a2e50716b50bbb05d7a
MD5 9daef826d94ebadc853d4f19470787f1
BLAKE2b-256 e4d84162ee44764af4b303df1f06b51d8c819e1513320937509f4cf8ef71216a

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