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:
claude/is the single source of truth;claude/scripts/gen_plugin.pyderivesplugins/orchestrata/, drift-gated by--checkinsidedeploy/ship.sh.- Identity invariant: installed plugin
gitCommitSha==~/.claude/VERSIONcommit ==origin/master== prod checkout HEAD. - Direct hook registration is deliberately retired.
register_hooks.pyis check/cutover only; it was retired to kill a double-fire defect where a newly declared hook registered directly against the stale plugin AND was provided by the synced one.settings.hooks.jsonbeing inert as a runtime file is the intended end state — it remains the authored DECLARATION. plugins/orchestrata/component directory NAMES are load-bearing: renamingtools/toscripts/once broke every hook resolving${CLAUDE_PLUGIN_ROOT}/toolsand made the brief gate deny all delegations.
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:
task_context_hook.pyrejected a zero-file systemd task as "a testing task" demanding an AST, because it matched the substringtestin prose. Every non-coding task whose brief mentions testing is currently un-delegatable.ast_live_sync --onceprintedRESULT: PASSwhile writing nothing into an empty graph (fixed 2026-08-23)./codequeried a:Filelabel that does not exist in the graph; all 8 of its Cypher templates returned zero rows silently (fixed 2026-08-23).
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)
- 7 byte-identical
agents/*.mdexist in BOTHclaude/agents/and.claude/agents/. Project-scoped definitions override same-named plugin agents, so the shipped copies are shadowed inside this repo. - 29 of 50
claude.hooksmodules are registered by nothing. 8 are imported by other unregistered modules; 21 are imported by nothing at all. Two are complete, internally-wired subsystems whose entrypoints were never registered:error_gate_hook → error_gate → {checks_lint, checks_structure, checks_syntax, checks_test, utils}andstop_auditor → {stop_audit.deception, stop_auditor_subagent}. - Prod code graph is now exact: 2977 AST modules == 2977 graph modules, 0
missing, 0 stale. A stale
agent-a55ea2123f8449549project subtree (865 modules) remains as worktree pollution.
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/*.shchanges 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)
- Retire
register_hooks.pyandsettings.hooks.json's runtime role; keep the declaration, delete the cutover path once nothing reads it. Prove withwho-imports, not grep. - Collapse the 7 duplicated
agents/*.mdto one home (claude/agents/, since it ships). Decideanalyst.md, which exists only project-side. - Adjudicate the 29 unregistered hook modules: wire
error_gateandstop_audit(both complete and internally consistent), or delete. Not "leave". - Purge the stale
agent-a55ea2123f8449549project subtree from the prod graph.
4. Definition of done
gen_plugin.py --checkclean, and 31/31 hook commands plugin-relative.~/.claude/hooks/contains no hook code.- The smoke suite enumerates every registered hook, and fails if it enumerates zero.
- A hook that does not fire fails
ship.shbefore anything is deployed. claude --plugin-dirgives a working dev loop with no push and no bump.- The test stage runs the plugin off the workstation, in an isolated config dir.
- A fresh machine reaches a working session from
extraKnownMarketplacesplus the installer, with no manual step. - 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 andsettings.local.json. - 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
- The application/infra pipeline (
promote_env.py) — only its vocabulary is mirrored. /opt/orchestrata-tools/bin— invoked BY PATH by kimi and cron; a versioned plugin cache path would break those callers. It stays a separate deployment.- Rewriting the LOGIC of the 24 project gates. W5 changes only where they are executed FROM, never what they do.
- Rewriting hooks' internal logic, beyond the W2.5 regression fix.
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 |