ADR-0083.
ADR-0083-exit-code-two-carries-several-meanings-say-which-before-splitting-it.md
ADR-0083 — Exit code 2 carries several meanings: say which, before splitting it
Date: 2026-09-28
Status: accepted 2026-09-28 — option A, chosen by the maintainer: keep 2 for every current case, make the class legible; doctor's critical exit stays 2 and is written into the port contract
Deciders: the maintainer
Method: every process.exit(2), exitCode: 2 and Commander misuse path in src/ read on the
branch of this ADR (base main at 0.42.2), and the cases below re-run on the built CLI on 2026-09-28
against a copy of the audit's red mission and an empty directory
Relates to: ADR-0012 (the gate is a port,
the exit code is its contract), ADR-0030 (additive only),
runward/contracts/port-contract.md (the published table)
Context
What the contract says. runward/contracts/port-contract.md publishes three values: 0 clean,
1 gaps, 2 "no mission found, or CLI misuse (unknown command/flag) — configuration error, distinct
from a gate failure by design, so a typo never reads as gate red", and adds "Exit code 2 is reserved
for 'the question could not be asked'; a red gate is always 1". The samples runward ships say less:
action.yml, the four CI and pre-commit adapters, packaging/README.md and the Kiro POWER.md read
"0 clean · 1 gaps · 2 no mission found".
What the code does. The CLI audit of 2026-09-27 (04#18) found 2 returned for a typo, a refused
gesture and a refusal to a harness alike, so a script that applies the samples' table concludes "no
mission" on each of them. Measured 2026-09-28, grouped by what the reader should do next:
| Class | Command and case | Message (first words) | JSON form |
|---|---|---|---|
| No mission | check, status, report, propose, ratify, manifest, compliance, update, wire outside a mission |
"No runward/ mission found here or above. Run runward init first." |
check --json: {"verdict":"no-mission","exitCode":2}; verify --json: reason: "no-mission" |
| Usage error | any unknown command or option (runward bogus, check --bogus, status --json), a missing argument (bundle, spec-check), an invalid choice (check --through topology) — Commander, mapped in src/cli.ts (MISUSE) |
"error: unknown option '--bogus'" | none |
flag combinations check refuses (--json --sarif, --through with --freeze, --vsa without --resource-uri) |
"--json and --sarif each write a different document…" | none | |
values a command refuses: rules --phase Govern, explain <unknown>, rules --for /abs, compliance without or with an unknown regime, report --out outside the project or on a directory, update --corpus not a directory, bundle artifact missing or outside the project, characterize on no directory, ratify --by "", ratify --accept without --agent |
"Unknown phase "Govern" — the gated phases are: …" | none | |
inputs verify and spec-check cannot read (file absent, not JSON, not in-toto, not runward) |
"Attestation not found: …" | verify --json: {"verified":false,"reason":"attestation-not-found",…,"exitCode":2}; spec-check --json: a verdict string |
|
| Refused gesture | ratify without a terminal; ratify --agent preflight (missing --for, --by/--all/--attest-blind beside it, no --accept, an unlisted or self-proposed row); wire --install/--uninstall under an agent signal or without a terminal; wire --install with no native target, an unreadable target, a failed read-back probe; characterize --mine on a governed mission; report refusing to overwrite a non-report file |
"refusing to ratify without a terminal — …" | none |
| Health | doctor with at least one critical issue |
"N critical issue(s), M warning(s)" | none |
| Harness protocol | gate-hook --harness claude or junie on a red tree: exit 2 is the harness's own "block, show stderr to the model" |
the named refusal | not runward's port: the harness reads it |
Two rows are not "the question could not be asked": doctor answers the question and reports a bad
answer with the configuration code, and gate-hook speaks its harness's protocol, where 2 means
block. Every other row is a configuration or invocation fault, as the port contract says, but the
samples name only the first class.
What a consumer can tell apart today. Only check --json (verdict: "no-mission"), verify --json (reason) and spec-check --json (verdict) say which case produced the 2. Everywhere else
the class lives in the English of stderr.
Decision
Keep 2 for every current case, and make the class legible instead of splitting the code.
- The samples say what the port contract says. Every shipped sample that prints the table
(
action.yml,templates/adapters/*,packaging/README.md,packaging/kiro/POWER.md) reads "2 no mission, a usage error or a refused gesture: the question could not be asked, never a red gate". A text change, no behaviour change. - Each 2 names its class for a machine, additively. Where a command has a
--jsonform, its error document carries a stableerrorfield (no-mission | usage | refused | unreadable-input) beside the existingverdict/reason, which keep their values (ADR-0030). Without--json, stderr keeps its sentences. - The two rows that are not configuration faults are named as such.
doctor's exit 2 on a critical issue is written into the port contract as the one health exit that reuses the code;gate-hook's exit 2 is documented as the harness's protocol, outside the port.
Alternatives discarded
- Distinct codes with a compatibility period (
64usage, followingsysexits.hEX_USAGE;77refused,EX_NOPERM;2kept for no mission): opt-in first (RUNWARD_EXIT_CODES=2), default at the next minor, the old mapping documented for a supported-version window (ADR-0068). It is the cleanest end state, and it is discarded for now because every consumer in the tree and in the samples branches on!= 0or on== 1only; none has been found that branches on 2, so the cost (every CI adapter, every harness sample, the ADR-0012 port table, a migration window) buys nothing measured yet. It stays the next step if option A's trigger fires. - Split only the refusals (a refused TTY or agent gesture gets its own code, the rest stays 2): it fixes the one case an agent is most likely to misread, and leaves the usage/no-mission conflation the audit also names; a partial taxonomy is a second vocabulary to learn.
- Keep everything as is and change nothing: the samples keep contradicting the port contract they claim to quote, which is how the audit's reader concluded "no mission" on a typo.
Consequences
- Positive. No consumer breaks: every exit code keeps its value. A script can tell a typo from a missing mission by reading one JSON field instead of parsing English, and the samples stop promising less than the code does.
- Negative, accepted. A consumer that reads only the exit code still cannot tell the classes apart; that is the price of not moving a code that CIs read blind.
- Found while implementing (2026-09-28). One shipped sample did act on 2: the BMAD review-layer
instruction (
templates/adapters/bmad-review-layer.toml) told its agent "exit 2 → no runward/ mission here; skip this layer", so a typo or a refused gesture skipped the layer. It is runward's own sample, not an external consumer, and its text now says what 2 means (RWD-2026-0157); the search of external workflows that "What would settle it" names has not been run. - On other boundaries. Nothing touches the verdict path (ADR-0054): the classes are assigned where each command already exits.
What would settle it
- For A: the maintainer's reading of the table above, and a search of public workflows and
harness configs that call
runwardfor any that branch on exit 2 specifically. None found would confirm A; one found that treats 2 as "no mission" and acts on it (skips the gate, for instance) would argue for B. - For B: a consumer, or a harness, that needs to act differently on a usage error and on a
missing mission from the exit code alone, without
--json.
Reevaluation trigger (mandatory, dated)
Reopen when a consumer reports acting on exit 2 as "no mission" when it was a usage error or a refusal, when a harness assigns a meaning of its own to 64 or 77, or when a new command adds a fifth class of 2.
Trigger set on: 2026-09-28 · Watched via: issues and the defect register (found-by any)
mentioning an exit code; each new process.exit(2) in review.
References
- The CLI audit of 2026-09-27, finding 04#18 (exit 2 overloaded).
runward/contracts/port-contract.md, "Exit codes — the load-bearing contract" and "Errors".src/cli.ts(MISUSE,onCommanderExit),src/commands/*(eachprocess.exit(2)).sysexits.h(BSD):EX_USAGE64,EX_NOPERM77.