magus v0.4.3 is out. See what's new

Contributing to magus

Thanks for being here. magus is a one-person project, and anyone who takes the time to try it, file an issue, or send a patch is doing me a favor.

Issues and PRs are both welcome. Replies may be slow, which is about how much time I have rather than how interested I am. If you're planning something big, open an issue first so we can talk it through before you spend an evening on it.

Using AI tools

I use AI assistance to build magus, so it would be strange to tell you not to. Use whatever you like.

If you're new to the codebase, one suggestion rather than a rule: make your first couple of PRs without it, and keep them small. A docs fix, a test, a one-line change. Nothing controversial.

That's less about the tools than about the size of the change. What I'd rather not get is a big multi-file PR rewriting how something works, from someone who hasn't had a chance to poke around yet. These tools are about as good as the context of the person steering them, and when they're wrong they're usually wrong in a way that still reads fine, which is hard to spot if you're new to a codebase. Doing a couple of small things by hand is the quickest way past that.

After that, go for it. I'm looking at the change, not at how you made it: can you explain what it does and why, does it match the conventions below, does magus affected ci pass. You don't need to tell me either way.

And if any of this is unclear, or the setup fights you, please open an issue. I'd much rather answer a question than have someone give up on it quietly. Friction is the thing this project exists to remove, so hitting some on the way in is worth telling me about.

Build and test

git clone https://github.com/egladman/magus
cd magus
go build ./cmd/magus
go test -race ./...

Integration tests sit behind //go:build integration and are named TestIntegration*. go test ./... runs the fast unit tests; go test -tags=integration ./... runs everything.

Lint and the vuln check run as pinned binaries from mise.toml, so the linter's large dependency tree stays out of magus's module graph entirely:

magus run lint

They deliberately do not go through go tool. That compiles the tool from source with whichever go leads PATH, which is how CI once built golangci-lint with a different toolchain than the one it analyzed with, and panicked. mise install gets you both binaries at the pinned versions.

Adding to the public interface

magus promises that a magusfile which works today keeps working, and there is no plan for a 2.0. docs/concepts/compatibility is the promise itself; this is how to work inside it.

MCP is an adapter, not another magus API

The MCP tool inventory and boundary lists the currently exposed tools and their CLI fallbacks. Before adding one, identify the existing domain operation it adapts and explain why an agent cannot use an existing MCP tool for the same job. Put the declaration on std.Magus; internal/handler/mcp translates the request and result, and the registry is generated. Do not implement a second graph query, VCS search, build runner or permission model in a handler.

buzz is for JSON-to-JSON transforms of data the caller supplies. Its worker does not register the magus module. Workspace reads and actions belong to the Magus MCP tools; full-host Buzz scripting belongs to the CLI. A new host module in the CLI does not automatically become an MCP transform capability.

Add, never substitute. A new magus\project key goes beside the existing ones, a new protobuf field takes a new number, a new flag joins the current set. If you find yourself changing what an existing name means, that is the thing the promise forbids, and the answer is a second name rather than a redefinition.

Four compatibility gates catch a break before review does, one per public interface:

Interface Gate
CLI internal/commands/testdata/api.lock, a drift-gated snapshot of every subcommand, flag, config key, and operand
protobuf buf breaking, composed into lint
magusfile keys the required_version floor, plus doctor's check that the floor is accurate
Go SDK internal/commands/testdata/api-go.lock, a drift-gated snapshot of every exported declaration of the root package, types, libs/testkit and std

A diff to api.lock is the signal to read carefully: a line removed there is a removed part of the public interface. Regenerate it with magus run man-generate . after an intentional addition, and treat a deletion as a question rather than a regeneration.

api-go.lock reads the same way, one line per function, type, field, method, constant and variable with its full signature, so a changed line is a changed signature for every program that imports magus. Regenerate it with magus run go-api-generate .. The package list lives in publicGoPackages in cmd/magus-utils/public_api.go; add a package there when it becomes public.

A fifth public interface is std/ host-module method names. Method.Name in a std/ module is run through std.CamelCase and becomes the identifier every magusfile calls, so renaming it breaks them. std/gen/std-api.lock pins every member, one <module>.<member> line each, and TestStdAPILocked fails naming each added and each removed one. Regenerate it with magus run magusfile-api-generate . after an intentional change; the lock is written only by that target. The magus.* names have their own lock, internal/interp/bindings/testdata/magus-api.lock.

The lock catches a rename but does not soften it: MGS1025's removed-API table covers only magus.*, and it is a friendlier error rather than a working alias either way. A deliberate rename belongs under Breaking in the changelog with the old and new spellings, and every in-repo caller must move with it: magusfile.buzz, spells/, docs/**/*.buzz, hack/, testdata scripts, and the .txtar fixtures under cmd/magus/testdata/.

The same descriptor's Doc: string is not documentation either: it is codegen input, reaching the generated .d.ts, docs/reference/buzz/*.md, and editor hover text. A wrong Doc teaches every Buzz author the opposite of the contract, which is how three fs and vcs methods came to promise a false/zero/empty return while their bodies raised.

A new key means the workspace needs a newer magus. Additive changes are safe for existing users and unsafe for the repo's own magusfile, which CI runs against a pinned release. The rule is release-first: if magusfile.buzz starts needing a feature no release contains, cut the release before the magusfile depends on it. When you hit this locally the symptom is a load error from the pinned binary, which is the mechanism working rather than a problem to route around.

Raise required_version in magus.yaml in the same commit that adds the key, naming the release that will carry it. Doctor's floor check asserts the two agree, so a floor left behind is caught rather than remembered.

