The baseline task vocabulary — one set of verbs, owned in three layers
State: open/active
Summary
Section titled “Summary”- Ten moon task names, the same in Rust and TypeScript:
check,typecheck,lint,lint-fix,fmt,fmt-check,test,build,clean,dev. buildanddevare the web form. A-desktopsuffix names the Tauri shell around it:build-desktopistauri build,dev-desktopistauri dev. That is the direction Tauri’s own config runs, so nothing is renamed underneath it.devand the-desktoppair 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
-fixsuffix is the form allowed to mutate.fmtandcleanare the two deliberate exceptions:fmtmatches what cargo and npm already do, andcleanremoves only untracked build output. checkis 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
installis 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
justfileowns 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.
1. The vocabulary
Section titled “1. The vocabulary”Ten names, no others. Rust and TypeScript answer to the same set; only the command underneath differs.
| moon task | Writes? | Rust | TypeScript |
|---|---|---|---|
fmt | yes | cargo fmt -p $project | bun run format |
fmt-check | no | cargo fmt -p $project -- --check | bun run format:check |
lint | no | cargo clippy -p $project --all-targets --all-features -- -D warnings | bun run lint |
lint-fix | yes | cargo clippy -p $project --all-targets --all-features --fix --allow-dirty | bun run lint:fix |
typecheck | no | not defined — lint already compiles the crate | bun run typecheck |
test | no | cargo test -p $project --all-features | bun run test |
build | untracked output only | cargo build -p $project | bun run build |
clean | untracked output only | cargo clean -p $project | bun run clean |
dev | no; persistent | not defined — a crate has nothing to run | bun run dev |
check | no | deps only: ~:fmt-check, ~:lint | deps 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 task | Command | Depends on the bare verb? |
|---|---|---|
dev | bun run dev — nuxt dev, astro dev, or a composer that starts a backend first | — |
dev-desktop | bun run dev:desktop — tauri dev | no; tauri dev runs beforeDevCommand itself |
build | bun run build — the static SPA into .output/public or dist/ | — |
build-desktop | bun run build:desktop — tauri build, under the tauri-bundle mutex | no; 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.
2. A bare name is a gate; -fix mutates
Section titled “2. A bare name is a gate; -fix mutates”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.
3. check is a deps-only aggregate
Section titled “3. check is a deps-only aggregate”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.
4. Three layers, three owners
Section titled “4. Three layers, three owners”| Layer | Owns | Must not own |
|---|---|---|
Native runner — package.json scripts, cargo | the command | the task graph |
moon aspect — .moon/tasks/*.yml | task names, deps, inputs, caching, affected-detection | a command the native runner already owns |
Root justfile | parameterized human and skill entry points | anything 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 task | package.json script |
|---|---|
fmt | format |
fmt-check | format:check |
lint | lint |
lint-fix | lint:fix |
typecheck | typecheck |
test | test |
build | build |
build-desktop | build:desktop |
clean | clean |
dev | dev |
dev-desktop | dev: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.
6. Which project earns which verb
Section titled “6. Which project earns which verb”The discriminator is a property of the manifest, its directory, or the moon layer, checkable without a curated list.
| Verb | Rule | Today |
|---|---|---|
format, format:check, lint | every project manifest | 156 |
typecheck | a tsconfig.json sits beside that manifest | 81 |
test | the project has tests | 76 declare one |
build | the project emits an artifact | 76 declare one |
clean | the manifest declares build | 76 |
dev | the moon project is layer: application or tool and has a dev loop | 43 of 53 declare one |
dev:desktop | a src-tauri/tauri.conf.json sits beside the manifest | 34 declare one |
build:desktop | dev:desktop, and the app builds a desktop binary | 31 declare one |
lint:fix | optional; declare it where the linter can fix | 3 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__/ortestdata/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 is | Its tasks depend on |
|---|---|
| a member of the root Bun workspace | root:install |
its own Bun root (its own lockfile, outside the root workspaces) | ~:install |
| neither, but resolving upward into the root tree | root: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.
How to apply
Section titled “How to apply”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.
Examples
Section titled “Examples”| Project shape | Gets | Does not get |
|---|---|---|
packages/ts/easy-git — manifest plus its own tsconfig.json, a library | format, format:check, lint, typecheck, test, build, clean | dev |
solutions/determined/experiences/* — manifest, real .vue source, no tsconfig | format, format:check, lint | typecheck, build |
solutions/determined/apps/site-builder — manifest plus a Nuxt-owned tsconfig | all of the above; its typecheck runs nuxt prepare && vue-tsc | — |
apps/pumice — a Tauri app, layer: application | format, format:check, lint, test, build (the SPA), clean, dev, dev-desktop, build-desktop (the bundle), and the tauri tag’s frontend-* gates | typecheck at the root; the frontend’s runs as frontend-typecheck |
solutions/determined/products/blank — the zero-experience starter template, a src-tauri/ and no installer | dev, dev-desktop, build, clean | a build:desktop script; its build-desktop is a no-op |
packages/rust/slug — a crate whose name collides with a crates.io package | the whole vocabulary, each leaf respelled --manifest-path Cargo.toml with merge: 'replace' | typecheck, dev |
packages/ts/waypost/tests/fixtures/showcase — a fixture | nothing; rule 7 excludes it | every verb |
Anti-examples
Section titled “Anti-examples”- A
lintscript defined aseslint . --fix. It is a bare name, so it is a gate, and a gate that rewrites source makesmoon run <app>:linta mutation. Two apps do this today. - A
moon.ymldeclaringcommand: 'bunx tsc --noEmit -p tsconfig.json'for a package whosepackage.jsonalready has atypecheckscript. The aspect restates a command it does not own, and the mirror — which ships withoutmoon.yml— gets nothing. - A
checktask with its owncommand:. It is then a leaf wearing an aggregate’s name, and adding a fourth gate means editing it rather than adding a dep. - A
typecheckortestthat runs the package manager but declares noinstalldependency. 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
buildscript on a Tauri app that runstauri build. Tauri’sbeforeBuildCommandcallsbun run buildfor the frontend, so the bundle under the bare name either recurses or forces the config to call a differently named script. The bundle isbuild:desktop. - A
build-desktoptask withdeps: ['~:build']and no comment saying what the ordering buys.tauri buildruns the frontend build itself; the dep generates the SPA twice. - A
devtask in a library aspect. A library has nothing to run, and a playground is reached withbun run devin its own directory. - Excluding a project from
typecheckbecause 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
justfilethat reimplementsfmt,lintandtestwith no reference to moon. It is a fourth layer, and it drifts. - A
cleanscript that deletesnode_modules,src-tauri/Cargo.lockor a foreign lockfile. Four Tauri apps did this before the verb was standardized: the nextbuildcould no longer run, and a deletedCargo.lockchanged what it built. - A bare
cargo cleanfrom 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.jsonset, excludingnode_modulesand.nuxt: 166 tracked, 10 outside the vocabulary by rule 7, 156 projects.scripts/guard-project-shape.tsrecomputes 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
lintenforces beyond the shared config is a separate question this standard does not answer. - S-0009-moon-project-ids governs the
idthat$projectresolves to, so the two standards have to stay consistent: renaming a project id changes which cratecargo fmt -p $projectformats.