Skip to content
commission

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.
  • missing is a list of concrete items, not prose.
  • hint contains 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 “see commission 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

optionmeaning
--jsonmachine-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.
  • --json output 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.
  • --json means 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 --json still 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> rm removes where a group already exists: criteria rm.
  • Subcommand groups (config, criteria, sync) use bare verbs inside the group: config show, not config 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.

wordmeansnot to be confused with
claimthe lease a session holds right now; expires, and an expired one is winnable by anyoneownership
--assigneeownership — a durable statement that outlives any sessiona claim
heartbeatextend the lease you hold, changing nothing elseclaim, which can also take one over
releasedrop a claim — the lease only, nothing on diskworkspace release, which tears down the directory
approvea human accepts the work as doneverify
verifya human confirms one specific claim is trueapprove
askan agent escalates a judgement it must not makenote, which records something already decided
answera human records the decision and its reasoningapprove
checktick an acceptance criterion (the checkbox sense)verify, config validate
statusa task’s position in the lifecycle, and nothing elsethe state of a config, workspace, or file set

Two near-misses are deliberate rather than accidental, and are kept:

  • commission release and commission workspace release take 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 --check is not --dry-run. --dry-run (on commission apply) previews and exits 0. --check is 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 instead

Currently 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).
  • --force requires -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 migrateconfig migrate --write).

Adding a command

  1. 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.
  2. Wrap the action in run() so error handling and exit codes are uniform.
  3. Wrap the command in common() unless it is genuinely project-independent.
  4. Support --json if the output is structured.
  5. Run bun run generate:docs — the reference page and the drift test are generated from the command tree.
  6. 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.