Metadata-Version: 2.4
Name: unasked
Version: 0.14.0
Summary: Scope drift detection for agent sessions: did the change stay inside what you asked for?
Project-URL: Homepage, https://github.com/Lakshay1509/unasked
Project-URL: Repository, https://github.com/Lakshay1509/unasked
Project-URL: Issues, https://github.com/Lakshay1509/unasked/issues
Project-URL: Changelog, https://github.com/Lakshay1509/unasked/releases
Author-email: Lakshay Gupta <yashvw25@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agents,claude-code,code-review,git,scope-drift
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Version Control :: Git
Requires-Python: >=3.10
Description-Content-Type: text/markdown

<!--
  Every number here is measured. Do not edit one without re-running `.temp/verify.py`,
  which checks next.md against out/*.json. The long form lives in ACCURACY.md.
-->

<div align="center">

# 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](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
[![License](https://img.shields.io/badge/license-MIT-green)](#license)
[![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](ACCURACY.md#zero-dependencies-on-purpose)
[![Tests](https://img.shields.io/badge/tests-448-blue)](#tests)
[![Model calls](https://img.shields.io/badge/model%20calls-0-lightgrey)](#how-it-decides)
[![Version](https://img.shields.io/badge/version-0.14.0-informational)](unasked/version.py)

[Quick start](#quick-start) · [How it decides](#how-it-decides) · [Accuracy](#accuracy) ·
[Commands](#command-reference) · [Measurement record](ACCURACY.md)

</div>

---

## 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](#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

```sh
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)

```sh
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:

```sh
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 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](ACCURACY.md#3-why-the-report-reads-the-way-it-does).

---

## 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](ACCURACY.md#-the-fitted-re-rank-v0140), 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](ACCURACY.md#-the-relative-anchor-bar--competitive-on-a-scale-that-travels),
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](ACCURACY.md#a-guessed-anchor-anchors-only-itself-v0130) 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](ACCURACY.md#-anchor-selection--where-the-work-is) 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](ACCURACY.md#what-the-numbers-still-cannot-see) — 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.md#2-how-each-signal-was-tuned).

---

## Accuracy

> **Recall does not travel between codebases.** The range across five repositories is
> **70–90%**. Run `unasked bench` on 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](ACCURACY.md#1-results-on-the-corpus) 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](ACCURACY.md#-the-fitted-re-rank-v0140),
pathname matches [weighted by word rarity](ACCURACY.md#each-match-is-weighted-by-how-rare-that-word-is-here-v0100),
[a hedge instead of a tick](ACCURACY.md#the-unverified-tier-v0110) on scope claims the request
did not carry, [a relative anchor bar](ACCURACY.md#-the-relative-anchor-bar--competitive-on-a-scale-that-travels),
and [a guessed anchor that no longer expands](ACCURACY.md#a-guessed-anchor-anchors-only-itself-v0130).

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](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](ACCURACY.md#two-mechanisms-that-were-deleted-2026-08-04) 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](ACCURACY.md#4-measuring-it-yourself)**.

### 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

```sh
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`](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](LICENSE).

---

<div align="center">

**unasked** — because tests, lints and reviews all pass on a diff that went somewhere else.

</div>
