Workspace and projects
A workspace is the whole tree magus operates on: a single root directory, its magus.yaml, and the set of projects discovered beneath it. A project is one directory inside that tree whose presence of a magusfile registers it, together with the targets it declares. Every target you run (see targets) is addressed by a project Path plus an operation Name; the workspace is the space those paths live in.
The split is deliberate. The workspace is the unit of discovery, caching, and affected-set computation - it is opened once and shared. A project is the unit of work - it owns a magusfile, binds spells, and declares its dependencies. magus never operates outside the one workspace it discovered.
Design intent
- Files first. A directory becomes a project because it contains a magusfile, not because you list it in a central manifest. Discovery reads the tree; there is no registry to keep in sync.
- Convention over ceremony. A bare magusfile with nothing but exported target functions is a complete project on defaults. The optional
magus\project({...})call only layers extra policy on top. - One root, repo-relative paths. Every project
Pathis stored relative to the workspace root. This keeps target identity portable across machines and lets the CLI,depends_on, and the cache all speak the same coordinate system. - Explicit dependencies. Cross-project edges are declared, never inferred.
depends_onis the single source of truth for ordering, the affected set, and cache-key propagation.
What a workspace is
The workspace root is the nearest ancestor directory carrying a root marker. FindRoot walks up from the current directory and stops at the first directory that contains any of these, in priority order:
| Marker | Why it roots a workspace |
|---|---|
magusfiles/ |
a split-magusfile directory |
magusfile.buzz |
a magusfile at the root |
magus.yaml |
the workspace config file |
go.mod |
the Go-module root, as a last-resort fallback |
magus markers precede go.mod, so an explicit magus.yaml or magusfile.buzz always wins over a stray module boundary. magus.yaml (workspace configuration - see config) lives at the root; it is optional, and its absence does not stop discovery once a root is found by another marker.
The root is canonicalised at discovery (symlinks resolved via filepath.EvalSymlinks). Every project path is then computed relative to that real path, and the sandbox enforces access against resolved paths (see sandbox and targets.md#symlinks).
What a project is
A project is a directory that carries a declaration file: magusfile.buzz, or a magusfiles/*.buzz file for the split-magusfile layout. Discovery registers the directory as a project keyed by its repo-relative path; the workspace root itself registers as the path ..
A project owns:
- its targets - the exported functions in its magusfile become the runnable operations (
build,test,lint, ...); no registration call is needed (see targets). - its bound spells - the tool libraries whose ops the targets compose (see spells and operations).
- its policy - dependencies, outputs, watch-ignore patterns, and per-target execution flags, all layered on by an optional
magus\project({...})call.
Project discovery
project.Discover walks the workspace root once with filepath.WalkDir and registers every directory that hasDeclaration reports true. The rules:
- A magusfile registers a project. A directory with
magusfile.buzz(or a matchingmagusfiles/*.buzz) becomes a project. Nothing else registers one: auto-detection from tool markers such as a straygo.modorpackage.jsonhas been retired. If you want a directory to be a project, give it a magusfile - or have another tool report it, which is the one other route in and is never inferred: a workspace provider the magusfile explicitly wires (magus\workspace.provider(nx)) supplies projects for a repo whose structure is owned by nx, gradle, pnpm or cargo. Those are folded in after discovery, and a magusfile always wins over a provider for the same directory. - The root is the project
.. The workspace root, if it carries a magusfile, is the project whose path is.. - Well-known directories are pruned. Discovery skips a fixed set of ignore directories at any depth and does not descend into them:
.git,.hg,.sl,.jj,.magus,.build,vendor,node_modules,target, andgen. A magusfile buried inside one of these is invisible. (genis treated as machine-written output, never a discoverable project.) - Symlinked directories are not followed.
WalkDirdoes not traverse symlinks, so a symlinked directory is silently skipped and never registered as a project.
Discovery is cached against directory mtimes, so a repeat open on an unchanged tree restores the project set without re-walking.
The magusfile
A project's magusfile is magusfile.buzz (or the split magusfiles/*.buzz form). Its mere presence registers the project on defaults - a magusfile that only exports target functions is complete:
import "magus";
import "spells/hello"; // ./spells/hello/spell.buzz
magus\project({ "spells": [hello] });
// Each exported function becomes a runnable target.
export fun build(ctx: magus\Context, args: [str]) > void { hello.build(); }
export fun test(ctx: magus\Context, args: [str]) > void {}
// 'ci' is the conventional anchor `magus affected ci` keys off.
export fun ci(ctx: magus\Context, args: [str]) > void {
ctx.needs(build, test);
}
magus\project({...}): layering policy
magus\project({...}) is optional. It does not create the project (the magusfile's presence already did that); it layers configuration onto it. The options map accepts:
| Key | Effect |
|---|---|
spells |
binds spell handles to the project, contributing their ops, sources, and outputs |
depends_on |
declares upstream project paths this project depends on (repo-relative or project-relative) |
outputs |
declares the project-relative file globs this project produces |
sources |
declares additional project-relative file globs feeding the cache key and affected set, on top of whatever the project's spells already claim - for real inputs a spell doesn't know about (non-code assets, sibling schemas, docs a generator reads) |
exclusive |
marks the project as must-not-run-alongside-peers in a batch |
watch_ignore |
appends glob / regex / literal patterns to the project's watch-ignore list |
no_language |
a reason string recording that this project binds no toolchain spell on purpose, exempting it from magus doctor's language-coverage check |
gate_low_risk |
project-relative globs the ci-gate redundancy check (MGS3010) classifies as prose; magus ships markdown defaults, any declaration replaces them workspace-wide, and [] turns the prose class off |
gate_inherit |
false stops magus affected ci --plan inheriting a green CI run's verdict, however the delta classifies; one declaration turns it off workspace-wide, and true restates the default (see below) |
tools |
the version window this project requires of each binary its spells drive, keyed by bin name (see below) |
targets |
a per-target policy table (see below) |
Unknown keys in either map (a typo like depend_on, or a per-target policy key
other than skip_cache/exclusive/slots) are a magusfile load error, not a
silently dropped option - the error names the offending key and suggests the
nearest known one. A key that resembles nothing magus knows is reported as one
this binary may be too old for, with the upgrade command, because a magusfile
schema key added upstream fails workspace load for every command at once.
tools: the version window this project requires
magus\project({
"spells": [typescript],
"tools": { "node": { "min": "22", "below": "25" } },
});
min is an inclusive floor and below is an exclusive ceiling, both plain
versions. below names the first version REJECTED, so below: "25" accepts
24.19.0 and rejects 25.0.0 - the off-by-one an inclusive max invites.
This states POLICY: what this project has qualified. It is intersected with the window the spell declares for its own ops (what those ops need to function at all), narrower bound winning on each side, so neither can loosen the other. A violation is MGS3005 or MGS3006, raised before the run does any work.
magus compares against the binary it probed; it never learns which versions exist upstream and never selects one. Sharing a window between projects is an explicit import of a shared module, never inheritance by position in the tree.
no_language takes prose, never true. A project with no toolchain spell is
legal and common, so doctor cannot tell an intentional one (a polyglot harness no
single pack describes) from a forgotten import "magus/spell/go" without being
told which it is. Requiring a reason keeps the exemption a decision the next
reader can evaluate rather than a switch someone flipped to get a green check:
magus\project({
"no_language": "promptfoo harness: yaml tasks, .mjs libs, .py tools; no single pack describes it",
});
gate_low_risk configures the prose class of the ci-gate redundancy check
(MGS3010): the paths whose changes,
on their own, never make a passed gate worth re-running. magus ships markdown
defaults (**/*.md, **/*.markdown). Declaring the key on any project
replaces those defaults workspace-wide with the union of declared globs (each
relative to its declaring project, like review_required); to extend the
defaults, restate them alongside the additions. [] is a legal declaration
that turns the prose class off entirely. Every refusal and advisory names, per
path, the glob that classified it and where it was declared, so the decision
reads straight back to this key. Only the prose class is a glob list: the
generated class stays structural (declared outputs), and comment-only stays a
mechanism - Go and Buzz through the lexers magus owns, other spelled languages
through a declared comment/string syntax table, and a language with no
declaration is always code.
magus\project({
"gate_low_risk": ["**/*.md", "**/*.markdown", "notes/**"],
});
gate_inherit governs the same classification one layer out, in CI rather than
on a laptop. When a CI provider is wired (magus\ci.provider(...)) and answers
which run of this pipeline last passed on this branch, magus affected ci --plan
classifies everything changed since that run's head commit with the classifier
above; if nothing classifies as code, the plan emits no shards and an inherit
block instead, and the workflow reads that one output to skip its fan-out. The
verdict is green with a report - the inherited run, its commit, and every changed
path with what classified it - never a silent skip. A merge pushed into the range
re-runs the fan-out regardless. Declaring false on any project turns the whole
mechanism off workspace-wide, the same reach a gate_low_risk declaration has,
because inheritance is one decision over the plan rather than a per-project one.
magus\project({
"gate_inherit": false,
});
The targets sub-map keys a target name to a policy table:
| Policy | Effect |
|---|---|
skip_cache |
a reason string stating why REPLAYING this target would be wrong; magus then always runs it and never replays or snapshots it. A bare true is a load error - for a merely fresh run use --no-cache (see cache) |
exclusive |
runs the target alone - no peer target runs concurrently while it does |
slots |
the target holds N concurrency slots while it runs, throttling parallel work |
memory_mb |
the peak memory this target needs, in megabytes; magus converts it to slots against the host's memory-per-slot share, so one declaration throttles correctly on a 16GB runner and a 64GB workstation. A composed target inherits the largest declaration in its chain |
timeout |
a duration string ("15m") bounding one run of this target, subprocesses included. Undeclared is unbounded. A runaway guard, not a budget: declare a multiple of the worst run on record. The deadline rides the context, so it also bounds every target this one composes |
drift |
what happens when this target's declared outputs move under a read-only run: "fail" (the default for a target that declares outputs), "warn" to report without failing, or "off". An unrecognized value is a load error |
drift_reason |
prose for turning the drift gate off; required when drift is "off", for the reason skip_cache's is |
retry_on_volatile |
a reason string stating why this target fails without the code being wrong; magus then routes its failures through volatility detection and reruns a predicted-volatile one once (see volatility). A bare true is a load error, and the opt-in binds to this target alone |
cache.include |
overrides the workspace's cache.include.os/arch.enabled for this target, for an artifact that varies along one axis but not the other. Nested to mirror magus.yaml exactly; a misspelled nesting level is a load error rather than a silent inherit |
magus\project({
"spells": [go],
"depends_on": ["../shared"],
"outputs": ["dist/**"],
"watch_ignore": { "glob": ["**/*.snap"] },
"targets": {
"test": { "slots": 4, "memory_mb": 8192 },
"build": { "skip_cache": "signs a fresh artifact per invocation" },
"security": { "timeout": "15m" },
"integration": { "retry_on_volatile": "talks to a shared broker that drops a connection under load" },
"image": { "cache": { "include": { "arch": { "enabled": false } } } },
},
});
depends_on: cross-project dependencies
depends_on declares that this project's work depends on one or more upstream projects. Paths resolve exactly like CLI project arguments (via file.Resolve): a bare path (shared) is repo-relative to the workspace root; a dot-relative path (../shared) is relative to the declaring project; absolute paths and paths that escape the root are rejected (see targets.md#path-resolution-on-the-cli).
Declared edges are unioned with any edges a bound spell contributes, then deduplicated. From there they drive three things:
- Ordering. The dependency graph (
depgraph.Build) adds an edgeproject -> depfor everydepends_onentry. Upstreams run before their dependents, and a cycle is a hard error. - The affected set. When a change touches a project, magus computes the reverse closure over these edges (
g.ReverseClosure), so every downstream dependent is also selected. A change insharedpulls in everything that depends onshared, transitively. - Cache keys. A dependent's cache key folds in the resolved cache keys of its upstreams as
dep:lines (see cache.md#the-cache-key). When an upstream's key changes, the new key flows into the dependent's key, so the dependent misses transitively. This is how a change ripples deterministically through the graph rather than by rerunning everything.
Monorepo patterns
A workspace can hold many projects. The common layout is one magusfile per project directory, each declaring its own spells and dependencies:
repo/ # workspace root (magus.yaml, go.mod)
magusfile.buzz # project "."
api/
magusfile.buzz # project "api"
web/
studio/
magusfile.buzz # project "web/studio"
shared/
magusfile.buzz # project "shared", an upstream of api and web/studio
The central (monorepo) form
magus\project also accepts an explicit path as its first argument. This is the rarer central form: one magusfile declares options for a discovered project at another workspace path.
magus\project({ "spells": [go] }); // configures THIS project (path from context)
magus\project("api", { "depends_on": ["shared"] }); // configures the discovered "api" project
The explicit-path form configures a project that discovery already found - it does not create one. The path is relative to the workspace root, not to the declaring magusfile's directory. Passing the magusfile's own directory name here is the classic footgun; to configure the calling project, omit the path. An explicit path that matches no discovered project is a hard error that lists the known projects.
Addressing projects on the CLI
Project selection is a positional argument to magus run, magus list, and magus clean, never embedded in the target token (see targets.md#cli-grammar):
| Input | Selects |
|---|---|
bare (api, web/studio) |
the project at that workspace-relative path |
dot-relative (./x, ../x) |
resolved against the current working directory |
. |
the project containing the current directory |
empty (omitted) or / |
all projects (fan-out) |
Empty and / both fan out to every discovered project. A ws: prefix is rejected: magus tells you to use / for all projects instead. An unknown bare path is an error with a did-you-mean suggestion. Because paths are repo-relative, a bare api means the same project regardless of your current directory, while ../foo behaves as a shell user expects.
How this connects to affected and the cache
The workspace/project model is the substrate the affected engine and the cache build on:
- Affected computation attributes each changed file to the project that owns it, seeds the change, and takes the reverse closure over
depends_onto select every dependent.magus affected ciruns only that set. See operations for where affected sits, and the seed/claim mechanics in cache. - The cache is content-addressed per target. A target's key includes its own inputs plus the
dep:keys of its upstream projects, so cross-project dependencies invalidate transitively without rerunning unaffected work. This page does not restate the key format; see cache.
Together, discovery gives magus the set of projects, depends_on gives it the edges, and the cache gives it the memory - so a run touches only the minimum the change demands.
Glossary
| Term | Definition |
|---|---|
| Workspace | The discovered root, its magus.yaml, and the set of projects beneath it. The types.Workspace value, keyed by root path. |
| Project | A directory registered by a magusfile; owns its targets, bound spells, and policy. The types.Project struct. |
| Root | The workspace root directory, found by FindRoot and canonicalised at discovery. Every project Path is relative to it. |
| Discovery | The single WalkDir pass (project.Discover) that registers a project per directory carrying a magusfile. |
| magusfile | magusfile.buzz (or magusfiles/*.buzz); its presence registers a project. Exported functions become targets. |
magus\project |
The optional call that layers policy (spells, depends_on, outputs, watch-ignore, per-target flags) onto a project. |
| depends_on | Declared upstream project paths. Drive ordering, the reverse-closure affected set, and dep: cache-key propagation. |
| Ignore dirs | The fixed directory names discovery prunes at any depth (.git, vendor, node_modules, target, gen, ...). |
See also
- targets: the addressable unit of work, project-path resolution, and the CLI grammar.
- dependencies:
depends_onversusmagus\needs, the fold between them, and how they feed the cache and the affected set. - operations: the Spell to Operation to Target hierarchy and where affected computation sits.
- spells: the tool libraries a project binds and composes into targets.
- cache: the content-addressed cache key, including the
dep:lines that propagate cross-project changes. - config: every
magus.yamlkey, its environment variable, and CLI flag.