Skip to content

The baseline task vocabulary — one set of verbs, owned in three layers

State: open/active

  • Ten moon task names, the same in Rust and TypeScript: check, typecheck, lint, lint-fix, fmt, fmt-check, test, build, clean, dev.
  • build and dev are the web form. A -desktop suffix names the Tauri shell around it: build-desktop is tauri build, dev-desktop is tauri dev. That is the direction Tauri’s own config runs, so nothing is renamed underneath it.
  • dev and the -desktop pair belong to applications and tools. A library has nothing to run.
  • A bare name is a gate and must not write to the tree. A -fix suffix is the form allowed to mutate. fmt and clean are the two deliberate exceptions: fmt matches what cargo and npm already do, and clean removes only untracked build output.
  • check is a deps-only aggregate in both languages. No leaf logic lives in it.
  • A task that resolves through an installed dependency tree declares a dependency on the install that provisions it. A verb is not allowed to assume someone ran an install first, and that install is cached on its manifests so the dependency is not a toll on every run.
  • Three layers own three different things: the native runner owns the command, the moon aspect owns the name and the graph, the root justfile owns the parameterized entry point.
  • Which project earns which verb is a mechanical rule over the manifest, not a curated list, so a guard can enforce it.

Ten names, no others. Rust and TypeScript answer to the same set; only the command underneath differs.

moon taskWrites?RustTypeScript
fmtyescargo fmt -p $projectbun run format
fmt-checknocargo fmt -p $project -- --checkbun run format:check
lintnocargo clippy -p $project --all-targets --all-features -- -D warningsbun run lint
lint-fixyescargo clippy -p $project --all-targets --all-features --fix --allow-dirtybun run lint:fix
typechecknonot defined — lint already compiles the cratebun run typecheck
testnocargo test -p $project --all-featuresbun run test
builduntracked output onlycargo build -p $projectbun run build
cleanuntracked output onlycargo clean -p $projectbun run clean
devno; persistentnot defined — a crate has nothing to runbun run dev
checknodeps only: ~:fmt-check, ~:lintdeps only: ~:typecheck, ~:fmt-check, ~:lint

Rust declares no typecheck. cargo clippy compiles the crate to lint it, so a separate typecheck task would be the same work under a second name.

--all-features is part of the Rust gates because dropping it would narrow them: 44 of the 55 Rust projects already carried it on lint and 43 on test. Two crates opt out for documented reasons — determined-engine-client trips an engine-port compile_error! tripwire, and determined-models fetches a native ONNX runtime from a CDN — and they keep their own lint and test.

build in Rust is cargo build -p $project, the crate’s own compile into the shared target. The overrides are the same countable set as fmt’s: the Tauri apps, whose build is the frontend (below); tools/cage, two crates in one project; pumice-core, whose id differs from its crate; and slug, whose name collides with a crates.io package. Each override sets merge: 'replace', because moon otherwise appends the aspect’s arguments into the local command.

build and clean in TypeScript delegate from one unscoped aspect (.moon/tasks/common.yml) that every project inherits, so the manifest’s script is the whole definition. clean is the inverse of build: it removes what build emitted and nothing else. In Rust it is cargo clean -p $project, the crate’s own artifacts out of the shared target — never a bare cargo clean, which empties the entire root workspace target. The Tauri aspect replaces the Rust form with the app root’s clean script, which chains the frontend’s with the crate’s. Reclaiming the shared cargo target as a whole is not a project verb: just clean runs every project’s clean and then scripts/clean-stale-targets.ts.

The bare verb is the web form; -desktop is the shell

Section titled “The bare verb is the web form; -desktop is the shell”

A project that ships a Tauri shell has two builds and two dev loops. The bare verb is the frontend and the suffixed verb is the shell around it:

moon taskCommandDepends on the bare verb?
devbun run dev — nuxt dev, astro dev, or a composer that starts a backend first—
dev-desktopbun run dev:desktop — tauri devno; tauri dev runs beforeDevCommand itself
buildbun run build — the static SPA into .output/public or dist/—
build-desktopbun run build:desktop — tauri build, under the tauri-bundle mutexno; tauri build runs beforeBuildCommand itself

The direction is Tauri’s, not this standard’s. Every tauri.conf.json here names bun run dev as beforeDevCommand and bun run build as beforeBuildCommand, then wraps the dev server in a window or embeds what the build wrote. So dev-desktop is dev plus a window and build-desktop is build plus a bundle. Naming the bundle build would either recurse or force every Tauri config to call a differently named frontend script.

