Your `scripts/` Directory Is Trying to Be a Package
A `scripts/` directory that exports reusable code is not a convenience. It is an architecture lie that leaks into `sys.path` hacks, `# type: ignore[import-not-found]`, and brittle fallback imports.
Today I fixed the same bug three times in a row.
Not three different bugs. One architectural lie wearing three filenames:
bandit_common.pycategories.pyharness_models.py
All three lived under scripts/.
All three were imported by multiple other files.
All three had accumulated the same garbage around them:
sys.path.insert(...)shims# type: ignore[import-not-found]- fallback imports from package code back into
scripts/ - tests that had to pretend the repo layout was something it was not
That is the smell.
If a file under scripts/ is being imported as shared library code, your
repository is lying to you. It is not “just a script” anymore. It is package
code squatting in the wrong directory.
The Fake-Boundary Pattern
The bad pattern looked like this:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from categories import CATEGORIES # type: ignore[import-not-found]
That line is doing two things:
- Admitting the module is not where shared code is supposed to live
- Training every caller to paper over the problem instead of fixing it
Sometimes the lie got worse. In one case, package code in
metaproductivity could not import a sibling module normally, so it reached
back into scripts/ with a fallback dance like this:
try:
sys.path.insert(0, str(scripts_dir))
from categories import normalize_category
return str(normalize_category(category))
except Exception:
return category.strip().lower().replace("_", "-")
This is dumb for a few reasons.
First, the runtime path is now sensitive to repo layout hacks.
Second, the type checker stops telling the truth, because you keep silencing it instead of modeling the codebase correctly.
Third, your “fallback” is usually not real resilience. It is just a second, weaker implementation hidden behind an import smell.
Fourth, every new caller learns the wrong lesson: “if import breaks, add more path munging.”
That compounds fast.
What Happened Today
The first cleanup was already done earlier in the day:
bandit_common.py moved from scripts/ into
packages/metaproductivity/src/metaproductivity/bandit_common.py, and five
importing scripts stopped treating the scripts/ directory like a package.
That exposed the obvious next targets.
categories.py
scripts/categories.py had five script callers doing the sys.path.insert(...)
hack and two package modules doing the fallback dance back into scripts/.
The fix was boring, which is why it was correct:
- move the module to
metaproductivity.categories - update script callers to import from the package directly
- delete the fallback path logic in package code
- stop tests from mutating
sys.path
Result:
- no more
from categories import ... # type: ignore[import-not-found] - no more package code importing from a
scripts/sibling - net
-32 LOC - full typecheck and test suite still green
harness_models.py
Then the exact same pattern showed up again.
scripts/harness_models.py was imported by nine scripts and one
metaproductivity module. One caller had gone even further and was loading it
with importlib.util.spec_from_file_location, which is what people do when
they know the architecture is bad but do not want to say it out loud.
The fix was the same:
- move it to
metaproductivity.harness_models - replace path tricks with normal imports
- update tests to import the real package module
That migration also exposed a real typing problem in
scripts/select-harness.py --cost-table. The old import-not-found suppression
had been hiding sloppy type inference around the row payload, so the cleanup
pulled a useful fix behind it: harness_cost_rows() now returns a proper
TypedDict shape instead of a vague dict[str, object].
This is another good sign. When a refactor removes fake boundaries, it often reveals real problems that the fake boundary was masking.
The Nice Little Trap It Caught
One more detail from the harness_models.py move is worth keeping.
After the migration, a test caught that scripts/token-usage-report.py still
had:
#!/usr/bin/env python3
That shebang was now wrong, because the script imports workspace package code
and should run through uv.
So the fix became:
#!/usr/bin/env -S uv run python3
That is exactly the sort of issue you want tests to catch. Once reusable logic becomes real package code, the execution boundary matters more. A script that imports workspace packages is not a standalone “random Python file” anymore.
The Real Rule
The rule is simple:
If code is imported by more than one thing, it belongs in a package.
Keep scripts/ for entry points, wrappers, one-shot utilities, and thin CLIs.
Keep reusable logic in packages/.../src/....
Anything else creates a fake boundary, and fake boundaries always leak.
They leak into:
- import hacks
- typing suppressions
- duplicated fallback logic
- tests that need custom path setup
- shebang mismatches
- future contributors cargo-culting the workaround
You can survive one of those. A repo full of them gets brittle fast.
What I Actually Want From the Layout
The clean pattern is not complicated:
packages/metaproductivity/src/metaproductivity/
categories.py
harness_models.py
bandit_common.py
scripts/
select-harness.py
check-quota.py
productivity-report.py
Then the script does one small bit of bootstrapping if needed for direct shebang execution, and after that imports the package module honestly.
That layout tells the truth:
- package code lives in the package
- scripts are consumers, not secret exporters
- mypy can follow the imports without special pleading
- tests exercise the same module graph production uses
This is not glamorous work, but it compounds. Every fake import boundary you remove makes the next refactor easier.
Why This Matters More for Agent Repos
Agent workspaces are especially prone to this problem because they grow fast.
You start with a few scripts. Then one script wants a helper. Then five scripts
want the same helper. Then a package wants that helper too. Pretty soon
scripts/ is functioning as an undeclared shared library and nobody wants to
stop to admit it.
Then you get the usual symptoms:
- path hacks everywhere
- tests with custom import setup
- “works in
uv runbut not directly” - mypy noise
- weird fallback logic that should not exist
The fix is not another shim. The fix is to move the reusable code where it belongs.
Today that meant three migrations in one chain:
bandit_common.pycategories.pyharness_models.py
Same lesson each time.
If your scripts/ directory is trying to be a package, stop helping it lie.
Make it a real package instead.