diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..8b0e28d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,71 @@ +name: Bug report +description: Report something broken or unexpectedly behaving in this FeedBack repo. +title: "[Bug]: " +labels: ["bug", "needs triage"] +body: + - type: markdown + attributes: + value: | + Use this for reproducible failures. Include the smallest chart, plugin state, or workflow that shows the issue. + - type: textarea + id: summary + attributes: + label: Summary + description: What is happening? + placeholder: Describe the problem in one or two sentences. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - backend API / server.py + - frontend app / static + - chart import/export + - player/highway rendering + - plugin system + - Docker or deployment + - documentation + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: What should someone do to see the issue? + placeholder: | + 1. Open FeedBack... + 2. Enable/open... + 3. Play/import/change... + 4. See... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: validation + attributes: + label: Useful validation + description: What should be checked when this is fixed? + placeholder: Run the app, exercise the affected route/screen/import path, and check console/server logs. + - type: input + id: environment + attributes: + label: Environment + placeholder: Browser/OS, FeedBack version or commit, plugin version, Docker image, etc. + - type: textarea + id: notes + attributes: + label: Additional notes + description: Logs, screenshots, chart files, console output, or related PRs/issues. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0086358d..b9badd57 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1,5 @@ -blank_issues_enabled: true +blank_issues_enabled: false +contact_links: + - name: Roadmap or planning question + url: https://github.com/get-flashbacks/feedBack/issues + about: Use the relevant GitHub Project board for roadmap-level planning when available. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..87d2d4a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,54 @@ +name: Feature request +description: Suggest a new capability or workflow improvement. +title: "[Feature]: " +labels: ["enhancement", "needs triage"] +body: + - type: textarea + id: problem + attributes: + label: User problem or opportunity + description: What workflow gap does this solve? + placeholder: When practicing or authoring in FeedBack, I want... + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - backend API / server.py + - frontend app / static + - chart import/export + - player/highway rendering + - plugin system + - Docker or deployment + - documentation + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: What should change from the user's perspective? + validations: + required: true + - type: textarea + id: implementation_notes + attributes: + label: Implementation notes + description: Mention relevant files, plugin lifecycle concerns, API/data format changes, or performance risks. + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: What must be true for this to be considered done? + placeholder: | + - [ ] The user can... + - [ ] The change survives reload/reopen... + - [ ] No console/server errors appear... + validations: + required: true + - type: textarea + id: references + attributes: + label: References or mockups diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml new file mode 100644 index 00000000..98c3aa80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -0,0 +1,56 @@ +name: Task or improvement +description: Track cleanup, refactors, documentation, research, or non-bug work. +title: "[Task]: " +labels: ["task", "needs triage"] +body: + - type: textarea + id: goal + attributes: + label: Goal + description: What should be accomplished? + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - backend API / server.py + - frontend app / static + - chart import/export + - player/highway rendering + - plugin system + - Docker or deployment + - documentation + validations: + required: true + - type: textarea + id: scope + attributes: + label: Scope + description: What is included, and what is intentionally out of scope? + - type: textarea + id: checklist + attributes: + label: Checklist + description: Break the work into concrete steps. + placeholder: | + - [ ] ... + - [ ] ... + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Priority + options: + - low + - medium + - high + validations: + required: true + - type: textarea + id: validation + attributes: + label: Validation + placeholder: Run the app, exercise the affected route/screen/import path, and check console/server logs. diff --git a/.github/workflows/fork-audit.yml b/.github/workflows/fork-audit.yml new file mode 100644 index 00000000..f1da59a6 --- /dev/null +++ b/.github/workflows/fork-audit.yml @@ -0,0 +1,76 @@ +name: fork-audit + +# Fork-only checks — see docs/fork-maintenance.md. Deliberately its own file +# (not a job added to upstream's ci.yml/ship-ci.yml) so pulling upstream +# changes to those workflows never conflicts with this policy's own +# enforcement of "keep core edits small and isolated". + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: read + +jobs: + + core-commit-labeling: + # Fails only on commits THIS PR introduces (relative to its base), so + # pre-existing history is never retroactively flagged. + name: core-commit-labeling + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Flag unlabeled core-touching commits introduced by this PR + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + commits=$(git rev-list "$BASE_SHA".."$HEAD_SHA") + + fail=0 + for sha in $commits; do + files=$(git show --name-only --pretty=format:"" "$sha") + core_hit=$(printf '%s\n' "$files" | grep -Ev '^(plugins/|docs/|tests/|scripts/|specs/|\.specify/)|^(CHANGELOG\.md|VERSION)$' || true) + [ -z "$core_hit" ] && continue + + subject=$(git show -s --format=%s "$sha") + if ! printf '%s' "$subject" | grep -qE '^(core|hook|sync|fix)(\([^)]*\))?:'; then + echo "::error::Commit $sha ('$subject') touches core path(s) but its subject isn't prefixed core:/hook:/sync:/fix: — see docs/fork-maintenance.md (Rule 2). Files touched: $(printf '%s' "$core_hit" | tr '\n' ' ')" + fail=1 + fi + done + exit $fail + + upstream-drift: + # Advisory only — never fails the PR. Nudges toward Rule 4 (sync often, + # in small increments) instead of letting drift build up silently. + name: upstream-drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Compare PR base against got-feedback/feedBack:main + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if ! git fetch --no-tags https://github.com/got-feedback/feedBack.git main:refs/remotes/canonical-upstream/main; then + echo "::warning::Unable to fetch got-feedback/feedBack:main; skipping upstream drift check." + exit 0 + fi + if ! behind=$(git rev-list --count "$BASE_SHA"..canonical-upstream/main); then + echo "::warning::Unable to calculate upstream drift; skipping check." + exit 0 + fi + echo "PR base is $behind commit(s) behind got-feedback/feedBack:main." + if [ "$behind" -gt 50 ]; then + echo "::warning::This branch's base is $behind commits behind got-feedback/feedBack:main. Consider running scripts/fork-sync.sh before adding more fork-only work (docs/fork-maintenance.md, Rule 4)." + fi diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml new file mode 100644 index 00000000..b34af7e9 --- /dev/null +++ b/.github/workflows/pullfrog.yml @@ -0,0 +1,67 @@ +# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED +name: Pullfrog +run-name: ${{ inputs.name || github.workflow }} +on: + workflow_dispatch: + inputs: + prompt: + type: string + description: Agent prompt + name: + type: string + description: Run name + +permissions: + contents: read + +jobs: + pullfrog: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + - name: Run agent + uses: pullfrog/pullfrog@v0 + with: + prompt: ${{ inputs.prompt }} + env: + # add at least one provider API key + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_GENERATIVE_AI_API_KEY: + ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + + # for Amazon Bedrock (https://docs.pullfrog.com/bedrock) + # AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} + # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # AWS_REGION: us-east-1 + # BEDROCK_MODEL_ID: + + # for Google Vertex AI (https://docs.pullfrog.com/vertex) + # VERTEX_SERVICE_ACCOUNT_JSON: >- + # ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }} + # GOOGLE_CLOUD_PROJECT: my-project + # VERTEX_LOCATION: global + # VERTEX_MODEL_ID: + + # for any OpenAI-compatible endpoint — LiteLLM, Cloudflare AI Gateway, + # self-hosted vLLM (https://docs.pullfrog.com/openai-compatible) + # OPENAI_COMPATIBLE_BASE_URL: https://litellm.example.com/v1 + # OPENAI_COMPATIBLE_API_KEY: ${{ secrets.OPENAI_COMPATIBLE_API_KEY }} + # OPENAI_COMPATIBLE_MODEL: + # both limits are required — set them to the real limits of that model + # OPENAI_COMPATIBLE_CONTEXT: "128000" + # OPENAI_COMPATIBLE_MAX_OUTPUT: "16384" diff --git a/.github/workflows/ship-ci.yml b/.github/workflows/ship-ci.yml index 3d6963eb..bdc37ccd 100644 --- a/.github/workflows/ship-ci.yml +++ b/.github/workflows/ship-ci.yml @@ -6,6 +6,9 @@ name: ship-ci # workflow_call — it never runs standalone, which would emit unprefixed # "" checks that the rulesets can't match. on: + # Keeps the wrapper discoverable and lets maintainers rerun the exact + # required-check workflow while diagnosing PR event delivery. + workflow_dispatch: pull_request: branches: [main, 'release/**'] # Trunk-based: post-merge CI on main catches semantic conflicts between diff --git a/.gitignore b/.gitignore index 4fe31983..17589e76 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,5 @@ Thumbs.db *.swp .idea/ plugins/support_creators +/library +/static/sloppak_cache diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..39558111 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,845 @@ +# FeedBack — AI Agent Guide + +FeedBack is a self-hosted web app for browsing, playing, and practicing interactive music notation, built around its own open `.sloppak` chart format. Charts come from importing Guitar Pro (GP5/GP8) or MusicXML, or from authoring in the built-in editor. It runs as a Docker container with a FastAPI backend (`server.py`), vanilla JavaScript frontend (`static/`), shared Python libraries (`lib/`), and an extensive plugin system (`plugins/`). There are no frontend frameworks — everything is plain JS, HTML, and Tailwind CSS. + +## Context & Philosophy + +FeedBack is a self-hosted, plugin-first music practice app. These principles guide every contribution: + +- **Performance is non-negotiable.** The highway renders at 60 fps on the main thread. Any change that adds per-frame DOM work, blocks the main thread, or increases memory pressure must be profiled and justified. +- **Backward compatibility is valued, but v3 is the only supported UI.** The classic v2 shell, `FEEDBACK_UI` / `/v2` opt-outs, and legacy player-chrome anchors are gone. Supporting them is out of scope. +- **Plugins are first-class citizens.** The plugin system is the primary extension mechanism. Core APIs (`playSong`, `showScreen`, `createHighway`, `highway.*`, `window.feedBack.*`) are stable contracts. Breaking a plugin contract is a regression. +- **Format changes require spec governance.** The `.sloppak` / `feedpak` format is defined in a separate repository. Any new manifest key, file, or directory must go through the FEP process before landing here. The `feedpak-spec` CI job enforces this with no in-repo bypass. +- **No frontend frameworks.** Core uses vanilla JS, fetch, and prebuilt Tailwind. Runtime CSS JIT (Tailwind Play CDN) is explicitly forbidden because it dropped ~26% of frames in profiling. +- **Security by default.** Plugin assets are sandboxed behind `/api/plugins//`. Diagnostics bundles are shared with maintainers and posted to GitHub issues — never include secrets. Server-side files are validated against allowlists on both export and import. + +## Architecture Quick Reference + +``` +server.py FastAPI app — library API, WebSocket highway, plugin loading +static/ + app.js Main frontend — screens, library views, player, plugin loader + highway.js Canvas note highway renderer (createHighway factory) + index.html Single-page app shell + style.css Custom CSS loaded alongside Tailwind +lib/ + song.py Core data models (Note, Chord, Arrangement, Song) + sloppak.py Sloppak format support + loosefolder.py Loose-folder XML chart support + audio.py OGG/MP3 audio handling + retune.py Pitch-shifting logic + tunings.py Tuning name/offset utilities + gp2rs.py Guitar Pro to arrangement XML conversion + gp2midi.py Guitar Pro to MIDI +plugins/ + __init__.py Plugin discovery, loading, requirements install + / Each plugin is its own directory (often a git submodule) +tests/ + test_*.py pytest test suite +``` + +## Plugin System + +Plugins are the primary extension point. Each plugin lives in `plugins//` with a `plugin.json` manifest. Plugins are typically their own git repositories — see [CONTRIBUTING.md](CONTRIBUTING.md) for the licensing policy (curated plugins should be AGPL-3.0 or AGPL-compatible: MIT, BSD, Apache-2.0). + +```json +{ + "id": "my_plugin", + "name": "My Plugin", + "version": "1.0.0", + "private": false, + "type": "visualization", + "nav": { "label": "My Plugin" }, + "screen": "screen.html", + "script": "screen.js", + "styles": "assets/plugin.css", + "routes": "routes.py", + "settings": { + "html": "settings.html", + "server_files": ["my_plugin.db", "my_plugin_models/"] + }, + "diagnostics": { + "server_files": ["my_plugin.diag.json"], + "callable": "diagnostics:collect" + } +} +``` + +All fields except `id` and `name` are optional. Plugins can have any combination of frontend (screen/script), backend (routes), and settings. + +`version` and `private` are advisory metadata — the plugin loader does not currently consume them, but plugins commonly include them for publishing/tooling purposes. + +`description`, `category`, and `icon` are **optional, additive v3 Pedalboard metadata** (surfaced in `/api/plugins`, consumed by the v3 Plugins page `static/v3/plugins-page.js`). `description` is a short one-sentence summary shown under the pedal name. `category` (`audio | creation | practice | game | tools`, free-form; unknown/absent → curated default → `"other"`) picks which pedalboard the plugin sits on. `icon` is an assets-relative thumbnail path (e.g. `"assets/thumb.png"`, ~square ~256×256, same containment rule as `styles`, served via `/api/plugins//assets/...`); if omitted the loader auto-detects `assets/thumb.png`, and plugins with no thumbnail get a default pedal graphic. All three are backward-compatible — omit them and the plugin still loads. See [docs/plugin-v3-ui.md](docs/plugin-v3-ui.md). + +`styles` is the **opt-in** for self-hosted CSS (Principle II — prebuilt Tailwind, no Play CDN). Core's `static/tailwind.min.css` only contains classes scanned from core source at build time, so a plugin installed at runtime (community / NAS) that uses classes core didn't scan — especially arbitrary values like `text-[11px]` — renders unstyled. Declaring `styles` makes the frontend inject one versioned `` into `` (covering the plugin's screen *and* its settings panel) pointing at the plugin's own compiled stylesheet. The value is a **plugin-root-relative path that must live under `assets/`** (e.g. `"assets/plugin.css"`) so it serves through the sandboxed `/api/plugins//assets/...` route. Build it with `corePlugins: { preflight: false }` (utilities only — core ships the single base reset; don't duplicate it) and **never** the Tailwind Play CDN. Plugins that use only core-guaranteed utilities, or ship no Tailwind, omit `styles` and are byte-for-byte unaffected. Full authoring guide + scaffold: [docs/plugin-styles.md](docs/plugin-styles.md). + +`settings.server_files` is the **opt-in** for the unified Settings export/import flow (feedBack#113). It's a list of relpaths under `context["config_dir"]` that the plugin wants included in user-triggered backups. A trailing `/` denotes a directory (recurse). Plugins that omit this field have no server-side files exported; their state lives entirely in browser `localStorage`, which is bundled wholesale on every export. Rules: +- Relpaths only. Absolute paths, drive letters, `..` segments, and backslashes are rejected at load time with a `[Plugin]` warning. +- The same allowlist is consulted at both export and import: a bundle that references a file the *importing host*'s manifest no longer declares is skipped with a warning (handles plugin updates between export and import). A bundle that references a file your host's manifest never declared is also skipped — no surprise writes. +- Files are encoded as `{"encoding": "json", "data": }` for `.json` files that parse cleanly (diff-friendly), `{"encoding": "base64", "data": "..."}` otherwise (sqlite, model blobs, IRs). +- Plugins own their internal data migration. Importing a bundle whose data schema predates your current code restores bytes verbatim — your plugin must cope at next load. +- Symlinks are skipped on export and never followed on import. + +`diagnostics` is the **opt-in** for the troubleshooting bundle (feedBack#166 — Settings → Export Diagnostics). Two independent fields: +- `diagnostics.server_files` — same allowlist semantics as `settings.server_files`: relpaths under `context["config_dir"]`, no `..`, no abs paths, no backslashes, no leading dots. Files listed here are copied verbatim into `plugins//` inside the bundle. Use this for snapshot-style state (small DB excerpts, model lists, last-error files). +- `diagnostics.callable` — `":"` (e.g. `"diagnostics:collect"`). Resolved lazily via `load_sibling` when the user clicks Export, then called as `func({"plugin_id": "...", "config_dir": Path(...)})`. Return `dict`/`list` → written to `plugins//callable.json`; `bytes` → `callable.bin`; `str` → `callable.txt`. Exceptions are caught and appended to the bundle's `manifest.notes` — a buggy plugin never crashes the export. + +Plugins that omit the field contribute nothing to the bundle from the backend side. Frontend plugins can independently push state via `window.feedBack.diagnostics.contribute(plugin_id, payload)` from their `screen.js` before the user hits Export. Bundle layout + per-file schemas: [docs/diagnostics-bundle-spec.md](docs/diagnostics-bundle-spec.md). + +Best practices: +- Embed your own `schema` field (e.g. `"my_plugin.diag.v1"`) in JSON returned by `callable` so future tooling can dispatch by version. +- Keep payloads small (< 100 KB). Diagnostics are not a backup channel — that's `settings.server_files`. +- Don't include user secrets, API keys, or session tokens. The bundle is shared with maintainers / posted to GitHub issues. + +`type` is an optional role hint (feedBack#36). Supported values: +- `"visualization"` — plugin provides a highway renderer. Declaring this makes the plugin eligible for the main-player viz picker AND splitscreen's per-panel picker. Must pair with a `window.feedBackViz_` factory exporting the setRenderer contract below. +- Absent → no declared role; plugin is loaded and its script runs, but it doesn't appear in role-specific UIs. + +**Backend routes** — `routes.py` must export a `setup(app, context)` function. The `context` dict provides: +- `config_dir` — persistent config path +- `get_dlc_dir()` — returns the DLC folder Path +- `extract_meta()` — metadata extraction callable +- `meta_db` — shared MetadataDB instance +- `library_providers` — shared library provider registry for source-aware browsing +- `register_library_provider(provider)` — register a plugin-provided library source. Providers expose `id`, `label`, optional `kind`/`capabilities`, and callable `query_page`, `query_artists`, `query_stats`, and `tuning_names` methods. Providers with `art.read` may also expose `get_art(song_id)` returning one of: a `Response` object (any media type, served as-is); raw `bytes` or `bytearray` (**assumed PNG** — use a `Response` or a `dict` with `content`+`media_type` keys for JPEG/WebP or other formats); a URL string (http/https → 302 redirect; other schemes are rejected with 400); a filesystem path string or `Path` (served as a file with auto-detected media type); or a `dict` with a `url`, `path`, or `content` key. Providers with `song.sync` may expose `sync_song(song_id)` returning `None` (success with no local file) or a `dict` — the dict is passed through as the JSON response and should include `filename`/`local_filename` if a local playable file was produced. +- `unregister_library_provider(provider_id)` — remove a plugin-provided library source by id. The built-in `local` provider cannot be removed. +- `get_sloppak_cache_dir()` — sloppak cache path +- `load_sibling(name)` — loads a sibling module from this plugin's directory under a unique, namespaced module name. See "Sibling imports" below. +- `log` — stdlib `logging.Logger` namespaced to `feedBack.plugin.`. Pre-configured with the app-wide level, format (including JSON mode), and correlation IDs. Use this for all backend plugin output instead of `print()`. See "Backend plugin logging" below. + +**Sibling imports — use `load_sibling`, not bare imports** (feedBack#33). The plugin loader inserts each plugin's directory onto `sys.path` so `from extractor import X` works, but Python caches imports by **module name** in `sys.modules`. Two plugins that each ship a top-level `extractor.py` (or any other generic name — `util.py`, `client.py`, `parser.py`, `config.py`, …) collide: whichever loads first wins, and the other plugin's `from extractor import X` either gets the wrong module or fails with `cannot import name 'X' from 'extractor'`. + +The fix is `context["load_sibling"](name)`, which loads the sibling under a namespaced module name (`plugin_.`, where plugin_id is bijectively encoded so reverse-DNS-style ids like `com.example.foo` work without colliding: `_` -> `_5f_`, `.` -> `_2e_`) so each plugin gets its own copy: + +```python +def setup(app, context): + helper = context["load_sibling"]("helper") + HelperClass = helper.HelperClass + # … +``` + +Notes: +- `name` is a bare module name — no `.py` suffix, no slashes, no `.`. The helper raises `ValueError` for path traversal / format issues and `ImportError` for missing files. +- Both single-file siblings (`extractor.py`) and package-form siblings (`extractor/__init__.py`) work. Package form wins when both exist (matches CPython's import-resolution precedence). +- Relative imports between siblings work — `from .shared import X` in a top-level helper, `from ..shared import X` from inside a sibling package. The synthetic parent package `plugin_` carries the plugin directory in its `__path__`. +- `from . import sibling` (attribute-style) also resolves: loaded children are exposed as attributes on the parent package. +- Repeat calls return the cached module. Concurrent first-time calls are serialized via per-module locks so no caller observes a half-initialized module. +- Bare `import sibling` from `routes.py` still works during the transition period, but the loader prints a startup warning when it detects two plugins shipping a same-named top-level module — covering both `.py` files and package directories. Migrate to `load_sibling` to silence the warning and immunize your plugin from future ecosystem collisions. (Don't mix bare imports and `load_sibling` for the same module — they'd execute the file twice and split module-level state.) + +**Frontend scripts** — `screen.js` runs in the global scope via a `