Skip to content

T-75LX-establish-plugin-lib-convention

Status: closed/done · Impact: medium · Complexity: small

As the plugin grows toward shipping multi-file Python libraries (starting with the lease library tracked in epic E0002), establish a project-wide convention for where library code lives, how entry-point scripts under plugin/scripts/ import from it, and how tests discover library packages. The convention is documented in plugin/conventions/python-runtime.md and validated by a tiny smoke-test library that exercises every load-bearing piece (bootstrap-from-script, pytest discovery, package importability). After this task lands, future library work (lease library, others) has a clear pattern to follow and a working example to mirror.

The plugin’s Python code lives in three established categories, none of which fits multi-file, importable, shared library code:

LocationRole today
plugin/scripts/One-shot entry points. Each file is a uv-inline-script (#!/usr/bin/env -S uv run --quiet --script plus an inline # /// script dependency block); uv is the implicit runtime that resolves declared deps in an ephemeral venv. Cross-file imports work only by colocation.
plugin/scripts/_schema_patterns.pyExisting shared module imported by a sibling via colocation (new_task.py does from _schema_patterns import schema_pattern) — the colocation pattern that breaks down once code must be multi-file and shared.
plugin/validators/Peer directory of validator scripts that aren’t invoked as entry points (validate_base.py, validate_frontmatter.py, validate_sdlc_yaml.py).
plugin/skills/Skill-specific code: each plugin/skills/<skill>/ holds a SKILL.md plus any Python the skill needs.
plugin/conventions/Convention docs (branch naming, commit messages, schema bumps, sdlc.yaml); no Python runtime/layout doc exists yet.

There is no pyproject.toml at the repo root: the project is intentionally not an installable package — every script must run directly via uv. The lease library defined in 2026-05-23-add-lease-protocol-library-and-schemas is the first thing that fits none of the categories above — it’s multi-file, importable, and shared across both entry-point scripts and future skills. Forcing it into plugin/scripts/ would mix library code with single-shot CLIs; forcing it into plugin/skills/<name>/ would tie a generic library to one skill.

Three new artifacts:

  • plugin/conventions/python-runtime.md — the convention doc. Records uv as the runtime dependency, the existing scripts/ vs new lib/ distinction, the 2-line sys.path.insert(...) bootstrap pattern for entry-point scripts that import from lib/, what NOT to use uv-inline-script for, and the migration path if/when the repo ever becomes a packaged Python project.
  • plugin/lib/ directory — new peer of scripts/, validators/, entities/, schemas/, skills/. Each plugin/lib/<name>/ is a normal Python package (with __init__.py). Library code lives here whenever it’s multi-file, importable, and shared across consumers.
  • pytest.ini at the repo root — a minimal file with pythonpath = plugin/lib so pytest plugin/lib/<name>/tests/ resolves imports of the form from <name> import ... without requiring a separate install step. Confirmed not to disrupt existing test invocations.

Plus a tiny smoke-test library under plugin/lib/_example/ whose only purpose is to validate the convention works end-to-end. It exports a single trivial function and is exercised by both a pytest test (validating pytest.ini’s pythonpath) and a sample entry-point script under plugin/scripts/ (validating the 2-line bootstrap). The smoke-test library and script are kept around as the project’s permanent reference implementation of the convention; future library authors can copy their shape.

  1. Write plugin/conventions/python-runtime.md. Sections:

    • Runtime dependency: uv — what it is, how to install (link to Astral install command + Homebrew + Windows options), why we’re using it (per-script dep resolution, no global venv, Python version management), explicit acknowledgment that this is a hard dependency for direct script execution.
    • Two code categoriesscripts/ for entry points (single-file, uv-inline-script), lib/ for libraries (multi-file, packages, importable). Decision rule: “If it’s invoked once and exits, it’s a script. If it’s imported by something else, it’s a library.”
    • Entry-point script anatomy — the shebang, the inline dep block, the 2-line bootstrap sys.path.insert(0, str(Path(__file__).parent.parent / "lib")), and example import from lease import primitives (or from <libname> import ...).
    • Library anatomyplugin/lib/<name>/__init__.py exporting the public surface; tests/ subdirectory with its own __init__.py for pytest discovery; how to declare deps (libraries don’t use uv-inline-script — they have a pyproject.toml if they need pinned deps, OR they assume callers have already resolved everything via their inline blocks).
    • What NOT to use uv-inline-script for — library code (callers’ inline blocks resolve deps), test files (pytest is invoked once for many tests), generated Python (no shebang).
    • Future migration — if/when the repo becomes a real Python package via root-level pyproject.toml, library code under plugin/lib/ is already package-shaped; uv-inline-script entry points convert mechanically to [project.scripts] console-scripts.
    • Reference implementation pointer — link to plugin/lib/_example/ and the matching plugin/scripts/_example_script.py as the canonical working example.
  2. Create plugin/lib/_example/ as the smoke-test library.

    • plugin/lib/_example/__init__.py exporting one function: def works() -> bool: return True. Docstring identifies this as the convention smoke-test library.
    • plugin/lib/_example/tests/__init__.py (empty file; just needed for pytest’s package discovery).
    • plugin/lib/_example/tests/test_bootstrap.py containing one test: from _example import works; def test_works(): assert works().
  3. Add pytest.ini at the repo root with:

    [pytest]
    pythonpath = plugin/lib

    No other entries. Keep the file minimal — its only job is making plugin/lib/ discoverable for tests.

  4. Create the smoke-test entry-point script. plugin/scripts/_example_script.py:

    • #!/usr/bin/env -S uv run --quiet --script shebang.
    • Inline # /// script block with requires-python = ">=3.10" and an empty dependencies = [] (the example has none).
    • The 2-line sys.path.insert(...) bootstrap.
    • from _example import works
    • print("OK" if works() else "FAIL") and sys.exit(0 if works() else 1).
  5. Verify the smoke test runs both ways. From repo root:

    • pytest plugin/lib/_example/tests/ — expect 1 test passing.
    • ./plugin/scripts/_example_script.py — expect “OK” to stdout, exit 0.
  6. Run the existing test surface to confirm no regression. Specifically:

    • python3 plugin/skills/pr-check/test_classify_pr.py (the existing pr-check tests).
    • python3 plugin/skills/setup/tests/run_evals.py (the setup evals).
    • Any other test runner the repo uses. Confirm pytest.ini’s pythonpath doesn’t inadvertently shadow modules that existing tests depend on.
  7. Cross-link the convention from existing docs that need it.

    • Add a one-line pointer from plugin/conventions/project-local-skill-extension.md to python-runtime.md if relevant.
    • Add a pointer in plugin/scripts/’s context (if any README exists there) to the new convention; if no README, leave for future skim cleanup.
LocationKindChange
plugin/conventions/python-runtime.mdnewThe convention doc — all seven sections enumerated in Approach step 1.
plugin/lib/_example/__init__.pynewSmoke-test library exporting works() -> bool.
plugin/lib/_example/tests/__init__.pynewEmpty file; pytest package-discovery marker.
plugin/lib/_example/tests/test_bootstrap.pynewSingle test: from _example import works; assert works().
plugin/scripts/_example_script.pynewSample entry-point demonstrating the 2-line sys.path.insert(...) bootstrap.
pytest.ininewMinimal [pytest] block with pythonpath = plugin/lib.
plugin/conventions/project-local-skill-extension.mdmodifyOptional one-line cross-link to python-runtime.md, if it fits.
.gitignoremodifyConfirm **/__pycache__/ coverage suffices for the new dirs; add a carve-out only if needed.

(The explicit non-edits — no root pyproject.toml, and no migration of plugin/scripts/_schema_patterns.py into lib/ — are recorded under Out of scope and protected by AC-7.)

  • AC-1: plugin/conventions/python-runtime.md exists and contains all seven sections enumerated in Approach step 1. Section headings appear verbatim (or with minor wording variations); each section is at least one paragraph (not just placeholder text).
  • AC-2: plugin/lib/_example/__init__.py exists, exports works() -> bool, and importing it via a Python REPL with sys.path set to include plugin/lib/ returns True from _example.works().
  • AC-3: pytest plugin/lib/_example/tests/ from the repo root runs exactly one test (test_works) and it passes. No additional pytest configuration is needed beyond the new pytest.ini.
  • AC-4: ./plugin/scripts/_example_script.py executed directly from the repo root prints OK to stdout and exits 0, with no setup beyond uv being on PATH. Verified by running the script in a subprocess in a test (so the AC is regression-checkable).
  • AC-5: Running the existing test surface still passes:
    • python3 plugin/skills/pr-check/test_classify_pr.py exits 0.
    • python3 plugin/skills/setup/tests/run_evals.py exits 0.
    • Any other existing test invocation passes unchanged. Specifically, the new pytest.ini’s pythonpath does not shadow any module that existing tests depend on.
  • AC-6: plugin/conventions/python-runtime.md cross-links the reference implementation: a paragraph or section explicitly names plugin/lib/_example/ and plugin/scripts/_example_script.py as the canonical working example and explains how to copy their shape for new libraries.
  • AC-7: No pyproject.toml is introduced at the repo root. (Negative AC — protects the explicit decision to stay unpackaged for now. If someone wants to revisit, they edit the convention doc first, then update this AC.)
  • The lease library itself (plugin/lib/lease/). That’s 2026-05-23-add-lease-protocol-library-and-schemas; it depends on this task but does not get implemented here.
  • Migrating existing scripts into plugin/lib/. plugin/scripts/_schema_patterns.py (the existing colocation-based shared module under scripts/) stays where it is. Future migration of such files is a separate decision per case; not blocking this convention.
  • Establishing a bosun/ Python convention. This task covers plugin/ only. If bosun/ ever gains its own Python code under similar pressure, that’s a parallel decision.
  • CI integration. No GitHub Actions workflow change. The convention works in dev runs of pytest and direct script execution. CI wiring (if/when it exists) reads the same pytest.ini and runs the same commands.
  • Tooling for “list all libraries under plugin/lib/ or similar discovery utilities. The directory is small; no tooling needed until it’s not.

None. This task lays foundational organization that future Python library work depends on; nothing depends on it being created first except 2026-05-23-add-lease-protocol-library-and-schemas (and that dependency is recorded on that task’s side).

Raised during planning of epic E0002 (the GitHub ref leases implementation). The lease library is the first multi-file Python library the plugin will ship, and its task definition initially bundled the layout conventions inline. Splitting them out gives the conventions independent reviewability, lets the smoke test validate the bootstrap pattern before a real library commits to it, and produces a project-wide artifact instead of a feature-coupled one. The lease library task now depends on this task landing first.

Captured by /sdlc:task-work on 2026-05-23. PR: pending.

  • AC-1: agent-manual — command grep -nE "^#{1,3} " on python-runtime.md confirms all seven sections present; content reviewed for substance.
  • AC-2: auto — from _example import works returns True (REPL with PYTHONPATH=plugin/lib, and via the pytest test).
  • AC-3: auto — pytest plugin/lib/_example/tests/ collects exactly one test (test_works) and passes (1 passed, configfile: pytest.ini).
  • AC-4: auto — ./plugin/scripts/_example_script.py prints OK and exits 0; regression-guarded by the subprocess test plugin/scripts/test_example_script.py (kept outside lib/_example/tests/ so AC-3’s one-test count holds).
  • AC-5: auto — plugin/skills/pr-check/test_classify_pr.py and plugin/skills/setup/tests/run_evals.py both exit 0; 11/12 sdlc.yaml quality verbs pass, confirming pytest.ini’s pythonpath shadows nothing.
  • AC-6: agent-manual — python-runtime.md’s “Reference implementation” section names both plugin/lib/_example/ and plugin/scripts/_example_script.py and gives a copy recipe.
  • AC-7: auto — no pyproject.toml at the repo root (confirmed absent; none created).
  • After the Today/Files-to-touch tables landed, the spec was complete enough that the implementing sub-agent shipped in three focused commits with no design questions.
  • The deterministic readiness parsers (parse_touchpoints.py, scan_placeholders.py) made the table-shape gap unambiguous and re-checkable after the fix.
  • The AC-3/AC-4 tension (one-test count vs a subprocess regression test) was resolved cleanly by siting the AC-4 test under plugin/scripts/ rather than plugin/lib/_example/tests/.
  • Task merged (PR #106) with invalid status: open/draft (not in the schema enum) — required a manual fix(tasks) correction commit on main before pickup. A pre-merge validate_frontmatter.py gate on planning/spec PRs would have caught it before merge. → T-M2OV-pre-merge-frontmatter-validation-gate
  • Task shipped in the legacy bulleted shape for ## Today / ## Files to touch, failing the v3 table-only gate and forcing a /sdlc:task-define round-trip mid-task-work — entity authoring/migration should convert bulleted Today/Files-to-touch to v3 tables (e.g. /sdlc:entities-migrate handling the reshape) so pickup doesn’t hit the gate. → T-6SNW-entities-migrate-reshapes-bulleted-sections
  • The define round-trip’s status churn (open/readyneeds-definitionopen/ready) made start_task.py’s git rebase main conflict (exit 3), leaving the worktree in REBASE state for manual resolution — the define loop should restore a pickable status without tugging the status line across commits, or start_task.py should recognize/squash the known define-churn instead of surfacing a hand-resolve. → T-2QXZ-start-task-handles-frontmatter-rebase-cleanly
  • The per-task quality gate (audit_entities.py) fails on pre-existing schema_version drift in four unrelated task files, so no task-work run can reach a green gate until someone runs /sdlc:entities-migrate — the gate should scope drift to files the branch touched (or the orchestrator should run entities-migrate on a cadence) so unrelated pre-existing drift doesn’t red-flag every task. → T-H69K-run-quality-checks-isolates-pre-existing-drift
  • pytest was not on PATH / not permitted initially; needed a /config grant of Bash(pytest:*) plus a uv run --with pytest fallback. The preflight_permissions probe flagged it correctly; projects adopting this pytest-based lib convention should seed Bash(pytest:*) at setup. → T-OTX2-setup-seeds-pytest-permission

← Back to Tasks