Home / Projects / Orchestrata Ultimate / Decisions / ADR index / ADR-0092

ADR-0092: The orchestrata plugin is self-contained, pinned by a tag, and installed by one verifying command

field value
Status Accepted
Date 2026-10-12
Register factory (docs/adr, 4-digit ids)
Source docs/adr/0092-the-orchestrata-plugin-is-self-contained-pinned-by-a-tag-and-installed-by-one-verifying-command.md — recorded on branch docs/adr-register-0080-0085 at 3f68a11a5, not yet on master
Wiki register entry ADR-0092 (wiki status: Accepted)

← ADR-0091 · ADR-0093 →

The record

Status: Accepted · Date: 2026-10-12 · Ruled: 2026-10-12 (Mohamed, with decision 7 added)

Context

Session goal (Mohamed, 2026-10-12, verbatim): "workimg instaaller that installs the project as a working and wired claude plugin". Its exit criteria are:

  1. One command installs the plugin from a pinned git ref into a clean config dir. It exits 0, or it refuses without leaving a half-install.
  2. A headless session in that config lists the plugin's hooks, skills, commands and agents, and each hook event is seen to fire its plugin hook.
  3. Every hook and tool runs from the installed copy. A verify step refuses success when the running plugin is not the installed ref.
  4. Re-running is a no-op, and it upgrades cleanly from the previous ref.
  5. The tests pass forced: 0 failed, 0 errors, 0 skipped.
  6. Merged, then installed on this machine from that ref.

An installer exists today: deploy/install-claude-config.sh, 481 lines. It copies claude/ into ~/.claude/{hooks,tools,agents,skills,commands} with its own ~/.claude/.venv. It then adds the git marketplace with no ref, installs the plugin, and checks the installed bytes. Everything below was measured on 2026-10-12 against origin/master = 762935ab6, using Claude Code 2.1.296.

Measured: the plugin works only beside a checkout

The plugin was installed into a clean CLAUDE_CONFIG_DIR with an isolated HOME and a PATH with no checkout .venv on it. Then claude --init-only --debug hooks ran. That run needs no credentials, and it writes every hook's exit status to $CLAUDE_CONFIG_DIR/debug/<id>.txt.

Registered 65 hooks from 4 plugins
SessionStart plugin hooks: 18 fired — 13 exit 0, 3 exit 2, 2 exit 1

hooks.json has 65 commands. 21 use bare python3, 10 use $HOME/.claude/…, and 8 use command -v python3.14t || command -v python3. Today they work on this machine only because direnv puts a checkout's .venv/bin first on PATH. The interpreter running them is a checkout's, which is deploy-in-place.

claude/scripts/gen_plugin.py rewrites only the literal /home/mohamed/.claude/ spelling to ${CLAUDE_PLUGIN_ROOT}. The 10 $HOME/.claude commands escape that rewrite.

Plugin hooks and tools import third-party packages, so a bare interpreter can never run them (AST who-imports --source claude/hooks,claude/tools):

The skills and commands point outside the plugin too. 3 of 11 skills and 5 of 14 commands name ~/.claude/tools/…. session_cli.py alone is named 16 times and test_isolation.py 6 times.

Measured: each agent is delivered twice

~/.claude/agents/ and plugins/orchestrata/agents/ hold the same seven files: auditor, builder, lint-fixer, refactorer, reviewer, test-writer and wirer. A session therefore lists both auditor and orchestrata:auditor, and likewise for all seven. The claude/ tree is installed twice: once as ~/.claude copies, with 46 hooks, 27 tools, 11 skills and 14 commands, and once as the plugin.

The repo's own 30 project-settings hooks are not duplicated. project-hooks/_dispatch.sh stands down when CLAUDE_PROJECT_DIR is the governed repo. All 30 match plugin entries by event and script.

Measured: how pinning, relocation and observation behave