build-desktop does not depend on ~:build. Tauri runs the frontend build itself, and the dep would generate the SPA twice. The one exception is documented in place: sdf orders its bundle after build, prepare and the frontend gates through deps, because every one of them is a Nuxt invocation that trips Nuxt’s build lock when two overlap, and a moon task holds one mutex — build-desktop’s is already tauri-bundle. It pays the second generate for the ordering.

dev, dev-desktop and build-desktop come from .moon/tasks/app.yml, inherited by layer: application and layer: tool only. A library with a playground dev script (waypost) keeps it as a local convenience, reached with bun run dev in its own directory. Whether a desktop binary becomes an installer is bundle.active in the Tauri config, which scripts/install-desktop-apps.ts reads; it is not a separate verb.

A task whose name carries no suffix is read-only. It reports and it exits non-zero; it does not touch a tracked file. lint-fix is the only name in the vocabulary that may rewrite source, and it is never a dependency of check.

fmt is the exception, and it is deliberate. A formatter is a total function with exactly one correct output, so writing that output is its safe default and fmt-check is the opt-in gate. A linter is an analyzer whose findings may have no mechanical fix at all, so reporting is its default and lint-fix is the opt-in. This is the split cargo and npm already ship — cargo fmt writes, cargo fmt --check gates, cargo clippy reports, cargo clippy --fix writes. Nothing here is being renamed; the moon layer keeps the names of the commands it runs.

clean is the other exception, for a narrower reason: what it deletes is untracked build output, so the tracked tree is unchanged after it runs. It never removes node_modules, a lockfile, or a tracked file — removing dependencies is not removing output, and a clean that does either is a defect rather than a policy choice. build and dev write the same class of output (dist/, .output/, .nuxt/, target/), all untracked, so the rule holds for them without an exception.

check declares dependencies and nothing else. It carries no command: and no script: in either language. ~: scopes each dependency to the inheriting project, so a project that overrides a leaf gets its own override run.

This changes what check means for the TypeScript libraries that inherit .moon/tasks/ts-lib.yml, where check used to be the tsc --noEmit run. Under this standard, that run is typecheck and check aggregates it with fmt-check and lint. CI names :check, so coverage grows rather than shrinks — but the first run after the change is worth watching.

LayerOwnsMust not own
Native runner — package.json scripts, cargothe commandthe task graph
moon aspect — .moon/tasks/*.ymltask names, deps, inputs, caching, affected-detectiona command the native runner already owns
Root justfileparameterized human and skill entry pointsanything moon expresses without parameters

A moon task that has a native script to delegate to declares bun run plus the script name and stops there. It does not restate the tool, its flags, or its config path. The global aspects add --if-present, so a project that has not earned a verb is a no-op under it rather than a failure, and moon run :build fans out over the whole repo.

5. Layer A keeps its own ecosystem’s script names

Section titled “5. Layer A keeps its own ecosystem’s script names”

The commands live under the names their ecosystem already uses, so a mirrored package reads normally to someone who has never heard of moon. The mapping is mechanical: a - in a moon task name is a : in the script name.

moon taskpackage.json script
fmtformat
fmt-checkformat:check
lintlint
lint-fixlint:fix
typechecktypecheck
testtest
buildbuild
build-desktopbuild:desktop
cleanclean
devdev
dev-desktopdev:desktop

The moon layer performs the mapping. Nothing renames a script to match a moon task name, and nothing renames a moon task to match a script.

The discriminator is a property of the manifest, its directory, or the moon layer, checkable without a curated list.

VerbRuleToday
format, format:check, lintevery project manifest156
typechecka tsconfig.json sits beside that manifest81
testthe project has tests76 declare one
buildthe project emits an artifact76 declare one
cleanthe manifest declares build76
devthe moon project is layer: application or tool and has a dev loop43 of 53 declare one
dev:desktopa src-tauri/tauri.conf.json sits beside the manifest34 declare one
build:desktopdev:desktop, and the app builds a desktop binary31 declare one
lint:fixoptional; declare it where the linter can fix3 declare one

format, format:check and lint need no discriminator because they operate on source files and require no compilable unit. Every project manifest gets them. 166 tracked package.json exist; 10 are excluded by rule 7, leaving 156 projects — 55 under packages/ and apps/, 100 under solutions/, and the repo root.

The typecheck rule is a colocated tsconfig.json, not “owns a real one”. Of the 156 projects, 81 have a tsconfig.json beside the manifest: 64 the project’s own, 17 framework-owned — extending ./.nuxt/tsconfig.json or astro/tsconfigs/*. All 81 get typecheck. The framework-owned 17 run a different tool inside their own script, which is precisely what rule 4 exists to make possible; excluding them would contradict the reason for delegating in the first place.

Classify on the parsed extends value, not on whether the file mentions a framework path. These configs are JSONC, and packages/ts/markdown-ui and packages/ts/ui both carry a comment explaining why ./.nuxt/tsconfig.json is the wrong spelling for them — while actually extending ../tsconfig/presets/vue.json. A grep for the string classifies both as framework-owned and is wrong by two in each direction.

clean needs no discriminator of its own. It is owed wherever build is, because a project that emits an artifact must be able to remove it; a project that emits nothing has nothing to remove. A build that writes tracked files is not a build in this sense — it is a generator, and it carries a different name (packages/vaults/fitness regenerates its vault under generate).

dev is a property of the moon layer, not the manifest alone. Only a layer: application or layer: tool project inherits the task, so a library that declares a dev script has a local convenience and no moon verb. Nine of the 52 applications and tools declare no dev script: CLIs, daemons and generators with no dev loop, the repo root, and the one project without a tracked manifest — sites/df-docs — which declares its loop in moon.yml with merge: 'replace'.

The -desktop pair is a property of the directory. Every Tauri app declares dev:desktop, because a native window is what the shell is for. build:desktop is narrower: it is the desktop-bundling opt-in, not a consequence of dev:desktop. Of the 25 determined products, every one but blank — the zero-experience starter template, per solutions/determined/products/README.md — declares it; the rest of its declarers are the standalone apps under apps/, solutions/*/apps/ and the sdf desktop.

