How-to: add a command-exit-zero gate to a capability
July 5, 2026 · View on GitHub
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
0on success and non-zero on failure, runnable viash. On Windows,shmust 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 —onErrordoes not apply to it.onErrorcovers 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 theshparent, not the backgrounded child. Keep your command foreground, or have it self-manage its children. (ADR-2008, documented limitation.) shmust 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.