Rembric plugin development
Authoritative specs: openspec/specs/{claude-code-plugin,codex-distribution,hermes-agent-plugin,opencode-plugin,pi-plugin,plugin-session-protocol}/. Archived decisions: openspec/changes/archive/.
Mandatory workflow
- OpenSpec change first. Run
/opsx:propose (or amend an existing change). Plugin work always touches ≥2 specs and ≥3 files. Skipping the change is the failure mode that produces drift.
- Two release tracks:
server + unified plugin (no cascade). release-please runs exactly two components, no node-workspace/linked-versions/grouping. server (apps/web, package @rembric/web, tag server-v*) builds the Docker image. plugin (apps/plugin — the WHOLE tree, no exclude-paths, package @rembric/plugin, tag plugin-v*) carries one unified version for all five clients; its extra-files update every client carrier in lock-step (.claude-plugin/{package,plugin}.json, .codex-plugin/{package,plugin}.json, .hermes-plugin/plugin.yaml, .opencode-plugin/plugin.ts comment, .pi-plugin/package.json). A change to ANY plugin file bumps the single plugin version — claude/codex/opencode/hermes/pi never diverge; the CHANGELOG (scoped by conventional commit) records what actually changed. A plugin release NEVER rebuilds the server image (publish-docker gates on server_release_created), but it IS what publishes @rembric/pi to npm (trusted-publishing OIDC, provenance, no long-lived token). .pi-plugin/ has to live inside apps/plugin/ for that: release-please attributes a release by the paths of the commits under the component's path, so a client outside it would never cause a release and its carrier would only move when something unrelated did. release-please.yml carries a concurrency guard (cancel-in-progress: false) so a rapid second merge can't cancel tag-minting. The former six-component + node-workspace cascade was retired (change unify-plugin-release-track) after its anchor-tag fragility produced phantom release PRs. Legacy per-client tags (claude-code-plugin-v*, …) stay in history, inert.
- End-to-end against a local dev server (
pnpm run dev, host Next.js on :3000) before reporting done — see E2E discipline below.
- Docs sweep:
README.md, docs/agents.md, apps/plugin/README.md, the in-plugin README.md, apps/plugin/CHANGELOG.md.
No tool watches the per-client manifest dirs for you. eslint.config.js ignores apps/plugin/*/**, which matches the dot-directories, and none of them match pnpm-workspace.yaml::packages (apps/*, packages/*) — so pnpm -r does not reach them, ESLint does not lint their TypeScript, and any dependencies they declare are not installed by the repo's own install (.claude-plugin/package.json's workspace:* dep is dead letter today). Their tests run only because apps/web/vitest.config.ts::include lists a literal glob per client; a new client without its glob leaves a test file written and never executed, and the suite is green on nothing.
The three single-source-of-truth rules
.rembric is the only per-repo slug source. Dotenv file with PROJECT_SLUG=<lowercase-hyphen>. Regex ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$.
apps/plugin/mcp-bridge/rembric-dotenv.mjs is the only JS/TS implementation of parseDotenv + readRembricSlug + SLUG_RE. The bridge, opencode plugin, and Pi extension import it (the installer rewrites the opencode import to the installed absolute path). Inlining any of those in another .mjs/.ts file fails the build (invariant test).
apps/plugin/bin/rembric-plugin-core.mjs is the only JS/TS implementation of the nudge strings, stripPrivateTags, truncate, diag, the session HTTP client, the transcript accumulator and the flush helpers. Both JS/TS clients (opencode, Pi) import it, which is what makes the nudge strings byte-identical and the <private> redaction identical by construction rather than by review. Its agent parameter is required with no default: sessions.agent is written once per session into append-only rows with no repair verb, so a default misfiles sessions permanently. rembric-plugin-core.d.mts is hand-written (apps/plugin has no build step and no typecheck) and is the only thing that makes the omission a compile error.
Bash (apps/plugin/scripts/_api.sh) and Python (apps/plugin/.hermes-plugin/__init__.py) keep their own implementations — cross-language wrappers cost more than the duplication. They MUST agree on the regex value.
Per-client gotchas (read on touch)
Each client has 3–5 non-obvious behaviors that bit us. Before modifying that client's files, skim its section in references/per-client-gotchas.md. Sample of what's there:
- Claude Code:
${user_config.*} works in both mcp.json::env AND hook commands; keychain is the SoT for credentials.
- Codex CLI:
${user_config.*} is NOT substituted; subprocess env is cleared before MCP spawn → MUST list every needed var in env_vars: [...]; ${CLAUDE_PLUGIN_ROOT} doesn't work in MCP args.
- Hermes Agent:
plugin.yaml::hooks: [...] array gates lifecycle invocation — overriding a method without listing the hook is a silent no-op.
- opencode: every named export of a plugin file is invoked as a Plugin function — export ONLY
RembricPlugin. The bridge MUST live outside ~/.config/opencode/plugins/. Sub-agent filtering (parentID || title.endsWith(" subagent)")) is mandatory.
- Pi: no built-in MCP (its own docs say so, deliberately) → this is the one client whose plugin holds the MCP client, discovering tools with
tools/list; tools register under provider-safe names (.→_) because a real provider rejects the whole payload on a dot; shutdown is awaited and two Ctrl-C presses within 500 ms fire it in the interactive TUI (a single press does not, and print mode registers no SIGINT); nothing is injected from its settings file, so credentials come from the shell.
Shared-vs-divergent discipline
Per memory 01KRNZM2VFCME5HNT8N78HZW18: shared logic lives in shared paths. Divergence is allowed ONLY when the platform forces it.
- MUST be shared:
apps/plugin/mcp-bridge/rembric-dotenv.mjs, apps/plugin/bin/rembric-plugin-core.mjs, apps/plugin/scripts/*.sh (Claude+Codex hooks), apps/plugin/commands/*.md (Pi consumes them verbatim as prompt templates — reference them, never copy). The published @rembric/mcp-bridge package is the shared stdio transport; there is no repository launcher to copy.
- Legitimately divergent today:
hooks/hooks.json vs hooks/hooks.codex.json (env-substitution rules differ); .claude-plugin/mcp.json vs .codex-plugin/mcp.json (Claude keychain substitution differs from Codex's curated env_vars); Python in-process provider for Hermes; JS/TS in-process for opencode; the MCP transport in .pi-plugin/index.ts, because that host has no MCP client for the published bridge to plug into.
Sanity check: git ls-files apps/plugin/ should show ONE copy of each shared resource. Two paths with near-identical content is a sync bug.
Install / uninstall script invariants
- Never edit the user's agent config file (
settings.json, config.toml, opencode.json, ~/.hermes/config.yaml). Print the snippet with <PLACEHOLDERS>; the user pastes.
- Idempotent: a second run produces an empty diff.
chmod 644 for files invoked as node <path> (the +x bit isn't needed for node x.mjs).
- Fail loudly on missing source files.
Don't overcommit the spec to behavior the server doesn't support
Before adding a handler that POSTs to an endpoint, verify the endpoint exists:
grep -rnE "export (async )?function (GET|POST)" apps/web/src/app/api/
If it doesn't exist: either add the endpoint first (separate OpenSpec change) OR drop the handler from this scope (tasks.md says DEFERRED, change CHANGELOG documents it). Don't ship a handler that 404s.
This bit us in add-opencode-plugin — first iteration speced chat.message POSTing to /prompts/passive, which didn't exist. Cost: spec rework + scope reduction post-merge.
Adding a brand-new client
Always start with a Phase 0 spike in tasks.md that validates platform assumptions BEFORE any plugin code — the platform assumption you can't cheaply prove is the one that sinks the change. Spike the cwd/PWD propagation chain and the hook/lifecycle delivery shape for the new host before writing any plugin code.
The spike's outcome MUST be recorded as a comment in BOTH tasks.md (<!-- spike result: plan-a -->) and the plugin's source file (// cwd-spike-result: plan-a).
End-to-end validation discipline
You MUST exercise the change against a real Rembric dev stack before reporting work as done, unless local e2e is genuinely impossible. Spec validation and unit tests are necessary but not sufficient for plugin work — the multi-process flow (agent → bridge → MCP → HTTP → SQLite → dashboard) breaks in non-obvious places.
Minimum required steps:
REMBRIC_DATA_DIR=./data-dev REMBRIC_ADMIN_TOKEN=<16+-char-token> pnpm run dev (host Next.js dev, http://127.0.0.1:3000). Wait for the server to answer /healthz, then capture the seeded demo-writer token from seed-dev's output (see references/e2e-walkthrough.md §1-2).
- Install the plugin (
bash apps/plugin/.<X>-plugin/install.sh), configure the client end-to-end with real URL + real token, drop .rembric with PROJECT_SLUG=demo in the working directory.
- Exercise the lifecycle path your change affects. The exact commands per client (opencode
mcp list, tsx-driven handler invocation, dashboard SQLite verification, etc.) are in references/e2e-walkthrough.md.
- Tear down: stop the dev server, uninstall, restore user's config file to its prior state (placeholders if it didn't exist before).
Prefer the isolated variant of steps 2-4 when the client's CLI supports it — scratch HOME, per-run extension load instead of install, deliberately invalid API key. It touches none of the operator's config, so there is nothing to restore and no half-restored state to leave behind, and it costs no LLM calls. Six rails plus the paired-control method: references/e2e-walkthrough.md § 5b.
If you still cannot drive the agent TUI (a client with no per-run load, or keychain integration you can't script):
- Direct handler invocation via
pnpm exec tsx covers ~95% of the handler logic. Pattern from add-opencode-plugin: import the installed plugin module, set env vars, call handlers with mock inputs that mirror the platform's event shape.
- Tell the user explicitly what you DID verify vs what you DID NOT. Don't say "verified e2e" when you only ran unit tests. List the manual smoke steps the user should run.
If local e2e is genuinely impossible (Codex /hooks interactive trust gate, Claude Code keychain integration, tool you don't have installed): say so out loud. Tell the user:
"I can't drive <specific path> from this environment. Unit tests cover <list>. Manual verification needed for <list>. Want the steps?"
Honest > glossing-over.
Self-check before commit
If any answer is "no" or "I don't know", stop and resolve it.