Metadata-Version: 2.4
Name: unasked
Version: 0.9.5
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-367-blue)](#tests)
[![Model calls](https://img.shields.io/badge/model%20calls-0-lightgrey)](#how-it-decides)
[![Version](https://img.shields.io/badge/version-0.9.5-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 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](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, 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.md#2-how-each-signal-was-tuned).

---

## 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](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#three-mechanisms-that-ship-switched-off), 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](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,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

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