Appearance
Install, map your first component, and write a rule the reviewer enforces from then on. You can do the whole thing without an API key. About five minutes.
New here?
Read How it works first for the mental model. This page is the hands-on version.
1) Install
Requires Node.js 22+.
bash
npm install -g @chrisdudek/yg2) Init
bash
cd your-project
yg initRun in a terminal, this scaffolds .yggdrasil/ — config, architecture defaults, and the agent-rules files (the AGENTS.md digest block, the @AGENTS.md import line in CLAUDE.md, and .clinerules/yggdrasil.md), identical for every agent — and walks you through one topic: which reviewer should judge your code (it asks for a provider, then a model, and — for an API provider — checks for a key). If you already run an agent CLI — Claude Code, Codex, Gemini CLI, or GitHub Copilot CLI — pick it: it needs no API key and adds no separate API bill: init checks that the tool runs, and for Codex and Gemini CLI asks it for one tiny probe verdict on your plan. Ollama runs locally with no API cost either. The API providers (Anthropic, OpenAI, Google) need a key. Export it first (ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY) and init uses it when you press Enter at the key prompt, without copying it to disk; the reviewer reads the same variable at run time. Without the variable, init asks for the key in a masked prompt (the input is never shown) and stores it in .yggdrasil/yg-secrets.yaml (automatically gitignored). No command takes the key as a flag, so it never lands in shell history. A key stored there outranks the environment variable, so when init later points the reviewer at another provider or endpoint it removes that key and says so; a key is never carried from one reviewer to another.
Each of the three agent-rules files is optional. Skip any of them with --no-agents-md, --no-claude-md, or --no-clinerules — the choice is recorded under rules_artifacts in .yggdrasil/yg-config.yaml, so a later yg init --upgrade keeps honoring it and prints which files it is leaving out. (--no-agents-md also switches off the CLAUDE.md import, which is an import of that file.)
Start keyless — no reviewer, no API key
Picking a reviewer is not required to get going. The last option in that list is "None for now", and it is a real answer, not a lesser one: script rules (check.mjs), dependency control (the built-in relation-conformance check), and the yg check CI gate all work immediately, for free, with nothing to configure. Nothing is judged by a reviewer yet, because nothing you've written needs one yet.
Same thing without answering any question — in a terminal or out of one:
bash
yg init --no-reviewerRun the plain yg init with no terminal attached — Docker, a devcontainer, CI — and it takes that route by itself, since there is nobody to ask.
Add a reviewer at any point, whenever you write your first reviewer rule — an aspect whose rule is a content.md the reviewer model reads and decides against, as opposed to a check.mjs script rule. This one is a flag, not a prompt — it runs identically whether or not you have a terminal:
bash
yg init --provider claude-codeUntil a reviewer is configured, a reviewer rule does not stop the free gate: yg check --approve --only-deterministic still fills every script rule, and the reviewer pairs stay unverified, each saying it has no reviewer. (Despite its name, the --approve flag runs a fill: it asks for the missing verdicts. It is not a human approval.) yg check names the missing reviewer as the first thing to fix — blocking when the rule is enforced, a warning when every reviewer rule is advisory (advisory rules alone do not make it block). Configuring a reviewer is your call, since it sends code to that provider; an agent proposes it rather than doing it unasked, or parks the rule at status: draft.
--model defaults to sonnet for the claude-code provider only; every other provider requires --model explicitly (copilot-cli takes one your Copilot plan allows, e.g. --model auto). For a CLI provider init checks that the CLI runs and warns if it does not; the configuration is written either way. --endpoint defaults to http://localhost:11434 for ollama; an OpenAI-compatible provider requires it (no default).
Prefer to be taught?
Tell your agent: "onboard me into Yggdrasil". Agents in an adopted repo know the tutor playbook (yg knowledge read onboarding) and will teach you on your own repository, in your own language. Repo not adopted yet? Tell the agent: "Install @chrisdudek/yg, then run yg knowledge read onboarding and follow it."
The architecture file (.yggdrasil/yg-architecture.yaml) ships with an empty architecture (node_types: {}) and commented examples — node types are defined per project, not pre-configured. You add the types your project needs: define new types, set default aspects per type, constrain relations. Until the first type exists, a node's type: is not checked, and yg check warns (type-undefined-pending) for every node whose type is not declared yet. Declare those types when you add the first one: from then on an undeclared type is a blocking type-undefined error. Tell the agent to do it:
"Add a node type 'api' with a default aspect 'requires-auth'."
3) Your first aspect
After init, you have an empty graph, and yg init starts you in "require nothing" mode (coverage.required: [] in yg-config.yaml). So your first yg check is green — every file shows up as a non-blocking warning, not a blocking error:
text
$ yg check
yg check: PASS 1 warning 0 nodes · 0/50 files covered · 4 excluded
warning[uncovered] 50 files belong to no node — not under coverage.required, so they never block
at: package.json
src/f1.ts
src/f10.ts
src/f11.ts
src/f12.ts
src/f13.ts
src/f14.ts
src/f15.ts
src/f16.ts
src/f17.ts
src/f18.ts
src/f19.ts
… +38 more (yg check --details)
why: Not under a coverage.required root — shown, but it never blocks. Your architecture has no type for this file yet.
fix: Map these files to a node, or add their root to coverage.required to make this an error. Or design an architecture type that covers files like it: yg type-suggest --file <path>.
note: Type-level coverage is on, but no type in yg-architecture.yaml declares 'when:' — no file can be type-covered until you add classifying types.
next: yg type-suggest --file src/f1.tsyg init turns coverage.type_level on by default (see Configuration), and the fresh architecture starts with no classifying types — hence the note: line. Add a when: predicate to a type and matching files start satisfying coverage on their own, with no node required. The block heading states require-nothing mode's consequence: warning[uncovered] files are not under coverage.required, so they never block.
Every finding reads the same way: a heading error[<label>] <subject> or warning[<label>] <subject>, then at: (where), why: (once per block) and fix:. Standing facts follow as note: lines, and the report ends with next: — the one step to take first — and sometimes then:, the step after it. See the report grammar.
Nothing is enforced yet — the warnings are your to-do list. Tell your agent to create the first rule.
Example prompt:
"Every service that handles payments must emit audit events. Create an aspect for this and apply it to the payments module."
The agent will create:
text
.yggdrasil/
aspects/
requires-audit/
yg-aspect.yaml ← name + description
content.md ← the actual rule (plain Markdown)
model/
payments/
yg-node.yaml ← maps src/payments/, lists requires-audit aspect(Once the architecture declares any type, a node's type must exist there and declare when: before the node can carry a mapping at all — independent of type-level coverage — so the agent also gives the payments module's type a when: path: "src/payments/**". That is why the zero-classifying-types notice from the first check above does not reappear below: the architecture now has one classifying type, even though the payments file itself is node-owned, not type-covered.)
Now run yg check:
text
$ yg check
yg check: FAIL 1 error 1 node · 1/1 file covered · 4 excluded
error[unverified] 1 pair with no verdict yet
at: requires-audit @ payments
why: The lock holds no entry for this pair: it is new (a new rule, component or mapped file), or the fill that would have judged it did not complete.
fix: yg check --approve (1 reviewer pair · 1 call · paid)Check detected that the requires-audit rule on src/payments/ has no recorded verdict. The block's fix: is the one step, so the report prints no separate next: line. (Once a verdict exists and the code changes, the same pair shows up as error[unverified] 1 pair whose inputs changed since the verdict; a script rule with no result in this checkout's local cache — a fresh clone — shows up as error[unverified] N pairs whose script check has not run on this checkout — free to run, fixed by the free yg check --approve --only-deterministic.) The agent runs yg check --approve and the reviewer reads the source code, checks it against the rules in content.md. The fill reports on stderr, in lines that start with fill, and the report is written to stdout — a clean run prints the PASS verdict line:
text
$ yg check --approve
fill 1 pair · 0 script (free) · 1 reviewer call (consensus included)
fill done in 6s — 1 passed · 0 refused · 0 failed · 1 reviewer call
yg check: PASS 1 node · 1/1 file covered · 4 excluded · 1 pair verified (reviewer)If the code didn't satisfy the aspect, the pair is refused and the report shows the refusal block with the reviewer's reason under at::
text
yg check: FAIL 1 error 1 node · 1/1 file covered · 4 excluded
error[refused] requires-audit — refused on payments
at: payments chargeCard() does not emit an audit event; no auditLog.emit() call in any mutation path.
why: Every mutation emits an audit event
fix: Four exits — the verdict is recorded for this exact code, so re-running the reviewer changes nothing:
1. Fix the code so it satisfies aspect 'requires-audit', then: yg check --approve
2. Sharpen the aspect's content.md if the rule is wrong or unclear — this re-reviews EVERY node using the aspect; check `yg impact --aspect requires-audit` first.
3. Propose a `yg-suppress` to the user for a deliberate exception — ask the user to approve the reason.
4. Not sure yet which it is: propose `status: advisory` on the aspect to the user — the refusal stays recorded but stops blocking while you decide.
next: change the code of payments so it satisfies requires-auditThe agent fixes the code and re-runs yg check --approve until all aspects pass.
Start new aspects at status: advisory
A brand-new aspect on an existing codebase often surfaces violations across many files. Authoring it as status: advisory runs the reviewer and lists refusals as warnings, without blocking CI — and with no reviewer configured yet, its pairs and the missing-reviewer notice are warnings too. Once the rule has been exercised across the repo and the warnings are clean (or knowingly accepted), promote to status: enforced. See Aspect Status for the full lifecycle.
4) Existing codebase (brownfield)
yg init writes coverage.required: [] — "require nothing" — so a fresh repo of any size is green from the first check, with every unmapped file shown as a non-blocking warning. You tighten coverage as you go: add a path prefix to coverage.required in yg-config.yaml (e.g. - src/payments/) and files under it become blocking errors until they belong to a node, or — with coverage.type_level on — until they match exactly one classifying type; files outside required (and not excluded) stay non-blocking warnings; files under coverage.excluded are silent. Subtrees with their own nested .yggdrasil/ are auto-skipped. See Configuration for details.
The handful of files yg init itself writes at your repo root — AGENTS.md, CLAUDE.md, .clinerules/yggdrasil.md, .gitattributes — are repository plumbing, not project source, so a fresh yg init already lists them under coverage.excluded. Your first uncovered list is your own code only.
Whole-repo default
An absent coverage block — or a repo initialized before this became the default — requires the whole repo (every file is a blocking error until mapped). Add an explicit coverage: { required: [], excluded: [] } to opt into require-nothing.
That default also applies to the handful of files Yggdrasil itself maintains at your repo root — AGENTS.md, CLAUDE.md, .clinerules/, .gitattributes — so on such a project (one initialized before yg init began excluding them) they show up as unmapped errors right after yg init --upgrade adds them. They're repository plumbing, not project source; exclude them. yg init --upgrade prints the exact stanza for your project, built from whichever of these files it actually wrote on that run — on a repo that installs all of them it looks like this:
yaml
coverage:
excluded:
- AGENTS.md
- CLAUDE.md
- .clinerules/yggdrasil.md
- .gitattributesOnly .gitattributes is unconditional. The three agent-rules entries drop out of the printed stanza when you have switched them off under rules_artifacts in .yggdrasil/yg-config.yaml (via --no-agents-md, --no-claude-md, or --no-clinerules), because a file that was never written cannot be unmapped.
The fast path: minimal nodes (no aspects) for everything you're not working on, proper nodes with aspects for what you are.
Tell your agent:
"Create nodes without aspects for: src/legacy/, lib/, scripts/. Then create a proper node for src/payments/ with the requires-audit aspect."
Nodes without aspects are cheap in reviewer terms — just a yg-node.yaml with a directory mapping. They produce no rule pairs, so there is nothing for the reviewer to judge and nothing to record, and they count as covered.
They are not free of checks, though. Every mapped node takes part in the built-in dependency check, which runs live on every yg check, keyless and uncached: when a file in one node imports code another node owns, the importing node must declare that relation in its yg-node.yaml. An undeclared import is a blocking relation-undeclared-dependency error. It is not a rule you can set to advisory or suppress. An import between files that no node maps is not checked this way, so the moment you map a directory that imports from another mapped directory, the check turns red and names each import, like this:
text
error[relation-undeclared-dependency] Node 'users' has undeclared dependencies on other nodes
at: users
src/users/index.js:1 → payments
why: A dependency on another component must be a sanctioned, declared relation. Undeclared edges erode the architecture allow-list of who may depend on whom.
fix: Declare the missing relation in .yggdrasil/model/users/yg-node.yaml (or remove the dependency if it is not legitimate):
payments: allowed relation types [uses, calls, extends, implements, emits, listens]. Add - { target: payments, type: uses } under relations: in .yggdrasil/model/users/yg-node.yaml.Add the relation it names under relations: in the importing node (- { target: payments, type: uses }). Declaring a relation re-opens no verdict except where a rule looked at the node through the graph: a script rule that read it with ctx.graph (node(), relationsFrom(), relationsTo()), or a reviewer rule whose companion.mjs did, folded the node's yg-node.yaml into its verdict, so editing that file re-opens those pairs — a script pair refills free, a reviewer pair is re-reviewed and paid. Every other verdict stays. Two things can make that harder:
- If your architecture restricts which types may relate, the message says that no relation type is allowed between the two node types. The fix is then to change a node's type or to widen the allowed relations in
yg-architecture.yaml. That is an architecture change, which your agent must confirm with you first. - Declared structural relations (
calls,uses,extends,implements) must not form a cycle. Two directories that import each other need a different split, not two relations. (Event relations,emitsandlistens, go both ways by design, but they never sanction an import.)
So budget for declaring relations as part of mapping: the coarser your nodes, the fewer edges there are to declare. Relations, flows & ports covers the check in full. Progressive mode (see below) does not skip this step, because a node you map on your branch is part of your change. It helps later: once those nodes are merged, findings in code a change does not touch stop blocking it.
When you start working on a covered area, add aspects to enforce rules. This is how coverage naturally expands into enforcement as you work.
A node without aspects still needs a type, and once yg-architecture.yaml declares any node type, that type must declare a when: that every file the node maps satisfies. A type with no when: is organizational: a node of that type with a non-empty mapping is a blocking type-without-when-with-mapping error, and a mapped file outside its type's when: is a blocking type-when-mismatch. So give the broad nodes a broad type — for example module with when: { path: "src/**" } — before you map them. While yg-architecture.yaml declares no node types at all, types are not checked yet: each node gets a type-undefined-pending warning, and the first type anyone declares turns every node whose type is still missing into a blocking type-undefined error. Adding a type is an architecture change, so your agent asks you first.
Practical steps for a 200-file repo:
- Create 5-8 nodes without aspects for broad directory mappings, of a type whose
when:covers every file they map - Run
yg checkand declare the relations it asks for between those nodes (relation-undeclared-dependency), or merge nodes whose code is too entangled to separate yet - Create 1-2 nodes with aspects for your active work area
- Run
yg check --approve(aspect-less nodes produce no rule pairs, so the only reviewer cost is the pairs on your active work area) yg checkpasses — CI is green- Add aspects to more nodes as you touch more code
For a large codebase with debt you cannot clear in one go, name a reference branch and turn on progressive mode: after that, a change blocks only on what it touched, and what it inherited stays visible as warnings.
5) CI integration
Add yg check --no-approve to your CI pipeline. It recomputes the input hash of every expected pair and compares it against the verdict recorded in the lock — no reviewer calls, no provider keys, runs instantly. Exit code 1 means a pair changed without being re-checked. The --no-approve flag pins the gate read-only even when someone commits auto_approve to yg-config.yaml, which would otherwise turn a bare yg check into a fill (see Configuration).
The lock's script verdicts live in a gitignored local cache (.yg-lock.deterministic.json), so a fresh CI checkout starts without them and yg check would report those pairs as unverified. Rebuild the cache first — it's free and needs no key — with yg check --approve --only-deterministic, which fills only the script pairs and writes the gitignored cache. See The lock for the file layout. That step reports on the tree after it and exits 1 on any blocking finding (an unverified reviewer pair, an unmapped file, a refusal), so it already fails the job before the second step runs; the second is the read-only gate. To fail the job in one place, mark the first step continue-on-error: true.
GitHub Actions:
yaml
- name: Rebuild the script-rule cache (free, no keys)
run: npx @chrisdudek/yg@6.1.0 check --approve --only-deterministic
- name: Check architecture
run: npx @chrisdudek/yg@6.1.0 check --no-approvePin the CLI version as above, and raise the pin in a commit of its own. A bare npx @chrisdudek/yg runs whatever release is newest on the day the job runs, so a new release, a major one included, would change your gate without a commit; and when CI runs one version while developers run another, the two can hash a pair differently and keep re-opening each other's verdicts in the committed lock.
On a large repository, restore the local cache before those steps so the rebuild fills only what changed since the cached run:
yaml
- name: Restore the Yggdrasil cache
uses: actions/cache@v4
with:
path: |
.yggdrasil/.yg-lock.deterministic.json
.yggdrasil/.ast-cache
key: yg-${{ runner.os }}-${{ github.sha }}
restore-keys: yg-${{ runner.os }}-This is safe to restore because every cached verdict is keyed by a hash of the inputs it judged: an entry whose inputs changed reads as unverified and is filled again, so a stale cache costs a refill, never a false green. A cache is as trusted as the run that wrote it, so restore only caches your own branches produced — GitHub Actions already keeps a fork's pull request from writing a cache the base branch reads.
If check fails, it means a pair's inputs changed without being re-checked. Tell the agent: "resolve all yg check issues" and it will run yg check --approve, fix violations, and fill again until check passes.
6) Core vs. advanced — what to learn when
Yggdrasil has a lot of surface area, but you only need a few ideas to be productive. Learn the rest the day you actually need it.
Core — everything above this point. Six concepts carry day-to-day work:
- Node — maps a set of source files (a
yg-node.yamlwith amapping:). - Aspect — one enforceable rule (
content.mdfor a reviewer rule, orcheck.mjsfor a script rule). - Pair — one aspect applied to one node (or, for a
per: filerule, to one file). It is what gets verified, cached and paid for: one verdict per pair. When the report says "3 pairs", it means three such (rule, subject) verdicts; findings that are not about a pair are counted as issues. - Lock — where those verdicts are recorded, each bound to a hash of everything it judged: the committed
yg-lock.nondeterministic.json(reviewer verdicts) andyg-lock.logs.json(log baselines), plus the gitignored local cache of script-rule results. A verdict counts only while its hash still matches. The Lock has the mechanics. yg check— the gate. By default hash-only, no reviewer, no keys, runs in CI. Red until every changed pair has a fresh verdict. (Ifauto_approveis set inyg-config.yaml, bareyg checkmay fill pairs automatically — see Configuration. The CI recipe above uses--no-approve, which always wins over the config; and when theCIvariable is set, a bareyg checkignores a committedauto_approve: full.)yg check --approve— fills the unverified pairs (script rules for free, then reviewer rules) and records the verdicts in the lock so check goes green.
Plus aspect status (draft → advisory → enforced) to control whether a rule blocks. That is enough to enforce real rules on a real codebase.
Advanced — reach for these only when a rule needs to scale past one node. Aspect inheritance through the node hierarchy, architecture type-defaults, implies chains, flows, ports, and conditional when predicates all exist for one purpose: applying a rule to many nodes without copy-pasting it onto each one. You do not need any of them to start. When "every X must do Y" spans more than a handful of files, that is the signal to read How it works and adopt one of these mechanisms.
You never trace the cascade by hand. No matter how many of those mechanisms are in play, you do not work out which rules apply to a file by reading the graph yourself. Ask the tool:
bash
yg context --file src/payments/charge.tsIt prints every aspect effective on that file and the read: path to each rule's text. The graph computes the cascade; you read the answer. To see where each rule comes from — which of the channels it arrived through, seven in all (see Aspects) — use yg context --node <path>, which adds a Source: line to each aspect.
Zero lock-in
Delete .yggdrasil/ and your project works exactly as before. No build dependencies, no runtime hooks.
Want to understand the model?
- How it works — the model: rails, the three players, the loop
- Aspects — write your first rule
- Nodes — group files into components
- Configuration — reviewer setup, quality thresholds
- CLI reference — all commands