This used to say the opposite: raise it in the release commit, because a floor naming an unreleased version would reject a source build of the very commit that raised it. Between releases a build describes itself from the last tag, so a tree 42 commits past v0.3.0 reports v0.3.0-42-gabc and compares as 0.3.0. That hazard is gone. ward.CheckRequiredVersion exempts dev builds outright, and types.IsDevMagusVersion counts anything carrying a -g<sha> suffix, which is every build from source. The floor is enforced only against a clean release tag, which is exactly who it is for.

Two things follow from raising it early, and both are the mechanism working: audit.yaml's compat job pins the newest release and fails until the one you named ships, and a released binary meeting the workspace gets MGS1021 naming the version it needs. The alternative is worse: a floor that still admits the old release lets it load far enough to fail on whichever key it happens to hit first, which reads as a typo rather than a version problem.

Before 1.0, renaming is on the table; after, it is not. magus is pre-1.0, so a badly chosen name can still be fixed, and the changelog records it under Breaking. That window closes at 1.0. If you are adding a name you are not sure about, say so in the pull request; it is much cheaper to argue about it now than to keep it forever.

Deprecating means "there is a better way now", not "this stops working". Keep the old behavior working, point at the replacement where someone meets it rather than only in the changelog, and list it under Deprecated.

Performance changes need evidence

This is the rule I care about most. Any change that claims to be faster ships with a checked-in Benchmark* and the benchstat numbers behind it. No speculative micro-opts.

Capture a baseline, make the change, then compare:

go test -run=^$ -bench=. -benchmem -benchtime=2s -count=10 ./PKG > before.txt
# ... your change ...
benchstat before.txt after.txt

Put the benchstat rows in the commit message, not the tree. Leave an inline optimization: comment at the hot path, in the form used in internal/cache/mtime.go:

// optimization: <what changed in one line>.
//   measured: <BenchmarkName> <delta> (benchstat, n=N).
//   trade-off: <legibility/portability cost>.
//   assumes: <platform/kernel/build constraint>.

so the trade-off is reviewable without re-running the bench. Per-OS fast paths (see internal/cache/reflink/) always keep a portable fallback; never gate behavior on a fast path.

Docs site

The docs site under docs/ renders into docs/gen/, which is not committed. .github/workflows/cd.yaml renders it on every push to main and publishes that. To look at your change, use the dev server rather than opening a file:

magus run build docs
magus run build console
magus run serve .

serve is the workspace-root loop for both deployables at once. It hosts one server with the GitHub Pages layout: / is docs/gen, /console/ is console/gen, and re-renders the affected tree when you edit a source, so a reload shows the change with no restart. It refuses to start until both gen/ trees exist, which is what the two builds above are for; a docs-only change still needs the console one, once.

Reach for magus run generate docs when you want the drift gate and the integrity, orphan, and abandoned-URL checks rather than a preview. It is the slower path, and it is what CI runs.

It was committed for years. The reason it no longer is: every page embeds the commit that last touched its source, so committing a source change immediately staled the rendered output committed beside it. That bought a second "refresh generated metadata" commit after every real one, and amending could not escape it, because the new hash restales the footer it just recorded. Rendering at deploy time knows the final commit already.

What still gates on drift is the generated Markdown that is tracked: MAGUS.md, docs/src/gen, and the derived pages under docs/reference/. A plain magus run generate docs fails if any of those was left un-regenerated, so CI still catches a forgotten regen for everything a reader can find in the repository.

One thing under docs/ stays tracked on purpose:

  • docs/active.urls.lock is the ledger proving a previously published URL never starts 404-ing. That gate only works if the ledger outlives a single build.

The playground wasm used to be a third, vendored because CI had no TinyGo to rebuild it. CI's stock Go builds it now, so it is ordinary render output under gen/ (magus run build-playground docs).

Pages use extensionless URLs (/magus/documentation/, served from docs/gen/documentation/index.html). If you rename or move a page, keep the old URL alive by listing it under aliases: in the page's frontmatter, so external links do not die:

---
title: Download
aliases: [install] # clean, gen-root-relative old paths
---

The build emits a redirect stub at each alias and fails if an alias collides with a real page or is claimed twice.

Dogfooding: how magus builds magus

CI runs the pipeline twice, in series, because there are two questions.

From source, and it gates. The plan job and the ci shards build magus from the commit under test and run the workspace with it. Only this pass can answer "does this change work", because a magusfile needing a new host binding has no released binary able to load it. A failure blocks the PR.

From the previous release, and it informs. The compat job runs the newest published magus against the same workspace: can the binary users already have still drive it? continue-on-error, deliberately. A failure means the magusfile now depends on something unreleased, so the remedy is to cut a release, not patch the PR. Gating on it would redden every such change until an unrelated release happened.

Expect compat to fail when you add a flag, target, or host-binding shape the magusfile uses. That is the release-first signal working. Do not move a job off the released binary to clear it; that deletes the check instead of answering it.

A source build resolving a git-ref must be reachable from main; an unverified build would otherwise run with the job's permissions. source-path skips that gate by construction, since the caller checked the tree out itself.

Your worktree's merge driver resolves with your own build

The rule above is about CI. Locally there is one place where "the released binary" is the wrong answer, and it used to cost an afternoon.

magus init registers the git merge driver as whichever magus leads PATH. That is correct for someone using magus, because the registration survives an upgrade-in-place. In a magus worktree it is backwards: PATH holds a release, and the merge driver is part of what you are changing. A rebase then resolves every generated conflict with the release, not with your tree.

