magus v0.4.2 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
14 min read

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 Path is 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_on is 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 matching magusfiles/*.buzz) becomes a project. Nothing else registers one: auto-detection from tool markers such as a stray go.mod or package.json has 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, and gen. A magusfile buried inside one of these is invisible. (gen is treated as machine-written output, never a discoverable project.)
  • Symlinked directories are not followed. WalkDir does 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 edge project -> dep for every depends_on entry. 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 in shared pulls in everything that depends on shared, 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_on to select every dependent. magus affected ci runs 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_on versus magus\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.yaml key, its environment variable, and CLI flag.
workspaceprojectsdiscoverymagusfiledepends-onmonorepo
Last updated (14ccbb4d)
Earlier changes on this page (7)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. See workspace.

Project

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

Magusfile

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

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. See targets.

Op

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

Spell

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

Module

A magus stdlib namespace a magusfile imports for host capabilities: filesystem, exec, vcs, and more. See the module reference.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Engine

The interpreter a magusfile runs on; magus embeds the Buzz engine. See engines.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. See cache.

Affected

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

Sandbox

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

CI

An ordinary magusfile-defined target you compose yourself with magus\needs - magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

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). See daemon.

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. See daemon.

Snapshot

A point-in-time view of live state - the pool's occupancy or a tick of exported metrics - as opposed to accumulated history. See daemon.

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. See volatility.

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.

Conventions

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