svrnty-hermes-webui-plugin/CONTRIBUTING.md
Svrnty bd73a2df24
All checks were successful
plugin-tests / test (push) Successful in 6s
docs(contributing): 5 recipes + decision flowchart for adding features
CONTRIBUTING.md closes the "how do I add anything" gap: the protocol PRD
defines the CONTRACT (what's allowed, what's verified), but until now
recipes were implicit — readers had to reverse-engineer patterns from
existing routes/vault_status.py + static/app.js + the STT migration.

Five recipes, one decision flowchart:
  A  Add a new /api/* endpoint
  B  Add a new UI element (CSS/JS/asset)
  C  Extend the loader API (add an 8th method) — STT migration is the example
  D  Add a config value
  E  Forced-internal escape hatch (use sparingly, discipline at 5 rows)

Each recipe: numbered steps, file paths, commit message template, test
requirement. Plus a PR checklist + quick-reference index.

README.md gets a top-of-page pointer so a new contributor lands on the
recipes within one click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:41:17 -04:00

10 KiB
Raw Blame History

CONTRIBUTING — svrnty-hermes-webui-plugin

Recipes for adding features to the plugin. The contract lives in hermes/docs/SVRNTY-PLUGIN-PROTOCOL.md; this file is the how-to layer.

Before anything else: read CLAUDE.md (the Karpathy 4 rules) and the protocol PRD §4 (the four hard rules). Every recipe assumes those are honored.


Decision flowchart — where does my change belong?

Q1. Does my change need new backend Python (a new /api/* endpoint,
    a server-side hook into streaming/agent, file I/O, DB access)?
   │
   ├── NO  → goes in plugin/static/ (CSS, JS, fonts, assets)
   │        Recipe B
   │
   └── YES → Q2. Can the 7-method loader API express it?
              (api.register_route · register_static · inject_script ·
               inject_stylesheet · config_get · logger ·
               register_audio_attachment_processor)
              │
              ├── YES → goes in plugin/routes/<feature>.py
              │        Recipe A
              │
              └── NO  → Q3. Would extending the loader API help all
                            future plugin features (not just this one)?
                            │
                            ├── YES → propose new API method
                            │        Recipe C  (needs PRD bump + JP review)
                            │
                            └── NO  → forced-internal escape hatch
                                     Recipe E  (CONNECTION-MAP entry + risk)

If you're unsure, default to plugin (Rule 1: never touch upstream unless forced).


Recipe A — Add a new /api/* endpoint

  1. Create routes/<feature>.py. Skeleton:

    """GET|POST /api/<feature> — <one-line purpose>."""
    import json
    
    def register(api):
        log = api.logger("svrnty.routes.<feature>")
        api.register_route("/api/<feature>", "GET", _handle)
        log.info("<feature> endpoint registered")
    
    def _handle(handler, parsed):
        payload = {"ok": True, "data": ...}  # your logic here
        body = json.dumps(payload).encode("utf-8")
        handler.send_response(200)
        handler.send_header("Content-Type", "application/json; charset=utf-8")
        handler.send_header("Content-Length", str(len(body)))
        handler.send_header("Cache-Control", "no-store")
        handler.end_headers()
        handler.wfile.write(body)
        return True
    
  2. Register the module in plugin.py _phase2_routes():

    return [
        "transcribe",
        "vault_status",
        "<feature>",        # ← add here
    ]
    
  3. Add tests/unit/test_<feature>.py — at minimum:

    • test_register_wires_one_routeapi.register_route called once with right path/method
    • test_handler_returns_<status>_on_<input> — success path
    • test_handler_handles_<failure> — error path returns clean response
  4. Regenerate connection map:

    python3 scripts/ast-connection-map.py
    
  5. Update manifest.yaml routes: section with the new path + status.

  6. Run full suite + commit:

    pytest tests/ -q                          # must be all green
    git add routes/<feature>.py plugin.py tests/unit/test_<feature>.py manifest.yaml CONNECTION-MAP.md
    git commit -m "feat(plugin): <feature> endpoint at /api/<feature>"
    

Reference implementation: routes/vault_status.py + tests/unit/test_vault_status.py.


Recipe B — Add a new UI element (CSS / JS / asset)

  1. Decide where it goes in static/:

    • CSS rulesstatic/app.css (loaded after upstream → wins via cascade)
    • JS behaviorstatic/app.js (loaded as defer)
    • Fonts/imagesstatic/<subdir>/
  2. For JS UI additions, follow the existing pattern in static/app.js:

    • Idempotent guard inside the IIFE (window.__svrntyExtLoaded already covers re-load)
    • Use a per-element dataset.svrntyXxx="1" sentinel to prevent double-install
    • Hook into existing DOM via MutationObserver (the WebUI rebuilds panels on tab switches)
    • Reference existing IDs only — never invent new IDs that upstream might use later
  3. Test (lightweight static checks in tests/unit/test_app_js.py):

    • File present + non-empty
    • Idempotent guard present
    • Any /api/* URL string the JS calls matches a registered plugin route
  4. CONNECTION-MAP auto-picks up /api/* URLs from JS — regen + commit.

Reference implementations:

  • Vault status panel: static/app.js:_injectVaultPanel + _loadVaultStatus
  • Voice-message mic: static/app.js:_vmStart + _vmAttachAndSend

Recipe C — Extend the loader API (add an 8th method)

Justification bar: the new method must enable a CLASS of features (audio processors enabled STT + future processors), not just one feature. Single-feature needs go through Recipe E (forced-internal).

  1. Open the PRD: hermes/docs/SVRNTY-PLUGIN-PROTOCOL.md. Edit §5.1's API table to add the new method with one-line purpose. Bump the row count in any "7-method" references.

  2. Open hermes-webui/api/svrnty_plugin_loader.py (the lone fork commit). Add:

    • Module-level registry: _NEW_THING: List[X] = []
    • Method on _PluginAPI: validates input, appends to registry, logs at INFO
    • Public accessor: def plugin_new_things() -> List[X]: return list(_NEW_THING)
    • Update the load_plugin() summary log to count the new dimension
  3. Wire the consumer in upstream (the hook point that fires the registered fns):

    • Find the loosely-coupled spot in upstream code (streaming, session, request lifecycle)
    • Add a small loop calling registered things; wrap each in try/except so a buggy plugin can't crash the agent
    • Keep the loop in the SAME loader commit (still 1 fork commit total)
  4. Update scripts/ast-connection-map.py's PUBLIC_API set to include the new method name.

  5. Update tests/evals/test_features.py:test_eval_loader_contract_unchanged — add the new method to required.

  6. Use the new method from the first consumer route under routes/.

  7. Amend the lone fork commit (hermes-webui/api/svrnty_plugin_loader.py + new hook site):

    cd hermes-webui
    git add api/svrnty_plugin_loader.py api/<hook-site>.py
    git commit --amend --no-edit
    git push openharbor jp --force-with-lease   # own fork branch
    
  8. Commit + push the consumer plugin route + PRD update.

  9. Run protocol-validate.sh — must still be 7/7 (the §10.1 check counts commits, not LOC; amending leaves the count at 1).

Reference implementation: the STT migration that added register_audio_attachment_processor — see hermes-webui commit 79c6665f + plugin commit 37123f57.


Recipe D — Add a config value

  1. Define the env var with the SVRNTY_<PROJECT>_<KEY> convention OR keep an upstream-style HERMES_<KEY> if the value semantically belongs to the upstream layer.

  2. Document it in the plugin .env.example (when one is added) or README.md install section. Never in upstream's .env.example.

  3. Read at call time via api.config_get(key, default) (which wraps os.environ.get). Never cache across calls — env can be edited + WebUI restarted.

  4. If the value gates a feature on/off, the absence path must return a clean 503 or skip gracefully — see routes/transcribe.py:_handle_transcribe (returns 503 when HERMES_WEBUI_STT_URL unset).

  5. Add a test that asserts the absence path doesn't crash + returns a useful signal.


Recipe E — Forced-internal escape hatch (use sparingly)

When the public API can't express what you need AND the change isn't worth extending the API for (e.g. one-off, deep upstream internal).

  1. Import the upstream symbol directly. Add a # CONNECTION: comment ABOVE the import naming:

    • Why the public API can't handle this
    • Risk if upstream renames or removes the symbol
    • Detection — how protocol-validate.sh / drift CI would catch breakage
    # CONNECTION: needs hermes-webui's internal session state — no public API
    # exposes this; risk medium (upstream renames _active_state_db_path roughly
    # 1-2× per year per git log); detection via tests/integration on each upstream tag.
    from api.profiles import get_active_hermes_home  # noqa: E402
    
  2. The AST walker picks up the import + the comment → CONNECTION-MAP shows the row under forced internal dependencies with your justification.

  3. Add an tests/evals/test_<feature>.py row that explicitly asserts the symbol still exists + behaves expected. This is the early-warning siren when upstream changes.

  4. Audit quarterly: every forced-internal row > 6 months old is a candidate for promotion to a public-API method (Recipe C) or refactor to remove the dep.

Discipline: if forced_internal count exceeds 5 rows total, stop adding features and refactor — protocol §11 risk row.


Commit message conventions (mirrors PRD §8.1)

Prefix When
feat(plugin): <subject> new feature inside plugin
fix(plugin): <subject> bug fix inside plugin
sync(upstream): rebase to <tag> manifest.yaml min_upstream_version bumped
map(connection): <subject> manual CONNECTION-MAP touch (rare; usually auto)
ci(drift): <subject> .github/workflows/* changes
docs(protocol): <subject> protocol PRD updates
feat(svrnty): loader … the lone fork commit (in hermes-webui, not here)

Body should answer: WHAT the change does, WHY (which recipe), and the test status (X/Y PASS).


PR checklist (drop into PR description)

- [ ] Recipe followed: [A | B | C | D | E]
- [ ] CONNECTION-MAP.md regenerated + committed (or N/A for static-only changes)
- [ ] tests/ added or extended; full suite passes locally
- [ ] manifest.yaml updated (routes section or upstream pin) if applicable
- [ ] No fork edits unless Recipe C (loader API extension)
- [ ] If Recipe C: PRD §5.1 bumped + hermes-webui loader commit amended
- [ ] No new forced-internal entries unless Recipe E (justified in source)
- [ ] Karpathy 4 rules followed (Think · Simple · Surgical · Goal-driven)

Quick reference

Need Read
The hard rules CLAUDE.md (Karpathy) + protocol PRD §4
What "done" looks like protocol PRD §10 + protocol-validate.sh
Existing patterns routes/vault_status.py · routes/transcribe.py · static/app.js
Map of what touches what CONNECTION-MAP.md (auto-generated)
One-command upgrade make sync-upstream
One-command smoke make smoke

If a recipe is missing or unclear, that's a CONTRIBUTING bug — open a PR against this file.