Decision (proposed)

  1. The plugin is the single delivery path, and it is self-contained.

    • Every hook command runs as "${CLAUDE_PLUGIN_DATA}/venv/bin/python" "${CLAUDE_PLUGIN_ROOT}/<path>" or as bash "${CLAUDE_PLUGIN_ROOT}/<path>".
    • No command may name $HOME/.claude, ~/.claude or /home/<user>/.claude. No command may use a bare python3/python or command -v python3.14t.
    • Skill, command and agent bodies name tools as "${CLAUDE_PLUGIN_DATA}/venv/bin/python" "${CLAUDE_PLUGIN_ROOT}/tools/<x>.py".
    • gen_plugin.py performs every rewrite. gen_plugin.py --check fails on any surviving forbidden pattern, in hooks.json or in any plugin markdown body.
  2. The plugin owns its venv in ${CLAUDE_PLUGIN_DATA}/venv.

    • It is free-threaded python3.14t, built by uv from a lock file shipped in the plugin.
    • A stamp file records the hash of that lock.
    • A plugin bootstrap hook builds or refreshes the venv on Setup and SessionStart, whenever the stamp ≠ the lock hash. If it cannot build, it fails loudly (non-zero, with the reason). It never falls back to another interpreter.
  3. The pin is a tag, plugin-<12-hex sha>.

    • The ship step creates the tag on the shipped commit and pushes it.
    • The installer accepts only --ref plugin-<sha12>, with the sha matching the tag's commit.
    • It adds the marketplace as <origin url>#plugin-<sha12>.
    • A wrong source or ref is re-pointed, never reported and left.
    • version is removed from plugin.json and the marketplace entry, so the cache keys on the commit and claude plugin update moves on every new tag. This premise is measured first. If the cache does not key on the commit, the existing uninstall + install reconciliation stays; no new ruling is needed for that.
  4. One installer command: deploy/install-plugin.sh --ref <tag> [--config-dir DIR] [--verify].

    1. It exports CLAUDE_CONFIG_DIR for every claude call.
    2. It adds or re-points the marketplace, then runs install -y --json and enable.
    3. It runs claude --init-only --debug hooks, which bootstraps the venv and fires SessionStart.
    4. It refuses success unless all of these hold:
      • (a) gitCommitSha = the tag's commit;
      • (b) the installed cache bytes = git archive <tag> plugins/orchestrata;
      • (c) the venv stamp = the lock hash, and sys._is_gil_enabled() is False;
      • (d) every SessionStart plugin hook in the debug log finished with status 0, and their count = the SessionStart count in the installed hooks.json.

    The install is staged: the prior state of the entries the installer owns (decision 7a) is kept until (a)–(d) pass. On failure those entries are restored and the exit is non-zero. --verify mutates nothing. Re-running at the same tag changes nothing; that is asserted by an unchanged tree hash of the plugin dirs.

  5. Every hook event is observed in a test, with a credential from the environment.

    • The live test installs into a clean config dir and runs one claude -p turn that uses Bash, with ANTHROPIC_API_KEY passed by environment (never written to the target dir).
    • It asserts, from the debug log, that every event declared in hooks.json fired its plugin hooks: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PermissionRequest, Stop and SessionEnd.
    • If an event cannot be provoked headless (PermissionRequest is the candidate), the test STOPs and reports it. It never skips. Treatment is then a separate ruling.
  6. The ~/.claude copies retire after an inventory. install-claude-config.sh stops copying hooks/tools/agents/skills/commands into ~/.claude only after an inventory of every outside reference to ~/.claude/{hooks,tools,…} has been resolved. That covers systemd units, other repos' CLAUDE.md and AGENTS.md, opencode and kimi configs, and cron. Each one is rewritten to the plugin path or reported. The copies it previously installed are then removed by its own manifest. Until then, both stay, and the duplicate agents stay visible.

  7. Other plugins, skills and hooks are left exactly as found (Mohamed's ruling, 2026-10-12).

    • Measured on this machine, 2026-10-12.
      • Two other plugins are enabled: tmux-status@tmux-status-dev 1.2.0, with hooks on 9 events including SessionStart and skill tmux-icons; and turn-navigator@mohamed-tmux 1.1.3, with SessionStart and SessionEnd hooks and skill help.
      • ~/.claude/skills/synced/ did not come from this repo.
      • ~/.claude/settings.json registers one Stop hook, ~/.claude/hooks/unshipped_guard.py under ~/.claude/.venv, which the plugin does not carry.
      • No orchestrata component name collides today, because plugin components are namespaced orchestrata:.
    • (a) Owned entries only.
      • The installer writes only what orchestrata owns: its key in known_marketplaces.json, its key in installed_plugins.json, enabledPlugins["orchestrata@orchestrata"] in settings.json, and its own cache/ and data/ dirs.
      • Every other key in those files is preserved, and so is every other file in the config dir: other plugins' entries, user hooks, skills, commands, agents and skills/synced/.
      • The installer hashes them before and after, and refuses success if any one changed.
    • (b) Rollback is scoped the same way. A failed install restores only the owned entries, never a whole-directory snapshot. A rollback therefore cannot revert another plugin's change made in the meantime.
    • (c) Verify judges only orchestrata.
      • Check 4(d) counts only debug-log lines attributed (plugin orchestrata@orchestrata).
      • Other plugins' hook results are printed as information. They never fail or pass the install.
    • (d) Overlaps are reported, never "fixed". The installer lists, and leaves unchanged:
      • name collisions between orchestrata components and another plugin's or user-level skill, command or agent;
      • user or project settings.json hooks that run the same script as an orchestrata plugin hook, which therefore fires twice.
    • (e) Retirement (decision 6) removes only manifest entries.
      • It deletes only files listed in the installer's own manifest, and only settings.json hooks the installer itself wrote.
      • skills/synced/ and every file not in the manifest are never touched.
      • The user-level unshipped_guard.py Stop hook enters decision 6's inventory: it moves into the plugin or stays, and either way is reported.
    • (f) Tested with neighbours present.
      • A coexistence test seeds the target config dir with a second real plugin from its git marketplace, a user-level skill and a user settings.json hook.
      • It asserts their bytes are unchanged after install, re-install at the same tag, upgrade to a new tag, and a forced-failure rollback.

Alternatives rejected

Consequences

Provenance

No other ADR is named in this record.

Rulings cited

None.

Specs cited

None (or no spec page exists for the ones named).