The other 75 projects get two verbs, not five. They are not empty shells: 71 of them carry real .ts, .vue or .tsx source, so they genuinely need formatting and lint. They simply cannot be typechecked in isolation.

7. Fixtures, templates and generated manifests are outside the vocabulary

Section titled “7. Fixtures, templates and generated manifests are outside the vocabulary”

A manifest that is itself test input is data, not a project. Its contents are fixed by whatever the suite asserts about them, so the vocabulary does not apply and a guard must not demand it. The discriminator is a property, not a path allowlist, because a hand-maintained list of fixture directories rots:

  • the manifest sits under a tests/, fixtures/, __fixtures__/ or testdata/ path segment, or
  • it is a scaffolding template whose manifest is copied into a generated project rather than run in place, or
  • it is written by a build script. A manifest a build emits is generated output, and anything added to it is destroyed by the next build. agent-pants writes every pack tree (.claude-plugin/packs/, .agents/packs/, .cursor-plugin/packs/, .pi/packs/), so a manifest under one is generated.

Ten of the 166 tracked manifests qualify: manifest-graph’s six-manifest mini-repo, waypost’s showcase fixture, solutions/ontological/site-template, and two wasm output directories — packages/ts/yaml-splice/wasm and solutions/agent-pants/packages/agent-pants/wasm. Each wasm manifest is printf-ed by its crate’s build.sh after an rm -rf of the directory it sits in, so the pair is a class rather than a one-off.

.../mini-repo/packages/broken/package.json is the case that proves the rule. It is deliberately malformed JSON, read by solutions/ontological/crates/manifest-graph/tests/mini_repo.rs, and “fixing” it to satisfy a vocabulary guard would break the suite. waypost’s showcase is the same shape: it declares lint: echo linting as a fake runner for waypost’s UI tests to photograph, not as a defect to correct.

8. A task depends on the install that provisions its tree

Section titled “8. A task depends on the install that provisions its tree”

A moon task whose command resolves through an installed dependency tree declares a dependency on the install that writes that tree. It does not assume one has already run.

Which install depends on where the project’s dependencies live, not on where the project sits:

The project isIts tasks depend on
a member of the root Bun workspaceroot:install
its own Bun root (its own lockfile, outside the root workspaces)~:install
neither, but resolving upward into the root treeroot:install

The dependency is declared on the aspect, once, beside the command that needs it — not repeated in each inheriting project’s moon.yml. install itself is the exception that cannot carry it, and a task shelling to a runner with no install step (cargo, rustfmt) does not need it.

Two failures make this a rule rather than a convenience. Without the dependency a verb is unrunnable in a fresh checkout or a new worktree — it fails on an absent node_modules instead of provisioning one, so the first thing a new clone does is fail. And moon is free to schedule the verb while the install is still rewriting node_modules, which surfaces as a generated file that exists when a task starts and not when the next one reads it.

