Files
msd-core/docs/how-to/command-exit-zero-gate.md
Tom Boucher 8de2ff9121 feat(#2008): generic command-exit-zero gate-predicate evaluator (#2011)
* feat(#2008): add generic command-exit-zero gate-predicate evaluator

Third-party capability gates declared via check.predicate were rendered for
display but never evaluated (only built-in check.query gates fired; the
security capability's gate worked solely via a hard-coded ship.md branch).

Add a generic, deps-injected gate-predicate evaluator (src/gate-predicate-evaluator.cts)
that dispatches by predicate.kind. Built-in kind: command-exit-zero — runs a
bounded sh -c command at the project root (via shell-command-projection.execTool),
inherits env, exit 0 => pass, non-zero => block, timeout => block, fail-closed.

Wire a 'check predicate' subcommand into check-command-router.cts and extend
the three generic workflow gate-dispatch sites (execute:wave:post, execute:post,
plan:post) to route check.predicate gates to the new evaluator. The two-step
gate contract (command-failure => onError; block => halt) is unchanged.

- src/gate-predicate-evaluator.cts: pure leaf, KIND_TABLE extensible
- src/check-command-router.cts: cmdCheckPredicate + buildPredicateDeps + parsePredicateFlags
- docs/adr/2008-*, docs/reference/gate-predicates.md, docs/how-to/command-exit-zero-gate.md
- tests: 38 unit + integration tests (exit mapping, timeout, interpolation,
  property-based bijection, malformed-predicate fail-closed, real subprocess e2e)

Closes #2008

* docs(#2008): backfill changeset pr number 2011
2026-07-05 14:04:29 -04:00

5.2 KiB

How-to: add a command-exit-zero gate to a capability

Diátaxis quadrant: How-To. A step-by-step recipe for a capability author who wants a gate that runs a shell command and blocks the loop on non-zero exit. For the full specification, see Reference: gate predicates.

When to use this

Use a command-exit-zero gate when your capability can be verified by an existing command-line check — a test runner, a linter, a custom validator, a git-hook-style probe. The gate runs your command at a loop point you choose and blocks the loop (advisory or hard) when it exits non-zero.

This is the generic extension path introduced in #2008 / ADR-2008. Before it, only built-in check.query gates could fire; a third-party capability's declared gate was display-only.

Prerequisites

  • A capability with a capability.json (see ADR-0894).
  • A command that exits 0 on success and non-zero on failure, runnable via sh. On Windows, sh must be present (git-bash / WSL) — otherwise the gate fails closed with exit 127.

Step 1 — author the command

Write a command that follows the exit-0-on-success contract. It receives the project root as its cwd and the inherited process environment.

# Example: a bundled check script shipped with the capability
node "${GSD_CAP_DIR}/checks/pre-ship.js"

You may interpolate three loop-context placeholders directly into the command:

Placeholder Meaning
${PHASE_NUMBER} the active phase number (e.g. 03)
${PHASE_DIR} the active phase directory
${PHASE_REQ_IDS} the phase's requirement ids

Anything else (${HOME}, $VAR, etc.) is left for sh to interpret against the inherited env.

Step 2 — declare the gate

Add a gates entry to your capability.json. Pick the loop point (one of the 12 canonical points), set blocking and onError, and gate it on a config key with when:

{
  "gates": [
    {
      "point": "ship:pre",
      "check": {
        "predicate": {
          "kind": "command-exit-zero",
          "command": "node \"${GSD_CAP_DIR}/checks/pre-ship.js\" \"${PHASE_DIR}\"",
          "timeout": 30
        }
      },
      "when": "my_cap.enabled",
      "blocking": true,
      "onError": "halt"
    }
  ]
}

Field rules (enforced by the evaluator):

  • predicate.command — required, non-empty string, ≤ 4096 chars.
  • predicate.timeout — optional, positive finite number of seconds (default 30).
  • predicate.kind — must be exactly "command-exit-zero".

Step 3 — choose blocking and onError

Field Effect
blocking: true A block: true result halts the loop at this point.
blocking: false Advisory — the gate prints its message and the loop continues.
onError: "halt" If the check command itself fails (malformed predicate, unknown kind, or a command that cannot be evaluated), halt.
onError: "skip" On a check-command failure, warn and continue (do not read block).

A non-zero exit of your command is a gate result (block: true), not a check-command failure — onError does not apply to it. onError covers only the case where the gate could not be evaluated at all.

Step 4 — test the gate directly

You can run the evaluator standalone to verify your predicate before wiring it into a loop point:

gsd_run check predicate \
  --predicate '{"kind":"command-exit-zero","command":"node checks/pre-ship.js \"${PHASE_DIR}\"","timeout":30}' \
  --phase-dir ".planning/phases/03-my-phase" \
  --phase-number "03" \
  --raw

Expected output on success:

{ "block": false, "message": "command exited 0", "details": { "kind": "command-exit-zero", "exitCode": 0 } }

Expected output when your command fails (e.g. exit 1):

{ "block": true, "message": "command exited 1: <stderr tail>", "details": { "kind": "command-exit-zero", "exitCode": 1, "signal": null } }

Step 5 — verify it fires in the loop

Once declared and the capability is active, the loop-resolver renders the gate at your chosen point. The workflow gate-dispatch (execute:wave:post, execute:post, plan:post, ship:pre, …) detects the predicate shape and dispatches the evaluator automatically — no further wiring needed.

gsd_run loop render-hooks ship:pre --raw

Gotchas

  • Backgrounded children escape the timeout. command: "sleep 100 &" returns before the sleep finishes; the timeout kills the sh parent, not the backgrounded child. Keep your command foreground, or have it self-manage its children. (ADR-2008, documented limitation.)
  • sh must be on PATH. On Windows without git-bash/WSL, the gate fails closed (exit 127). This matches the runtime's existing bash dependency.
  • Output is trimmed. Only the last 2000 chars of stderr/stdout surface in the gate message. Emit a concise diagnostic; don't rely on grepping long output downstream.
  • Env is inherited. The command sees the full GSD process environment. Do not put secrets in the command string; read them from env or a file like any shell script.