The CLI contract
Commission’s CLI is an API. Agents parse its output, branch on its exit codes, and act on its refusals without a human in the loop, so the surface has to be predictable in ways a human-only CLI does not.
This document is the contract. tests/cli-contract.test.ts enforces the
mechanical parts of it by walking the real command tree, so a new command cannot
quietly break the shape. The parts a test cannot check are stated here so a
reviewer can.
The generated inventory of every command lives in cli.md — it is walked from the same command tree and never hand-edited.
Exit codes
The four codes — 0 ok, 1 error, 2 refusal, 3 not found — and how each
maps onto an HTTP status and an MCP result are defined once, in
exit-codes.md. This page states only what the command tree is
tested against.
The distinction between 1 and 2 matters most. A refusal is a normal,
expected part of the loop: commission done refusing because a criterion is
unticked is the system working. An agent should treat 2 as an instruction,
never as a crash.
Refusals are prompts
Every refusal states what is missing and the exact command that fixes it. In text mode:
cannot complete relay-31: 2 unchecked criteria
missing: criterion 0: all six subcommands implemented
missing: criterion 3: config migrate is idempotent
hint: tick each with 'commission check relay-31 0', 'commission check relay-31 3' — only once it is actually true; or --force to override (logged)In --json mode the same refusal is { error, message, missing?, hint? } on
stderr.
Rules:
- A refusal names the offending thing by id, not by description alone.
missingis a list of concrete items, not prose.hintcontains a runnable command wherever one exists, with real ids substituted — never a placeholder the caller has to fill in from elsewhere. A hint that says “seecommission actors” is a redirect, not a fix: commission knew the answer and charged the caller a round trip for it.- A hint must not reference a command or flag that does not exist. The contract test checks hint text against the live command tree.
refusals.md audits the twenty refusals an agent actually hits, one by one, and states which mechanical rules back each judgement.
Options every project-scoped command carries
| option | meaning |
|---|---|
--json | machine-readable output on stdout; refusals become JSON on stderr |
--project <slug> | override the project resolved from .commission.json |
Commands that do not touch a project (mcp, serve, --version) are exempt.
Everything else gets both, from a single common() helper — they are not added
per command, so they cannot drift apart.
Output
- stdout carries the answer. stderr carries everything else — warnings, deprecation notices, refusals. A caller can pipe stdout into a parser without filtering.
--jsonoutput is a single JSON value, pretty-printed, nothing else on stdout.- Text output is for humans and may change between minor versions. JSON shapes are covered by the compatibility policy in ../compatibility.md.
--jsonmeans JSON, everywhere, with no exceptions. A command whose text output is one string (commission branch,commission prime,commission export,commission define,commission completions) prints it bare in text mode, so it can be substituted directly into a shell command —git switch -c $(commission branch shop-12). Passing--jsonstill yields JSON, a single object naming the payload. A flag that is accepted and ignored is worse than a missing flag: the caller has nothing to branch on. The contract test runs those commands both ways and parses the output.
Naming
- Verbs for actions (
add,claim,done,note,check). - Nouns for views (
board,ready,log,list,inbox). un<noun>removes an attachment that has no command group of its own:unbind,undep,unlink,unevidence.<group> rmremoves where a group already exists:criteria rm.- Subcommand groups (
config,criteria,sync) use bare verbs inside the group:config show, notconfig show-config. - Flags are
--kebab-case. A flag meaning the same thing in two commands has the same name in both —--force,--json,--project,--lease,--write,--file.
What board shows
board answers “what is the position?”, and the answer has to include work that
was deliberately dropped. Its sections are in-progress, in-review, the ready
frontier, blocked, recently done, recently cancelled, and stale claims.
Cancelled work earns a section because cancelled is not deleted. A task that
appears in no section reads as lost rather than closed, and the difference
matters most to the person who remembers the task and cannot find it. Each entry
carries the reason — cancellation always has one, since cancel refuses without
-m or --superseded-by — and the superseding task where there is one.
Two consequences worth stating, because both are easy to get wrong:
- The reason lives in the transition event, not on the task row, so it survives exactly as written and is never summarised.
- A task cancelled, reopened, and cancelled again shows the most recent reason. The earlier one is still in the event log; it is simply no longer the reason the task is closed.
Vocabulary
A surface this size is only learnable if each word means one thing. These are the words that carry the most weight, and the distinctions they hold.
| word | means | not to be confused with |
|---|---|---|
claim | the lease a session holds right now; expires, and an expired one is winnable by anyone | ownership |
--assignee | ownership — a durable statement that outlives any session | a claim |
heartbeat | extend the lease you hold, changing nothing else | claim, which can also take one over |
release | drop a claim — the lease only, nothing on disk | workspace release, which tears down the directory |
approve | a human accepts the work as done | verify |
verify | a human confirms one specific claim is true | approve |
ask | an agent escalates a judgement it must not make | note, which records something already decided |
answer | a human records the decision and its reasoning | approve |
check | tick an acceptance criterion (the checkbox sense) | verify, config validate |
status | a task’s position in the lifecycle, and nothing else | the state of a config, workspace, or file set |
Two near-misses are deliberate rather than accidental, and are kept:
commission releaseandcommission workspace releasetake the same argument and do different things. The group name is the resource, which is what the subcommand convention is for, and both descriptions name the other.commission migrate --checkis not--dry-run.--dry-run(oncommission apply) previews and exits 0.--checkis an assertion for CI: it exits 2 when the database is behind. Same-looking flags, genuinely different contracts.
Subcommand groups (config, context, workspace, criteria) share one
shape: bare verbs inside the group, and the reader is show for a single merged
view or list for a collection. Never status — that word is already taken by
the task lifecycle, including commission list --status.
commission sync is the one group that also acts, because the group name is itself
the primary verb. commission sync syncs; commission sync accept <id> resolves one
divergence.
Renames
A rename that breaks a caller is a broken API, so:
- The old name keeps working. A renamed flag or subcommand stays accepted.
- It says what it is now called, once per process, on stderr, and never
under
--json— stderr is part of a machine’s stream, and a pipeline must see byte-identical output either way. - The deprecation names its replacement by name. The contract test checks that the replacement it names is actually in the command tree.
warning: commission ready --actor is deprecated — it switches whose eligibility is
evaluated, it does not filter by owner
fix: use commission ready --as insteadCurrently deprecated: commission ready --actor (→ --as), commission context status
(→ commission context show).
Descriptions
Every command has a one-line description saying what it does, not what it is. “filtered task list” is weak; “board shows position; this answers ‘which tasks match X’” tells the reader when to reach for it.
Where a command’s purpose overlaps another’s, the description says how they differ. That is the single most common point of confusion in a surface this size, and it is cheaper to answer in the help text than in the docs.
Destructive actions
- Anything that discards work refuses by default and requires an explicit flag
(
--force,--write). --forcerequires-m "<why>", and the reason is written to the event log. An override with no recorded reason destroys the context the next session needs.- Cancellation requires a reason or
--superseded-by, for the same reason. - Commands that write a file show what would change and change nothing until
asked (
config migrate→config migrate --write).
Adding a command
- Put it in the
src/cli/commands/*module matching what the user is trying to do — plan, work, observe, configure, interop, system. Do not add a seventh module without a reason; do not grow one past readability. - Wrap the action in
run()so error handling and exit codes are uniform. - Wrap the command in
common()unless it is genuinely project-independent. - Support
--jsonif the output is structured. - Run
bun run generate:docs— the reference page and the drift test are generated from the command tree. bun test— the contract test will tell you which rule you broke.
This page is docs/reference/cli-contract.md in the Commission
repository, rendered in place — the site keeps no copy of it. The repository is private, so there is no edit link to follow.