An install that every task depends on must be cached on its manifests. An uncached one is a toll on every moon run, because a package manager re-verifies the whole tree even when nothing changed — measured here at 20-26 seconds against a 171ms task. Hash the manifests and the lockfile and the warm path drops to about a second.

Caching does not weaken the rule, because the case the rule exists for is the case that cannot hit the cache: a task cache lives in an ignored directory, so a fresh clone or git worktree add starts empty and its first run always installs. What it does not catch is a dependency tree damaged by hand — the hash is over the manifests, not the tree — which is the same exposure that existed before the rule and is repaired by running the install directly.

A mutex on the install is what keeps a graph-wide fan-out to one serialized run rather than N racing ones.

The command lives in the native runner because that is what travels. .github/workflows/publish-lib.yml rsyncs each mirror with --exclude 'moon.yml', so a task declared only in moon does not exist in the published package. A typecheck in a moon.yml is a gate the mirror never gets. A typecheck in scripts ships with it. The same holds for dev and build: an app checked out on its own still answers bun run dev and bun run dev:desktop, and its Tauri config still finds the build it names.

Delegation is what lets one aspect serve both stacks. .moon/tasks/ts-lib.yml covers TypeScript libraries on both stacks because every task runs bun run <script>. A bare tsc --noEmit cannot resolve a .vue import, so an aspect that ran tsc itself would have to exclude the frontend; one that runs bun run typecheck never faces that question, since the package’s own script picks tsc or vue-tsc. dev is the same shape: the app knows whether its loop is nuxt dev, astro dev, or a script that starts a dashboard and then the window, and the aspect does not.

The suffix goes on the shell because Tauri already made that choice. tauri build is the whole desktop pipeline, and its first step is the frontend’s own build. Keeping the bare verb as the frontend leaves every tauri.conf.json as Tauri generates it. Products that open in a window for development but ship no installer get dev-desktop and no build-desktop script, and the aspect’s --if-present makes that a no-op rather than a special case.

The justfile survives because just has named parameters. Recipes take an argument with a default, as check-markdown *paths="docs packages/ts ..." does. moon tasks and bun scripts have no equivalent, so the parameterized human and skill entry points stay in just and delegate down to moon.

The Nuxt layers cannot be typechecked alone, and this is measured, not assumed. .moon/tasks/nuxt-product.yml records the experiment: narrowing include to a single experience drops Nuxt’s generated types from the program and reports about 83 errors that are not real, every auto-import becoming a TS2304. solutions/determined holds 24 products and 24 experiences with no tsconfig of their own. They are checked through the composed product, and they earn format and lint on their own account because they contain real source.

One Rust aspect replaced 166 per-crate override blocks because $project substitutes in a moon command:. Verified across the 57 Rust moon projects: 47 have id == alias == Cargo package name with 0 mismatches, 1 has no alias (pumice-core), and 7 have no Cargo.toml at their project source — the six Tauri apps, whose crate lives under src-tauri/, plus tools/cage. So cargo fmt -p $project resolves to the right crate for every project that has one, and the exceptions are countable and documented rather than a reason to hand-write the commands.

Measured after the collapse: override blocks fell from 215 to 49 across 55 files down to 14, no project lost a task, and lint resolved byte-identically for all 59 projects. The 14 survivors each carry an in-file reason — two crates sharing one moon project, an isolated cargo workspace whose packages are named differently from the project, a name that collides with a crates.io package, or a documented --all-features opt-out.

Adding a TypeScript project: declare format, format:check and lint in scripts. Add typecheck if a tsconfig.json sits beside the manifest, test if there are tests, build if it emits — and clean beside every build, removing exactly what build wrote. An application or tool also declares dev. Write no tasks: block in moon.yml unless the project needs something the aspect does not give it.

Adding a Tauri app: dev and build are the frontend commands the app’s tauri.conf.json names as beforeDevCommand and beforeBuildCommand. dev:desktop is tauri dev and build:desktop is tauri build. Declare build:desktop only if the app builds a binary. Tag the project tauri so it inherits the frontend gates and the clean that chains both halves.

Adding a Rust crate: declare nothing. The aspect in .moon/tasks/rust.yml supplies the whole vocabulary through $project. Override a leaf only when the crate genuinely differs, and say why in a comment.

Running an app: moon run <app>:dev for the browser, moon run <app>:dev-desktop for the window. Installing one: just install-apps, which runs <app>:build-desktop for every app whose Tauri config bundles.

