@archangel-tools/agent-ctx
v0.10.0
Published
Engine-independent context compiler for coding agents. One neutral source (.agent/ + context repos) compiles to CLAUDE.md, GEMINI.md, and engine configs.
Readme
agent-ctx
An engine-independent context compiler for coding agents. It takes a neutral
source — .agent/ folders plus context repositories — and compiles it into
engine-specific artifacts: CLAUDE.md for Claude Code, GEMINI.md for Gemini
CLI. One source → many engines, no vendor lock-in.
It compiles, and does nothing else. Scaffolding a layer, advising on its
structure, and delivering skills into an organization all need judgement and a
human at the boundary, so they are skills — not commands. What is left here is
what must be deterministic, verifiable, and runnable with no model and no
network: the CI gate, the pre-commit rebuild, and the first git clone by
someone who has never heard of this tool.
agent-ctx knows nothing about any particular content backend. A content
backend (Notion, a wiki, anything) is just one of the MCP servers described in
the layers, which the tool renders into engine config. It compiles a map;
the live content is read by the agent at runtime through MCP, never compiled.
See CONTRACTS.md for the full format contract — it is the
core document every component refers back to.
Install
npm install -g @archangel-tools/agent-ctx
# or, from a clone:
npm install && npm run buildRequires Node 22.5+ (the mirror uses node:sqlite). The published package is a
pinned dependency for consuming repos and CI — it is not vendored/copied into
each repo.
Public on purpose. The tool is MIT, holds no secrets, and knows nothing about any organization's content — it compiles whatever layers it is pointed at. A private registry would add an authentication step to every consumer (container, CI job, laptop) and buy nothing, because the thing worth protecting is the layers, and those are protected by git access. Isolation between organizations is enforced there, not here.
A version is published only by pushing a v* tag; the release workflow refuses
a tag that disagrees with package.json. main is not a release channel, so a
consumer pinning a version is pinning something that was deliberately cut.
The layer model
agent-ctx composes the three layers that are properties of the repository:
org / solution / repo.
- org —
<org>/agent-context. Holds_schema.md(the lens, sorts first),solutions/<name>/,skills/,mcp/, andresearch/(a working zone, NOT a layer). - solution —
<org>/solutions/<name>/: operational material only (conventions, skills, subagents) plusnotion-ref.md. The durable unit of value. The term issolutions/, neverprojects/. - repo — the workplace's own
.agent/.
There is a fourth layer conceptually — personal — but agent-ctx does not
build it. See personal is outside the tool.
Isolation between orgs is enforced by git access, not engine privacy.
agent-ctx is open and holds no secrets. No access → the build fails closed.
Manifest
A directory with .agent/manifest.yaml is a compilable workplace.
extends:
- org: acme
- solution: billing # → <org>/solutions/billing/
sources:
org:acme:
repo: https://github.com/acme/agent-context.git
ref: v2026.06.1 # a pin = reproducibility
compose:
precedence: [repo, solution, org]
context: [repo, solution, org] # optional — which layers contribute PROSE
skills:
exclude: [deliver-skill] # optional — skills this place declines
targets: [claude, gemini]
decisions_cache: false # optional — embed the org's Active decisionsOrg layers with a sources entry are cloned to a cache before composition (the
ref is resolved to a concrete SHA, recorded in the artifact header). A local
--registry path is an override over org sources — iterate without
committing; no clone happens.
compose.context, compose.skills.exclude and decisions_cache are optional
and each defaults to what a manifest written before it meant: every layer
contributes prose, every skill is installed, and no decisions are embedded. They
are explained below — the first under
a place above other places, the
second under a place declines a skill it cannot use,
the third under memory splits by volatility.
A place above other places bounds its prose
compose.context names the layers whose context docs are composed. Omit it and
that is compose.precedence — all of them.
Narrow it only for a place that is a parent directory of other places. Claude
Code reads CLAUDE.md up the tree, so such a place lends its prose to every
session held beneath it, including another organization's. The constellation root
is the case that produced the field: it sets context: [repo], because inlining
the vendor's org layer there would hand a client's session the vendor's schema
lens and the ids of a backend that session must never write to.
Capabilities are deliberately unaffected — skills, subagents, MCP servers and
hooks still come from every layer in precedence. They are read per project and
do not travel upward, so they leak nowhere, and a root that wants log-activity
should have log-activity. Where a place wants fewer skills than its layers
carry, the key for that is the next one.
A place declines a skill it cannot use
A skill costs its name and description in the standing prefix of every
request made in that place, whether or not it can ever fire there. A skill whose
work lands in another repository — a rollout across an organization's layer, a
delivery from a catalog shelf — cannot fire in a workplace at all, and still
bills it. compose.skills.exclude lets the place decline it by name:
compose:
skills:
exclude: [deliver-skill, schema-generation]Exclude-only, and never an allow-list: a skill added upstream arrives everywhere until a place declines it deliberately. Nothing is deleted — the skill stays in the layer and stays installed in every place that did not decline it, so declining is a statement about where a description is paid for, not about what the organization can do. One line and a rebuild puts it back.
Two mistakes are reported rather than guessed at. An entry that matches no
installed skill — the shape of a skill renamed upstream, where the place goes
on believing it declined something. And an entry declining a skill whose
compiled .claude/skills/<name>/ directory something else this place composes
names: a hook command, an MCP server's command, a subagent's prompt, another
skill's body, or a context doc. The build prunes that directory, so a hook whose
script is missing fails on every event and a document that named a file there
sends its reader to open nothing.
An allow-list is refused, not ignored: compose.skills.include fails to
load, by name, rather than parsing and doing nothing.
personal is outside the tool
The manifest is committed and identical for everyone. org and solution are properties of the repository; personal is a property of the person. Naming personal in a committed manifest breaks in three ways: a colleague can't build (no access to your private layer → fail-closed), your preferences leak into the whole team's artifact, and CI — which has nobody's personal — can't build at all.
So agent-ctx doesn't know about personal at all. personal in extends or in
sources is a validation error — the build fails rather than quietly
producing an artifact that only one person can reproduce.
Personal is handled by a different system, one layer below:
- your dotfiles (chezmoi / stow / a dotfiles repo) own
~/.claude/CLAUDE.md; - the engine applies it automatically, in every project, on top of the
project
CLAUDE.mdthatagent-ctxgenerates.
Personal still wins on precedence — that's the engine's doing, not ours.
agent-ctx compiles what the team shares; your dotfiles compile what is yours.
Commands
# Compile a place into the committed artifacts (repo + solution + org):
agent-ctx build ./work --registry ./registry
# CI gate — writes nothing, exits non-zero if the artifact is stale:
agent-ctx build ./work --registry ./registry --check
# Provenance & freshness:
agent-ctx status ./work # built SHA vs live source, plus unbuilt worktrees
agent-ctx refresh ./work # re-fetch sources, rebuild, show diff (no commit)
agent-ctx cache clear # drop the source cache
# The read side (see below):
agent-ctx mirror ./work # pull the org's backend into a local sqlite cache
agent-ctx recall ./work # print what the artifact cannot know (fail-soft)
agent-ctx audit ./work # check the decision log's integrity over that cache
agent-ctx build ./work --decisions-cache # embed the decisions for this one buildFlags default from the environment: AGENT_REGISTRY, AGENT_CACHE,
AGENT_STATE. The cache defaults to the OS cache directory, the mirror state
dir to the XDG state directory (never a hardcoded ~).
--decisions-cache and --state-dir are accepted by build and refresh;
recall takes --org, --scope, --limit and --state-dir, and derives the
org and the scope from the place's manifest when they are not given. audit
takes --org and --state-dir and derives the org the same way.
The read side: mirror, recall and audit
The compile guarantees are unchanged — mirror and recall sit outside them.
mirror is the ONLY command that speaks a backend API (Notion first, behind a
narrow adapter), driven by the org's own ## Pattern bindings in _schema.md;
it pulls the org's activity log, decisions and knowledge into one sqlite file
per org under the state dir, outside every repository. The token comes from
NOTION_TOKEN at call time and goes to HTTPS and nowhere else. recall reads
that cache and is fail-soft: any failure is a one-line HTML-comment
diagnostic and exit 0 — a missing cache must never block a session. The mirror
is a dated cache; the backend always wins. See the read-side section of
CONTRACTS.md.
audit reads that same cache and does the opposite of fail-soft. It runs the
five decision-log integrity checks specified by the decisions-consistency-check
skill — supersession pointing back in time, an Active record replaced by an
older one, one live answer per topic, chains that resolve, and projections that
keep their source — and prints the reach it actually got to before any finding,
naming every check it could not make in full. It classifies and never repairs:
each finding carries the records, the remedy and who may perform it. Because an
audit that cannot read its input and exits 0 has reported a clean log it never
opened, a missing or unreadable mirror is exit 2 with a diagnostic, never a
report; findings are exit 1 and a sound log exit 0.
A sync pulls only what changed since the last one (with a margin, because the
backend reports edit times to the minute), so refreshing an unchanged backend
costs one empty page per database however large the history has grown.
Nothing is aged out — the whole history is kept. The one thing an incremental
pull cannot see is a DELETION, because the backend returns no tombstone for a
trashed row, so a full unfiltered pass runs on the first sync, on
agent-ctx mirror --full, and automatically once a week.
Memory splits by volatility
Decisions used to reach a session one way only: the recall hook printed all of
them, at every start, and every session paid for the lot again. They are the
stable half of an org's memory — a decision recorded in June is the same text in
August — and stable content billed per session is content in the wrong place.
So the artifact carries them. With decisions_cache: true in the manifest,
agent-ctx build appends the org's in-scope Active decisions as a synthetic org
context doc, dated by the mirror's own snapshot rather than by "now". It is a
property of the place, not an argument of the command, on purpose: a block
that appears or disappears depending on who typed what makes two people building
the same source produce different bytes, and --check stops meaning anything.
The --decisions-cache flag still exists on build and refresh and ORs with
the field — it is for trying the block out before a manifest commits to it.
recall then prints only the delta. It looks in the place's artifact for the
decisions-cache: marker and shows the decisions dated on or after that
snapshot, plus recent activity. The date comparison is by day and deliberately
generous: a decision recorded on the day of the build is reprinted rather than
risked, because printing one row twice is cheap and dropping one that landed
after the build is not. A place whose artifact carries no cache gets every
Active decision from recall, exactly as before — the split degrades to the old
behaviour, never to silence. Measured on this repository, the recall block went
from 5797 to 2059 bytes, with 28 of the 30 in-scope decisions moving into the
artifact where a rebuild pays for them once.
Both halves are scoped the same way, from the manifest: the solution named in
extends is the place's decision scope, so a rule governing a sibling tool stops
arriving. A scope matches its own decisions plus general and unscoped
ones: narrowing a view never hides what applies everywhere. recall --scope
and --org override the derivation for a one-off look at somebody else's slice.
The price of the cache is that the mirror db becomes an input to the build. A
rebuild after each agent-ctx mirror writes a fresh as-of line even when no
decision changed, and a machine with no mirror for the org builds the artifact
without the block at all — a capability warning, an otherwise normal build, and
a --check that reports the committed artifact as stale. Rebuild a place with
decisions_cache: true where its mirror lives.
A decision written down leaves both halves
EncodedIn is an optional field on the decision-log role: it names the layer
file where that decision's rule is now stated as prose. When it is set, the
decision drops out of the artifact's cache and out of recall — the agent
already reads the rule in the layer, and saying it twice costs tokens and adds
nothing.
That turns what is left into a backlog rather than a bill: the block's remaining length is "decided, but not yet written down". The way to shorten it is to write the rule into a layer doc and record where, not to trim the output.
EncodedIn is not Artifacts/Commits. Those say the rule is
implemented — some code obeys it. EncodedIn says the rule is legible —
an agent reading the compiled context will find it stated. A decision can be
either, both, or neither, and only the second one makes repeating it redundant.
The org's ## Pattern bindings may alias the field like any other; in the mirror
it is the decisions.encoded_in column. A mirror written by an older engine
gains that column by an additive ALTER the next time agent-ctx mirror opens
it — the mirror is a disposable cache, so that is the whole migration. Until then
recall over the old db fails soft with its usual diagnostic, and a build asking
for the decisions cache fails on it; one agent-ctx mirror run settles both.
Skills may carry assets
A skill is not always one file. A vendored third-party skill points its body at
scripts and reference documents by relative path, and those paths only resolve
if they sit next to the rendered skill. Put them in skills/<name>/files/:
skills/docx/
skill.yaml
body.md
files/
scripts/validate.py
reference.mdThey are copied verbatim into .claude/skills/<name>/, read recursively and
sorted for determinism, and pruned per file when the source drops them. Assets
are TEXT: one that is not valid UTF-8 aborts the build rather than being written
back mangled. Gemini has nowhere to put them — a command is a single prompt — so
it emits a capability warning instead of dropping them silently (invariant 5).
Delivering somebody else's skill into an organization — vendoring it at a pinned
upstream commit, stamping provenance, updating it later without clobbering local
edits — is not this tool's job. skill.yaml is passthrough, so provenance
markers written by a delivering skill ride along inertly.
What is compiled
Only these, and only from a layer's root (invariant 1 — subfolders like
research/ never enter context):
| Source | Model | Claude artifact | Gemini artifact |
| -------------------------------------- | ---------- | ----------------------------------- | ------------------------------ |
| root *.md (minus generated names) | contextDoc | CLAUDE.md (layered) | GEMINI.md (layered) |
| skills/<n>/skill.yaml + body.md | Skill | .claude/skills/<n>/SKILL.md | .gemini/commands/<n>.toml |
| skills/<n>/files/** | SkillFile | .claude/skills/<n>/** | — (capability warning) |
| subagents/*.yaml | Subagent | .claude/agents/<n>.md | .gemini/settings.json agents |
| mcp/*.yaml | McpServer | .mcp.json | .gemini/settings.json mcp |
| hooks/*.yaml | Hook | .claude/settings.json | — (capability warning) |
Gemini has no lifecycle hooks, no disallowed-tools, and nowhere to put skill assets; those become capability warnings, never silent drops (invariant 5).
A context artifact over 600 lines of composed prose opens with a generated
## Contents index of every # and ## heading, each top-level entry naming
the source file and layer it came from. There is no manifest key: length is a
property of the document, not a preference, and an index regenerated with the
document is the one kind that cannot go stale. CONTRACTS.md states it in full.
Determinism & committed artifacts
Identical source → byte-for-byte identical artifact: no timestamps, sorted keys,
and a source-hash (sha256, 12 chars) plus the resolved source SHAs in the
header. A repeat build is a no-op; --check is the CI "build is clean" gate.
The header also names the engine that wrote it, read from the package's own
package.json so that a version nobody remembered to edit cannot make it lie.
A place with decisions_cache: true has one input more than its layers — its
mirror db; see memory splits by volatility.
The artifact contains repo + solution + org and nothing personal, so it is identical for everyone and never ping-pongs between people.
Commit the artifacts next to their source and wire the harness in
templates/:
gitattributes.sample→.gitattributesmarks artifactslinguist-generated(copy it in by hand; there is noinitcommand, and CONTRACTS says why).pre-commit.sample— soft guarantee: rebuild +git addin the same commit.ci-snippet.yaml— hard guarantee:build --checkfails on stale artifacts.
The six invariants
- source/artifact separation — artifacts are never read back as source; the
loader skips generated names and reads only a layer's root
*.md. - fail-closed — an unreachable source aborts the build with a clear error, writing nothing.
- determinism — identical source → identical bytes, and identical TREES: pruning is per file and recursive, so a removed asset leaves nothing behind.
- multi-story — any dir with a manifest compiles; an org repo can be both a workplace and a source.
- capability warnings — anything an engine can't represent is surfaced, not dropped.
- auth delegation — the tool never stores, reads, parses, passes, or logs tokens; cloning is delegated to the system git and its credential helper.
Development
npm install
npm test # vitest — git is injected, no network
npm run typecheck
npm run build
npm run dev -- build ./work --registry ./registryLibrary API is exported from the package root (buildPlace, Resolver,
composeLayers, adapters, …) for embedding in other tooling. The read side is
exported too: activeDecisions, solutionNameOf, decisionsCacheDoc,
DECISIONS_CACHE_MARKER. One breaking change in 0.5.0 — the decision query is
an options object, activeDecisions(db, { scope, unencodedOnly }), where 0.4
took a bare scope string, because scope is no longer the only filter.
