Files
msd-core/hooks/lib/cli-exit.js
Tom Boucher d24e22b156 enhance(#3912): gsd-tools declares outcomes, pinned at v1 (#3983)
* enhance(#3912): gsd-tools declares outcomes, pinned at v1

ADR-3889 §4. Phase 6 already moved error()'s terminator onto the seam, so what
remained was the declaration — and the pin that makes it invisible today.

The census corrected two documented figures before any code changed.
ERROR_REASON has exactly 25 members (the ADR and epic were right; an earlier
note of mine claiming 23 was wrong and is corrected). And output({error}) is
**64 sites across 9 files, not the 60 ADR-2980 ratified** — the module shape
holds but the total drifted +4: frontmatter 7 not 6, phase 4 not 2, roadmap 3
not 2. That matters because this phase's criterion demands the pin be asserted
over the enumerated population rather than sampled; asserting over a stale 60
would leave four sites unpinned while claiming full coverage, which is the
shape of failure this epic exists to remove.

The issue does not state the fact that shapes the design: output() never
touches the exit code. Confirmed by reading it — it writes fd 1 and returns.
So a declared outcome for those 64 sites had nowhere to be READ. The mapping
was never the work; wiring somewhere for the declaration to land was.

The seam already existed twice over. cli-exit.cts holds two globalThis-Symbol
cells, each because the module is emitted to three locations and a module-level
`let` would let instances disagree, and runMain already maps a code returned by
main(). A third cell inherits that solution. output() records DEGRADED for any
{error} payload — key-order agnostic, which is exactly why the "42 sites"
figure undercounts — and runMain projects the cell only when main() returns
nothing, so an explicit return still wins.

error() maps its reason through a table over the closed 25-member enum, leaving
all 278 call sites untouched; 226 of them pass no reason at all. The version
gate lives in error(), NOT in projectOutcome: registered names are
version-invariant there, so mapping a reason straight through would make USAGE
project to 64 under v1 and break the pin on its first line. projectOutcome is
left exactly as Phase 2 shipped it, DEGRADED's 0/80 asymmetry included.

Proven rather than asserted. v1 is byte-identical across three real CLI paths —
config-get plain, config-get --json-errors, and an output({error}) path —
matching exit code and exact bytes against the pre-change build. Under
GSD_EXIT_CONTRACT=v2 the same commands now exit 66 (CONFIG_KEY_NOT_FOUND ->
NO_INPUT) and 80 (DEGRADED), both looked up through the registry. An
anti-vacuity test pins that v1 and v2 genuinely differ for at least one reason,
because without it a mapping where everything projects to 1 under both versions
would satisfy every other assertion and the declaration would be theatre.

A1 iterates all 25 enum members and A3 asserts over the measured 64-site
population, so a 26th reason or a 65th site fails until it is given a mapping —
the drift guard this phase needs, given ADR-2980's own count had drifted +4
unnoticed.

Verification runs on the remote runner.

Refs #3912

* fix(#3912): the outcome cell must never lower an exit code

The remote run caught a fail-open that this phase introduced, in the phase
whose entire purpose is removing fail-opens.

`state validate --strict` on a missing STATE.md exited **0** where it must exit
1. Mechanism: `runMain` projected the pending outcome whenever `main()` returned
void, and under v1 DEGRADED projects to 0 — so a `process.exitCode` already set
non-zero by the command was clobbered down to success. Confirmed live against a
fixture, before and after.

This refutes a review conclusion recorded earlier in this phase, that the cell
was "fail-closed and can never mask a failure as success". It could, and did.
Recording that plainly so the assumption is not repeated: the cell's danger was
never only that it might add a failure — it was that projecting it
unconditionally overwrites whatever decision came before.

Projection is now guarded: it may set a code only when none is set, and an
already-non-zero exit code always wins. The full precedence — explicit `main()`
return, then an existing non-zero exitCode, then the declared outcome — is
written at the projection site. A regression test drives a void return with a
pre-set non-zero code and a pending DEGRADED, and fails against the pre-fix
build.

The second failure was my test encoding the wrong contract, not a code defect.
It asserted `output({found:false, error: undefined})` records DEGRADED because
the KEY is present. `JSON.stringify` drops undefined, so the payload the user
receives is `{"found":false}` — carrying no error at all, and calling that
degraded would hand back exit 80 under v2 for output that reads as clean. The
discriminator is a serializable error VALUE, not key presence. The test now
pins `{error: undefined}` as explicitly NOT degraded, and the design doc's
wording is tightened to match.

Verification runs on the remote runner.

Refs #3912

* docs(#3912): the versioned exit contract, and a flag defect the docs found

Diataxis pass for Phase 8, plus a real fix that only surfaced because writing
the how-to meant running its own examples.

The docs. ADR-2980's "Revisit if" clause asked for exactly the versioned
projection this phase provides, so it gets an amendment naming #3912 /
ADR-3889 section 4 as that boundary: v1 stays 0 byte-for-byte, v2 projects
DEGRADED to 80. The amendment also records the count drift rather than
restating a stale figure — the ADR ratified 60 output({error}) sites in 9
modules; the AST-measured population is 64 across the same 9 (frontmatter 7
not 6, phase 4 not 2, roadmap 3 not 2). The pin is asserted over the
enumerated 64. json-errors.md gains the outcome-declaration reference,
including the precedence order a review pass got wrong and the suite refuted:
an explicit main() return, then an already-set non-zero process.exitCode, then
the declared outcome. Projection may only ever set a code, never lower one.

A how-to is owed here and is written, not skipped. Under v1 nothing changes,
so the audience is an operator opting into v2 and needing to know what the
codes mean for a CI gate — a migration, which is how-to shaped. It covers
turning v2 on, the code table, why 80 is "ran and reported a condition" rather
than a crash, and how to split a gate that treats any non-zero as fatal. No
tutorial: there is no new entry point to learn, and under the default contract
a reader would be walked through observing nothing.

The defect. Running the how-to's own Step 1 example returned

    $ gsd-tools --exit-contract=v2 state validate --strict
    Error: Unknown command: --exit-contract=v2          (exit 64)

while the same flag trailing the subcommand worked and exited 80. The flag
half-worked, by argv position. resolveContractVersion scans argv
non-destructively, so the token survived into the dispatcher, which treats
argv[2] as the command name. --json-errors had already solved precisely this
at gsd-tools.cjs:4455, under a comment naming the hazard verbatim: "The argv
splice must happen here too, otherwise the dispatcher below sees
--json-errors as an unknown command." The later flag never got the same
treatment.

Fixed rather than documented around: the version is resolved first — which
memoizes the cell and makes an invalid value throw early — and then every
--exit-contract= token is spliced out of the dispatcher's argv copy.
--exit-contract is now listed in TOP_LEVEL_USAGE, where it never was. The
regression test pins leading position, trailing position, agreement between
the two, and a loud failure on v3 rather than a silent fall back to v1.

Neither review engine would have caught this: the defect is invisible in the
diff, because the diff does not touch argv handling. It surfaced only from
running the documentation's own example. Writing a how-to is an execution pass.

Verification runs on the remote runner.

Refs #3912

* fix(#3912): the flag splice has to run before the run-with-timeout return

An isolated review of the previous commit found that the fix did not deliver
what it claimed, and that two of its own tests were weak. All three findings
reproduced by execution before any change was made.

The fix was placed below a return. main() intercepts `run-with-timeout` at
gsd-tools.cjs:4436 and returns from there — above both the --json-errors block
and the --exit-contract splice added in the previous commit. So the flag still
died in leading position for that one command:

    $ gsd-tools --exit-contract=v2 run-with-timeout 5 -- node -e "..."
    Error: Unknown command: run-with-timeout        (exit 64, child never ran)

The previous commit message and the test's describe-block both claimed
position-independence unconditionally. That was an overclaim, not a gap left
open, and it is the part worth naming: the fix was verified by hand on the
commands I happened to think of, and `run-with-timeout` returns before the
code I was verifying.

Both global-flag blocks now run above the interception, with a comment naming
it so a later edit cannot slide them back down. Moving --json-errors up fixes
the identical pre-existing bug for that flag, verified failing beforehand
(exit 1, sdk_unknown_command). Fixing the sibling is deliberate: same defect,
same block, and a known-broken twin next to a fixed one is not a resting state.

Two tests were not pulling their weight. The invalid-value test was vacuous —
it passed against the pre-fix build, because `--exit-contract=v3` already
exited 1 there and already printed the resolve error lazily through
error() -> getContractVersion. Both its assertions held before the fix, so it
pinned nothing. The real discriminator is that the pre-fix build emits BOTH
"Unknown command: --exit-contract=v3" and the resolve error, while the fixed
build emits only the latter; the test now asserts that absence.

The leading-position and leading==trailing tests asserted proxies — "not 64",
"no Unknown command", "the two agree" — none of which pin a value, and all of
which would survive both positions being identically broken. With a .planning
directory and no STATE.md, state-snapshot exits exactly 80 under v2 and 0
under v1 in both positions. Those numbers are pinned now. The multi-token case
the descending splice loop exists for is covered too, and run-with-timeout has
regression tests for both flags.

The lesson is narrower than "test more". Hand-verifying the production
behavior does not verify that the test would have caught its absence. The
pre-fix binary has to be run against the test's own assertions.

Investigated and deliberately not changed: splicing before --cwd parsing
degrades one diagnostic from "Missing value for --cwd" to "Invalid --cwd:
<path>", but that is pre-existing — verified on the pre-fix build via
--json-errors, which already did it. This change joins the pattern rather than
creating it, and both forms exit 64 on malformed input either way.

Verification runs on the remote runner.

Refs #3912

* chore(#3912): backfill changeset pr numbers to 3983

* test(#3912): pin the reason-table invariant as set equality, not a count

A graph-backed review flagged the unchecked lookup in
expectedErrorCode3912. Investigated by execution: the drift guard DOES
hold — for an unmapped reason under v2 the production error() yields 1
while the table yields undefined, so the assertion fails. Not a
correctness defect, and deliberately NOT made tolerant, since a tolerant
lookup would destroy the guard.

Two real problems remained. The guard asserted the wrong invariant: it
counted the TABLE's keys at 25 rather than checking they match the
ENUM's values, so a renamed member keeps the count at 25 and slips past,
and a 26th member leaves the table at 25 and slips past too. Both were
then caught only indirectly, by an undefined mismatch producing 'must
exit undefined'. It is now a sorted set equality, so the failure names
the specific missing or extra reason.

And the comment above it described a '?? FAIL' fallback that does not
exist anywhere in the function. It now states what the code actually
does, verified by running it rather than by reading it.

Refs #3912

---------

Co-authored-by: sim <sim@local>
2026-08-28 08:09:05 -04:00

561 lines
31 KiB
JavaScript

// GENERATED FILE — DO NOT EDIT BY HAND.
// Source of truth: src/cli-exit.cts. Regenerate with:
// node scripts/gen-hooks-cli-exit.cjs --write
// Byte-compared by `npm run lint:generated-sync` (#3911, ADR-3889 Phase 7).
//
// Why this copy exists: hooks/ runs straight from a raw, unbuilt clone — a
// shipped hook must be able to `require('./lib/cli-exit.js')` relative to
// its own __dirname and terminate through `terminateNow` without depending
// on any build artifact. gsd-core/bin/lib/cli-exit.cjs is gitignored tsc
// output and doubles as the build sentinel, so it cannot be required from
// here. `.js`, not `.cjs`, to match the hooks/lib/*.js convention. Hence one
// source, three emitted locations (gsd-core/bin/lib, scripts/lib, hooks/lib).
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
/**
* Process-exit primitives (ExitError, runMain, terminateNow) plus the
* json-error-mode and contract-version cells.
*
* Must import nothing but `node:fs` and `./exit-code-registry.cjs` — this
* source is emitted to TWO locations, gsd-core/bin/lib/cli-exit.cjs (tsc
* build output) and scripts/lib/cli-exit.cjs (a generated, committed
* artifact regenerated by scripts/gen-scripts-cli-exit.cjs), and the latter
* must load on an unbuilt clone before anything under ./lib exists. The
* registry require is safe here for the same reason: scripts/gen-exit-code-
* registry.cjs (ADR-3889 Phase 1/2, #3905/#3906) dual-emits its OWN sibling
* artifact, exit-code-registry.cjs, into both of these exact locations, so
* a relative `./exit-code-registry.cjs` resolves next to whichever copy of
* this module loaded it.
*/
const node_fs_1 = __importDefault(require("node:fs"));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const exitCodeRegistryModule = require("./exit-code-registry.js");
// Called only as exitCodeRegistryModule.exitCodeFor(...), never destructured:
// @typescript-eslint/unbound-method flags a bare function-typed property
// pulled off an object at the point of destructuring, since a detached
// reference COULD be called with the wrong `this` — keeping the member
// access qualified sidesteps that regardless of whether the callee ever
// actually touches `this` (it does not; exitCodeFor is pure).
const exitCodeFor = (name) => exitCodeRegistryModule.exitCodeFor(name);
/**
* The wire value `runMain` stamps into its structured envelope. Declared HERE,
* not in io.cts, because this module must not import anything (see the module
* header): io.cts builds ERROR_REASON.SDK_FAIL_FAST from this constant, so the
* two surfaces share ONE definition rather than two literals kept in step by a
* parity test.
*/
const EXIT_ENVELOPE_REASON = 'sdk_fail_fast';
/**
* Process-level flag: when true, error paths emit structured JSON to stderr
* instead of plain text. Set by gsd-tools.cjs when the CLI is invoked with
* `--json-errors`; re-exported by io.cts, which is where most callers reach it.
*
* Held in a Symbol-keyed cell on globalThis rather than in module scope, and
* that is load-bearing: this module is emitted to TWO locations
* (gsd-core/bin/lib/cli-exit.cjs and the generated scripts/lib/cli-exit.cjs),
* so a process that loads both would get two independent module instances. A
* module-level `let` would give them two independent flags — one copy could
* think json mode is on while the other thought it was off, which is exactly
* the divergence class ADR-3889 exists to remove. One cell, keyed by a
* registry Symbol, makes that unrepresentable.
*/
const JSON_ERROR_MODE_KEY = Symbol.for('gsd.exit.jsonErrorMode');
function setJsonErrorMode(v) {
globalThis[JSON_ERROR_MODE_KEY] = !!v;
}
function getJsonErrorMode() {
return globalThis[JSON_ERROR_MODE_KEY] === true;
}
/** The single registered name code 2 may ever be produced for (ADR-3889 §1). */
const HOOK_DENY_NAME = 'HOOK_DENY';
const HOOK_DENY_CODE = exitCodeFor(HOOK_DENY_NAME);
/**
* Currently-resolved exit-contract version (ADR-3889 §4). Held in a
* Symbol-keyed globalThis cell rather than a module-level `let`, for the
* exact reason JSON_ERROR_MODE_KEY is (see its comment above): this module
* is emitted to two locations and thus loaded as two independent module
* instances in any process that requires both, so a module-level variable
* would let those two instances disagree about which contract is active.
* `resolveContractVersion` is the only writer; `terminateNow`/`runMain`
* read it internally when projecting a declared outcome.
*/
const CONTRACT_VERSION_KEY = Symbol.for('gsd.exit.contractVersion');
function setContractVersion(v) {
globalThis[CONTRACT_VERSION_KEY] = v;
}
/**
* Resolve the active exit-contract version, wiring the ambient process to the
* two terminators (ADR-3889 §4/§3). Mirrors how JSON_ERROR_MODE_KEY already
* works: a process-global cell means no entrypoint needs per-call wiring, so
* a `scripts/` tool or a hook gets the same behaviour as `gsd-tools` without
* this module touching either (P8 owns `gsd-tools`; P7 owns hooks).
*
* Precedence: if the cell already holds an explicit version, that wins —
* this is what lets `setContractVersion` override the ambient process (a
* later `GSD_EXIT_CONTRACT=v2` in the same process must NOT unseat an
* explicit `setContractVersion('v1')` call). Otherwise resolve from argv/env
* via `resolveContractVersion`, which itself persists the result into the
* cell — so this is a one-time resolution per process; every later read is
* just the cached cell value. An invalid ambient value (e.g. `v3`) is NOT
* softened to a silent v1 here: `resolveContractVersion` throws, and that
* throw propagates — swallowing it would reintroduce the "nothing fails with
* success" defect ADR-3889 exists to close, on the very selector meant to
* demonstrate the fix. Absent both flag and env, resolution still yields
* 'v1' (the documented default) and that too gets memoized.
*/
function getContractVersion() {
const cached = globalThis[CONTRACT_VERSION_KEY];
if (cached === 'v1' || cached === 'v2')
return cached;
return resolveContractVersion({ argv: process.argv, env: process.env });
}
/**
* Project a declared outcome onto an integer exit code for a given contract
* version. Pure and total over its own input space: throws for anything not
* an exact-case registered name (mirrors exitCodeFor's contract) or an
* unrecognized version — it never returns undefined/NaN.
*
* PASS/FAIL and every registered name project IDENTICALLY under v1 and v2
* (registered names are version-invariant) — the sole exception is DEGRADED:
*
* v1: DEGRADED -> 0. Deliberate, NOT a bug: ADR-2980 ratified 60
* `output({error})` call sites that already exit 0 on a payload-carried
* error, and ADR-2980's own "Revisit if" clause is what ADR-3889 §4
* answers — normalizing this to a non-zero code was explicitly
* DECLINED there on measured blast radius. A future reader must not
* "fix" this to look more consistent with v2; the inconsistency IS the
* compatibility boundary.
* v2: DEGRADED -> exitCodeFor('DEGRADED') (80). Looked up through the
* registry, never hardcoded, so a re-allocation of DEGRADED's code
* cannot silently desync this projection from the shipped table.
*/
function projectOutcome(outcome, version) {
if (typeof outcome !== 'string' || outcome.length === 0) {
throw new Error(`projectOutcome: outcome must be a non-empty string, received ${JSON.stringify(outcome)}`);
}
if (version !== 'v1' && version !== 'v2') {
throw new Error(`projectOutcome: version must be 'v1' or 'v2', received ${JSON.stringify(version)}`);
}
if (outcome === 'PASS')
return 0;
if (outcome === 'FAIL')
return 1;
if (outcome === 'DEGRADED')
return version === 'v1' ? 0 : exitCodeFor('DEGRADED');
// Any other registered name: version-invariant, resolved through the
// registry (throws for anything unregistered/empty/non-string/wrong-case —
// exitCodeFor's own contract, which this function inherits verbatim).
return exitCodeFor(outcome);
}
/**
* Pending declared outcome (ADR-3889 §4, #3912): the outcome `output()`
* records when it detects a payload-carried error (`{ error }`, any key
* order) on a call that otherwise just returns — there is no thrown
* ExitError and no explicit `main()` return for `runMain` to project, so
* without this cell the declaration has nowhere to land. `runMain` reads it
* ONLY when `main()` itself returns no explicit code (void/undefined); an
* explicit number/string return always wins over whatever this cell holds.
*
* Held in a Symbol-keyed globalThis cell for the exact reason
* JSON_ERROR_MODE_KEY / CONTRACT_VERSION_KEY are (see their comments above):
* this module is emitted to three locations and thus loaded as independent
* module instances in any process that requires more than one, so a
* module-level variable would let those instances disagree about whether a
* degraded result was ever declared.
*
* LIFETIME (#3912 review fix): LAST DECLARATION WINS, CLEARED ON CONSUMPTION.
* This cell is NOT "was DEGRADED ever declared this process" — it is "is a
* degraded outcome pending RIGHT NOW". `output()` sets it to 'DEGRADED' on a
* payload-carried error and CLEARS it (`undefined`) on a clean payload, so a
* later clean `output()` call undoes an earlier degraded one in the same
* invocation. `runMain` clears it immediately after consuming it (in a
* `finally`, on both the pending-cell branch and the case where nothing was
* pending), so a second `runMain` in the same process starts clean. Without
* both halves the cell is monotonic for the life of the process: any later
* `main()` returning void would inherit a stale DEGRADED from an unrelated,
* earlier call — this is the leak #3912 review found and fixed.
*/
const PENDING_OUTCOME_KEY = Symbol.for('gsd.exit.pendingOutcome');
function setPendingOutcome(v) {
globalThis[PENDING_OUTCOME_KEY] = v;
}
function getPendingOutcome() {
return globalThis[PENDING_OUTCOME_KEY];
}
const EXIT_CONTRACT_FLAG_PREFIX = '--exit-contract=';
/** Scan argv for the FIRST `--exit-contract=<value>` token; undefined if absent. */
function findExitContractFlag(argv) {
for (const arg of argv) {
if (typeof arg === 'string' && arg.startsWith(EXIT_CONTRACT_FLAG_PREFIX)) {
return arg.slice(EXIT_CONTRACT_FLAG_PREFIX.length);
}
}
return undefined;
}
/**
* Resolve which exit-contract version is active from argv/env, per ADR-3889
* §4, and persist it to the shared contract-version cell so a later
* `terminateNow`/`runMain` call (through EITHER module copy) projects
* against it without re-parsing argv/env itself.
*
* Precedence: an explicit `--exit-contract=<v>` flag BEATS
* `GSD_EXIT_CONTRACT`, in both directions (flag=v1 + env=v2 -> v1; flag=v2 +
* env=v1 -> v2). Neither present -> 'v1' (the documented default). An empty
* env var reads as UNSET, not as an explicit empty selection — a shell that
* exports `GSD_EXIT_CONTRACT=` with nothing after the `=` must not silently
* select a version.
*
* Casing is decided, not accidental: only the exact lowercase tokens `v1`/
* `v2` are accepted (matching every example in ADR-3889 and this module's own
* usage docs, both of which write `v2` never `V2`). Anything else recognized
* as PRESENT but not a valid version — `v3`, `garbage`, or an explicitly
* empty flag value (`--exit-contract=`) — THROWS rather than silently
* defaulting to v1. A selector for a contract whose whole thesis is "nothing
* fails with success" must not itself fail open.
*/
function resolveContractVersion(opts = {}) {
const argv = opts.argv ?? process.argv;
const env = opts.env ?? process.env;
const flagValue = findExitContractFlag(argv);
const rawEnvValue = env.GSD_EXIT_CONTRACT;
const envValue = rawEnvValue === undefined || rawEnvValue === '' ? undefined : rawEnvValue;
const selected = flagValue !== undefined ? flagValue : envValue;
let resolved;
if (selected === undefined) {
resolved = 'v1';
}
else if (selected === 'v1' || selected === 'v2') {
resolved = selected;
}
else {
throw new Error(`resolveContractVersion: unrecognized exit-contract version ${JSON.stringify(selected)} `
+ `(expected 'v1' or 'v2')`);
}
setContractVersion(resolved);
return resolved;
}
/**
* Error carrying a process exit code. CLI logic throws this instead of calling
* process.exit() (banned by n/no-process-exit); runMain() translates it into
* process.exitCode at the entrypoint.
*/
class ExitError extends Error {
code;
hasUserMessage;
constructor(code = 1, message) {
super(message === undefined ? `process exit ${code}` : message);
this.name = 'ExitError';
this.code = code;
this.hasUserMessage = message !== undefined;
}
}
/**
* Run a CLI main and translate its outcome into process.exitCode (never
* process.exit, so n/no-process-exit stays satisfied; output flushes and
* process.on('exit') cleanup still fires — this is precisely why runMain and
* terminateNow are two different functions: drain-then-exit vs write-then-
* terminate). main may be sync or async. Every arm below except the new
* string one and the void/pending-cell one is UNCHANGED from before
* ADR-3889 Phase 2:
* number return -> process.exitCode = it (unchanged)
* string return -> NEW: process.exitCode = projectOutcome(result, getContractVersion()),
* UNLESS that projection is the HOOK_DENY exit code (see
* the refusal below — 2 may only be produced by terminateNow).
* void/undefined return -> NEW (#3912, ADR-3889 §4): an explicit return
* already handled above always wins, so this arm only
* runs when main() declared no outcome of its own. If
* the pending-outcome cell holds a value (currently only
* ever 'DEGRADED', set by io.cts's output() on a
* payload-carried error), project THAT through the
* current contract version — BUT ONLY when
* process.exitCode is not already a non-zero value.
* FULL PRECEDENCE ORDER for the code a void-returning
* main() ends up with:
* 1. An explicit number/string return from main()
* (handled in the arms above) — always wins.
* 2. A non-zero process.exitCode already set by main()
* itself before it returned (e.g. `state validate
* --strict`'s `emit()` setting 1 directly) — wins
* over the pending cell.
* 3. The pending-outcome cell's projection — used only
* when process.exitCode is still unset/0.
* 4. Otherwise process.exitCode stays 0 (default).
* This is a regression fix: unconditionally projecting
* the pending cell here used to CLOBBER an
* already-non-zero process.exitCode down to DEGRADED's
* v1 projection (0) — turning a real declared failure
* (e.g. `state validate --strict` against a missing
* STATE.md, which sets process.exitCode = 1 directly)
* into a false success. `runMain` must never LOWER an
* exit code that main() itself already raised. DEGRADED
* still projects to 0 under v1 when nothing else set a
* code — the same value this arm produced before this
* phase by doing nothing — so v1 behavior is
* byte-identical for every caller that never sets its
* own exit code.
* thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0) (unchanged)
* other throw -> when json-error mode is active, emits structured { ok:false, reason, message }
* to stderr; otherwise writes raw stack trace. exit code = 1 in either case. (unchanged)
*/
function runMain(main) {
Promise.resolve()
.then(() => main())
.then((result) => {
// Cleared on EVERY branch below, not only the pending-cell-consuming
// void arm: an explicit number/string return means main() declared
// its own outcome and the cell (if anything set it earlier in this
// same invocation) is now stale — leaving it set would leak into the
// NEXT runMain call in this process, reintroducing the #3912 leak one
// level up. "Cleared on consumption" therefore means "consumption of
// this runMain call", not just "consumption of the pending value".
try {
if (typeof result === 'number') {
process.exitCode = result;
return;
}
if (typeof result === 'string') {
const projected = projectOutcome(result, getContractVersion());
// ADR-3889 §3: exit code 2 (the hook-protocol deny) may
// ONLY be produced by terminateNow, never by runMain. runMain is
// drain-then-exit; a deny drained this way can be truncated on
// Windows, which is exactly why terminateNow (write-then-terminate)
// exists. Gated on the PROJECTED code, not on the literal string
// `'HOOK_DENY'`, so a future registry rename that still resolves to
// this code cannot slip past the guard.
if (projected === HOOK_DENY_CODE) {
process.stderr.write(`runMain: refusing to exit with code ${HOOK_DENY_CODE} — outcome ${JSON.stringify(result)} `
+ `projects to the ${HOOK_DENY_NAME} exit code, which is reserved to terminateNow. `
+ `A hook-protocol deny must be delivered write-then-terminate via terminateNow(${JSON.stringify(result)}, payload), `
+ 'never drain-then-exit via runMain — a drained deny can be truncated on Windows. '
+ 'This is a caller bug: runMain must not be given a main() that returns HOOK_DENY.\n');
process.exitCode = exitCodeFor('INTERNAL');
return;
}
process.exitCode = projected;
return;
}
// result is undefined (void return): main declared no outcome itself.
// Fall back to the pending-outcome cell, if anything set it — but
// NEVER lower an exit code main() already raised on its own (see the
// precedence order in this function's doc comment above). Without
// this guard, a void-returning main() that set process.exitCode = 1
// directly (e.g. `state validate --strict` on a missing STATE.md)
// would have that 1 clobbered down to DEGRADED's v1 projection (0)
// by a payload-carried error the SAME call also recorded via
// io.cts's output() — a real failure silently reported as success.
const pending = getPendingOutcome();
if (typeof pending === 'string' && pending.length > 0 && !process.exitCode) {
process.exitCode = projectOutcome(pending, getContractVersion());
}
}
finally {
// Cell is consumed exactly once per runMain call regardless of which
// branch above ran (see the cell's own doc comment — "last
// declaration wins, cleared on consumption") — so a later `runMain`
// in the same process never inherits this one's declaration.
setPendingOutcome(undefined);
}
})
.catch((err) => {
if (err instanceof ExitError) {
if (err.hasUserMessage && err.code !== 0)
process.stderr.write(`${err.message}\n`);
process.exitCode = err.code;
return;
}
if (getJsonErrorMode()) {
const e = err;
const payload = JSON.stringify({
ok: false,
reason: EXIT_ENVELOPE_REASON,
message: (e && e.message) ? e.message : String(err),
}) + '\n';
node_fs_1.default.writeSync(2, payload);
}
else {
const e = err;
process.stderr.write(`${e && e.stack ? e.stack : String(err)}\n`);
}
process.exitCode = 1;
});
}
/**
* Write `payload` fully to fd 1 (and, for a deny, fd 2 too) and terminate the
* process IMMEDIATELY with `outcome` projected through the current contract
* version. This is write-then-terminate, the other half of ADR-3889 §3's
* "two terminators over one registry": hooks fire from contexts (e.g. a
* `setTimeout` stdin-timeout guard) where `process.exitCode = N; return;`
* terminates nothing, so they need an immediate, synchronous exit — the
* exact gap `eslint.config.mjs:563-582` documents for `hooks/**`.
*
* This is THE ONLY sanctioned `process.exit` call site in the repo, and the
* only place exit code 2 can be produced: 2 is reserved to the hook-adapter
* protocol (ADR-3889 §1), and the registry's own one-owner rule already
* guarantees no other registered name resolves to it — the check below is a
* defense-in-depth assertion of that invariant, not the sole thing enforcing
* it.
*
* @param outcome - declared outcome name, projected via projectOutcome.
* @param payload - JSON-serializable value written to fd 1 (and, on a deny
* for which no `stderrPayload` is given, fd 2 too — this is the
* backward-compatible default every existing caller relies on).
* @param stderrPayload - optional, deny-only. When omitted (the default),
* fd 2 gets the SAME serialized `payload` fd 1 got — unchanged behavior.
* When provided, fd 2 gets THIS instead: a string is written raw
* (verbatim, not JSON-stringified), anything else is JSON-stringified
* like `payload`. This exists because `hooks/gsd-write-guard.js`'s
* emitBlock does NOT write the same bytes to both streams today — it
* writes the full JSON `output` to stdout but only the plain-text
* `output.reason` STRING to stderr, because Kimi's native hook bus reads
* stderr verbatim back to the model on exit 2. Migrating that call site
* onto terminateNow requires a way to say "fd 2 gets this different,
* plain-text value" — `stderrPayload` is that seam. Ignored entirely for
* a non-deny outcome: stderr is a deny-only channel.
*
* PAYLOAD-SIZE CONSTRAINT FOR CALLERS (measured for #3906, relevant to P7/
* #3911 wiring 19 enforcement hooks onto this function): the write-until-
* drained loop above delivers a payload whole regardless of size — verified
* up to 1MB (Node's own `spawnSync` default `maxBuffer`) with no truncation
* and no stall, both with a concurrently-draining async reader (~30ms for a
* 256KB payload) and with the default (internally-drained) pipe stdio a
* spawnSync-based test harness gets for free. Node's `spawnSync` does NOT
* suffer the classic "child blocks writing past the pipe buffer because
* nothing on the parent side is reading yet" deadlock some other languages'
* synchronous-subprocess primitives have; it drains stdout/stderr
* concurrently at the libuv layer while the child runs. The constraint that
* DOES bite on Linux is unrelated to pipe buffering: `execve(2)` enforces
* `MAX_ARG_STRLEN` (128KiB per single argv/envp string; see `man execve`
* NOTES) — so a CALLER that embeds a large literal payload directly into a
* spawned command line (e.g. `node -e "...<huge string>..."`) can fail to
* even start the child on Linux (macOS has no equivalent per-string cap),
* with no relation to this function's own behavior. See
* tests/cli-exit.test.cjs's "a large payload (bigger than a pipe buffer)
* arrives whole" test, which hit exactly this constructing its own fixture
* before being rewritten to build the payload inside the child instead.
*/
function terminateNow(outcome, payload, stderrPayload) {
// terminateNow is total by construction: its callers are enforcement hooks
// (P7/#3911, 19 of them) whose OWN outer catch may fail open (some end in
// `process.exit(0)`). If resolving the contract version, projecting the
// outcome, or the HOOK_DENY-collision guard below threw and that throw
// propagated out of this function, it would unwind straight into that
// caller's catch — turning a deny into a silent allow, exactly the defect
// ADR-3889 exists to close. So every one of those steps is wrapped here:
// on ANY failure this still terminates, deterministically, with INTERNAL
// (never by returning or re-throwing) — a malformed call is a programming
// error to be diagnosed on stderr, not a reason to hand control back.
let versionForDiagnostics = '(unresolved)';
try {
const version = getContractVersion();
versionForDiagnostics = version;
const projected = projectOutcome(outcome, version);
if (projected === HOOK_DENY_CODE && outcome !== HOOK_DENY_NAME) {
throw new Error(`terminateNow: exit code ${HOOK_DENY_CODE} is reserved to the ${HOOK_DENY_NAME} outcome; `
+ `got outcome ${JSON.stringify(outcome)}`);
}
// m2 (round 5, hooks/gsd-write-guard.js:159-175): emission must itself be
// exception-safe. A failed write (EPIPE, a full pipe buffer, a throwing
// fs.writeSync in a test) must NOT change the exit code — if it propagated
// out of this function, a caller whose payload could not be delivered
// would fall into ITS OWN outer catch and fail OPEN, which is the exact
// outcome the fail-closed branches this function serves exist to prevent.
// The decision to terminate with `projected` stands regardless of whether
// the payload could be delivered.
//
// The two streams are emitted in TWO SEPARATE try/catch blocks, not one
// shared block (the pre-#3911 defect): fd 1 and fd 2 (deny-only) are
// independent channels with independent failure modes, and a shared try
// meant a serialization failure on fd 1 (e.g. `payload` throwing on
// JSON.stringify) aborted the block before fd 2 ever ran — silently
// dropping a deny's reason. `deny(undefined, 'some reason')` used to exit
// 2 with EMPTY stderr because of exactly this. Each block independently
// treats an `undefined` value for ITS OWN stream as "nothing to write"
// and skips the write cleanly, rather than serializing `undefined` (which
// is not valid JSON text) and throwing into the catch.
try {
// fs.writeSync, never process.stdout.write: pipe writes via
// process.stdout/stderr are async on Windows, and process.exit() below
// does not wait for them to flush — a truncated payload is a silent
// half-emission. Looped over a Buffer (not a bare string call) so a
// payload larger than the destination pipe's buffer — where a single
// write() syscall can legitimately return fewer bytes written than
// requested — still arrives whole rather than truncated.
if (payload !== undefined) {
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
let offset = 0;
while (offset < buf.length) {
offset += node_fs_1.default.writeSync(1, buf, offset, buf.length - offset);
}
}
}
catch {
// fd 1 emission failed; the exit code decision still stands (see
// above), and fd 2 below is unaffected — it has its own try/catch.
}
if (projected === HOOK_DENY_CODE) {
try {
// Backward-compatible default: when no `stderrPayload` is given, fd 2
// gets the SAME value fd 1 got (still subject to fd 2's own
// undefined-skips-the-write and string-vs-JSON rules below).
const resolvedStderr = stderrPayload === undefined ? payload : stderrPayload;
if (resolvedStderr !== undefined) {
const stderrBuf = Buffer.from(typeof resolvedStderr === 'string' ? resolvedStderr : JSON.stringify(resolvedStderr), 'utf8');
let stderrOffset = 0;
while (stderrOffset < stderrBuf.length) {
stderrOffset += node_fs_1.default.writeSync(2, stderrBuf, stderrOffset, stderrBuf.length - stderrOffset);
}
}
}
catch {
// fd 2 emission failed; independent of fd 1 above, and the exit code
// decision still stands regardless.
}
}
// n/no-process-exit is not registered for src/**/*.cts (see the ADR-3889
// reference note in the module header) and both compiled .cjs copies of
// this module are lint-ignored build/generated artifacts, so no disable
// directive is needed here for the one sanctioned process.exit call site.
process.exit(projected);
}
catch (err) {
// Anything above threw: an unrecognized --exit-contract/GSD_EXIT_CONTRACT
// value, a non-string/empty/unregistered `outcome`, or the HOOK_DENY
// collision guard. Diagnose on stderr — swallowing this silently would
// make a typo'd outcome name or a bad contract-version env var
// undebuggable — then terminate unconditionally. The diagnostic write
// itself gets its own swallow-on-failure guard, because even a failed
// diagnostic must not stop the exit below from happening.
try {
const detail = err instanceof Error ? err.message : String(err);
const message = `terminateNow: programming error — outcome=${JSON.stringify(outcome)} `
+ `version=${JSON.stringify(versionForDiagnostics)}: ${detail}\n`
+ `This is a caller bug (unrecognized outcome/exit-contract, or the HOOK_DENY collision `
+ `guard), not a declared outcome. Terminating with INTERNAL rather than propagating: an `
+ `enforcement-hook caller's own outer catch may fail open (process.exit(0)), and unwinding `
+ `into it here would silently convert a deny into an allow.\n`;
node_fs_1.default.writeSync(2, message);
}
catch {
// Diagnostic emission itself failed; the exit below is unconditional
// regardless.
}
process.exit(exitCodeFor('INTERNAL'));
}
}
module.exports = {
ExitError,
runMain,
setJsonErrorMode,
getJsonErrorMode,
EXIT_ENVELOPE_REASON,
projectOutcome,
resolveContractVersion,
getContractVersion,
terminateNow,
setPendingOutcome,
getPendingOutcome,
};