gsd-tools supports a JSON error mode that emits most errors as structured
JSON objects on stderr instead of free-form text. This is the recommended
surface for tests and tooling that need to assert on error types without
grepping raw text (see CONTRIBUTING.md — "Prohibited: Raw Text Matching on
Test Outputs"). Usage errors are an intentional exception — see the
ExitError carve-out below.
This page describes one of two failure channels. A second, equally intentional one reports conditions in the result payload on stdout with exit 0. A caller that branches on exit status alone will not see it. Read Degraded results vs faults before writing anything that consumes
gsd-toolsoutput.
Either flag or env var activates the mode:
# Flag (preferred in test code):
node gsd-tools.cjs --json-errors <command> [args]
# Env var (preferred for shell wrappers and CI):
GSD_JSON_ERRORS=1 node gsd-tools.cjs <command> [args]On any error, exactly one JSON line is written to stderr and the process exits with code 1:
{ "ok": false, "reason": "<error_code>", "message": "<human text>" }Fields:
| Field | Type | Description |
|---|---|---|
ok |
false |
Always false for error objects. |
reason |
string | Typed reason code from the taxonomy below. |
message |
string | Human-readable description (may change; do not assert on it). |
Usage errors and explicit exit-code signals take a different path: they
throw ExitError (src/cli-exit.cts), which runMain catches before the
JSON-envelope branch. An ExitError writes its message as plain text
to stderr (not a JSON object) and exits with the error's own code (which
may differ from 1). This is intentional — usage messages are operator-facing
prose, not structured failures.
If you are testing a usage/flag error, do not parse stderr as JSON;
assert on the exit code and (if needed) the plain-text message. The
"parse stderr as JSON" guidance below applies only to the structured-envelope
branch (non-ExitError failures).
Which tools honor this. Both surfaces that run
runMaindo: the compiledgsd-core/bin/lib/cli-exit.cjsand thescripts/lib/cli-exit.cjsthat the repo's ownscripts/**tooling requires. Before #3904 the latter was a separate hand-written copy that never gained the structured-envelope branch, so ascripts/-side tool failing unexpectedly printed a raw stack trace even under--json-errors. It is now generated from the same source and byte-compared bynpm run lint:generated-sync, so the two cannot answer differently again.
gsd-tools has two ways of telling you something went wrong, and they use different exit
codes. The wire format above describes only one of them. If you write a caller that branches on
exit status alone, you will silently miss the other.
| Fault | Degraded result | |
|---|---|---|
| Produced by | error(message, reason) |
output({ error: … }) |
| Stream | stderr | stdout |
| Exit code | 1 | 0 |
| Shape | { "ok": false, "reason": …, "message": … } |
the command's ordinary result object, with an added error key |
Honors --json-errors |
yes | no — it is a payload, not an error envelope |
| How a caller detects it | exit code | inspect the payload |
A degraded result means: the command ran to completion and is reporting a condition through its result. It is not a process failure. The command succeeded at the job of determining that, for example, the artifact you asked about is absent.
$ gsd-tools state-snapshot # in a project with no STATE.md
{
"error": "STATE.md not found"
}
$ echo $?
0Some verbs return a companion result alongside the key, which is the shape that makes the intent clearest:
$ gsd-tools roadmap get-phase --phase 1 # no ROADMAP.md
{
"found": false,
"error": "ROADMAP.md not found"
}
$ echo $?
0This is a ratified contract, not an accident — see
ADR-2980 for the decision and the blast
radius that drove it. It applies to 64 call sites across nine modules — state, verify,
workstream, frontmatter, commands, template, phase, roadmap, and gsd2-import.
(Issues #2966 and #2980 record this as "42 sites"; that figure counts only the sites where error
happens to be the object's first key. ADR-2980 itself re-derived the population as "60" by
brace-matching; a further AST re-measure for #3912
found the true current count is 64 — the same nine modules, with frontmatter, phase, and
roadmap each having grown since. See ADR-2980's amendment for the breakdown.)
The obvious shell form is wrong for a degraded result:
# WRONG — the process exits 0, so this branch never runs
if ! gsd-tools state-snapshot > snap.json; then
echo "failed"
fiCheck both channels — the exit code for faults, the payload for degraded results:
if ! out=$(gsd-tools state-snapshot); then
echo "fault (exit non-zero)" >&2 # error() path
exit 1
fi
if err=$(printf '%s' "$out" | jq -er '.error // empty'); then
echo "degraded: $err" >&2 # output({error}) path
fi--json-errorsdoes nothing here. It governserror()only. A degraded result is byte-identical with and without the flag, and still exits 0.--rawis not uniform on this path. Most sites pass no raw value, so--rawstill yields the JSON object rather than bare text — but eleven sites do pass one and behave differently. Do not infer either behavior from--rawalone; check the verb.- Not every degraded result is an absent artifact. A missing required argument is reported the
same way —
gsd-tools state add-blockerwith no--textreturns{"error":"text required"}and exits 0. So is unusable input:gsd-tools state advance-planagainst a STATE.md it cannot parse returns an{"error": …}naming the plan-position shapes it accepts (the list is derived fromSTATE_FIELD_SCHEMA.current_plan.acceptedShapes, so do not quote it verbatim), also exit 0. The exit code does not distinguish absent from malformed from misinvoked — see ADR-2980's Consequences, where this is recorded as a known cost. message/errortext is not stable. Assert on structure and on typedreasoncodes, never on prose. The rule in "Writing tests" below applies to both paths.
Prefer the fault path, or a result with a named field. ADR-2980 ratifies an existing population;
it is not a license to add a 61st output({ error: … }) site. Where a verb needs to report a
non-fatal condition in its payload, prefer the shape state update-progress already uses — a named
field plus a reason, with no overloaded error key:
$ gsd-tools state update-progress # STATE.md present, no Progress field
{
"updated": false,
"reason": "Progress field not found in STATE.md"
}Both failure channels above now declare an outcome on every terminating path, per ADR-3889. Declaration is unconditional; whether it changes the observed exit code depends on which exit-contract version the process is running under.
Turn on v2 with either --exit-contract=v2 or GSD_EXIT_CONTRACT=v2 (a flag beats the env var if
both are given). Absent either, the process runs v1 — today's default and, for every existing
caller, byte-identical to pre-#3912 behavior. See
Adopt the v2 exit contract for a worked migration.
error()'s reason argument now maps onto a declared outcome name (USAGE, NO_INPUT,
UNAVAILABLE, INTERNAL, or FAIL) via a fixed table over all 27 ERROR_REASON members.
- Under
v1, the mapping is recorded but never projected.error()still throwsExitError(1)unconditionally, exactly as before — stderr and the exit code are byte-identical to every prior release. - Under
v2, the mapping is projected through the exit-code registry.error()throwsExitError(exitCodeFor(<mapped outcome>))instead of a hardcoded1— so, for example, a call withERROR_REASON.SDK_MISSING_ARGorERROR_REASON.SDK_UNKNOWN_COMMANDexits64(USAGE) underv2, and one withERROR_REASON.CONFIG_KEY_NOT_FOUNDexits66(NO_INPUT). - Most call sites are unaffected either way. 226 of the 278
error()call sites in the repo pass noreasonat all, defaulting toERROR_REASON.UNKNOWN, which maps to the genericFAILoutcome (exit1) under both versions.
The degraded-result idiom above now declares the outcome DEGRADED whenever output()'s payload
carries a serializable error value — any key order, and regardless of that value's own
truthiness (0/null/'' all count). The one exclusion: { error: undefined } does not
declare DEGRADED, because JSON.stringify (the exact serializer output() uses) drops an
object property whose value is undefined before it ever reaches the wire — a payload built that
way reaches the caller as {}, with nothing to be degraded about.
- Under
v1,DEGRADEDprojects to0— deliberately: this is ADR-2980's compatibility boundary, pinned so all 64 ratified sites keep exiting0byte-for-byte. - Under
v2,DEGRADEDprojects to80— looked up from the exit-code registry, never hardcoded, so a future re-allocation ofDEGRADED's number cannot silently desync this doc from the shipped table.
A command's main() can end up producing a code from more than one source. The order, highest
precedence first, is:
- An explicit
main()return (a number or a registered outcome-name string) — always wins. - A non-zero
process.exitCodemain()already set directly before returning — wins over anything declared throughoutput(). This is what keepsstate validate --strictcorrect: it setsprocess.exitCode = 1itself on a missingSTATE.md, and aDEGRADEDdeclared earlier in the same call must not clobber that1back down toDEGRADED'sv1projection of0. - The declared outcome pending from
output()— consulted only when neither of the above set anything. - Otherwise the process exits
0.
Projection may only ever set a code, never lower one. A prior review pass concluded the pending
declaration was fail-closed by construction; it was not — without rule 2 above, state validate --strict briefly exited 0 on a case that must exit 1. If you add a new call path that sets
process.exitCode directly, check it still wins over a later output({error}) in the same
invocation.
The declaration does not accumulate across calls. output()'s declaration follows
last-write-wins: a clean payload clears a prior DEGRADED declaration in the same invocation, and
runMain clears the cell on every exit regardless of which branch produced the final code, so a
later runMain call in the same process never inherits a stale declaration.
A gate verb declares its outcome from the verdict it built, through one total function
(gateExitOutcome), never by choosing a code itself. Two modes:
- Status mode — callers branch on
$?:phase uat-passed,verify artifacts,verify plan-structure,verify phase-completeness,verify references,verify commits,verify key-links. A positive verdict exits0, a negative one1(the JSON is unchanged and still authoritative), a scope that was read and is genuinely empty exits66(NO_INPUT), and a verb that could not look exits69(UNAVAILABLE). - Payload mode — the verdict is read from stdout and a non-zero exit means the command failed:
every
check <verb>and the three drift verbsverify schema-drift,verify codebase-driftandverify context-drift(the capability gate dispatch routes a non-zero exit byonError, so a blocking verdict must stay exit0). Only "could not look" exits69. The full exit table and verb list are in CLI Tools: Gate verb exit statuses.
69 is never a pass and never 0: an unreadable file, an unresolvable phase, a path that cannot be
examined. A document that exists but is empty was read (found ''), so it is not "File not found".
Codes are frozen constants in gsd-core/bin/lib/core.cjs under
ERROR_REASON. Tests must assert on reason values (stable), not message
text (unstable).
| Code | When emitted |
|---|---|
sdk_unknown_command |
Unknown top-level command (gsd-tools bogus-cmd) |
sdk_unknown_command |
Unknown dotted command (gsd-tools foo.bar where foo is not a known command) |
sdk_unknown_command |
Unknown subcommand within a domain (e.g. gsd-tools intel bogus-sub) |
sdk_missing_arg |
Required argument omitted by an SDK-level guard |
sdk_fail_fast |
SDK fail-fast policy triggered |
| Code | When emitted |
|---|---|
usage |
--pick flag used without a following value |
usage |
Version flag (--version, -v) which gsd-tools never accepts |
usage |
Top-level no-args invocation (usage text) |
| Code | When emitted |
|---|---|
pick_field_absent |
--pick <field> names a field that does not exist in the command's JSON output (missing key, out-of-range index, a partially-missing dotted path, or a non-object JSON root) — see CLI-TOOLS.md's --pick contract |
pick_output_not_json |
--pick <field> is combined with a command whose output is not JSON (including --raw output) |
| Code | When emitted |
|---|---|
config_key_not_found |
config-get for a key that is absent from the config file |
config_no_file |
Config operation when .planning/config.json does not exist |
config_parse_failed |
Config file exists but is not valid JSON |
config_invalid_key |
config-set for a key outside the allowed whitelist |
| Code | When emitted |
|---|---|
phase_not_found |
Phase directory lookup returns no match |
summary_no_planning |
Summary operation when no .planning/ directory exists |
verification_status_invalid |
A phase's *-VERIFICATION.md frontmatter status is outside the closed set passed | gaps_found | human_needed (for example verified, Passed, stale, or a non-string value). Emitted, with stdout empty, by every surface that reads the report — verification status, phase uat-passed, phase complete, roadmap analyze, state sync, planning inspect, init *, smart-entry, audit-open, audit-uat. The message names the report file, the value, and the accepted values, and states the recovery: set the report's status: to an accepted value, or delete the report and re-run the phase's verification. validate health reports the file as warning W030 instead (#5118) |
| Code | When emitted |
|---|---|
estimate_phases_unreadable |
estimate-calibrate when .planning/phases/ exists but could not be read (EACCES/EIO) — refused rather than silently rebuilding calibration from a phantom empty sample set (#3882, ADR-3473 §8.5) |
| Code | When emitted |
|---|---|
graphify_no_graph |
Graphify query or diff when no graph has been built |
graphify_invalid_query |
Graphify query with a malformed query string |
| Code | When emitted |
|---|---|
hooks_opt_out |
Hooks are disabled via opt-out config |
security_scan_failed |
Security scan produced a finding that blocks the operation |
| Code | When emitted |
|---|---|
unknown |
All other errors without a specific reason code assigned |
For non-usage errors (the structured-envelope branch), parse stderr with
JSON.parse and assert on typed fields. Never use .includes(), .match(),
or regex on the raw error string.
// CORRECT: parse then assert on typed field
const result = runGsdTools(['--json-errors', 'bogus-command'], tmpDir);
assert.strictEqual(result.success, false);
const err = JSON.parse(result.error);
assert.strictEqual(err.ok, false);
assert.strictEqual(err.reason, 'sdk_unknown_command');
// WRONG: text matching (banned by lint-no-source-grep policy)
// assert.ok(result.error.includes('Unknown command'));- Add the constant to
ERROR_REASONingsd-core/bin/lib/core.cjs(snake_case, prefixed by subsystem). - Pass it as the second argument to
error()at the call site. - Add a row to this document.
- Add a test asserting the new
reasoncode viaJSON.parse.