That is not a theoretical gap. A released driver regenerated the whole docs site once per conflicted file, so a rebase over a handful of generated files looked exactly like a hang, while the version in the tree resolved the same file in under a second. Worse, it was silent: git reads a driver that exits non-zero as "could not merge", so a broken driver and no driver look identical.

Nothing to run. A workspace that has built its own ./magus registers that as its driver, and magus doctor fails if the registered one cannot read this workspace. To pin a specific build instead (so a long rebase cannot have the driver rebuilt under it), register one by hand and it sticks:

git config --worktree merge.magus.driver "$PWD/magus vcs merge-driver %O %A %B %L %P"

git config --worktree keeps the override local to that checkout. .git/config is shared by every linked worktree, so an absolute path there would aim all of them at one checkout's binary.

Where a rule lives

A rule has one home. Writing it in two places leaves two copies that drift, and a rule kept only in prose holds about half the time. Before choosing where to describe a rule, check whether a program can enforce it. Ask the questions in order and stop at the first yes.

  1. Can a program check it? Then it is an enforcement point, and prose only links to it.
  2. Does it need judgment? Then it is a sentence for a reader. Pick the reader by how often they must see it.
  3. Is it a decision someone may be tempted to reverse? Then it is a record.

Mechanical rules

the rule is home example here
a call that cannot be undone compiled guard rule that denies a whole-tree git reset --hard destroys a concurrent agent's work (The guard)
a call a later command can redo, where a command answers it compiled guard rule that denies and serves that command stage-all refuses git add -A and serves magus vcs ls-files
a call this repository refuses and a user's task runner would not a rule in hack/policy/*.buzz, wired in the root magusfile brief-builds-magus and worker-builds-magus in hack/policy/builds.buzz
a property of files, checked at the gate a lint rule in hack/lint/ or a test banned-words and provider-io-is-buzz in hack/lint/; adr-template in docs/lib/adr.buzz
a name that must keep resolving a drift test every command path in a hint is walked against the registry (Hints belong in hint)
a refusal by the verb that owns the decision a coded error that names the next step a budget magus cannot fit is refused with MGS3009
structure the tool reads a magusfile declaration a target declares its outputs, and CI runs the target (Workflow targets, not inline logic)

Choosing among them:

  • Deny, and name a remedy the caller can take now (ADR 0013). A rule whose remedy does not clear it is a notice, which states the fact and never refuses.
  • A guard fires at the moment of the edit; a lint or test fires at the gate. Use the guard when the author must not finish the wrong work, and the lint when finding it at CI is soon enough.
  • The verb that owns a decision refuses it. A skill sentence that says "do not pass X to this verb" is a missing error.
  • ask is for consent to a costly or publishing act, never for waving a denial through. The push gate asks an unleased session before it publishes a commit no passing gate covers, and a host that cannot prompt gets a deny. A rule whose denial a person might want to clear still denies; the person changes the rule (A record of refusals).

Rules that need judgment

the rule is home example here
needed on every edit by every session a line in AGENTS.md "Before fixing behavior that looks wrong, find the test that pins it."
needed for one kind of task, about how to use magus a shipped magus-* skill magus-vcs-hygiene before a commit
needed for one kind of task, about this repository the local magus-local-development skill triaging a review finding against magus's own Go
a decision with alternatives and a built consequence an ADR in docs/decisions/ ADR 0001 records what it rejected "so they are not re-proposed"
a measured decision not to build, or to remove a row in A record of refusals the cd guard rule, deleted: 207 of 238 denies already passed
a standing principle about how the tool behaves a page in docs/design/ that names its mechanism A refusal carries its reason
an explanation for someone using magus a docs page The guard

Choosing among them:

  • Reach decides between a line and a skill. A skill loads when a brief names it or a hook demands it, so a rule that must hold on every edit goes in AGENTS.md, which every session reads. Every line there costs every session and is deleted when a tool starts saying it. The measured reach of a skill is in the record of refusals.
  • A shipped skill teaches a stable procedure, so it never names this repository. A rule true only here goes in the local skill, stamped with its evidence and a retire-when condition (magus-workspace-rules).
  • A skill covers what a lint cannot see; it does not restate what the lint teaches on its first failure. The ADR template is the lint's. Whether Alternatives holds real options with reasons is the idiomatic-adrs skill's.
  • Consult before you propose. Before adding a flag, rule or check, or reopening a removed one, search the records: magus query "kind=docsection id=~decisions/ <terms>" and the refusals table.
  • An ADR records a decision that landed. A refusal row records one line of evidence for something not built, and links to an ADR when one decision has several.
  • A design page without an enforcing mechanism is a debt and is listed as one. Edit the page in the same commit that changes the behavior.

Shipped or only this repository

magus ships whatever lives in the binary: compiled guard rules, error text, the shipped skills and their hints. It never ships hack/, libs/conventions, the local skill or AGENTS.md. A person who only wants a task runner meets the first kind and must never be annoyed by it, so a shipped rule needs all of:

  • a case for it that does not depend on magus's own data;
  • a correctness property or a documented tool norm, not a taste;
  • near-zero false positives on three repositories that are not this one;
  • a denial that names a remedy the caller can take now, or a notice when there is none;
  • a default that a workspace can change by name with magus\guard.builtins.

A rule that fails any of these is repository policy: write it in hack/lint, hack/policy or .golangci.yml, and the root magusfile applies it here. Policy that proves itself on other trees can graduate into the binary later; the reverse never happens by default.

Rules for a user's own workspace use the same homes with different owners. Where guidance belongs and the magus-workspace-rules skill cover that case.

Rolling out a stricter rule

