Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
147 lines
7.7 KiB
Markdown
147 lines
7.7 KiB
Markdown
# How to adopt the v2 exit contract
|
|
|
|
`msd-tools` runs, by default, under exit-contract **`v1`** — every observable exit code is
|
|
byte-identical to every prior release. **`v2`** is an opt-in projection, per
|
|
[ADR-3889](../adr/3889-process-exit-contract.md) §4, that turns a small set of previously
|
|
same-looking outcomes into distinct, non-zero exit codes a CI gate can branch on without parsing
|
|
stdout. This page covers how to turn it on, what the new codes mean, and how to migrate a gate that
|
|
today treats "any non-zero exit" as fatal.
|
|
|
|
## Should you turn this on?
|
|
|
|
Turn it on if a script or CI gate wraps `msd-tools` and needs to tell "ran fine", "you called it
|
|
wrong", "there was nothing to find", "a prerequisite was missing", "it crashed", and "it ran but is
|
|
reporting a condition in its payload" apart **from the exit code alone**, instead of parsing JSON on
|
|
stdout to find out. If your caller only ever needs pass/fail, `v1` already gives you that — there is
|
|
nothing to adopt.
|
|
|
|
## Step 1 — turn it on
|
|
|
|
Either a flag or an environment variable activates `v2`. The flag beats the env var if both are
|
|
given in the same invocation.
|
|
|
|
```bash
|
|
# Per-invocation (preferred in test code and one-off scripts):
|
|
node msd-tools.cjs --exit-contract=v2 state validate --strict
|
|
|
|
# Process-wide (preferred for CI and shell wrappers):
|
|
export MSD_EXIT_CONTRACT=v2
|
|
msd-tools state validate --strict
|
|
```
|
|
|
|
The flag works in any argv position — before the subcommand or after it — and `msd-tools` strips
|
|
it before dispatch, exactly as it already does for `--json-errors`.
|
|
|
|
Only the exact lowercase tokens `v1`/`v2` are accepted. Anything else present — a typo, `V2`,
|
|
`v3`, or an explicitly empty `--exit-contract=` — throws rather than silently falling back to `v1`;
|
|
a selector for a contract whose whole point is "nothing fails with success" must not itself fail
|
|
open. That throw surfaces as a stack trace at exit `1`, not as a tidy `USAGE` `64`, and that is
|
|
deliberate: the failure is *the version being unresolvable*, so there is no contract version yet
|
|
under which to project a code. Loud and coarse beats quiet and wrong. An empty
|
|
`MSD_EXIT_CONTRACT=` (nothing after the `=`) reads as **unset**, not as an explicit selection, so a
|
|
shell that exports it empty still gets `v1`.
|
|
|
|
## Step 2 — read the code table
|
|
|
|
`v2` projects a declared outcome through one generated registry
|
|
(`msd-core/bin/lib/exit-code-registry.cjs`). Every code below is stable and machine-checked; do not
|
|
hardcode the integers in your own scripts — call `exitCodeFor(name)` if you are writing Node, or
|
|
just compare against the number after reading it here once.
|
|
|
|
| Code | Name | Meaning |
|
|
|--:|---|---|
|
|
| `0` | (PASS) | The operation ran and its verdict is affirmative. |
|
|
| `1` | (FAIL) | The operation ran and its verdict is negative — the honest default when nothing more specific applies. |
|
|
| `64` | `USAGE` | Caller error — bad argv, unknown subcommand, missing required argument. |
|
|
| `66` | `NO_INPUT` | Ran; zero units were in scope, and that emptiness is known to be genuine. |
|
|
| `69` | `UNAVAILABLE` | Could not run — a prerequisite was absent, unreadable, or scope was never established. |
|
|
| `70` | `INTERNAL` | Self-failure — the run itself broke (crash, timeout, killed subprocess), not its inputs. |
|
|
| `80` | `DEGRADED` | Ran to completion and is **reporting a condition through its result payload**, not failing as a process. |
|
|
|
|
`2` is reserved to the Claude Code hook-protocol deny and is never produced by `msd-tools` itself —
|
|
you will not see it from a CLI invocation.
|
|
|
|
## Step 3 — understand `80` specifically
|
|
|
|
`80` (`DEGRADED`) is the one code most CI authors get wrong, because it is the one code where "ran
|
|
to completion" and "found a problem" are the same event. It fires when a command's JSON payload
|
|
carries a **serializable** `error` key — for example `msd-tools state-snapshot` in a project with
|
|
no `STATE.md` returns `{"error": "STATE.md not found"}`. Under `v1` that exits `0`; under `v2` it
|
|
exits `80`.
|
|
|
|
`80` is **not** a crash. The process did its job: it determined, correctly, that the artifact you
|
|
asked about is absent, unparseable, or that a required argument was missing — see
|
|
[ADR-2980](../adr/2980-payload-carried-error-is-a-degraded-result.md) for the full population this
|
|
covers (64 call sites across nine modules) and why it stayed a payload-carried signal rather than a
|
|
thrown fault. `70` (`INTERNAL`) is the code for an actual crash. Do not conflate the two: a gate
|
|
that maps `80` to "the tool is broken" will page someone for a condition the tool successfully
|
|
diagnosed.
|
|
|
|
## Step 4 — migrate a gate that treats any non-zero exit as fatal
|
|
|
|
The naive shell form,
|
|
|
|
```sh
|
|
if ! msd-tools state-snapshot > snap.json; then
|
|
echo "msd-tools failed" >&2
|
|
exit 1
|
|
fi
|
|
```
|
|
|
|
is **correct as a fail-safe** under `v2` — every registered code is non-zero, so this can only turn
|
|
a false green red, never a red green (ADR-3889 §5). What it cannot do on its own is tell you *which*
|
|
non-zero condition fired, which matters if your policy is "treat `DEGRADED` as a soft warning but
|
|
still hard-fail on `USAGE`/`UNAVAILABLE`/`INTERNAL`":
|
|
|
|
```sh
|
|
set +e
|
|
msd-tools --exit-contract=v2 state-snapshot > snap.json
|
|
code=$?
|
|
set -e
|
|
|
|
case "$code" in
|
|
0) ;; # PASS
|
|
80) echo "degraded result — inspect snap.json" >&2 ;; # ran, reported a condition — your call whether this gates the pipeline
|
|
64|66|69|70)
|
|
echo "msd-tools failed (exit $code)" >&2
|
|
exit 1
|
|
;;
|
|
*) echo "msd-tools failed (unrecognized exit $code)" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
```
|
|
|
|
Whether `DEGRADED` itself should gate your pipeline is a policy decision only you can make — the
|
|
contract's only promise is that `80` is distinguishable from `64`/`66`/`69`/`70`, not that it is
|
|
always safe to ignore. A gate that wants the old, coarser behavior (any non-zero is fatal, including
|
|
`80`) needs no case statement at all; the naive form above already does that correctly.
|
|
|
|
## Four things that will surprise you
|
|
|
|
1. **`--json-errors` is a different, orthogonal switch.** It governs whether `error()`'s stderr
|
|
envelope is JSON or plain text; it does nothing to `output()`'s exit code. You can run `v2` with
|
|
or without `--json-errors`.
|
|
2. **Precedence can surprise a caller reading only `output()`'s contract.** An explicit `main()`
|
|
return, or a non-zero `process.exitCode` a command set directly, always wins over a `DEGRADED`
|
|
declared earlier in the same invocation — projection can only raise a code, never lower one. See
|
|
[`docs/json-errors.md`](../json-errors.md#outcome-declaration-and-the-versioned-exit-contract-adr-3889-4-3912)
|
|
for the full precedence order.
|
|
3. **The declaration does not accumulate.** If a command calls `output()` more than once — a
|
|
diagnostic degraded payload followed by a clean final one — only the **last** call's declaration
|
|
is live when the process exits.
|
|
4. **`v1` and `v2` are the only recognized versions today**, and the default flips to `v2` at the
|
|
next major (ADR-3889 §4). Pin `--exit-contract=v1` explicitly in a script that must keep today's
|
|
codes indefinitely, rather than relying on the current default staying `v1` forever.
|
|
|
|
## Related
|
|
|
|
- [ADR-3889](../adr/3889-process-exit-contract.md) — the exit-code registry and the versioned
|
|
projection this page walks through
|
|
- [ADR-2980](../adr/2980-payload-carried-error-is-a-degraded-result.md) — why `output({error})`
|
|
exits `0` under `v1`, and the population `DEGRADED` covers under `v2`
|
|
- [`docs/json-errors.md`](../json-errors.md) — the full reference for both failure channels,
|
|
the error-code taxonomy, and the outcome-declaration precedence rules
|
|
- [Resolve a raw-terminator finding](resolve-a-raw-terminator-finding.md) — the sibling page for the
|
|
lint rule that keeps every termination path routed through this same seam
|