Orchestrata Ultimate › Plans › claude-surface-promotion-plan

SUPERSEDED — see plans/claude-surface-pipeline.md

Source: plans/claude-surface-promotion-plan.md at 8a2cb42ad · last changed 2026-08-23 · first committed 2026-08-23 · 395 lines.

Prose pending.

Plan text

SUPERSEDED — see plans/claude-surface-pipeline.md

Status: superseded 2026-08-23. Do not implement from this file.

This draft rested on a false premise: that the project Claude surface (.claude/) had no deployed copy and therefore no dev/prod separation. It does. /home/mohamed/prod/orchestrata-ultimate is a separate checkout pinned at 143310ec (detached, 0 dirty) carrying its own .claude/ — 24 hooks, 8 agents, 6 skills, matching dev exactly — and the systemd units run from it. The elaborate source relocation this file specified (old W5) solves a problem that does not exist and is deleted.

Everything below is retained only as the working notes that produced the replacement. The verified plan is plans/claude-surface-pipeline.md.


Original text (unverified — retained for provenance only)

The Claude surface (hooks, commands, agents, skills, tools) has a strong deployment and no pipeline. Every edit is a prod deploy to the workstation, because there is nowhere else to run a plugin. This plan gives it the same dev → test → prod shape the application already has, and closes the one verification rung that lets behavioural defects reach every session.


1. Verified baseline

Everything below was measured on 2026-08-23, not inferred.

1.1 What is already strong

deploy/install-claude-config.sh climbs three rungs and refuses success at each:

Rung Check Enforced
registered install record exists, installPath readable yes — fail
enabled claude plugin enable + enabledPlugins asserted yes — fail
copied every plugin file byte-compared against the commit; also fails if 0 files were checked yes — fail
executes — NO CHECK EXISTS

The byte comparison exists because of a recorded incident: on 2026-08-19 the plugin pin read c24da189 for five days while content had advanced to 19c26470. Ten files in the live cache were stale, including hooks/task_context_hook.py and tools/module_context.py — the context-injection machinery that runs on every prompt. Restarting Claude did not activate the new code and nothing noticed, because nothing compared the bytes.

Also already in place, and NOT to be rebuilt:

1.2 The three defects this plan fixes

D1 — the plugin is not self-contained (a regex gap, not a design).

claude/settings.hooks.json declares 31 hook commands, all 31 authored as $HOME/.claude/.... gen_plugin.py::_rewrite_command rewrites the script path to "${CLAUDE_PLUGIN_ROOT}"/... — but _SCRIPT_PATH_RE matches the LITERAL /home/mohamed/.claude/... form. The 10 commands wrapped as sh -c 'exec "$(command -v python3.14t || command -v python3)" "$HOME/.claude/hooks/x.py"' use the $HOME variable, do not match, and pass through unrewritten. Its docstring says so explicitly: "returned byte-identical rather than mangled".

Result: 21 plugin-relative, 10 pointing outside the plugin.

Hook Interpreter
agent_mail_inbox.py (×2) python3.14t
ast_sync_hook.py python3.14t
convention_guard.py python3.14t
platform_conventions_prompt.py python3.14t
project_context_refresh.py python3.14t
task_context_hook.py python3.14t
workflow_context_inject.py python3.14t
claude_turn_capture.py (×2) plain python3

This violates the documented plugin contract — "Copied plugins cannot reference files outside their directory… those external files are not copied to the cache" — and means the plugin alone is insufficient: install it on a fresh machine without running install-claude-config.sh and 10 hooks silently fail.

Unblocking measurement: all 7 free-threaded hooks are stdlib-only. Their only non-stdlib imports are sibling hook modules that ship in the same directory. They need the interpreter (python3.14t, on PATH), NOT the deployed venv. So the file dependency is removable while keeping interpreter selection.

D2 — "executes" is unverified.

Nothing proves a registered hook fires. Three defects found on 2026-08-23 were enabled, registered and byte-perfect, and still wrong:

D3 — there is no dev or test stage.

ship.sh goes repo → workstation in one step. promote_env.py implements validate | test | promote for the application and infra, and does not cover the Claude surface at all. Two pipelines, two vocabularies, one of them missing two thirds of its stages.

1.3 Adjacent findings (recorded, not in scope here)


1.4 The separation that makes development possible

The deployed surface is a set of COPIES, never the checkout. Verified 2026-08-23: ~/.claude/hooks, ~/.claude/tools, the plugin cache and /opt/orchestrata-tools/bin are all real directories — no symlink anywhere points into the working tree.