The person who adds a rule, or tightens one, pays its cost in the same change. After that, a change answers only for what it did.

  • A mechanical rule, one whose fix needs no knowledge of the code, is enforced everywhere, and the sweep lands with it.
  • A measured rule, one whose fix is a refactor anyone can do, lands with a baseline listing every item already past it. The baseline only shrinks. magus run format . deletes the items that no longer violate and follows an item through a rename or a move when its recorded shape names exactly one candidate, so nobody edits it to keep a passing change passing.
  • A rule whose fix needs knowledge of the code, or a taste, advises and never fails.

A rule's header comment names its tier, then the reason: // Tier 1, mechanical, // Tier 2, measured shape or // Tier 3, judgment. A rule with parts in two tiers names both. rules-name-their-tier in hack/lint/ fails a rule that names none.

Naming

Names are the API most people meet first, so they get decided deliberately rather than by whatever the file was called. From the outside the results can look arbitrary (go lives in spells/golang/, ls and describe both list things), so here is the reasoning, which is not arbitrary.

Source layout and registered identity are independent

A spell's directory and the name it registers answer different questions, and they are allowed to differ.

The directory is where a contributor finds the code. The registered name (mgs_getName) is the identity users type and every listing prints.

spells/golang/          <- idiomatic directory name for Go source
  registers "go"        <- the language's actual name

Both are right. golang is the conventional directory spelling; go is what the language is called. Forcing one to match the other would make one of them wrong. Do not "fix" a mismatch on sight; check which question each is answering first.

A registered name has to stand alone

The registered name appears in magus describe spells, in diagnostics, and in error text, always without the directory around it to supply context. So it must be unambiguous on its own:

  • Name the thing, not the job it does here. The S3 backend registers aws-s3, not s3-cache. Its siblings are named for products, and a capability name reads as a different kind of entity in the same list.
  • Qualify when the bare word names nothing. spells/github/actions/ registers github-actions, not actions: "GitHub Actions" is the product's real name, and actions alone identifies nothing.
  • Never take a word the core model already owns. spells/gitlab/ci/ used to register ci, which collides with the ci target that magus affected ci anchors on. A listing then showed a ci spell beside a ci target meaning entirely different things. It registers gitlab-ci.

One verb, one job

Subcommands are split by the question they answer, not by the noun they touch, so two verbs never differ only in verbosity:

  • ls enumerates. Breadth. What exists here, what can I run. Everyday.
  • describe explains one thing fully. Depth. A definition plus the complete record. Occasional.

That is why magus ls shows targets but not source globs: the globs are the full record, which is magus describe project's job, and printing them in both would make the boundary mush. When adding output, ask which question it answers and put it in exactly one place.

Prefer a noun on an existing verb over a new subcommand. magus ls targets rather than magus targets, because the latter invites magus spells, then magus charms, and the CLI becomes a pile of noun-commands with no rule to learn. One verb, a noun that says what.

Enumeration is spelled ls everywhere: magus ls, magus run ls, magus notes ls, never list.

Package names mirror the contract they serve

Where a package exists to serve one wire contract, it takes that contract's name, so the two are correlated by reading rather than by grep. A wire-mapping subpackage internal/handler/<name> owns the over-the-wire concerns of the protobuf package magus.<name>.v1:

proto/magus/graph/v1     <->  internal/handler/graph
proto/magus/status/v1    <->  internal/handler/status
proto/magus/viewer/v1    <->  internal/handler/viewer

Adding proto/magus/foo/v1 means adding internal/handler/foo, same name, no exceptions for the wire packages. Two subpackages there are deliberately not proto-backed and so are not part of the mapping: mcp is a protocol adapter and trailrpc is a transport concern.

internal/handler/README.md is the authority and carries the full table plus what does and does not belong in the layer; keep the rule there rather than restating it per package.

Hints belong in hint

A command path printed inside output goes through internal/hint, never a string literal. A drift test walks every registered command and asserts it still resolves, so a rename cannot leave a hint pointing at a command that no longer exists. That has already happened once: a failing target printed magus query <ref> long after the command became magus query output <ref>.

Generated files in this repository

The general rules are in Generated files and your toolchain: linters never gate on generated output, formatters cover it as long as the generator emits formatter-normal output, and the scope for both derives from ctx.writesFiles rather than a hand-maintained ignore list. What follows is how that lands here.

