This repo is the source of truth for one thing: the rules an AI coding session in another repo must follow. Those rules are plain markdown. The work is getting them, unchanged, into the context window of three different tools — Claude Code, Cursor, and Codex CLI — and keeping them there across upgrades without stepping on state the target repo owns.
Five layers, each with exactly one owner:
canonical/ layer 0 source of truth (this repo, git)
│
│ mandrel deploy layer 1 copy + render + resolve + record
▼
<target repo>/ layer 2 deployed payload + target-owned memory
│
│ hooks / imports layer 3 three IDE surfaces, one eager set
▼
session context layer 4 loading contract (eager / lazy / delivered)
Everything under canonical/ is authored here and copied verbatim into target
repos. Nothing is authored in a target repo; a local edit there is drift, and
mandrel status reports it as such.
canonical/ bucket |
lands in target as | what it is |
|---|---|---|
repo-root/ |
/ (repo root) |
CLAUDE.md, the loader |
protocols/ |
.ai-protocol/protocols/ |
conduct + the role contracts (dev-advancement, dev-remediation, review, plan, intake) |
meta/ |
.ai-protocol/meta/ |
data schemas: taskfile.md, memory.md, init.md |
workflow/ |
.ai-protocol/workflow/ |
caller-side procedure: runbook.md, rolemapping.md, skills |
claude/ |
.claude/ |
settings.json (hook wiring) + hooks + project skills |
cursor/ |
.cursor/ |
hooks.json + hooks + rules/*.mdc |
codex/ |
.codex/ |
config.toml.template + hooks + adaptation docs |
orchestrator/ |
.mandrel/orchestrator/ |
the SDK-driven multi-session caller |
Two boundary rules are mechanically enforced by scripts/boundary-lint.sh:
- Contracts don't know about callers.
protocols/*.mdmay not mention the orchestrator, dispatch, hooks, other roles' sessions, or slash commands. - The ambient channel carries no role contract (charter rule 12). The only
protocols/file allowed in a session-start eager set or in the loader's import block isconduct.md.
./bin/mandrel deploy <target> walks canonical/, and for each file:
- Filters forbidden paths —
.git,.venv,__pycache__,logs/,.env*(except.env.example),sessions.json,settings.local.json,*.pyc. These never leave the source repo. - Renders templates. Only
codex/config.toml.templateis a template: it becomes.codex/config.tomlwith{{REPO_ROOT}}replaced by the target's absolute path. That ties the rendered hook commands to the checkout path, so moving or re-cloning a target requires a redeploy. - Resolves the loader's memory entrypoints against the target's own
.ai/— see Entrypoint resolution below. - Writes the bytes and the file mode. This is an unconditional overwrite: every deployed file is deploy-owned.
Then, once per deploy:
.gitignoregets a managed block (# BEGIN/END mandrel) that ignores the whole deployed payload, so target repos don't commit it..ai-deploy-manifest.json— target-local state: rendered file hashes and modes, absolute paths, source commit, timestamp. Gitignored. This is whatstatuscompares against..ai-deploy-lock.json— portable: canonical file hashes and modes plus the source commit, with no machine paths. Deliberately not gitignored, so a target repo can commit it and prove which protocol version it is running..registry/repos.local.jsonin this repo — a machine-local inventory of deployed targets, somandrel status --allcan sweep them.
mandrel deploy --dry-run previews add/update/unchanged/blocked without writing.
mandrel status <target> reads the manifest and reports drift, nonzero exit on
any finding:
| kind | meaning |
|---|---|
target modified |
a deployed file's bytes or mode no longer match what the deploy left |
canonical changed |
this repo moved ahead, in content or in mode; target needs a re-deploy |
missing target file |
a deployed file was deleted |
extra deployed file |
tracked in the manifest but gone from canonical/ |
stale eager import |
the loader points at a memory doc that is no longer the current entrypoint, or routing points somewhere illegal |
ambiguous memory entrypoint |
both x.md and x/index.md exist for one topic |
shadowed skill |
a deployed skill name also exists personal-level, where it takes precedence |
invalid manifest entry |
a malformed manifest record, or a receipt that records no deployed mode (written before modes were recorded) |
<target>/
CLAUDE.md deploy-owned loader: imports + verb→contract map
.ai-protocol/ deploy-owned contracts, schemas, caller procedure
.claude/ .cursor/ .codex/ deploy-owned hook wiring per tool
.mandrel/orchestrator/ deploy-owned the unattended scheduler + its venv
.ai-deploy-manifest.json deploy-owned local state (gitignored)
.ai-deploy-lock.json deploy-owned portable proof (committable)
.ai/ TARGET-OWNED the project's memory snapshot
.ai-tasks/ TARGET-OWNED one file per task + index
The split above is the thing to understand: .ai/ and .ai-tasks/ are not
deployed. They are created by /ai-init inside the target and evolve with the
project — .ai/ version-controlled there (memory §1), .ai-tasks/ covered by
the managed gitignore block so task files stay local. Deploy never writes
either, and never reads them except to answer one question: which file is each
eager memory topic currently in.
That single dependency is where this system used to leak, and it is what Entrypoint resolution covers.
The same set of documents has to reach the model under three tools with three different context-assembly mechanisms.
| Claude Code | Cursor | Codex CLI | |
|---|---|---|---|
| wiring | .claude/settings.json |
.cursor/hooks.json |
.codex/config.toml |
| how protocol text arrives | native @import in CLAUDE.md |
sessionStart hook injects additional_context |
SessionStart hook injects additionalContext |
| entrypoint resolution | static — resolved at deploy time | dynamic — resolved per session by the hook | dynamic — resolved per session by the hook |
| session id | $CLAUDE_CODE_SESSION_ID |
injected by the hook (no env var exists) | injected by the hook (session_id) |
| fallback if hook fails | n/a (imports are native) | rules/protocol.mdc (alwaysApply) lists the read order |
.codex/README.md documents user-scope promotion |
| session-end check | Stop hook |
stop hook |
Stop hook |
Notes that matter in practice:
- The Cursor and Codex hooks are ports of each other. Same eager set, same
resolution order, same housekeeping hint; only the stdin/stdout JSON shape
differs. The Codex hook additionally self-gates on the deployed protocol
marker (
.ai-protocol/protocols/conduct.md), so it stays a silent no-op in unrelated repos even when promoted to user scope — the documented fallback when project-scope hooks don't fire in the TUI. AI_ORCH=1disables the Cursor hook. Orchestrated sessions (.mandrel/orchestrator/) assemble their own context and own the lifecycle; double injection would fight them.- Skills ship on the ordinary channel.
canonical/claude/skills/deploys to<target>/.claude/skills/with everything else, so the manifest and the lock cover them and each target's skills match its protocol revision. Claude Code finds them by native discovery; Cursor and Codex are pointed at.claude/skills/<name>/SKILL.mdby their rule/injection text. A same-named~/.claude/skills/<name>/copy overrides the deployed one and no content hash can detect it, sostatuschecks the personal skills root by name and reportsshadowed skill— the same shape asambiguous memory entrypoint: two legal locations present, the wrong one winning.
What ends up in the context window, per .ai-protocol/meta/memory.md §2:
Eager — always present:
- the loader (
CLAUDE.md) — carries the verb→contract mapping conduct.md,taskfile.md,memory.md— behavior + the two data schemas.ai/index.md,.ai/map.md— routing spine, always single-file- the current entrypoint of
overview,architecture,design,conventions .ai-tasks/index.md
Lazy — read on demand via .ai/index.md routing: everything else in .ai/
(modules, apis, features, sub-indexes). A task may name 2–5 of these in its
prefetch: field.
Delivered, never ambient — role contracts. dev-advancement,
dev-remediation, review, plan, intake are not in the eager set. The
caller hands the contract text over at invocation: the /invoke skill, a paste
(runbook §6), or wrapper injection by the orchestrator. "Read it on demand" is
not a delivery channel — a contract that isn't in context isn't in force.
The memory protocol lets a doc that outgrows its size limit be upgraded from a
single file to a directory: .ai/design.md becomes .ai/design/index.md, with
.ai/index.md routing re-pointed. It is a rename, so exactly one form exists at
a time — and which form is current is target state, while the loader that
imports it is deploy-owned.
That is the whole problem in one sentence: two owners, one line of text.
Every surface now answers "which file is topic X in?" the same way:
.ai/index.mdrouting table, if it names one of the two legal forms and that file exists;- else file shape — directory form if
x/index.mdexists andx.mddoes not; - else the single-file default.
The Cursor and Codex hooks run this per session, in shell. Deploy runs it in
Python when it renders CLAUDE.md, because Claude Code's @import is static
text and cannot resolve anything at load time.
Why this needed fixing. Deploy used to write the canonical single-file form
unconditionally, so a housekeeping upgrade was silently reverted on the next
deploy. Status was already tolerant of both forms for hashing — which meant the
revert was invisible from both directions. And Claude Code ignores a missing
@import with no error at all: the document simply disappears from the eager
set. Three silent failures stacked on one line.
What now covers it, per layer:
| layer | mechanism |
|---|---|
| deploy write path | bytes_for_target() resolves entrypoints per target, so a @import upgrade survives a re-deploy — and a loader left on the old path is repaired by one |
| deploy status | stale eager import / ambiguous memory entrypoint — explicit checks, because the content hash is deliberately blind to which form is deployed |
| session start (Claude) | the SessionStart hook re-runs the same check and warns in-session, covering the window between a housekeeping split and the next deploy |
| session start (Cursor/Codex) | nothing to cover — those hooks resolve dynamically every session |
| protocol | memory.md §2/§4 state that the forms are mutually exclusive and that a static loader must be re-pointed |
| tests | tests/test_hook_eager_set.py asserts the hooks and the deploy tool cover the same four topics, in the same order, with matching path forms |
The four upgradeable topics are overview, architecture, design,
conventions. index.md and map.md are the routing spine and stay
single-file — a directory-form map/index.md is drift, and status says so.
./bin/mandrel deploy ../target-repo # deploy or upgrade
./bin/mandrel deploy ../target-repo --dry-run # preview, writes nothing
./bin/mandrel status ../target-repo # drift check, nonzero exit on drift
./bin/mandrel status --all # sweep every registered target
scripts/boundary-lint.sh # charter + reference invariants
python -m pytest tests/ -q # deploy, hook-consistency, orchestratorAfter changing anything in canonical/, every deployed target reports
canonical changed until it is re-deployed. That is the intended signal, not a
problem to suppress.
After a housekeeping split in a target, re-deploy that target. Cursor and Codex sessions pick up the new entrypoint immediately; Claude sessions need the loader rewritten, which is what the deploy does.