Adopting VULCAN in a project
VULCAN replaces each project's own copy of the agent setup, the rules, the knowledge tooling and atlas with one pinned devDependency. You keep only your project's layer: its config, its own rules and its knowledge bundle. Follow the steps in order. Each one ends with a check.
bun add -d --exact @venizia/vulcan @venizia/dev-configs
bun x --no-install vulcan init
bun x --no-install vulcan sync
bun x --no-install vulcan doctorAlways write bun x --no-install vulcan. The unscoped npm name vulcan belongs to an unrelated package, and without the flag bun x downloads it when @venizia/vulcan is not installed yet.
1. Install and pin
Pin exactly, and commit bun.lock. Without a tag, bun add takes the latest release and --exact pins it:
bun add -d --exact @venizia/vulcan @venizia/dev-configs@venizia/vulcan pins @venizia/vulcan-okf and @venizia/vulcan-atlas to its own version. VULCAN depends on no IGNIS package, so it never pins your project to an IGNIS release.
Check: bun x --no-install vulcan version prints the version bun.lock pins (0.2.0 or newer).
2. Scaffold, then reconcile
vulcan init writes only what is missing and never overwrites. For a file you already have, it prints what to add instead.
| File | What it is |
|---|---|
AGENTS.md | the only tracked instruction file - routing only |
.configs/vulcan.config.ts | the one place the project configures VULCAN (--config mts|mjs|js for another format) |
.agents/rules.md | the project's own rules, bare IDs |
.agents/knowledge/index.md | the knowledge bundle's root (OKF v0.2) |
.mcp.json | the atlas server over this project (--root .); an existing file is kept, and one without an atlas entry gets the entry printed |
.configs/{oxlint,eslint,oxfmt}.config.* | lint configs building on @venizia/dev-configs; a project whose package.json declares workspaces gets none at the root, because each app keeps its own .configs/ |
Makefile | -include node_modules/@venizia/vulcan/base/make/vulcan.mk, and a fallback rule: without node_modules a shipped target (okf-check-strict, vulcan-doctor) stops with "run 'make install' (bun install) first" instead of "No rule to make target" |
Two files carry a marker that doctor fails until you act on it: .agents/knowledge/index.md holds a vulcan:scaffold line (describe the project, delete the line), and a workspaces project must list its packages in the config. Add CLAUDE.md and .claude/ to .gitignore: vulcan sync writes them per developer. Commit .agents/vulcan.manifest.json: sync writes it too, and other projects read it (step 4).
3. Configure .configs/vulcan.config.ts
The config is strict: an unknown key is an error, and every error names the file and the field.
import type { VulcanConfig } from '@venizia/vulcan';
export default {
agents: ['claude'],
rules: { 'V-W-01': 'on-user-word' }, // slot: 'on-user-word' | 'read-only'
packages: [ // in release order
{ dir: 'kernel', name: '@venizia/ignis-kernel' },
{ dir: 'core-server', name: '@venizia/ignis-core-server', aliases: ['core'] },
{ dir: 'worker', name: '@venizia/ignis-worker', retired: true },
],
knowledge: {
bundle: '.agents/knowledge',
wiki: 'docs/wiki/content',
changelogs: 'docs/wiki/content/changelogs',
syncAreas: '.agents/knowledge/process/knowledge-sync-areas.md',
},
atlas: {
tagPattern: '<dir>-v<version>',
sources: [{ name: 'ignis', repo: 'VENIZIA-AI/ignis', ref: 'develop' }],
},
digest: { oftenDropped: ['V-B-03', 'V-P-06', 'W-00'] },
claude: { settings: { permissions: { allow: ['Bash(make test)'] } } },
hooks: [{ event: 'Stop', command: 'bun .agents/hooks/notify.ts' }],
} satisfies VulcanConfig;| Key | Meaning |
|---|---|
name | the project name in the manifest and in every atlas result (lowercase, digits, -); default: the root package name, unscoped |
agents | the coding agents sync writes adapters for |
rules | the slot choices, by base ID |
packages | every package, in release order: dir (a unique key, not necessarily the directory name: two packages in directories both called core are umbras-core and kits-core, told apart by path), npm name, path (default packages/<dir>), aliases (old directory names, tag prefixes or npm names), retired. A repeated dir, name or live path is a config error |
knowledge.bundle | the knowledge bundle (default .agents/knowledge) |
knowledge.wiki, knowledge.changelogs | what atlas indexes besides the bundle, and where the docs-update skill writes |
knowledge.syncAreas | the knowledge-sync skill's map of code areas to concepts |
atlas.tagPattern | how a release tag is spelled: <dir>, <version>, <date>; without <dir> the whole repository is one release line. <dir> may hold slashes: <dir>/<version> reads apps/core/0.0.1-0 as apps/core, which resolves through an alias of the package |
atlas.releaseCommit, atlas.legacyTagPrefixes, atlas.ignoreTags | older release commits and tags atlas still reads, or skips (globs) |
atlas.sources | other projects atlas serves next to this one (atlas guide) |
okf | renderers, regions, sections, coverage, denylist, contents, viz (below) |
digest | the rule IDs the session digest repeats as the rules sessions drop most |
claude.settings | team-shared Claude settings, merged over each developer's personal keys |
hooks | the project's own hooks; sync registers them |
remoteHosts | the remote-host guard: hosts a Bash command may reach, and hosts it may only read (below) |
Check: bun x --no-install vulcan doctor reports no [config] violation.
remoteHosts: the remote-host guard
Setting remoteHosts makes vulcan sync register a PreToolUse hook that asks before a Bash command reaches a host you have not listed: ssh, scp, rsync, sftp, mosh, nc, telnet, kubectl/helm/k9s (by context or server), docker -H, /dev/tcp/<host>, ssh:// URLs, curl/wget/http to a readOnly host, and the same behind sudo, env, xargs, bash -c, eval, find -exec, a for/while/if body and the remote command of an ssh. A project without the key has no guard.
remoteHosts: {
trusted: ['lab1', '*.lab.example.com', '10.0.0.5'], // any command may reach these
readOnly: ['prod1', 'prod2.example.com'], // only the strict read-only forms pass
localFile: '.agents/hooks/trusted-hosts.local', // default; one personal host per line, gitignore it
},Matching is by the whole host name. An entry is an exact name or a pattern whose only wildcard is
*, and*stays inside host characters. A name is never searched for inside the command, solab1does not admitlab1.evil.com,evil-lab1,ssh lab1@evil.comorscp f evil.com:/tmp/lab1. An entry may be a$VAR, which matches the same unexpanded token. An entry with a user, a path or a port is a config error, and so is a bare*.A
readOnlyentry is a whole name too.prod1does not coverprod1.corp.example; list that asprod1.*(or*.corp.examplefor a domain):tsreadOnly: ['prod1', 'prod1.*', '*.prod.corp.example'],A jump host counts.
-J,-o ProxyJumpand-o Hostnamename hosts the command reaches, andsshoptions after the destination are read, as OpenSSH reads them.What cannot be read asks. A
$(...)or backtick inside the command, a-o ProxyCommand,-For-S,rsync -ewith anything but plainssh,kubectlwith no--contextor with--kubeconfig,docker -H,GIT_SSH_COMMANDandDOCKER_HOST, and clients whose target the guard does not parse (ansible,socat,pdsh, ...). A command piped or fed into a shell (| sh,| bash,bash <<<,sh -s) cannot be read either, so it asks. So does a host held in a variable (for h in ...; do ssh $h), unless the variable is listed as$VAR.The remote command of an
sshis read.ssh lab1 "ssh prod1 id"reachesprod1throughlab1, and asks unlessprod1is trusted. A trusted host runningkubectlordockeron its own default context is its own business and passes.Docker over ssh is parsed.
docker -H ssh://lab1 pspasses whenlab1is trusted;--contextanddocker contextask, because the context resolves elsewhere.HTTP clients only matter for a listed host.
curl,wget,http,httpsandxhask when their URL names areadOnlyhost (an admin endpoint behind a read-only host is not a read). A URL on the open internet passes, so the guard stays quiet in ordinary work.git config core.sshCommandasks on purpose. It rewrites how git reaches a host, and under theread-onlyV-W-01 slot the agent does not write git configuration anyway.The strict read-only forms for a
readOnlyhost aressh <host> <cmd>with no ssh options and acmdofuptime,hostname,df -h,free -m,docker ps,ls/cat/tail -n Nof an absolute path,systemctl status,journalctl -u,docker logs --tail N;scp/rsyncfrom the host to a local path;kubectl --context <host> get|describe|logs|top. A redirect or a second remote host makes it an ask.Reads through kubectl are allowed on a
readOnlycontext.kubectl --context prod1 get podspasses, and so dodescribe,logsandtop: that is what read-only means. The prompt for any other kubectl verb names these four. Aget secret -o yamlis a read too; keep a secret out of reach with the cluster's own RBAC, not with this guard.The policy travels in the manifest.
syncrendersremoteHostsinto.agents/vulcan.manifest.jsonand the hook reads it there pluslocalFile. The hook fails closed: a missingnode_modules,bunor manifest key blocks the call (exit 2) and says what to run.
Check: vulcan sync adds the hook to .claude/settings.json; ssh unlisted-host asks.
okf: renderers, regions, coverage
okf runs exactly what okf lists. With renderers and regions empty, okf gen writes nothing and says so. Each built-in renderer ends its page with a "see also" link to a concept of the usual name (makefile-targets to process/build-system.md, source-map to overview/monorepo-layout.md, binding-keys to conventions/binding-key-namespaces.md). The link appears only once that concept exists, so a new project passes okf check --strict. Set the seeAlso option to write your own line.
okf: {
renderers: [
'source-map',
'makefile-targets',
{ id: 'binding-keys', options: { source: 'packages/kernel/src/common/keys.ts' } },
{ id: 'providers', module: '.agents/okf/providers.ts' },
],
regions: [{ id: 'packages-table', file: 'overview/monorepo-layout.md', renderer: 'packages-table' }],
coverage: [{ name: 'packages', path: 'packages/*' }],
},A coverage axis globs directories. Each directory needs a concept at <name>/<directory>.md, or any concept whose frontmatter resource is that directory (resource: auroras/apps/shop), wherever the concept lives. An axis that matches nothing fails. To leave directories out of one axis, give it exclude (globs from the root, such as auroras/apps/mobile-*) or denylist (directory names); the top-level okf.denylist still applies to every axis. An axis left with no directory fails too. A renderer's render returns one file or an array of files, so one renderer can own a page per entity. A project module default-exports a renderer and imports the API from @venizia/vulcan/okf:
import { defineRenderer } from '@venizia/vulcan/okf';
export default defineRenderer({
id: 'providers',
collect: ({ context }) =>
context.source.exportedClasses({ dirs: ['packages/admin/src/providers'], suffix: 'Provider' }),
render: ({ context, facts }) => ({
path: 'reference/providers.md',
content:
context.page.header({ title: 'Providers', description: 'Every provider class.', resource: 'packages', tags: ['reference'] }) +
facts.map(entry => entry.classes.join(', ')).join('\n') + '\n',
}),
});okf gen also writes a contents list near the top of every agent-read file over 100 lines. okf.contents.files names those files outside the bundle: literal paths or globs from the root (.agents/skills/*/SKILL.md). A file with generated: in its frontmatter belongs to its generator and gets none. The bundle's log.md gets none either.
4. Meet the project contract
atlas reads a project only through what the project commits. Four things make it readable:
The manifest.
vulcan syncrenders.agents/vulcan.manifest.jsonfrom the config. Commit it.doctorfails a stale one.Package identity is the npm name. Every table, tool answer and changelog names
@venizia/ignis-kernel, neverkernel. A directory or an alias still works as input.Changelogs declare their packages. Each entry's frontmatter lists npm names:
yaml--- title: Faster boot packages: ['@venizia/ignis-kernel'] ---bun x --no-install vulcan x atlas changelogs migratewrites the list into every entry that has none. Run it once with--dry-run, then for real, then review the diff. An entry that belongs to a release its date does not show addsrelease: '@venizia/ignis-kernel@0.2.0-13'. An entry about no package, such as repository tooling, declarespackages: [].Tags. atlas computes every release from the git tags
atlas.tagPatterndescribes.doctorchecks each tag it reads.
Delete what atlas no longer reads: a committed reference/releases.json and reference/symbols.json. Releases come from the tags, symbols from the built .d.ts.
A project that commits reference/public-surface.md runs vulcan x atlas surface gen once before the first okf gen: the regenerated page declares generated:, so okf gen leaves it whole.
Check: bun x --no-install vulcan x okf check --strict passes (it checks the changelog frontmatter), and doctor reports no [manifest] or [tags] violation.
5. The Makefile
Keep the -include line from step 2. Remove every project target with a shipped name from the Makefile and every file it includes:
| Shipped targets |
|---|
vulcan-sync, vulcan-doctor, agent-setup |
okf-check, okf-check-strict, okf-gen, okf-coverage, okf-viz |
surface-gen, surface-check |
atlas-symbols, atlas-releases, changelogs-migrate, knowledge-symbols-check |
The old symbols-gen, symbols-check, releases-gen and releases-check are gone: a CI job that still calls them fails once, loudly. To route a shipped target somewhere else, override the command, not the target:
VULCAN := bun scripts/vulcan-bridge.ts # before the -include; vulcan.mk uses $(VULCAN)Check: doctor reports no [make] violation, and make prints no "overriding recipe" warning.
6. .mcp.json
init writes this file when it is absent. Add the entry by hand to one you already have:
{ "mcpServers": { "atlas": { "command": "bun", "args": ["x", "--no-install", "vulcan", "x", "atlas", "mcp", "--root", "."] } } }The file works as soon as the project has a knowledge bundle; the wiki and changelogs are indexed when the config names them. --root pins the project to the directory the client launches atlas from, so an agent started in a subdirectory still reads this project. Without it atlas serves the working directory.
Check: bun x --no-install vulcan x atlas tools prints the five atlas tools - search, get, symbol, version, changes - without an MCP client. The atlas guide covers using them and reading other projects.
7. Sync and verify
bun x --no-install vulcan sync
bun x --no-install vulcan doctor
bun x --no-install vulcan x okf check --strictsync writes CLAUDE.md (@AGENTS.md), .claude/settings.json, the skill links and the manifest. It records what it applied, so a choice you remove later is removed. After an upgrade it prints each base rule that was added, changed or removed, with its new text.
Check: doctor prints vulcan doctor: OK.
CI on a fresh clone
A clean checkout has neither node_modules nor the gitignored adapters, and two things follow:
- Run
installas its own step, never as a prerequisite of the CI target. make reads the Makefile, including the-includeofvulcan.mk, before any recipe runs, soci: install okf-check-strictfails withNo rule to make target 'okf-check-strict': the file did not exist yet when make parsed it. - Run
vulcan syncbeforevulcan doctor.CLAUDE.md,.claude/and the skill links are gitignored, sodoctoron a clean checkout fails with one line,[adapters] ... not synced yet - run 'bun x --no-install vulcan sync' (make agent-setup) first, untilsyncwrote them.
- run: make install
- run: make ci # ... okf-check-strict vulcan-sync vulcan-doctor, in that order8. Move your rules onto the base
The base rules ship with VULCAN, IDs V-* (V-W-01, V-C-04). Your own rules keep bare IDs.
- Define your rules in
.agents/rules.mdwith bare IDs, plain or bold:| W-06 |or| **W-06** |. - A
V-ID in your rules points at a base rule:Extends V-C-04in a rule's text, or a row whose first cell lists base IDs (| V-W-02, V-W-03 | ... |) to say more about them. doctorfails aV-ID the base does not carry, and a retired one:V-W-05,V-C-03,V-C-05(see the base rules changelog).- Choose the
V-W-01slot in the config.
To show the session digest a picture of the project, put it between markers in .agents/rules.md:
<!-- minimap-shape:start -->
apps/ - one app per directory; libs/ - shared code, no app imports.
<!-- minimap-shape:end -->Check: doctor reports no [rule-ids] or [rule-slots] violation.
9. Move hooks and skills
- Shipped skills (
knowledge-sync,docs-writing,docs-update) reach every project throughsync, linked throughnode_modules/@venizia/vulcan. Delete your own copies with those names. - A wiki-update skill of your own overlaps
docs-update, which reads its paths from the manifest. Move what is project-specific - the docs build targets, the generator's limits - into.agents/rules.md, then delete the skill. - Your own skills stay in
.agents/skills/<name>/, one level deep.synclinks them next to the shipped ones. - Your own hooks go in the config's
hooks, never into.claude/settings.jsonby hand. - Shipped hooks fail closed. When
node_modulesor bun is missing, the write guard blocks every Bash call except a barebun installorbun i. Only these flags pass with it:--frozen-lockfile,--production,-p,--no-save,--ignore-scripts,--force,--verbose,--silent. The message names what is missing:node_modules/@venizia/vulcan(runbun install), or bun onPATH(Claude Code may have started without your shell'sPATH). - Old plugin hooks. Delete a hook file the shipped digest now covers (a session digest, a minimap). Then run
bun x --no-install vulcan sync --prune --dry-run, and without--dry-run. It removes old VULCAN and plugin-era hook commands and dead skill links, never your own hooks.
Check: doctor reports no [skills], [hooks] or [prune] violation.
10. Delete the old copies
Once doctor is OK and the gates pass, delete what VULCAN now provides: the old agent plugin, the old agent setup script, the local atlas package or scripts, and the local check-catalog.
Known gaps
- The shipped skills have not been evaluated against a model.
knowledge-sync,docs-writinganddocs-updatepass the skill lint, andmake skill-evalscan grade them, but no real-model run has been made yet. Treat what they produce as a draft to review.