This is not bookkeeping, it is the precondition for developing the harness AT ALL. If a session resolved hooks out of the checkout, a half-saved hook would break the tool being used to write it. Live proof from this session: claude/commands/code.md and claude/commands/ast.md were rewritten in the working tree, and the running session kept executing the OLD copies — which is exactly why they could be edited safely mid-session.

Every stage in §2 preserves this. Dev never mutates the deployed copy; it runs a second, isolated session against the working tree.

The surface where it does NOT hold yet — and must (W5)

.claude/settings.json registers 25 project-scoped hooks, every one of them cd "$CLAUDE_PROJECT_DIR" && bash .claude/hooks/<x>.sh — resolved inside the checkout. All 24 gate files are tracked. An edit to any of them is live IMMEDIATELY in every session whose cwd is this repo. There is no deployed copy and no staging.

Editing .claude/hooks/*.sh changes your own session's guards mid-flight. A syntax error there disarms or breaks a gate instantly, with no deploy step to catch it, in the very session you would use to fix it.

Ruling (Mohamed, 2026-08-23): hooks must be copies too. An earlier draft of this plan argued the gates should stay live so they travel with the branch. That is overruled — the rule is uniform. Every hook that executes, on every surface, runs from a deployed copy. Branch-tracking is preserved by redeploying on branch change and failing loudly when the deployed copy is stale (W5), not by executing the checkout.

The same argument that protects the user surface applies here with more force: these gates are the rules you develop UNDER. A broken gate does not merely misbehave, it silently disarms an enforcement point — and a disarmed guard looks exactly like a passing one.


2. Target architecture

One artifact moves: plugins/orchestrata/. Nothing else deploys.

Stage How the plugin runs Config scope Gate
dev CLAUDE_CONFIG_DIR=/tmp/claude-dev-<n> claude --plugin-dir ./plugins/orchestrata isolated, disposable none; iterate with /reload-plugins
test installed from a pushed ref into a throwaway CLAUDE_CONFIG_DIR, inside the orchestrata-test container isolated, disposable hook smoke suite: 100% of registered hooks fire
prod ship.sh → install-claude-config.sh → --verify ~/.claude drift gate + identity invariant + byte check + smoke suite

Dev requires no version bump and no push — that is the loop missing today, and the reason every edit currently feels like a release. Test is the first stage that runs the plugin the way a real session does, on a machine that is not the workstation.

Rungs, restated

registered  →  enabled  →  copied  →  EXECUTES
   ✅            ✅          ✅          ← this plan

Each existing rung was added after an incident the rung below could not see. The next incident is behavioural.


3. Workstreams

Order is load-bearing. W1 shrinks the surface, W2 makes correctness checkable, W3 gives it somewhere to run. Doing W3 first would automate the current mess.

W1 — Collapse to one track (make the plugin self-contained)

Goal: all 31 hook commands resolve ${CLAUDE_PLUGIN_ROOT}; ~/.claude/hooks/ and ~/.claude/tools/ hold no hook code.

# Task Verification
W1.1 Extend _SCRIPT_PATH_RE in claude/scripts/gen_plugin.py to match $HOME/.claude/<rest> and "$HOME"/.claude/<rest> in addition to the literal /home/mohamed/.claude/<rest> unit test: each of the 3 forms rewrites; a path outside .claude is untouched
W1.2 Move claude_turn_capture.py into the plugin payload (claude/tools/), keeping ${CLAUDE_PLUGIN_ROOT}/tools/ — the directory name is load-bearing gen_plugin.py --check clean
W1.3 Regenerate; assert 31/31 plugin-relative, 0 $HOME-referencing assertion in the generator test, not a manual count
W1.4 Confirm the sh -c 'exec "$(command -v python3.14t || command -v python3)"' interpreter selector survives the rewrite untouched 8 commands still select python3.14t
W1.5 Delete the hook .py files from ~/.claude/hooks/, leaving unshipped_guard.py (registered by settings.json, not the plugin) until W3.4 decides its home a full session runs with 0 hook errors
W1.6 Add a generator gate: fail if any generated hook command references a path outside ${CLAUDE_PLUGIN_ROOT} gen_plugin.py --check fails on a reintroduced $HOME path

Risk: a hook that silently depended on the deployed venv rather than only the interpreter. Measured as stdlib-only for all 7, so the risk is low; W2's smoke suite catches it immediately if wrong.

Rollback: revert the regex; regenerate; redeploy. One commit.

W2 — The smoke suite (the "executes" rung)

Goal: a hook that does not fire, or returns a malformed decision, fails the build — in every stage.

# Task Verification
W2.1 tests/claude/test_hook_smoke.py: read the GENERATED plugins/orchestrata/hooks/hooks.json, enumerate every command test count == count_hook_commands(); fail if 0 enumerated
W2.2 Per event, a synthetic stdin payload matching the real schema (tool_name, tool_input, cwd, session_id) payloads derived from the live schema, not invented — intercept_write.py once denied every Write for a day on exactly this mismatch
W2.3 Assert per hook: exit code in {0,2}; stdout is empty or valid JSON; a decision/permissionDecision value is one the harness accepts a hook that crashes, hangs, or emits garbage FAILS
W2.4 Negative cases for guards: intercept_write blocks a context.json write; convention_guard asks on pip install; no-xdist blocks -n auto each guard proven to block what it exists to block
W2.5 Regression case for D2: task_context_hook must NOT demand an AST for a brief with no .py files that merely mentions "test" reproduces today's false positive; fails until the classifier matches mechanism, not vocabulary
W2.6 Timeout every hook at 10s; a hang is a FAIL, never a skip wall-clock asserted
W2.7 Wire into ship.sh before the deploy stage, and into install-claude-config.sh --verify after it a behavioural break blocks the deploy

Non-negotiable: no mocking. Real hook processes, real stdin, real exit codes. Cap the whole suite at 5 minutes.

W3 — The stages

# Task Verification
W3.1 deploy/claude-surface.sh dev — regenerate, mint a throwaway CLAUDE_CONFIG_DIR, seed it with only extraKnownMarketplaces, and launch claude --plugin-dir ./plugins/orchestrata against it a hook edit is live in the DEV session with no push, and the prod session is provably unaffected (~/.claude mtimes unchanged, deployed bytes still match the deployed commit)
W3.1a Assert dev isolation in the smoke suite: after a dev run, ~/.claude and the plugin cache are byte-unchanged a dev session that mutates the deployed surface FAILS — that would be deploy-in-place wearing a sandbox's clothes
W3.2 deploy/claude-surface.sh test <ref> — push-gated; install into a throwaway CLAUDE_CONFIG_DIR in the orchestrata-test container; run W2 there suite green in an environment that is not the workstation
W3.3 deploy/claude-surface.sh prod — thin wrapper over the existing ship.sh path; add W2 post-install all four rungs enforced
W3.4 Decide unshipped_guard.py: into the plugin, or keep as the one settings.json-registered hook recorded either way; no third category
W3.5 Reduce ~/.claude/settings.json to what the installer does not own; confirm extraKnownMarketplaces is the ONLY genuine bootstrap seed (enabledPlugins is installer-owned and installer-verified) a fresh machine reaches a working session from the seed alone
W3.6 Mirror promote_env.py's validate | test | promote verbs so both pipelines read the same one mental model

W5 — The whole project surface becomes copies

Ruling (Mohamed, 2026-08-23): hooks AND skills — everything that executes.

Goal: nothing that executes or is injected as instruction runs from the checkout, on any surface. .claude/ stops being a source directory and becomes a deployment target, exactly like ~/.claude.

Why this needs a layout inversion, not a path rewrite

Hooks are registered by a COMMAND STRING in settings.json, so they can be repointed. Skills, agents and workflows are discovered by directory convention — .claude/skills/<name>/SKILL.md — and nothing in settings.json names their path. Measured 2026-08-23: 6 skills, 8 agents, 1 workflow, all tracked, zero registered by a repointable path.

So the discovered directory must ITSELF be the deployed copy, which means the tracked source has to move out of .claude/:

claude/project/            NEW tracked SOURCE for the project surface
  hooks/*.sh                 (from .claude/hooks/       — 24 files)
  skills/<name>/SKILL.md     (from .claude/skills/      —  6 skills)
  agents/*.md                (from .claude/agents/      —  8 agents)
  workflows/*.js             (from .claude/workflows/   —  1 file)
  settings.json              (from .claude/settings.json — hook registrations)

.claude/                   DEPLOYMENT TARGET — generated, gitignored
  hooks/ skills/ agents/ workflows/ settings.json
  deployed/COMMIT            the ref this surface was built from
  deployed/MANIFEST          sha256 per file

.gitignore:182 already ignores .claude/*, so the generated tree needs no new rule — the tracked entries simply stop being tracked once their source moves.

What STAYS tracked inside .claude/ — and why

The rule is executes → copy; data → tracked in place. Moving a data file buys nothing and breaks its readers:

Path Why it stays
.claude/banned-libraries.txt 21 consumers including core/banned_libraries.py and two Prolog rulebases (code_facts.pl, gate_rules.pl). It is INPUT to gates, not executed code.
.claude/weasel-words.txt same class, 1 consumer
.claude/rulings/ reference data, 0 code readers
.claude/settings.local.json user-owned — permissions + outputStyle. Never deployed, never overwritten.
.claude/run/, .claude/worktrees/ runtime state, already untracked
Tasks
# Task Verification
W5.1 git mv the four executable surfaces into claude/project/; leave the data files and settings.local.json untouched git ls-files .claude/ lists ONLY data files + settings.local.json
W5.2 deploy/install-project-surface.sh — git archive <ref> -- claude/project extracted into .claude/, then write deployed/COMMIT + deployed/MANIFEST. Refuses a dirty claude/project/ unless --allow-dirty (dev only): deploying uncommitted code is unreproducible deploying twice from one ref yields byte-identical trees
W5.3 Generate .claude/settings.json hook commands pointing at the DEPLOYED .claude/hooks/<x>.sh 25/25 generated; 0 commands reference claude/project/
W5.4 Freshness guard, shipped in the PLUGIN (it must not live in the tree it validates): at SessionStart compare .claude/deployed/MANIFEST against claude/project/ at HEAD. Mismatch → exit 2 naming the redeploy command. Missing → exit 2 a stale surface blocks the session; it never runs another branch's rules
W5.5 Second, distinct signal: claude/project/ differs from HEAD (uncommitted edits) → warn that edits are NOT deployed never ambiguous which copy is running
W5.6 Extend scripts/provision_worktree_env.py to deploy the project surface into every new worktree a fresh worktree has working gates and skills with no manual step
W5.7 Extend the W2 smoke suite to all 25 gates: each fires, exits in {0,2}, and each guard still BLOCKS what it exists to block (no-xdist on -n auto, no-banned-deps on a banned add, force-ast-toolkit on a raw .py Read) a disarmed gate FAILS the build — today nothing would notice
W5.8 Assert every project skill and agent resolves after deploy (/help lists them; each SKILL.md parses) a skill that vanished in the move FAILS, rather than silently not existing
W5.9 Add install-project-surface.sh --verify to ship.sh beside the plugin byte check one identity rule, every surface

Why per-worktree: each worktree is a different checkout on a different branch, and this surface is branch-specific. A machine-wide /opt copy would apply one branch's rules and skills to every worktree.

Bootstrap hazard (must be handled, not assumed): once .claude/ is generated, a FRESH CLONE has no project surface at all — no gates, no skills — so an unguarded session is the default state rather than a failure. W5.4's guard ships in the plugin precisely so it is present before .claude/ is, and must treat "missing" exactly as severely as "stale". A checker inside the tree it checks cannot report its own absence.

Risk: a gate or skill with paths relative to the checkout. All 25 gate commands already begin cd "$CLAUDE_PROJECT_DIR", so cwd is unchanged; only the SCRIPT path moves. W5.7 and W5.8 prove it per file.

Rollback: git mv back and regenerate. The move is pure relocation — no file CONTENT changes in this workstream.

W4 — Cleanup (after W1–W3 are green)


4. Definition of done

  1. gen_plugin.py --check clean, and 31/31 hook commands plugin-relative.
  2. ~/.claude/hooks/ contains no hook code.
  3. The smoke suite enumerates every registered hook, and fails if it enumerates zero.
  4. A hook that does not fire fails ship.sh before anything is deployed.
  5. claude --plugin-dir gives a working dev loop with no push and no bump.
  6. The test stage runs the plugin off the workstation, in an isolated config dir.
  7. A fresh machine reaches a working session from extraKnownMarketplaces plus the installer, with no manual step.
  8. Nothing that executes or is injected as instruction runs from a checkout, on any surface. 31/31 plugin hooks under ${CLAUDE_PLUGIN_ROOT}; 25/25 project gates, 6 skills, 8 agents and 1 workflow under a generated .claude/. git ls-files .claude/ lists ONLY data files and settings.local.json.
  9. A stale or missing deployed project surface blocks the session (exit 2) with the redeploy command named, and never runs another branch's rules. A fresh clone cannot produce an unguarded session.

5. Explicitly out of scope

6. Standing risks

Risk Mitigation
A hook needs the deployed venv, not just the interpreter measured stdlib-only for all 7; W2 catches it on the first run
The smoke suite passes vacuously (enumerates 0 hooks) W2.1 fails on a zero count — the same failure mode the installer's byte check already guards
--plugin-dir cannot override a managed-enabled plugin documented limitation; verify in W3.1 before depending on it
Deleting ~/.claude/hooks/ breaks something unmeasured W1.5 lands only after W1.3 proves 31/31, and is one git revert