blastradius-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. blastradius_mcp-0.1.0/.gitignore +10 -0
  2. blastradius_mcp-0.1.0/LICENSE +21 -0
  3. blastradius_mcp-0.1.0/PKG-INFO +412 -0
  4. blastradius_mcp-0.1.0/README.md +378 -0
  5. blastradius_mcp-0.1.0/fixtures/README.md +81 -0
  6. blastradius_mcp-0.1.0/fixtures/check-corpus.py +120 -0
  7. blastradius_mcp-0.1.0/fixtures/expected.json +101 -0
  8. blastradius_mcp-0.1.0/fixtures/grade.py +158 -0
  9. blastradius_mcp-0.1.0/fixtures/make-fixtures.sh +357 -0
  10. blastradius_mcp-0.1.0/plugin/.claude-plugin/plugin.json +22 -0
  11. blastradius_mcp-0.1.0/plugin/.mcp.json +9 -0
  12. blastradius_mcp-0.1.0/plugin/hooks/hooks.json +29 -0
  13. blastradius_mcp-0.1.0/plugin/skills/blast-radius/SKILL.md +68 -0
  14. blastradius_mcp-0.1.0/pyproject.toml +70 -0
  15. blastradius_mcp-0.1.0/src/blastradius/__init__.py +0 -0
  16. blastradius_mcp-0.1.0/src/blastradius/cli.py +323 -0
  17. blastradius_mcp-0.1.0/src/blastradius/config.py +153 -0
  18. blastradius_mcp-0.1.0/src/blastradius/cvss.py +74 -0
  19. blastradius_mcp-0.1.0/src/blastradius/hooks.py +377 -0
  20. blastradius_mcp-0.1.0/src/blastradius/install.py +392 -0
  21. blastradius_mcp-0.1.0/src/blastradius/lockfile.py +112 -0
  22. blastradius_mcp-0.1.0/src/blastradius/monitor.py +213 -0
  23. blastradius_mcp-0.1.0/src/blastradius/osv.py +148 -0
  24. blastradius_mcp-0.1.0/src/blastradius/repo.py +97 -0
  25. blastradius_mcp-0.1.0/src/blastradius/scoring.py +94 -0
  26. blastradius_mcp-0.1.0/src/blastradius/semver.py +149 -0
  27. blastradius_mcp-0.1.0/src/blastradius/server.py +200 -0
  28. blastradius_mcp-0.1.0/src/blastradius/service.py +256 -0
  29. blastradius_mcp-0.1.0/src/blastradius/store.py +637 -0
  30. blastradius_mcp-0.1.0/tests/conftest.py +34 -0
  31. blastradius_mcp-0.1.0/tests/test_config.py +93 -0
  32. blastradius_mcp-0.1.0/tests/test_cvss.py +40 -0
  33. blastradius_mcp-0.1.0/tests/test_grader.py +160 -0
  34. blastradius_mcp-0.1.0/tests/test_hooks.py +500 -0
  35. blastradius_mcp-0.1.0/tests/test_install.py +283 -0
  36. blastradius_mcp-0.1.0/tests/test_lockfile.py +76 -0
  37. blastradius_mcp-0.1.0/tests/test_monitor.py +306 -0
  38. blastradius_mcp-0.1.0/tests/test_scoring.py +77 -0
  39. blastradius_mcp-0.1.0/tests/test_semver.py +99 -0
  40. blastradius_mcp-0.1.0/tests/test_service.py +162 -0
  41. blastradius_mcp-0.1.0/tests/test_store.py +279 -0
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ *.db
8
+ *.db-wal
9
+ *.db-shm
10
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Harris Ahmad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,412 @@
1
+ Metadata-Version: 2.5
2
+ Name: blastradius-mcp
3
+ Version: 0.1.0
4
+ Summary: Cross-repo infrastructure memory for coding agents
5
+ Project-URL: Homepage, https://github.com/harris-ahmad/blastradius-mcp
6
+ Project-URL: Repository, https://github.com/harris-ahmad/blastradius-mcp
7
+ Project-URL: Issues, https://github.com/harris-ahmad/blastradius-mcp/issues
8
+ Author: Harris Ahmad
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude-code,cve,dependencies,docker,github-actions,helm,mcp,npm,osv,supply-chain,terraform
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: Software Development :: Quality Assurance
24
+ Classifier: Topic :: System :: Systems Administration
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.11
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: mcp>=1.2.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: build>=1.2; extra == 'dev'
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Requires-Dist: twine>=5.1; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ <p align="center">
36
+ <picture>
37
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/harris-ahmad/blastradius-mcp/main/assets/banner-dark.svg">
38
+ <img src="https://raw.githubusercontent.com/harris-ahmad/blastradius-mcp/main/assets/banner.svg" width="620"
39
+ alt="BlastRadius — cross-repo infrastructure memory for coding agents">
40
+ </picture>
41
+ </p>
42
+
43
+ # BlastRadius
44
+
45
+ [![ci](https://github.com/harris-ahmad/blastradius-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/harris-ahmad/blastradius-mcp/actions/workflows/ci.yml)
46
+
47
+ It remembers what every repository you open depends on, tells your agent who else
48
+ is affected *before* it changes one, and watches those dependencies for
49
+ vulnerabilities while nobody is asking.
50
+
51
+ Not a code graph. Excellent tools already index functions, classes and imports. BlastRadius indexes the other half — Docker images, Terraform modules, GitHub Actions, Helm charts, npm packages — across repository boundaries, and answers the question a single session cannot: *if I bump this, who breaks?*
52
+
53
+ ---
54
+
55
+ ## What it actually produces
56
+
57
+ **43 advisories from OSV. 9 that apply to your pinned versions.**
58
+
59
+ ```
60
+ [CRITICAL] vitest CVE-2026-47429
61
+ When Vitest UI server is listening, arbitrary file can be read and executed
62
+ reaches: 3.2.4 (installed version)
63
+ in: acme/checkout
64
+ [HIGH ] lodash CVE-2021-23337
65
+ lodash vulnerable to Code Injection via `_.template` imports key names
66
+ reaches: 4.17.21, ^4.17.21
67
+ in: acme/checkout, acme/notifications, acme/web
68
+ [HIGH ] vite CVE-2026-53571
69
+ vite: `server.fs.deny` bypass on Windows alternate paths
70
+ reaches: 5.4.19 (installed version)
71
+ in: acme/web
72
+ [MEDIUM ] lodash CVE-2025-13465 (2 advisory records)
73
+ lodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit`
74
+ reaches: 4.17.21, ^4.17.21
75
+ in: acme/checkout, acme/notifications, acme/web
76
+
77
+ critical: 1 high: 2 medium: 4 low: 2
78
+ 10 advisory record(s) covering 9 distinct vulnerability(ies)
79
+ ```
80
+
81
+ Those two lodash entries are the point. They have **no fixed version**, so even an
82
+ exact `4.17.21` pin is still exposed — and three separate repositories carry it.
83
+ Nothing in a single-repo scan surfaces that.
84
+
85
+ Every line above is reproducible: `./verify.sh && ./run-capture.sh && blastradius alerts`.
86
+
87
+ ## What it does unasked
88
+
89
+ Open a repo, ask for something ordinary — *"bump react to 19"* — and before the
90
+ agent reads a line of `package.json`, a hook has already told it:
91
+
92
+ > `acme/checkout` (a separate repo) also depends on `react@^18.2.0`, and `lodash`
93
+ > here is shared with `acme/notifications` and `acme/checkout` too.
94
+
95
+ Two repositories that were not open, not mentioned, and have no trace in the
96
+ working directory. The agent never called a tool to find them.
97
+
98
+ ---
99
+
100
+ ## Why hooks, not just MCP
101
+
102
+ MCP tools are model-elective — the agent may or may not call them. So the things
103
+ that must always happen are hooks, which the harness runs whether the model
104
+ thinks to or not:
105
+
106
+ | Lane | Mechanism | Fires |
107
+ |---|---|---|
108
+ | **Push** | `PreToolUse` on `Read`/`Edit` | Agent opens a manifest → cross-repo impact injected unprompted |
109
+ | **Capture** | `Stop` | Session ends → unindexed manifests flagged for recording |
110
+ | **Pull** | MCP tools | Agent asks: `blast_radius`, `hygiene`, `record_dependencies` |
111
+ | **Watch** | Daemon | OSV polling on an interval, with local alerts |
112
+
113
+ Only the asking is optional.
114
+
115
+ ## Extraction is the model's job
116
+
117
+ BlastRadius ships **no manifest parsers**. Regex-matching `FROM` lines gets
118
+ multi-stage aliases, templated base images and heredocs wrong; the agent reads
119
+ those correctly already. The hook decides *when* extraction is owed, the model
120
+ does it, BlastRadius stores and joins and watches the result.
121
+
122
+ The one exception is lockfiles, which are machine-generated and schema-stable —
123
+ no judgement required, so no session required either.
124
+
125
+ ---
126
+
127
+ ## Install
128
+
129
+ ```bash
130
+ pip install blastradius-mcp
131
+
132
+ blastradius install # wires hooks + MCP server into Claude Code
133
+ blastradius link # puts the CLI on PATH, no venv activation needed
134
+ blastradius doctor # verifies it — by running the hooks for real
135
+ ```
136
+
137
+ Or as a Claude Code plugin, which wires the hooks and MCP server for you:
138
+
139
+ ```
140
+ /plugin marketplace add harris-ahmad/blastradius-mcp
141
+ /plugin install blastradius@blastradius
142
+ ```
143
+
144
+ The plugin still needs the package — it declares hooks and an MCP server that
145
+ shell out to `blastradius`, so `pip install blastradius-mcp` comes first either
146
+ way. What it saves you is `blastradius install` and keeping the wiring current.
147
+ `claude plugin details blastradius@blastradius` puts its always-on cost at
148
+ ~126 tokens per session; the hooks themselves are harness-side and cost
149
+ nothing until they fire.
150
+
151
+ <details>
152
+ <summary>From a clone</summary>
153
+
154
+ ```bash
155
+ git clone https://github.com/harris-ahmad/blastradius-mcp
156
+ cd blastradius-mcp && pip install .
157
+ blastradius install && blastradius doctor
158
+ ```
159
+
160
+ Prefer a plain install over `-e` unless you are working on the package itself:
161
+ an editable install resolves imports through `src/` at runtime, and anything
162
+ that disturbs that path produces `ModuleNotFoundError` while the console script
163
+ sits there looking fine.
164
+ </details>
165
+
166
+ Everything is local: one SQLite file at `~/.blastradius/index.db`. No account, no
167
+ server, no API key, nothing leaves your machine.
168
+
169
+ `install` merges into `~/.claude/settings.json` rather than overwriting it —
170
+ your other hooks and settings survive, re-running never duplicates, and the
171
+ previous file is backed up first. `uninstall` reverses it cleanly.
172
+
173
+ ## Commands
174
+
175
+ | Command | Does |
176
+ |---|---|
177
+ | `blastradius consumers <artifact>` | Who uses it, at which file and line, how tightly pinned |
178
+ | `blastradius hygiene` | Shared artifacts ranked worst-pinned first, flagging version drift |
179
+ | `blastradius alerts` | Open advisories, which pins they reach, which repos carry them |
180
+ | `blastradius check [--refresh]` | Query OSV now; `--refresh` re-evaluates recorded alerts |
181
+ | `blastradius resolve <repos…>` | Read lockfiles, making vulnerability matching exact |
182
+ | `blastradius watch` | Poll on an interval, in the foreground |
183
+ | `blastradius service install` | Run the watcher in the background, across reboots |
184
+ | `blastradius service stop` / `start` / `uninstall` | Pause it, resume it, remove it |
185
+ | `blastradius cost [--days N]` | What injection has spent on context, and what dedupe saved |
186
+ | `blastradius repos` / `stats` / `doctor` | Index and wiring state |
187
+
188
+ The MCP tools are `blast_radius`, `hygiene` and `record_dependencies`. `type`
189
+ disambiguates names shared across ecosystems: `node` is both a Docker image and
190
+ an npm package, and they are different rows with different blast radii.
191
+
192
+ ## Controlling what it does
193
+
194
+ ```bash
195
+ blastradius config # what is active right now
196
+ blastradius config --init # write an example to ~/.blastradius/config.json
197
+ ```
198
+
199
+ ```json
200
+ {
201
+ "inject": {
202
+ "enabled": true,
203
+ "max_artifacts": 8,
204
+ "max_consumers": 5,
205
+ "types": ["terraform_module", "github_action"],
206
+ "only_when_shared": true,
207
+ "min_cve_severity": "medium"
208
+ },
209
+ "exclude": {
210
+ "repositories": ["acme/internal-*"],
211
+ "paths": ["vendor/**", "examples/**"],
212
+ "artifacts": ["registry.internal.*"]
213
+ }
214
+ }
215
+ ```
216
+
217
+ Injection spends context on every matching read, so it is tunable: narrow it to
218
+ the artifact types you care about, raise the severity floor, or turn it off.
219
+
220
+ Because it is capped, **what gets cut matters more than what fits**. Artifacts
221
+ are ranked before truncation — an open advisory dominates, then breadth of use,
222
+ then version drift — so a `package.json` with fifty dependencies surfaces the
223
+ two that matter rather than the first eight alphabetically. Ranking costs two
224
+ batched queries, because this runs inside a five-second hook timeout.
225
+
226
+ **The injected block is terse on purpose.** Measured across the fixture corpus,
227
+ the compact format is **58% smaller** than prose for identical facts — 202
228
+ characters per injection against 475. Three savings, largest first: the trailing
229
+ "call blast_radius before making a change" instruction is dropped entirely, since
230
+ it is identical every time and the bundled skill already teaches it; consumers go
231
+ inline rather than one indented line each; and a consumer's file path is
232
+ shortened against the file being read, because every repo's `Dockerfile` is
233
+ called `Dockerfile`.
234
+
235
+ ```
236
+ blastradius .github/workflows/ci.yml
237
+ actions/checkout L5 → 5 repos: acme/checkout main UNPINNED deploy.yml:7 ·
238
+ acme/legacy-cron main UNPINNED nightly.yml:5 · acme/payments v4 partial
239
+ actions/setup-node L6 → 1 repo: acme/checkout v4 partial deploy.yml:8
240
+ ```
241
+
242
+ Set `"format": "verbose"` for the original prose form.
243
+
244
+ **Repeats within a session are suppressed** for `dedupe_minutes` (default 120).
245
+ An agent re-reads the same manifest constantly — before an edit, after an edit,
246
+ when re-checking — and the second injection tells it nothing the first did not.
247
+
248
+ Suppression expires rather than lasting forever, because a session id cannot be
249
+ fully trusted to be unique: six separate `claude -p` runs were observed sharing
250
+ one. Without the window, a single early injection would silence that file
251
+ permanently.
252
+
253
+ **Injection needs the file to already be indexed**, so a repo's first visit
254
+ captures and the visits after it enrich. On a corpus BlastRadius has never seen,
255
+ pass one records and pass two starts speaking.
256
+
257
+ `blastradius cost` shows what the tool is actually spending:
258
+
259
+ ```
260
+ 4 injection(s) across 2 session(s)
261
+ 1,270 characters ≈ 334 tokens
262
+ 318 characters each ≈ 84 tokens
263
+
264
+ 4 repeat(s) suppressed within a session
265
+ ≈ 334 tokens not spent re-telling the same thing
266
+
267
+ Most expensive files
268
+ 736 ch 2x acme/web:package.json
269
+ ```
270
+
271
+ Token figures are estimates — Claude's tokenizer is not available locally, so
272
+ this uses ~3.8 characters per token, which suits paths and version strings
273
+ better than the usual prose ratio. The character counts are exact.
274
+
275
+ Exclusions govern capture as well as injection. Dependency names are usually
276
+ dull, but a private repository name or an internal registry hostname is not, so
277
+ excluded repositories, paths and artifacts are never written to the index in the
278
+ first place. Every setting has a working default — an absent config file behaves
279
+ exactly as if this section did not exist, and a malformed one falls back to
280
+ defaults rather than breaking a session.
281
+
282
+ The plugin also ships a **skill** that teaches when consulting the index is
283
+ worth it — before a version bump, when pinning or removing a shared dependency —
284
+ and how to read pinning quality, since a SHA-pinned consumer will not receive
285
+ your change at all while an unpinned one gets it immediately.
286
+
287
+ ---
288
+
289
+ ## How the alert filtering works
290
+
291
+ Advisories are matched against what your pins can **actually resolve to**.
292
+
293
+ | Your pin | Advisory fixed in 4.17.21 | Why |
294
+ |---|---|---|
295
+ | `4.17.21` | not affected | exact, at the fix |
296
+ | `^4.17.20` | **affected** | may still resolve to `.20` |
297
+ | `^4.17.21` | not affected | floor already fixed |
298
+ | `latest` | **kept** | unknowable — see below |
299
+
300
+ **Lockfiles make it exact.** A manifest says `^5.2.0`, which permits 5.2.0 and
301
+ therefore every advisory affecting it. `package-lock.json` says `5.4.19`, which
302
+ permits none of them. On the fixture corpus that is the difference between 13
303
+ vite alerts and 6. Reads `package-lock.json` (v1 and v2/v3) and `yarn.lock`;
304
+ pnpm needs a YAML parser and is not covered.
305
+
306
+ **Uncertainty keeps the alert.** A floating tag, a digest, a git ref, or an
307
+ advisory with no usable range data all resolve to *unknown*, and unknown is
308
+ treated as affected. Hiding a possible vulnerability is a far worse failure than
309
+ showing one that turns out not to apply.
310
+
311
+ Severity comes from the CVSS v3.1 vector computed with the real formula, because
312
+ OSV reports a vector far more often than a number. Only ecosystems OSV genuinely
313
+ covers are monitored — GitHub Actions and npm. Docker images, Terraform modules
314
+ and Helm charts are indexed but never reported as "no known CVEs", which would
315
+ be a lie.
316
+
317
+ ---
318
+
319
+ ## Extraction quality is measured, not asserted
320
+
321
+ Since extraction is delegated to a model, quality is the thing worth proving.
322
+ `fixtures/` builds six local repositories that share artifacts and pin them
323
+ inconsistently, with the hard cases planted on purpose: multi-stage stage
324
+ aliases, `ARG`-templated bases, a `FROM` inside a heredoc, a registry with a
325
+ port, local module sources, `workspace:` and `github:` protocols, and `redis`
326
+ appearing as three different artifact types across three repos.
327
+
328
+ ```bash
329
+ ./fixtures/make-fixtures.sh
330
+ ./run-capture.sh # six headless sessions via `claude -p`
331
+ python3 fixtures/grade.py
332
+ ```
333
+
334
+ ```
335
+ recall 39/39 (100%)
336
+ specs 39/39 (100% kept intact)
337
+ traps 0 false positive(s)
338
+ ```
339
+
340
+ - **recall** — of the artifacts genuinely present, how many were found
341
+ - **traps** — stage aliases, local paths and heredoc text wrongly recorded
342
+ - **specs** — how many version strings survived intact
343
+
344
+ **`specs` is the one that matters.** A model that quietly normalises `^18.2.0` to
345
+ `18.2.0` scores full recall while destroying the exact signal the tool reports
346
+ on. `grade.py` exits non-zero on a miss, a trap, *or* a stripped operator —
347
+ all three, because the number that only gets printed is the number that stops
348
+ being read.
349
+
350
+ Scoring a real run needs Claude, so it stays a local step. What CI does check
351
+ is everything around it: `fixtures/check-corpus.py` generates the corpus and
352
+ fails if `make-fixtures.sh` and `expected.json` have drifted apart, and
353
+ `tests/test_grader.py` scores a synthetic index built from the ground truth so
354
+ the grader cannot quietly start reporting a number nobody can check.
355
+
356
+ ## Limitations
357
+
358
+ - **npm cross-repo impact is weaker than infrastructure.** Packages install
359
+ independently per repo, so a shared npm dependency is a drift and
360
+ CVE-exposure signal rather than a breakage signal. Terraform modules, Actions
361
+ and base images are where a shared artifact genuinely *is* the same thing.
362
+ - **pnpm lockfiles are not read.**
363
+ - **The index only knows repos you have opened** with BlastRadius installed.
364
+
365
+ ## Status
366
+
367
+ Early, but complete across all four lanes and verified end to end on two
368
+ machines. **305 tests**, run on Python 3.11–3.13 in CI, which also builds the
369
+ distributions and installs the wheel on a machine that has never seen the
370
+ source.
371
+
372
+ Next: pnpm lockfiles, and measuring what capture costs in context the way
373
+ injection already is.
374
+
375
+ Deliberately not built: a web viewer. The read-side UI is what made the original
376
+ BlastRadius something you had to deploy, and a local tool that answers through
377
+ the agent does not need one.
378
+
379
+ ## Development
380
+
381
+ ```bash
382
+ python3 -m venv .venv && source .venv/bin/activate
383
+ pip install -e ".[dev]"
384
+ python -m pytest -q # `python -m` beats a global pytest shadowing the venv
385
+
386
+ python scripts/check-packaging.py # version agrees across all three manifests
387
+ python fixtures/check-corpus.py # generator and ground truth still match
388
+ claude plugin validate . # marketplace + plugin manifests
389
+ ```
390
+
391
+ Releases are cut by tag — `git tag v0.1.0 && git push origin v0.1.0` — which
392
+ builds, checks the tag against the packaged version, and publishes to PyPI
393
+ through a trusted publisher. There is no API token anywhere in the repo.
394
+
395
+ Two things that bite, both now detected automatically:
396
+
397
+ **After `git pull`, run `pip install .` again.** A plain install copies the
398
+ package, so pulling updates the source and leaves the running code untouched —
399
+ silently. `doctor` detects this and `run-capture.sh` refuses to run against a
400
+ stale build.
401
+
402
+ **Prefer a plain install over editable when testing.** An editable install puts
403
+ only a `.pth` in site-packages and resolves imports through `src/` at runtime, so
404
+ anything disturbing that path produces `ModuleNotFoundError: No module named
405
+ 'blastradius'` while the console script sits there looking fine.
406
+
407
+ When a hook stays quiet — which it does by design — `BLASTRADIUS_DEBUG=1`
408
+ narrates every decision to stderr rather than passing through silently.
409
+
410
+ ## License
411
+
412
+ MIT