diff --git a/CONTEXT.md b/CONTEXT.md index 5be4446cf..61c45d71a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -169,7 +169,7 @@ Module owning validation for Installer Migration Module records and planned acti Primary installer for all runtimes. Single production file: `bin/install.js` (hand-authored JS — it is NOT generated from `src/*.cts`; ADR-1508 keeps it hand-authored deliberately, and no `npm run build` step emits it). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (18 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, hermes, kimi, kimi-code, kilo, opencode, pi, qwen, trae, windsurf, zcode). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). The same module exposes `detectAntigravityDirAmbiguity(opts)` — a side-effect-free probe reporting whether multiple `~/.gemini/antigravity{,-ide,-cli}` dirs coexist and which one GSD's `gsd-core/VERSION` marker (the `dot-home-nested` `probeExists`) resolves to, for installer / `/gsd-update` operator guidance when a pre-#217 install landed in the wrong sibling dir (#1441). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Five runtimes with non-recursive skill loaders (cline, qwen, hermes, augment, trae) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `/skills//SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). claude (reverted from nested per #924 — the Skill tool errors on unrouted names) and antigravity (one-level scan, but concrete skills must be top-level discoverable) plus the remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module. ### I/O Module -Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`). +Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). **Degraded result vs fault (ADR-2980, #2980):** the two emitters are a deliberate two-channel failure contract, not a drift. A **fault** is `error(message, reason)` — stderr, exit **1**, structured `{ok:false,reason,message}` envelope under `--json-errors`. A **degraded result** is `output({ error: … })` — stdout, exit **0**, `--json-errors` does not apply — and means the command ran to completion and is reporting a condition (absent artifact, and in practice also missing-argument and unusable-input cases) through its result; a caller detects it by inspecting the payload, never by exit code. Ratified across **60 sites in 9 modules** (`state` 25, `verify` 8, `workstream` 7, `frontmatter` 6, `commands` 5, `template` 3, `gsd2-import` 2, `phase` 2, `roadmap` 2) because normalizing them to exit 1 is a Hyrum's Law break over a CRITICAL radius (`get_impact(cmdStateSnapshot)`; `output` has 170 direct callers). #2966/#2980 record "42 sites" — that counts only literals whose FIRST key is `error` (the `output\(\{\s*error:` regex); 18 more put another key first (`{found:false, error}`) and are identical in contract, so 60 is the population and 42 is a subset. New code prefers the fault path or a named-field result (`{updated:false, reason}`), not a 61st site. Known cost carried by the decision: the exit code does not distinguish absent from unusable, which is ADR-1411's "corrupt is not absent" open edge. Docs: `docs/json-errors.md` → "Degraded results vs faults". Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`). ### Markdown Sectionizer Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`, generated from `src/markdown-sectionizer.cts`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `stripInlineCode(content) → string` (per-line CommonMark inline-code-span stripper — removes `` `code` `` spans while leaving fenced blocks to `stripFencedCode`; #2365); `scanInlineCodeSpans(content) → InlineCodeSpan[]` (locates every inline code span as `{ start, end, content }`, offsets into the full string and spans never crossing a `\n`; callers that need the span CONTENT (e.g. api-coverage's package-name evidence, #2365) use this, callers that just want spans gone use `stripInlineCode`); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `…` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern); `withSection(content, target, edit) → string` (resolve the section whose heading matches `target` — exact heading text or a `HeadingToken` predicate — and run `edit(body)` against ONLY that section's body before splicing the result back; bounded no-op when no heading matches or `edit` returns the same/non-string body; ADR-2143 §4 structurally retires the #2130/#2067/#2080 boundary-crossing class by confining any regex the caller runs to the matched section). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7). diff --git a/docs/README.md b/docs/README.md index 5aa89fe0f..e04611459 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +56,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Commands](COMMANDS.md) — every command with flags and examples - [Configuration](CONFIGURATION.md) — full config schema, model profiles, git branching strategies - [CLI tools](CLI-TOOLS.md) — `gsd-tools.cjs` programmatic API for workflows and agents +- [JSON error mode](json-errors.md) — `gsd-tools` failure channels: faults (stderr, exit 1) vs degraded results (stdout, exit 0), and the reason-code taxonomy - [Features](FEATURES.md) — complete feature index - [Inventory](INVENTORY.md) — installed skills and surface map - [STATE.md schema](reference/state-md.md) — field-by-field reference for `.planning/STATE.md` diff --git a/docs/adr/2980-payload-carried-error-is-a-degraded-result.md b/docs/adr/2980-payload-carried-error-is-a-degraded-result.md new file mode 100644 index 000000000..0b281d938 --- /dev/null +++ b/docs/adr/2980-payload-carried-error-is-a-degraded-result.md @@ -0,0 +1,184 @@ +# ADR-2980: A payload-carried `error` key is a degraded result, not a fault + +- **Status:** Accepted +- **Date:** 2026-08-09 +- **Issue:** [#2980](https://github.com/open-gsd/gsd-core/issues/2980) +- **Supersedes:** — +- **Relationship to prior work:** Resolves the decision [ADR-2966](2966-loop-qa-walk.md) explicitly deferred when its `soft-error-exit-zero` smell first fired. Constrained by [ADR-1411](1411-resolution-provenance.md) (resolution must report its provenance) and [ADR-227](227-input-validation-shape-not-just-type.md). + +## Context + +`gsd-tools` reports failure through two different idioms, and they disagree about the exit code. + +| Idiom | Stream | Exit | Honors `--json-errors` | +|---|---|---|---| +| `error(message, reason)` | stderr | **1** | yes → `{ok:false,reason,message}` | +| `output({ error: … })` | stdout | **0** | no — it is a payload, not an error envelope | + +The second idiom is used at **60 call sites across nine modules**: + +| Module | Sites | | Module | Sites | +|---|--:|---|---|--:| +| `src/state.cts` | 25 | | `src/template.cts` | 3 | +| `src/verify.cts` | 8 | | `src/gsd2-import.cts` | 2 | +| `src/workstream.cts` | 7 | | `src/phase.cts` | 2 | +| `src/frontmatter.cts` | 6 | | `src/roadmap.cts` | 2 | +| `src/commands.cts` | 5 | | **Total** | **60** | + +> **On the number 42.** [#2966](https://github.com/open-gsd/gsd-core/issues/2966) and +> [#2980](https://github.com/open-gsd/gsd-core/issues/2980) both record this population as *42 sites +> across six modules*. That figure counts only the sites where `error` is the object literal's +> **first** key — the shape a line regex such as `output\(\{\s*error:` matches. Eighteen further +> sites put another key first (`src/roadmap.cts:260` is +> `output({ found: false, error: 'ROADMAP.md not found' }, raw, '')`) and are identical in contract. +> 42 is a real subset, not the size of the contract; the count was re-derived here by brace-matching +> the first argument rather than by line regex. **Do not "correct" 60 back to 42.** + +Observable today: + +```console +$ gsd-tools state-snapshot # in a project with no STATE.md +{ + "error": "STATE.md not found" +} +$ echo $? +0 +``` + +`docs/json-errors.md` described only the first idiom. A reader consulting it would conclude that +every `gsd-tools` error goes to stderr with exit 1, and would write the obvious shell caller: + +```sh +if ! gsd-tools state-snapshot > snap.json; then + echo "failed" # never reached — the process exited 0 +fi +``` + +The failure is visible only to a caller that parses the payload and knows to look for an `error` +key. Workflows invoke `gsd_run ` and branch on exit status, so the gap is not hypothetical. + +This surfaced from the loop QA walk ([ADR-2966](2966-loop-qa-walk.md)) as a `soft-error-exit-zero` +smell — *legal under today's implementation but structurally questionable*. That ADR measured the +blast radius, declined to act inside a QA-harness ADR, and said the question "warrants a separate, +deliberate decision". This is that decision. + +### Why it is not simply a bug + +Many sites show deliberate intent — an `error` key returned **alongside a valid result**: + +```js +// src/roadmap.cts:310 — cmdRoadmapAnalyze +output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw, undefined); + +// src/roadmap.cts:260 — cmdRoadmapGetPhase +output({ found: false, error: 'ROADMAP.md not found' }, raw, ''); +``` + +Under that reading exit 0 is correct: the command succeeded in determining that the artifact is +absent. That matches the project's existing guidance for bounded subprocesses — +`CONTEXT.md`'s `DEFECT.UNBOUNDED-SUBPROCESS.fix-forward` prescribes "on timeout return degraded +result + structured warning rather than throw" — and it is the same instinct +[ADR-1411](1411-resolution-provenance.md) encodes: a resolution miss is reported, not thrown. + +## Decision + +**The payload-carried `error` key is a ratified contract, not an accident. It stays.** All 60 call +sites are unchanged; no code moves. + +Precisely, the contract now documented in [`docs/json-errors.md`](../json-errors.md): + +> A JSON result on **stdout** carrying an `error` key, with **exit 0**, means the command **ran to +> completion and is reporting a condition through its result**. It is not a process failure. A +> caller that needs to detect it must inspect the payload; the exit code will not tell it, and +> `--json-errors` does not apply. + +A **fault** keeps the other path: `error(message, reason)` → stderr, exit 1, structured envelope +under `--json-errors`. Usage errors keep the third: `ExitError` → plain text on stderr, its own +exit code. + +**New code should prefer the fault path, or a named-field result.** This ADR ratifies an existing +population; it is not a license to add a 61st site. Where a verb genuinely needs to report a +non-fatal condition in its payload, the richer shape `state update-progress` already uses — +`{"updated": false, "reason": "Progress field not found in STATE.md"}`, with no overloaded `error` +key — is the better model. + +### Options declined + +**Option 2 — split the vocabulary** (`status: "absent"` in place of `error`). Declined. It buys a +cleaner vocabulary at the same compatibility cost as Option 3: any caller already reading `.error` +stops seeing it, and it still requires sweeping every site. + +**Option 3 — normalize every site to exit 1.** Declined on measured blast radius. +`get_impact` rates `cmdStateSnapshot` — the function this idiom threads through — **CRITICAL**: +[ADR-2966](2966-loop-qa-walk.md) recorded 55 affected symbols across 23 processes, and a re-measure on 2026-08-09 at depth 5 +reports ≥200 affected symbols across 41 files and 21 processes. `output` itself has **170 direct +callers**. Flipping 0 → 1 across that seam would break every caller currently treating exit 0 as a +soft signal. + +That is a textbook **Hyrum's Law** break: the exit-0 behavior is observable, has been in production +across 60 sites, and is therefore depended upon whether or not anything promised it. A CLI's exit +code has no versioning escape hatch — there is no `/v2/` for `$?`. Hyrum's own prescription for a +long-lived observable behavior is to *document what is stable*, which is what this ADR does. + +## Consequences + +**Good.** + +- The idiom is a chosen contract with a written rule, so a caller can be correct on purpose rather + than by accident. The gap that made the obvious shell caller wrong is closed at the documentation + layer, which is where it existed. +- Zero risk. No call site, exit code, or payload shape changes. +- [ADR-2966](2966-loop-qa-walk.md)'s ratchet rule — *a smell must terminate in either an assigned defect or a corrected + detector* — is satisfied through the assigned-defect branch, resolved as "keep". + +**Costs, stated plainly.** + +1. **The 60 sites are not a uniform population, and the contract is broader than the motivating + example.** The issue framed the idiom as "an `error` key alongside a valid empty result". That + shape is real and common — the 18 sites that put another key first are largely it + (`{found:false, error}`) — but it does not describe the whole population. The rest divide into: + - *absent artifact* — the largest group; a bare `{error}` or `{error, }`, e.g. + `{"error":"STATE.md not found"}`; + - *missing required argument* — **at least seven** sites, all verified within the error-first + subset: `src/state.cts` 599, 834, 902, 969, 1080 and 2814 (`'text required'`, + `'summary required'`, `'phase, plan, and duration required'`, + `'milestone required (--milestone )'`) plus `src/template.cts:269` + (`'File already exists'`). These are faults wearing the degraded shape. Under this ADR they are + correctly *shaped* but arguably wrongly *classified*. "At least" is deliberate — the 18 + error-not-first sites were not individually classified; + - *unusable input* — `cmdStateAdvancePlan` (`src/state.cts:553-585`) reports an unparseable + STATE.md through the same channel as a missing one. + + Reclassifying any of them is a code change and is out of scope here. + +2. **Absent and unusable are not distinguishable by exit code.** [ADR-1411](1411-resolution-provenance.md)'s + 2026-07-26 amendment ("corrupt is not absent") requires those classes to stay distinguishable. + Today only the message text separates them — and the message is explicitly documented as + unstable, so a caller cannot depend on it. This ADR does not close that gap; it names it. + +3. **`--raw` is not uniform on the error path.** `output(result, raw, rawValue)` prints `rawValue` + only when it is not `undefined`. Most sites pass `undefined` (eight in `src/verify.cts` omit the + argument entirely), so `--raw` still hands the caller the JSON error object. **Eleven sites pass + something else** and therefore behave differently under `--raw`: `src/commands.cts` 1481, 1546, + 1553, 1569, `src/phase.cts` 246, 692, `src/roadmap.cts:260`, `src/state.cts:436` and `:2566` pass + `''` or `'false'`; `src/state.cts:2436` passes the message text; `src/template.cts:100` passes a + template path. A caller cannot assume either behavior from `--raw` alone. + +4. **The `soft-error-exit-zero` smell keeps firing** on `state-snapshot` and `roadmap get-phase`, + now against behavior this ADR ratifies. Per [ADR-2966](2966-loop-qa-walk.md) §5 a smell never fails a build and never + folds into `.failed`, so this costs nothing but noise — but a detector reporting a ratified + contract is reporting a decision, not a finding. Re-pointing it is a code change and is out of + scope here. + +## Revisit if + +- A caller needs to distinguish **absent** from **unusable** programmatically. That is + [ADR-1411](1411-resolution-provenance.md)'s open edge, and it is the most likely reason this + decision gets reopened. The in-band mechanism that ADR already prescribes — naming the cause in a + provenance field — would fit these payloads without touching a single exit code, which makes it + strictly cheaper than Options 2 and 3. +- The missing-required-argument sites (cost 1 above) produce a real caller bug. Those seven are the + subset with the weakest claim to exit 0, and they could be moved to `error()` on their own, with a + far smaller radius than the full 60. +- A future `gsd-tools` major version provides a compatibility boundary that a CLI exit code + otherwise lacks. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6a0579e9f..e210d6678 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -183,6 +183,7 @@ These govern the system as it stands. Cite these. | [ADR-2782](2782-reviewer-lane-capability-surface.md) | Reviewer Lane — the cross-AI reviewer handoff becomes a declared capability surface | Accepted | — | | [ADR-2866](2866-install-surface-resolution.md) | Install-surface resolution — the install pipeline resolves `(runtime × scope × trigger)` as a value | Accepted | — | | [ADR-2966](2966-loop-qa-walk.md) | Test the five-step loop as a continuous walk, not isolated points | Accepted | — | +| [ADR-2980](2980-payload-carried-error-is-a-degraded-result.md) | A payload-carried `error` key is a degraded result, not a fault | Accepted | — | | [ADR-3180](3180-planning-semantic-model-single-owner.md) | Planning Semantic Model — Single Owner per Derivation | Accepted | — | | [ADR-3212](3212-lexical-seam-consolidation.md) | The Lexical Seam — Safe Pattern Construction, Line-Terminator Normalization, and Tokenizer-First Stateful Grammars | Accepted | — | | [ADR-3660](3660-runtime-artifact-layout-module.md) | Runtime Artifact Layout Module owns per-runtime artifact placement | Accepted | [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) | diff --git a/docs/json-errors.md b/docs/json-errors.md index 2da5a9869..496a6f1a6 100644 --- a/docs/json-errors.md +++ b/docs/json-errors.md @@ -9,6 +9,12 @@ 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](#degraded-results-vs-faults--read-this-before-writing-a-caller) +> before writing anything that consumes `gsd-tools` output. + ## Activating Either flag or env var activates the mode: @@ -52,6 +58,108 @@ 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). +## Degraded results vs faults — read this before writing a caller + +`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. + +```console +$ gsd-tools state-snapshot # in a project with no STATE.md +{ + "error": "STATE.md not found" +} +$ echo $? +0 +``` + +Some verbs return a companion result alongside the key, which is the shape that makes the intent +clearest: + +```console +$ gsd-tools roadmap get-phase --phase 1 # no ROADMAP.md +{ + "found": false, + "error": "ROADMAP.md not found" +} +$ echo $? +0 +``` + +This is a **ratified contract**, not an accident — see +[ADR-2980](adr/2980-payload-carried-error-is-a-degraded-result.md) for the decision and the blast +radius that drove it. It applies to **60 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. See ADR-2980 for why the real number is 60.) + +### Writing a correct caller + +The obvious shell form is **wrong** for a degraded result: + +```sh +# WRONG — the process exits 0, so this branch never runs +if ! gsd-tools state-snapshot > snap.json; then + echo "failed" +fi +``` + +Check both channels — the exit code for faults, the payload for degraded results: + +```sh +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 +``` + +### Four things that will surprise you + +1. **`--json-errors` does nothing here.** It governs `error()` only. A degraded result is + byte-identical with and without the flag, and still exits 0. +2. **`--raw` is not uniform on this path.** Most sites pass no raw value, so `--raw` still yields + the JSON object rather than bare text — but eleven sites do pass one and behave differently. + Do not infer either behavior from `--raw` alone; check the verb. +3. **Not every degraded result is an absent artifact.** A missing required argument is reported the + same way — `gsd-tools state add-blocker` with no `--text` returns `{"error":"text required"}` and + exits 0. So is unusable input: `gsd-tools state advance-plan` against a STATE.md it cannot parse + returns `{"error":"Cannot parse Current Plan or Total Plans in Phase from STATE.md"}`, 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. +4. **`message`/`error` text is not stable.** Assert on structure and on typed `reason` codes, never + on prose. The rule in "Writing tests" below applies to both paths. + +### Which one should new code use? + +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: + +```console +$ gsd-tools state update-progress # STATE.md present, no Progress field +{ + "updated": false, + "reason": "Progress field not found in STATE.md" +} +``` + ## Error code taxonomy Codes are frozen constants in `gsd-core/bin/lib/core.cjs` under