Declaring the install dependency: put it on the aspect, next to the command. An inheriting project needs nothing. A project that is its own Bun root uses ~:install so the scope follows the inheritor; every other project names the install that provisions the tree it resolves through. Give the install it names inputs covering its lockfile and every manifest that feeds it, and cache: true — an install depended on by everything and cached by nothing is the slowest edge in the graph.

Overriding an inherited task: moon appends local over inherited by default, so a replacement must set options.merge: 'replace'. A local block that only adds deps, inputs or outputs keeps the inherited command and needs no merge.

Adding a test fixture that contains a manifest: put it under tests/, fixtures/, __fixtures__/ or testdata/, and rule 7 keeps the vocabulary guard away from it.

Checking what a task actually runs: moon task with a project-scoped target prints the resolved command after token substitution. That is the read-only way to confirm a $project interpolation, and it is how the Rust column above was verified.

Confirming a bare name is still a gate: run it, then git status. A clean tree is the whole assertion.

Project shapeGetsDoes not get
packages/ts/easy-git — manifest plus its own tsconfig.json, a libraryformat, format:check, lint, typecheck, test, build, cleandev
solutions/determined/experiences/* — manifest, real .vue source, no tsconfigformat, format:check, linttypecheck, build
solutions/determined/apps/site-builder — manifest plus a Nuxt-owned tsconfigall of the above; its typecheck runs nuxt prepare && vue-tsc—
apps/pumice — a Tauri app, layer: applicationformat, format:check, lint, test, build (the SPA), clean, dev, dev-desktop, build-desktop (the bundle), and the tauri tag’s frontend-* gatestypecheck at the root; the frontend’s runs as frontend-typecheck
solutions/determined/products/blank — the zero-experience starter template, a src-tauri/ and no installerdev, dev-desktop, build, cleana build:desktop script; its build-desktop is a no-op
packages/rust/slug — a crate whose name collides with a crates.io packagethe whole vocabulary, each leaf respelled --manifest-path Cargo.toml with merge: 'replace'typecheck, dev
packages/ts/waypost/tests/fixtures/showcase — a fixturenothing; rule 7 excludes itevery verb
  • A lint script defined as eslint . --fix. It is a bare name, so it is a gate, and a gate that rewrites source makes moon run <app>:lint a mutation. Two apps do this today.
  • A moon.yml declaring command: 'bunx tsc --noEmit -p tsconfig.json' for a package whose package.json already has a typecheck script. The aspect restates a command it does not own, and the mirror — which ships without moon.yml — gets nothing.
  • A check task with its own command:. It is then a leaf wearing an aggregate’s name, and adding a fourth gate means editing it rather than adding a dep.
  • A typecheck or test that runs the package manager but declares no install dependency. It reports green wherever someone has already installed and fails on a fresh worktree, which reads as a broken checkout rather than a missing edge in the graph.
  • A build script on a Tauri app that runs tauri build. Tauri’s beforeBuildCommand calls bun run build for the frontend, so the bundle under the bare name either recurses or forces the config to call a differently named script. The bundle is build:desktop.
  • A build-desktop task with deps: ['~:build'] and no comment saying what the ordering buys. tauri build runs the frontend build itself; the dep generates the SPA twice.
  • A dev task in a library aspect. A library has nothing to run, and a playground is reached with bun run dev in its own directory.
  • Excluding a project from typecheck because its tsconfig is framework-generated. The script is the seam; the aspect does not need to know which tool ran.
  • Adding the vocabulary to a fixture manifest to satisfy a conformance guard. The fixture is an assertion, and editing it moves the failure into a test suite where it reads as an unrelated regression.
  • An app-local justfile that reimplements fmt, lint and test with no reference to moon. It is a fourth layer, and it drifts.
  • A clean script that deletes node_modules, src-tauri/Cargo.lock or a foreign lockfile. Four Tauri apps did this before the verb was standardized: the next build could no longer run, and a deleted Cargo.lock changed what it built.
  • A bare cargo clean from a member of the root cargo workspace. It empties the target every crate shares. Only the isolated workspace (apps/ontological-legacy) may run it, because its target is its own.
  • Counts over the tracked package.json set, excluding node_modules and .nuxt: 166 tracked, 10 outside the vocabulary by rule 7, 156 projects. scripts/guard-project-shape.ts recomputes all three on every run and prints them in its success line; the numbers here are copied from it and are worth re-copying when they drift.
  • The verbs are the contract. What lint enforces beyond the shared config is a separate question this standard does not answer.
  • S-0009-moon-project-ids governs the id that $project resolves to, so the two standards have to stay consistent: renaming a project id changes which crate cargo fmt -p $project formats.

← Back to Standards