Language Formatter Generated output Why
Go gofmt, via go-fmt covered internal/generate/emit gofmts before writing, so the check is a no-op
TypeScript Biome, via biome-format excluded (!src/gen/**) bundler and protobuf output is not Biome-normal and never will be
Markdown dprint excluded (dprint.json) generators do not emit dprint-normal output yet; see below

Two things worth knowing before you change any of it:

  • Go formatting is gated by golangci-lint, not by the format target. gofmt -l lists unformatted files on stdout and exits 0, and magus reads an op's verdict from the exit code alone, so unformatted Go used to pass green. .golangci.yml enables gofmt under formatters:, which reports the same finding and exits 1. The format target keeps gofmt -l as the local reporter, and format:rw flips it to -w.

  • Markdown formatting is dprint, and generated Markdown is excluded from it. That exclusion is the documented fallback, not the goal: it stays until each Markdown generator emits dprint-normal output, at which point its entry comes out of dprint.json. dprint over prettier because it needs no Node in the format path, and over Biome because Biome does not read Markdown at all. .prettierignore was deleted when prettier stopped being installed; it had outlived the tool by long enough that six of its seven paths no longer existed.

  • The console lint target conflates formatting with linting, and should not. The typescript spell exposes biome-check and biome-format as separate ops precisely so a target can compose them independently, but console/magusfile.buzz calls proc\exec("pnpm", ["exec", "biome", "check", "src"]) directly, and biome check reports formatting as lint findings. That is the exact blur the concepts page argues against, in our own tree. Route it through the spell ops when you next touch it.

Byte-stability is the one rule with no exceptions, and this repository has paid for it more than once. Every instance was a real bug in a generator, never a reason to relax the rule:

  • The knowledge store merged shards in Go's randomized map order, so which shard supplied a node's source changed run to run. Node and edge counts never moved, so it surfaced only as magus run generate failing its drift gate on provenance lines. Fixed by sorting shard names before the merge (internal/graph/knowledge/store.go).
  • catalog_fingerprint identifies the binary, not the graph, so two builds of one source produced different values and a regeneration that changed no node and no edge still rewrote the file and failed CI with the fingerprint as the entire diff. It is excluded from graph export --reproducible for that reason.
  • The @runtime shard put locally observed diagnostics into MAGUS.md's anchor ranking, so the committed index depended on which codes that machine had tripped. Routing() now excludes runtime edges.

Graph.Output and writeShard both sort before emitting, and both say why in a comment. Do the same in any new generator.

When you add a generated artifact here, follow the five-step checklist in the concepts page. The magus-specific part is that the root generate target already drift-gates every generated file in the workspace, so a per-artifact drift test in Go is redundant and drifts from the real //go:generate directive.

Coverage badges are published, never committed

The README shows three language badges: Go, Buzz, and TypeScript. None of them lives in the tree. The CD site job (.github/workflows/cd.yaml) measures all three on every push to main with magus run coverage-render ., renders the SVGs, and publishes them with the docs site under assets/. Nothing to refresh, nothing to commit.

A committed badge cannot be gated consistently. Go coverage differs by platform, and Node's line coverage moves with its patch version, so a badge measured on one machine is stale on the next. What stays local is a floor per language, each a threshold on the figure the suite that just ran measured:

  • Go: 70%, in the root test target, over this module with generated files removed.
  • Buzz: 62%, in buzz-test.
  • TypeScript: 67%, in console's test, merged across the console and libs/textsearch.

Lower a floor deliberately, in the change that explains why.

The published Go figure is a different number from the floor on purpose: it spans the whole Go codebase, and no single machine compiles all of that. 119 non-test files sit behind //go:build constraints, so macOS and Linux read 67.6% and 66.9% for the same commit. The published figure is therefore split in two.

The denominator is static. Which statements exist does not depend on where you stand, so hack/magusfile/coverage.buzz enumerates every statement block of every Go file in every module by running go tool cover -mode=set over one file at a time. That instruments syntactically, resolving no import and consulting no build context, so a Windows-only file yields its block table on a Mac and lands in the denominator at zero. Verified against a real profile: over the 418 files both a go test -coverprofile run and this pass covered, the block sets were identical.

The numerator is what the CD job's Linux run entered, across every Go module's suite. A file only another platform compiles stays in the denominator at zero, so the published figure can only understate coverage, never overstate it.

Pull requests

Pull requests are squash merged, so the title becomes the commit subject main records. .github/workflows/pr.yaml runs three checks on every pull request, and one push reports all three:

  • The title is a conventional commit subject, <type>(<scope>): <description>, with the scope and a ! optional. The types are build, chore, ci, docs, feat, fix, perf, refactor, revert and test. The description keeps the lowercase imperative this repository writes, with no trailing period, in 100 bytes at most. feat(git): install version-controlled buzz hooks passes; Add git hooks. does not. Check one with magus run pr-title . -- "<title>".
  • The title and description follow the proofread rules (pr-description). The description opens with what the change set out to do and why, says what it changes in meaning rather than files, gathers what a reviewer should weigh under Considerations (with unfinished work as unchecked tasks), keeps its claims next to their evidence, and describes the situation rather than a person or the earlier code. The guard's pull-request-text rule runs the same checks when a description is posted. The shape, the claim and tone rules, and the decisions they take are in Writing rules, and each rule has a page. Check a draft with magus run pr-description . -- "--title=<title>" "--body=<body>".
  • A feat, fix or perf change to shipped code adds a changelog fragment, as does any title marked breaking with !. Every other type passes without one, and so do changes that touch only docs, tests, workflows, repository tooling and plans; hack/magusfile/changelog.buzz lists what does not ship. A change no user can notice is not a feat, fix or perf, so retitle it rather than add an entry. Check a branch with magus run pr-changelog . -- "<title>".

Fragments. The entry is its own file, changes/unreleased/<branch>.md: a ### <group> heading and one entry, exactly as it reads in the changelog. There is no changelog file to edit: concurrent pull requests add different files and never touch a shared one. The docs changelog page renders the fragments and the release manifests (magus run changelog-page docs), and cutting a release folds the fragments into its manifest. changes/README.md has the format, and hack/magusfile/changelog.buzz is its one parser; a malformed fragment or an unknown group fails the check.

Your branch's own commits are squashed away, so they keep the lowercase imperative style with no prefix. magus run git-hooks-install . installs this repository's git hooks from hack/git-hooks/; its commit-msg hook applies the title rule only to commits made directly on main. magus run git-hooks-remove . takes them out again. To add a hook, see Your own hooks, in Buzz.

Workflow targets, not inline logic

The GitHub Actions workflow files are intentionally thin. Every meaningful CI operation lives in a target in magusfile.buzz or in magus itself, not in a workflow YAML step. The workflow orchestrates: it sets up the toolchain, calls magus, carries magus's output to where the host wants it, and uploads artifacts.

Concretely:

  • The workflow YAML names jobs and steps, sets env vars, uploads artifacts, and holds thin provider glue: a few lines that redirect what magus printed into a file the host reads, such as >> "$GITHUB_OUTPUT" or >> "$GITHUB_STEP_SUMMARY". Glue moves bytes. It never decides anything: no conditionals, loops, parsing (jq, sed, awk) or string building. If a step needs one of those, the work belongs in magus or a target, and magus should print the finished bytes.
  • magus and the magusfile targets hold the actual work: which projects to fan out over, what to render, how to interpret drift. What a host consumes is rendered by magus in a provider-neutral shape, with -o choosing the encoding, -o template=... included.
  • A target that wants host-specific behavior declares it via a charm (gha, cd, rw) rather than by sniffing the environment it runs in.

Why glue in the workflow rather than a target: the file names ($GITHUB_OUTPUT, $GITHUB_STEP_SUMMARY) belong to the host, and a redirect is the host's own interface for them. A target that wrote those files had to read stdin, detect whether it was on a terminal, skip the cache, and turn a charm into a write switch, and it made the plan job's two magus invocations contend for one project lock. The CI plan step (.github/workflows/ci.yaml) is the worked example: it computes the plan once with magus affected ci --plan, renders it twice with magus run --stdin --dry-run -o 'template=...' < plan.json, and redirects each render. magus never learns either file's name, so a port to another CI system rewrites the redirects and nothing else.

Workspace references: the bare path

A project reference is a workspace-relative path written bare. That is the only canonical spelling, in and out:

pkg/foo               # a nested project, measured from the workspace root
.                     # the project the cwd is in
./pkg/foo  ../foo     # measured from the cwd

Every command that takes a project argument accepts those, and every command that prints a project prints the bare path, rendered via types.ProjectLabel, which reads the root as the workspace directory's name rather than a bare .. What magus prints is what magus takes, so nothing has to be rewritten between the two.

project:pkg/foo is a different grammar, not a second path syntax: it is the kind-prefixed node reference the graph commands (explain, query, path) use where kinds mix, alongside target: and spell:.

The workspace://pkg/foo scheme is DEPRECATED. It still parses, with a warning (internal/file/resolve.go), so a ref written against it keeps resolving; it is not taught or printed any more, and it never bought a reading the bare path did not already have. Nothing renders it; ProjectLabel routes through types.ProjectRef{Path, Dir}, so new rendering rules go on the struct's methods.

Release-signing key rotation

magus trusts a KEYRING, not a key: internal/selfupdate/release-keys.json lists every Ed25519 public key a signature may come from, each with a state. Signing uses exactly one; verification accepts any that is not revoked. The ring has reviewable copies in docs/gen/install, the setup action, and the download guide. TestReleaseTrustAnchorMatchesInstallerAndCI fails CI when the installer or the action disagrees with the ring, and the docs project's conventions target when the guide does not publish the active key. Never put a private Ed25519 seed in the repository or a release artifact.

state signs verifies
active yes yes
standby no yes
retired no yes
revoked no no

The fingerprint is derived from the key (KeyID, the first 16 hex digits of its SHA-256), never written down, so an entry cannot name a key it does not hold.

A planned rotation, which is why the ring exists. The old procedure was a chain: ship a compatibility release signed by the current key that embeds the replacement. It only worked for someone who passed THROUGH that release, and SelectRelease takes the newest by default, so anyone a version or two behind jumped straight to a signature their binary had never been told to trust and was stranded with no in-band way back. The rotation was correct on paper and skipped exactly the people who most needed it.

Instead, ship the replacement as standby long before it signs anything:

  1. Generate the keypair. Add the public half to release-keys.json as standby, and to the installer and setup action. Run the trust-anchor test.

    Rebuild with --no-cache. release-keys.json is embedded but matches none of the go spell's source globs, so an ordinary magus run build reports cached and leaves a binary carrying the old ring. Shell completions have the same gap.

  2. Release normally, more than once. Every binary in the field now trusts a key that has signed nothing.

  3. When you are ready, flip standby to active and the old entry to retired, and move the MAGUS_SIGNING_KEY secret to the replacement private key.

  4. Run the Release index workflow. The served index is signed with the same key, so until it is re-signed docs/gen/public/release/index.json.sig still carries the old signature and magus self update refuses it. That workflow is manual precisely for this: a rotation, a corrected manifest, and a yank are the moments the index must change without a version being cut.

  5. Drop a retired entry entirely once nothing you still need to verify was signed by it.

Revoking a compromised key. Set its state to revoked and delete it from the installer and the setup action, then run the Release index workflow. Its fingerprint is published in the signed index's revoked[], and a client refuses any signature from a listed key.

This is worth stating precisely, because it is easy to overclaim. Revocation only works because the revoking index is signed by a DIFFERENT key (the standby one) which an attacker holding the compromised key does not have. Signing a revocation with the revoked key would prove nothing, so the client refuses an index that revokes its own signer.

What it does NOT fix: an attacker holding the active key signs whatever they like, and binaries trusting that key accept it until they see a revocation. The residual hole is that the attacker can keep serving an OLD index carrying no revocation. expires_at is what bounds that: past it the client refuses the file rather than trusting a stale one, converting an indefinite compromise into a denial of service, which is the right trade. It is 180 days, and nothing republishes on a timer: magus doctor warns as the deadline approaches and the Release index workflow is the remedy.

If the compromise is real, treat it as an incident rather than a procedure: stop using the signing secret, publish the replacement binary and public key through an independent trusted channel, and ask affected users to reinstall manually. Do not describe that path as an automatic update.

Glossary

Glossary

The vocabulary that runs through the rest of the docs. Each term has its own page at a stable address (glossary/<term>/), so a diagnostic, a review comment or another page can cite one definition. A term page gives:

Edit a term on its own page under glossary/; the list below is generated from each page's front matter.

Core model

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. Workspace in full.

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. Project in full.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. Magusfile in full.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. Target in full.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). Op in full.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. Spell in full.

Manifest

The file that declares what a package is: its name, dependencies, entry points and tool config (go.mod, package.json, pyproject.toml, Cargo.toml). How the workspace builds, tests and lints the package belongs in the magusfile, not in the manifest's scripts. Manifest in full.

Charm

An execution modifier attached with : (lint:rw) that changes how a target runs, not which one; the built-in rw flips a check-only target to mutate in place, and ci always strips it. Charm in full.

Ward

A coded diagnostic that inspects a resolved op and nudges or blocks an anti-pattern before it runs. Ward in full.

Module

A magus stdlib namespace a magusfile imports for host capabilities: filesystem, exec, vcs, and more. Module in full.

Buzz

The language magusfiles are written in (the .buzz engine). Buzz in full.

Engine

The interpreter a magusfile runs on; magus embeds the Buzz engine. Engine in full.

Execution and caching

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. Cache in full.

Affected

The set of projects touched by a change; magus affected <target> runs a target only over them. Affected in full.

Sandbox

The restricted filesystem and environment a target runs in, so builds stay reproducible and side-effect-free. Sandbox in full.

Service

A long-running or shared process magus manages across runs, distinct from a one-shot target. Service in full.

Broker

The per-user background process that holds this host's capacity: the machine budget every run claims slots from, and the shared services runs keep warm. A run starts it on demand; broker: off in magus.yaml runs without one. Broker in full.

Daemon

The background process a person starts with magus server start. It serves MCP, the console, background jobs and the warm knowledge graph, and adopts nested magus calls into one pool. Daemon in full.

CI

An ordinary target you compose yourself with ctx.needs; magus does not hardcode its stages. It is the anchor magus affected ci keys off, and ci strips the rw charm. CI in full.

Output reference

A short, shareable id such as out1a2b3c for one target execution's captured output; magus query output prints those exact bytes. In OpenTelemetry terms it names a span. Output reference in full.

Trace

OpenTelemetry's name for one whole magus invocation; every target it runs is a span beneath it. Trace in full.

Span

OpenTelemetry's name for one unit of work under a trace: a target execution, whose sub-operations are child spans. An output reference points at a span's captured output. Span in full.

Pool

The concurrency pool: the shared set of slots that caps how many targets run in parallel on one machine. Its capacity defaults to MAGUS_CONCURRENCY, then 4 on GitHub-hosted runners, then min(NumCPU, 8); magus status and the dashboard report it live. Pool in full.

Slot

One unit of the pool's capacity. A target acquires the slots it needs to run (most take one) and releases them when it finishes; the pool tracks capacity (total slots), running (acquired), and queued (blocked). Slot in full.

Concurrency

How many targets run at once. It is bounded by the pool's capacity and set with --concurrency, MAGUS_CONCURRENCY, or the concurrency config key. Concurrency in full.

Queued

A target that wants a slot while the pool is full; it blocks first-in-first-out until a slot frees. The dashboard colors a sample with queued > 0 accordingly. Queued in full.

Pool mode

Which pool a run uses: server (one shared pool the daemon owns across every workspace and client) or proc (a per-process pool for a single one-off invocation). Pool mode in full.

One-off

A single magus invocation that runs a target and exits, using a per-process pool; the opposite of the long-lived daemon or a service. One-off in full.

Remote cache

A CI-only backend that shares content-addressed artifacts across runners: a cold machine replays a build another runner already did instead of rebuilding. Every remote artifact must be signed by a trusted key. Remote cache in full.

Snapshot

A point-in-time view of live state (the pool's occupancy or a tick of exported metrics), as opposed to accumulated history. Snapshot in full.

Backfill

The recent history the daemon replays to a dashboard on connect, so its charts start populated instead of empty. It is served from a bounded ring buffer of the last few hundred samples. Backfill in full.

Telemetry and health

Latency

How long an operation takes. magus records latency as OpenTelemetry histograms per family (target execution, cache op, pool wait, and graph query) and reports each as a count, sum, and percentiles. Latency in full.

Percentile

A latency value at a given rank, interpolated from a histogram's buckets: p50 is the median, p95 and p99 are the tail that most latency budgets care about. Percentile in full.

Health

The at-a-glance daemon state derived from the pool: healthy when the pool is reporting, degraded when it reports an error, down when there is no pool. The dashboard color-codes each state. Health in full.

Volatility

A target that fails once and passes on rerun is volatile, as opposed to a regression that started failing and stays failing. magus keeps per-target pass/fail history and a Wilson-score volatility rate to tell them apart and auto-retry the noise. Volatility in full.

Insight and knowledge

Knowledge graph

The queryable graph of a workspace's spells, targets, docs, and code relationships; query it with magus query/explain/path. Knowledge graph in full.

MAGUS.md

The committed routing index at a workspace root, regenerated from the knowledge graph: it lists every node and points at the exact query for a given question, so it is the entry point an agent reads first. MAGUS.md in full.

Insight

The reports magus derives over the graph and history (hotspots, affinity, ownership, trend, volatility, unreferenced). Insight in full.

Hotspot

An insight lens: edit frequency times complexity, the prime refactoring targets. The project view heat-colors the dependency graph by churn; --files ranks individual files. Hotspot in full.

Affinity

An insight lens: projects that change together (temporal coupling). A pair that co-changes without either declaring a dependency on the other is a candidate architectural smell. Affinity in full.

Ownership

An insight lens for author concentration: the primary author and their share, the distinct-author count (the bus factor), and abandonment. Ownership in full.

Trend

An insight lens: the recent half of the window against the earlier half. A positive delta is a rising hotspot; a negative one is cooling. Trend in full.

Diagnostic code

A stable MGSxxxx identifier attached to a magus warning or error, so it can be referenced and looked up; some are guardrails (see wards), others hard errors. Diagnostic code in full.

Design and scope

Scope test

The question every proposed capability has to answer: does it read the model magus already had to build, or does it make magus learn something new about the world? Reads stay small; acquisitions are where a tool loses its shape. Scope test in full.

One-vocabulary rule

Each concept gets one name, used everywhere: target, spell, charm, op. A second word for the same thing is a house dialect, and it costs every reader (and every agent) a lookup that never ends. One-vocabulary rule in full.

Agents

Agent

A model working through an agent host such as Claude Code, Codex, Cursor or OpenCode. magus treats an agent as one more caller of its CLI, MCP tools and console, held to the same checks as a person. Agent in full.

Tool

An action a model can call through its host: a name, a description, a typed input and a result. magus serves its tools over MCP, each a read of the workspace model or a run of the CLI it already has. Tool in full.

Harness

The deterministic scaffolding around the work on a change, for a person or an agent: compiler errors, formatters, linters, checks, gates, leases and hooks, placed at points a person chose. Around an agent's loop, magus supplies one and keeps it outside. Harness in full.

Skill

A packaged set of instructions an agent host loads when a task matches its description: a directory holding a SKILL.md. magus ships its magus-* skills in the binary and writes them with magus agent install. Skill in full.

Sessions and leases

Session

An agent host's conversation, by the id the host delivers to its hooks. magus never mints one: a record with no session is unattributed, and the OS user it carries says whose account ran it. Session in full.

Invocation

One magus process's recorded facts (the targets it finished, their outcomes, the lease it acted as, and the session it ran in when a host delivered one), kept in a repo-scoped store every worktree shares. magus session lists them; the store prunes itself by last-fact age. Invocation in full.

Window

The terminal a command runs in. It keys fire-once notices for a caller no host gave a session, and is never recorded as a session. Window in full.

Attention request

A durable record that an agent is blocked, opened by a magus session notify event and held until a person disposes it. Nothing closes one on its own. Attention request in full.

Dispose

The human act of closing an attention request: a judgment rendered, recorded with who and why. Distinct from resolving a review thread or a merge conflict: a disposition answers a request; it does not merge anything. Dispose in full.

Job

The unit of delegated work, and one row of the job store: what an orchestrating agent handed out, with its goal, the checkpoint it was cut against, the paths it may write or must not touch, and the one check it runs. A job's holder is either a session, for work an orchestrator handed out, or the daemon, for its own maintenance. The store records; the agent guard is what reads those facts back when grading a write. Job in full.

Run

One target executing under one magus invocation, such as magus run test web or magus affected ci. A run keeps its captured output behind an output reference. Every magus run is a run whether or not any job asked for it; see Job for how the two relate. Run in full.

Lease

The grant a holder takes on a job: the write and read paths that job declared, enforced in the checkout that took it with magus job exec. A job is the piece of work; a lease is permission over it. Lease in full.

Lease id

The short identifier a worker carries (the --lease flag, or the magus.lease member of the W3C BAGGAGE environment channel) so its runs, journal facts, and guard verdicts attribute to the job it holds. Letters, digits and -_./: only. Lease id in full.

Spawn claim

What a spawning tool said about itself in the environment: TRACEPARENT (the W3C trace and the parent span this process runs under) and the magus.spawner baggage member (a label for whoever spawned it). magus records each verbatim beside the invocation's own minted span id, and no verdict reads any of them; the ancestry is a relation between recorded invocations, the way a process tree is a relation between pids. Spawn claim in full.

Advisor

One read-only check from the advice suite: it reads the changeset through magus and writes one titled section of findings. The same advisors run as a pull request comment in CI and inside magus diff --impact locally. Advisor in full.

Console

Console

The browser app that reads a magus workspace: a tabbed, tiling page hosting the log viewer, graph explorer, dashboard, and activity trail. It is a separate static app, not something the daemon serves; the daemon exposes a loopback API it calls: read-only views plus one bearer-gated job-control service for maintenance jobs. Console in full.

Console app

One of the console applications (Runs, Log Viewer, Graph Explorer, Dashboard, Activity Trail, Settings), mounted into a tab or a pane. Each is single-instance. Console app in full.

Pane

A split within a tab. Splitting divides the focused pane along its longer side, so the same action tiles side-by-side on a desktop and stacks on a phone; a tab with no split is a single pane. Drag the divider to re-weight the split. Pane in full.

Chord

A key combination bound to a console command, written mod+k, where mod is Cmd on macOS and Ctrl elsewhere, so one binding fits both. Every chord is rebindable (Settings > Keybindings), and a command remains reachable from the command bar whether or not it has one. Chord in full.

Command bar

The console's runner: one searchable list of every command and its chord, opened with mod+k. It is the discoverable route to any action: the menus and chords dispatch the same commands it does. Command bar in full.

The URL that points an app at a running daemon. The daemon serves the console from its own loopback origin, so the link is that origin plus the app path and a bearer token in the fragment (http://127.0.0.1:7391/console/graph/#token=...). The daemon prints it; the console consumes the token, stores it, and strips it from the URL, so the secret never lingers in history or a copied link. The origin must be literal loopback: localhost and hostnames are rejected before any request. Without one, an app reads only what rides in the link itself. Live link in full.

See also

Conventions

This page uses none of the site's convention markers. The full set is on the conventions page.