Appearance
State
One file in the maxims home, state.json, records what should be installed. sync reads that file and makes the machine match; nothing else on disk is ever read back as a record of what maxims did.
This page owns what belongs in the file, the schema, and migrations. The guarantees page owns what happens when a run fails, and the share page owns the file a project commits.
State holds intent, never actuality
Each fact has exactly one owner. State records only what nothing else on the machine can tell you.
| category | what belongs | example |
|---|---|---|
| user intent | what the user asked for | which source, which memories, whether it publishes rule lines, which harnesses, where |
| fetch facts | what the last fetch found | the sha, when, the content hashes, the last error |
| provenance | when it was added and which maxims wrote the file | addedAt, writtenBy |
| not in state | the real owner |
|---|---|
| whether a memory is in the rule file | derived at sync from the rule flag, the selection, the renames, and the cap |
| installed paths and store paths | derived from the destination and the store's naming scheme |
| whether the hook is registered, its command, whether it is async | the harness registry, read at sync |
| each target's path, strategy, and tier | the harness definition; the tier achieved is a sync-time result list reports |
| collisions | re-derived by walking the name index; a resolution is a rename entry |
| retired memories | the log; a retired memory drops out of the regenerated block on its own |
updatedAt | the state file's mtime, plus the log |
user defaults: which harnesses, --yes, --rule, --add-hook, the cooldown, the cap | config.json, beside state; sync reads the cooldown and cap, add reads the rest |
| the project's source list for a fresh clone | the project lock: state is per machine, the lock per repository |
A maxims upgrade that changes the hook command needs no migration; the next sync rewrites the entry. What add fills in from config.json becomes ordinary intent.
Storing "it is installed" beside "it should be installed" creates two fields that can disagree the moment a user hand-edits a settings file. With no actuality fields there is nothing to reconcile, and recovery from any crash is maxims sync again.
The schema
json
{
"version": 1,
"writtenBy": "maxims@0.4.1",
"hooks": { "global": ["claude-code", "codex"], "project": { "/home/user/project": ["codex"] } },
"sources": {
"@Octocat/rules": {
"intent": {
"from": { "type": "github", "repo": "Octocat/rules", "ref": "HEAD" },
"auth": false,
"select": ["commit-small-and-often", "prefer-timeouts-to-hangs"],
"rename": { "commit-small-and-often": "commit-small-and-often-dotfiles" },
"rule": true,
"destination": { "scope": "global" },
"copy": false,
"harnesses": ["claude-code", "codex"],
"memoryPath": "memories",
"fullDepth": false
},
"fetched": {
"at": "2026-08-27T04:12:09.113Z",
"sha": "fc675572711b0a1c9e00000000000000000000aa",
"memoryPath": "memories",
"memories": {
"prefer-timeouts-to-hangs": {
"content": "sha256:9f2a1c9f2a1c9f2a1c9f2a1c9f2a1c9f2a1c9f2a1c9f2a1c9f2a1c9f2a1c9f2a",
"description": "sha256:11cd11cd11cd11cd11cd11cd11cd11cd11cd11cd11cd11cd11cd11cd11cd11cd"
}
},
"lastError": null
},
"addedAt": "2026-08-20T08:38:04.471Z"
}
},
"disabled": {
"global": ["commit-small-and-often-dotfiles"],
"project": { "/home/user/project": ["prefer-timeouts-to-hangs"] }
}
}The example is hand-written and parses against the current schema; a test keeps it that way.
versionis the integer schema version, bumped on any breaking shape change.writtenBysays which maxims wrote this, so a bug report is reproducible without asking.hookslists the harnesses where the user wants a sync hook kept, and the MCP server where declared, per scope:globalis one sorted list for the user scope,projectone sorted list per project root, soadd --add-hookin one project says nothing about the user scope or another project. A harness leaves the list with its last source there. Lists, not records: registration is read from the harness.overridesis reserved for the one hook fact that is intent, a config path the user chose over the harness definition; accepted as an open record, and nothing writes or reads it yet.intent.fromisgithubwithrepo,ref, andhostonly whenGH_HOSTnamed an enterprise instance ataddtime, so the source is never re-expanded againstgithub.comlater;gitwith the remoteurlas you typed it andref; orlocalwithpathand optionallive. A pinned local directory or a live fetched source cannot be written down.refisHEADfor the default branch's head; the branch name is never stored because a repo can rename it.intent.authis whether refreshes of this source use yourghlogin; set by--auth, false by default, so an anonymous install never turns authenticated on its own.intent.selectis*or an explicit list; applied every sync, so a refresh can never widen the selection.intent.renamemaps upstream name to local name; why it exists is not stored,listre-derives whether it still resolves a live collision.intent.ruleis whether this source publishes one-liners; the field that separates--rulefrom--add-hook.intent.destinationisglobal;projectwithroot, the realpath of the project, sosyncandlistfind a project's sources from state alone and a moved folder shows as a root that no longer exists; oroutwith apath. A project entry withoutrootis corrupt, and-gwith-ohas no representation.intent.copy,intent.memoryPath,intent.fullDepth,intent.pathsrecord--copy,--from,--full-depth,--pathsper source.intent.allowHiddenistruewhenadd --allow-hiddenwas given, or wheninstallreplays a lock entry carrying it, and otherwise absent. It is standing permission for descriptions with hidden characters, whichaddotherwise refuses with exit 3; the check runs ataddtime only.intent.harnessesis which harnesses this source writes to.intent.reviewistrueor absent, neverfalse: set byadd --revieworreview, so refreshes wait foracceptinstead of applying. The review hold owns the verbs.intent.sharedistrueor absent:truemarks a project source as projected into.agents/maxims.lock, set byadd --share,share, andinstall; on aglobaloroutdestination it is corrupt. The sharing section owns the verbs.fetched.atandfetched.shadrive the cooldown and staleness; the sha is what was fetched, whererefis what was asked for. It is the 40-hex commit sha the remote reported for a GitHub or git source, or asha256:<64 hex>hash of the directory contents for a copied local source, spelled like a memory hash. A live local source has nofetchedblock, because the tree is the record.fetched.memoriesholds a content hash and a description hash per memory; a refresh diffs the content hashes to report each memory added, removed, or changed.fetched.lastErroris why the last fetch failed (network,ratelimit,missing,auth,invalid), so the staleness notice can say which.pendingis the revision held for review: itssha,at, andsummary, the diff againstfetched.memories. It is absent while nothing waits, and a live source never has one, since its directory is read in place. Apendingon a source withoutintent.review, without afetchedblock, or at the installed sha is corrupt.addedAtis provenance; there is noupdatedAt. Every timestamp is ISO 8601 UTC in millisecond form; a hand-edited spelling of another precision reads as the same instant in that form and is written back so on the next write.disabledholds the memoriesdisablewithheld, by local name:globalis one sorted list for-g,projectone sorted list per project root, so a memory disabled in one project stays live everywhere else. The project lock carries a copy of its own root's list.
Each source is keyed by what identifies it, never by a memory name, which is what makes an upstream rename disappear cleanly. The block is regenerated from the store's current content, so a vanished name cannot survive in the output.
| source | key |
|---|---|
| GitHub | @owner/repo, or @<host>/owner/repo on a GitHub Enterprise host |
| any other git remote | the URL as you typed it, never rewritten |
| local directory | its absolute path |
| any pinned source | the key above plus #<ref> |
A GitHub key keeps the case you typed, but GitHub names are case-insensitive and the store folds them, so two keys whose owner/repo differ only by letter case are corrupt and the file is refused; a #<ref> suffix is compared as typed. @acme/rules and @acme/rules#v2 are two sources and may both be installed.
Migrations
State migrates forward only.
- A version below the current one runs the ordered steps, each a pure function over the JSON, then the file is written back atomically and the command continues.
- A version above the current one is a clean stop: in quiet mode exit 0 with "state written by a newer maxims, skipping", otherwise a request to upgrade, because a rewrite would destroy fields the older binary cannot see.
The ladder is src/state/migrations/state-ladder.ts, run by the mechanism in src/state/migrations/runner.ts that every versioned file shares. Its registry order is the version order: a step's position is the version it migrates away from, and the current version is the first version plus the number of steps, never typed by hand. Each step is named for what it changes and ships with the document it migrates away from.
config.json has its own ladder, src/state/migrations/config-ladder.ts, with the same shape and the same two outcomes: an older file climbs it before the strict parse, and a newer one is refused until maxims is upgraded. The user defaults section owns the file.
The project lock has src/state/migrations/project-lock-ladder.ts, which derives the version the lock schema pins. Its read boundary refuses an older lock and points at a fresh share instead of climbing.
Compatibility with an older file lives in the ladder and nowhere else. A step may run twice on a retried update, so each is idempotent; a step is deleted once a hard break makes it unreachable, and downgrading past one is quarantine and re-add.
A corrupt state file is moved aside to state.json.corrupt-<timestamp> and the user is told to re-add; a dry run or a read-only verb never moves it. It is not rebuilt from the rule files, because no reading of a managed block reveals which memories the user selected or whether they asked for rule lines.
Only intent-shape changes ever need a migration; everything derived is regenerated by the next sync at the current spec.