For AI agents
Driving mmz from an agent: machine-readable state, honest exit codes, and the one mistake an agent is most likely to make with it.
Why this page exists
An agent working in a repository pays for every re-run twice — once in wall clock, once in the context the output consumes. mmz is well suited to that: its whole job is answering “has this already passed?” without producing the output again.
But the same properties that make it useful make one failure mode likely, and it is worth stating before anything else.
strict or narrow a scope to make a red gate go green. A rule that reports fresh because its inputs stopped resolving is worse than a rule that fails: it is a check that will never run again, and nothing will say so.Machine-readable state
Every question has a JSON answer, so nothing needs to be parsed out of a table meant for humans:
mmz --status=json # every rule, its inputs, their hashes
mmz --status=json-schema # the schema for the above
mmz --schema # the manifest schemaThe state vocabulary is small and total: fresh, stale, never, failed, no-inputs, missing-output, expired — see the CLI reference. Reading --status=json is the right move when a gate is red and the question is which input moved:
mmz --status=json | jq '.rules[] | select(.state != "fresh") | {name, state, missing_output}'Honest exit codes
Every code means one thing, so branching on $? is safe:
1is only ever a--is-freshverdict, never an error.3is a strict refusal — the manifest declined to guess.4is a manifest problem, and is the one case no setting relaxes.5and6both mean a run happened and deliberately left no record: a declared output was missing, or a probe failed.7is a gate that selected no rule — a tag nothing carries, most often. It is not a stale build and not a passing one; it is a gate asking about nothing.
An agent should treat 4, 5, 6 and 7 as “fix the cause”, never as “retry”.
Recording a pass
mmz observes only the commands it wraps. Running just check by hand and then asserting freshness will fail, correctly, and the gate says so:
mmz: `./bin/validate.sh` is stale (inputs changed since it last passed)
mmz: `./bin/report.sh` is stale (inputs changed since it last passed)
mmz: re-run each listed command under mmz (e.g. `mmz ./bin/validate.sh`) to record a pass; a standalone run is not trackedSo the loop is: run the check under mmz to record the pass, then assert. The assertion costs a hash comparison rather than a re-run, which is what makes it usable in a hook or at the end of a task.
mmz just check # runs, or skips if already fresh; records on success
mmz --is-fresh --tag gate # asserts, runs nothing, exits 1 if any gate is staleAdding a rule
When you add a memoized command, the scopes are the whole decision. The rule: list every file the command could read.
- Sources, and the manifests and lockfiles that pin its dependencies.
- The toolchain pins, if a toolchain change should re-run it —
rust-toolchain.toml,flake.lock.mmztrusts file content, never the ambient environment. A wholeflake.lockin a scope is the simple default, and it over-busts once the lockfile is big: a hundred nodes hashed together means any transitive bump re-runs every rule that pins it. Name the one node a rule’s tools come out of instead — afile:+json:probe on.nodes["<input>"]["locked"]["narHash"]— remembering that such a probe is exact for a tool that is its own flake input and only a proxy for one out ofnixpkgs. - The recipe or script body that defines the command, if it lives in a file. If it lives in part of a file, use a probe rather than hashing the whole file.
- Not a tree another command writes while the rule is checked, such as a cache a sibling gate rewrites. A glob over it hashes a moving target, so leave it out with
excluderather than narrowing the glob.
Then check your work with mmz --status=json: if the rule’s resolved input list is shorter than you expected, a glob is not matching — very often because the path is git-ignored or excluded.
Do not commit the cache
.mmz/cache is derived, throwaway, machine-local state. mmz --init writes a .mmz/.gitignore covering it. If a cache directory ever appears in a diff, the fix is to ignore it, never to commit it.