Errors & Exit Codes
The complete contract for every exit code, named error code, and error event Plan Forge emits, pforge CLI, the run-plan orchestrator, MCP tool responses, REST status shapes, and OS-level subprocess signals. The reference CI scripts and on-call runbooks depend on.
Orientation
Plan Forge exits and errors come from four layers, each with its own conventions:
| Layer | What it returns | Where the codes live |
|---|---|---|
pforge CLI | POSIX exit codes 0 / 1 / 2 | § CLI exit codes |
pforge run-plan orchestrator | POSIX exit codes 0 / 1 + structured statusReason | § Orchestrator exit codes |
MCP tools (forge_*) | JSON envelope with { ok, code, error } | § MCP tool errors |
REST API (POST /api/…) | HTTP status (400/404/409/429/500) + JSON { error, code? } | § REST error shape |
| OS subprocess signals (worker, gate) | Native exit codes, including 0xC000013A Ctrl+C | § OS subprocess exits |
CLI exit codes (pforge)
The pforge launcher (pforge.ps1 on Windows, pforge.sh on POSIX) uses a deliberately small surface so wrappers stay simple. Anything that's not a true failure exits 0; true failures exit 1; only special cases use 2.
| Code | Meaning | When you see it |
|---|---|---|
0 | Success. The command completed and produced its intended side effect. May still emit warnings on stderr. | Every happy path. Also includes nothing-to-do states (e.g. pforge release-notes in a repo without a roadmap). |
1 | Generic failure. A subcommand failed, validation rejected input, or an external tool (git, node, network) errored. | Most error paths. Examples: missing .forge.json, validate found problems, self-update couldn't fetch a release, audit drain aborted, setup couldn't reach the template repo. |
2 | Environment-level refusal. Plan Forge cannot run at all because a prerequisite is wrong or the action is intentionally blocked. | Three cases today: (1) pforge invoked outside a git repository; (2) pforge self-update when the GitHub update check itself failed (not a stale version, a network failure that prevents confirming you're current); (3) pforge audit when no scanners ran and the tempering config is empty or misconfigured. |
Orchestrator exit codes (pforge run-plan)
The orchestrator (pforge-mcp/orchestrator.mjs) is the long-running process that drives a plan slice-by-slice. Its exit code reflects the overall plan status, and a structured statusReason in the final JSON output narrows down why.
| Code | Plan status | Meaning |
|---|---|---|
0 | completed | Every slice passed its validation gate, the completeness sweep was clean, the Review Gate (if configured) approved, and the final commit landed. |
0 | completed-with-warnings | Plan landed but the audit-loop or post-deploy hook surfaced advisories. Treat as success in CI but post the warnings to the run log. |
1 | failed | A slice's validation gate failed after exhausting retries / escalation, a forbidden-action hook fired, the Review Gate rejected, or an LLM call errored without a recoverable path. statusReason contains the precise reason. |
1 | aborted | The user pressed Ctrl+C, an extension's preDeploy hook returned blocked: true, or --strict-gates rejected a plan that would otherwise have escalated. Run state is preserved at .forge/runs/<runId>/ for --resume-from. |
err.exitCode | failed | If an internal error throws with a numeric exitCode property, the orchestrator propagates that value. Used by the workers to surface specific failures like git is in a detached HEAD (no defined code today, reserved for future use). |
Common statusReason values
| Reason | What it means |
|---|---|
gate-failed | The slice's bash validation gate exited non-zero after retries / escalation. |
worker-failed | The worker process (the LLM call) returned an error envelope, e.g. API timeout, rate-limit-exhausted, model refused. |
worker-signaled | The worker process was killed by a signal. On Windows the native code 0xC000013A (STATUS_CONTROL_C_EXIT) maps here. See § OS subprocess exits. |
drift-detected | The PreToolUse hook caught the worker editing a file listed in the plan's Forbidden Actions. |
review-rejected | The Review Gate (Session 3) explicitly rejected the slice. The reviewer's notes are at .forge/runs/<runId>/review-slice-<N>.md. |
escalation-exhausted | All models in the escalation chain failed. Try a different model with --model or split the slice. |
quorum-all-failed | Quorum mode: every model in the panel timed out or errored. See QUORUM_ALL_FAILED in the named error catalog. |
preDeploy-blocked | A LiveGuard preDeploy hook returned severity ≥ high, usually forge_secret_scan finding a secret or forge_env_diff finding an unauthorized variable. |
manual-import-rejected | --strict-gates with a hand-authored plan that lacks a crucibleId: frontmatter and was not invoked with --manual-import. |
MCP tool errors (forge_*)
MCP tools never crash the server, they return a structured envelope. The contract is:
// Success
{ "ok": true, "…": "tool-specific payload" }
// Failure
{ "ok": false, "code": "NAMED_ERROR_CODE", "error": "Human-readable message", "details": { /* optional */ } }
Callers should branch on code, not on the message text (messages are wording-stable but not API-stable). The full catalog lives in § Named error catalog; the most common are:
| Code | Tool | Cause |
|---|---|---|
NO_REASONING_MODEL | forge_master_ask | No model configured and no provider API key detected. |
CRITICAL_FIELDS_MISSING | forge_crucible_finalize | Smelt blocked, the draft plan is missing one of: build-command, test-command, scope, gates, forbidden-actions, rollback. |
PLAN_ALREADY_EXISTS | forge_crucible_finalize | Refused to overwrite an existing hand-authored plan. Pass overwrite: true if intentional. |
ASK_QUESTION_MISMATCH | forge_crucible_ask | Client passed a stale questionId. Re-fetch state with forge_crucible_preview. |
QUORUM_ALL_FAILED | forge_quorum_analyze, forge_diagnose | Every model in the panel timed out (60s each) or errored. |
NO_API_KEY | Any provider-bound tool | Required env var (e.g. XAI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY) is unset and no secret file fallback found. |
PLAN_NOT_FOUND | forge_run_plan, forge_plan_status | The plan file path does not exist or is outside the workspace. |
PLAN_PARSE_ERROR | forge_run_plan, forge_validate | The plan file is missing required sections (e.g. ## Execution Slices) or has malformed slice headers. |
ERR_UPDATE_DURING_RUN | forge_self_update | Refused to self-update while a plan run is in flight. Wait for the run or abort it. |
REST error shape
The REST surface (Appendix W) uses standard HTTP status codes plus a JSON body. The body is always the same shape:
{ "error": "Human-readable message",
"code": "NAMED_ERROR_CODE", // optional, when a stable code applies
"retryAfterMs": 30000 // only on 429 }
| Status | Meaning | When |
|---|---|---|
200 | OK | Request completed. Body is the tool-specific payload. |
400 | Bad request | Missing or malformed body fields. Example: POST /api/audit/lookup without sha256Prefix. |
404 | Not found | Resource doesn't exist. Example: GET /api/plan/status/{runId} with an unknown run id, or POST /api/audit/lookup with a sha256 prefix that doesn't resolve. |
409 | Conflict | State prevents the action. Example: POST /api/self-update while a plan run is in flight returns { "code": "ERR_UPDATE_DURING_RUN" }. |
429 | Rate limited | Server-side rate limit hit. Body includes retryAfterMs. Bridge to Retry-After header in your client. |
500 | Internal error | Uncaught exception in the handler. The message is the JS err.message; err.stack is logged server-side but never returned. Treat as retry once, then page. |
WWW-Authenticate, Retry-After, or Content-Location. Clients should derive equivalents from the JSON body (retryAfterMs → Retry-After: ms÷1000). See Appendix W — Error shape for the full discussion.
OS subprocess exits
The orchestrator spawns worker processes (the LLM call) and gate processes (bash commands). When these are killed by a signal, the native exit code is preserved and mapped through:
| Code | Platform | Meaning |
|---|---|---|
0xC000013A (3221225786) | Windows | STATUS_CONTROL_C_EXIT, subprocess was killed by Ctrl+C or its parent. Mapped to statusReason: "worker-signaled". Was historically silently treated as success (bug #82-class); now correctly marked failed. |
130 | POSIX | Killed by SIGINT (Ctrl+C). Same handling as Windows Ctrl+C. |
137 | POSIX | Killed by SIGKILL (OOM kill, kernel terminator). Surfaces as statusReason: "worker-signaled" with signal: "SIGKILL" in the slice record. |
143 | POSIX | Killed by SIGTERM (graceful shutdown). Same handling. |
124 | POSIX | GNU timeout killed the command (gate exceeded its budget). |
process.exit(0) immediately after a fetch() on Windows can trip Assertion failed: !(handle->flags & UV_HANDLE_CLOSING) because undici keepalive sockets are still closing. The orchestrator uses process.exitCode = 0 on the success path of --analyze / --diagnose to avoid this. If you embed the orchestrator in your own Node process, do the same.
Named error catalog
Every named error code Plan Forge emits, alphabetized. Codes are stable across releases; new failure modes get new codes rather than reusing existing ones.
| Code | Origin | Cause & fix |
|---|---|---|
ASK_QUESTION_MISMATCH | Crucible | Client passed a stale questionId to forge_crucible_ask. Re-fetch with forge_crucible_preview, then retry with the current question id. |
auditor-spawn-failed | Orchestrator / PostRun hook | PostRun auditor hook could not be spawned. Check forgeMaster.auditor.outputPath permissions and the selected model tier; the parent run still exits 0. |
CRITICAL_FIELDS_MISSING | Crucible finalize | Draft plan is missing build-command, test-command, scope, gates, forbidden-actions, or rollback. Call forge_crucible_preview for criticalGaps, then continue the interview. |
diff-classify-blocked | forge_diff_classify / PreCommit chain | The diff classifier returned blocked for one or more files. Revert or move out-of-scope changes, then retry the commit. |
DRIFT_DETECTED | PreToolUse hook | Worker tried to edit a file listed in the plan's Forbidden Actions. Revert the change, then re-run the slice. |
ERR_UPDATE_DURING_RUN | REST 409 | POST /api/self-update was rejected because a plan is currently running. Abort the run or wait for it to finish. |
GATE_COMMAND_FAILED | Orchestrator | Slice validation gate exited non-zero. Fix the build or test failure, then resume from the failed slice. |
lock-hash-mismatch | Orchestrator / PreCommit chain | The plan's lockHash no longer matches the current plan body. Re-harden the plan to regenerate lockHash, then retry. |
network-allowlist-violation | Orchestrator | Outbound call targeted a host outside network.allowed. Add the host to the allowlist or remove the outbound call. |
NO_API_KEY | Provider tools | No provider API key is configured. Set XAI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY, or use the zero-key Copilot path when supported. |
NO_REASONING_MODEL | Forge-Master | Forge-Master has no model configured and no provider key available. Set forgeMaster.reasoningModel or configure a provider key. |
observer-budget-exceeded | Observer daemon | Forge-Master Observer hit its daily USD cap or hourly narration cap. Wait for the budget window to reset or widen the cap in .forge.json. |
PLAN_ALREADY_EXISTS | Crucible finalize | Refused to overwrite an existing hand-authored plan. Read both files, then re-finalize with overwrite: true if you really mean it. |
PLAN_NOT_FOUND | forge_run_plan | Plan path doesn't exist or is outside the workspace. Verify the path and keep plans under docs/plans by convention. |
PLAN_PARSE_ERROR | forge_validate | Plan is missing required sections or has malformed slice headers. Run forge_validate to see the specific gap and repair it. |
QUORUM_ALL_FAILED | Quorum mode | All quorum models timed out or errored. Check API keys and network connectivity, then retry; consider --quorum=speed if flagship models are unavailable. |
RATE_LIMITED | REST 429 | Request was throttled. Honor retryAfter or the provider reset window before retrying. |
REVIEW_REJECTED | Review Gate | Session 3 reviewer rejected the slice. Read the review artifact, address the findings, then rerun the slice. |
SCOPE_VIOLATION | PreToolUse hook | Worker edited a path outside the allowed scope contract. Revert the change and rerun with the correct scope. |
STRICT_GATES_REJECTED | Orchestrator | Strict gates refused a plan that would otherwise have escalated. Drop --strict-gates or strengthen the failing gate. |
tool-denied | Orchestrator | A worker or hook tried to invoke an MCP tool listed in tools.deny. Remove the tool from the denylist or update the prompt to avoid it. |
WORKER_TIMEOUT | Orchestrator | Worker exceeded its per-slice execution budget. Split the slice or switch to a faster model. |
Error events on the hub
In addition to exit codes and named errors, the WebSocket hub broadcasts error-class events that the dashboard and external watchers consume. The full taxonomy lives in Appendix V — Errors & warnings; the most operationally relevant are:
| Event | Severity | What it signals |
|---|---|---|
slice-orphan-warning | warn | Failed slice's worker deliverables were staged but not committed. Recovery commands at .forge/runs/<runId>/orphans-slice-<N>.json. |
drift-detected | error | PreToolUse hook caught a forbidden-file edit. Plan run aborts. |
quorum-model-failed | warn | Individual model in a quorum panel timed out or errored. The panel proceeds with remaining responders unless threshold breaks. |
gate-retry-exhausted | error | Slice gate failed all retries. Orchestrator marks slice failed, exits 1. |
preDeploy-blocked | error | LiveGuard hook found a secret or unauthorized env var. Run aborts before the deploy slice executes. |
observer:budget-blocked | warn | Forge-Master Observer hit its daily cost cap or hourly narration cap. Narrations are silently skipped until the budget window resets. No impact on plan execution. |
CI / scripting recipes
The smallest useful contract for a CI gate:
# Bash: fail the build on any exit ≠ 0
set -euo pipefail
pforge run-plan docs/plans/Phase-NN.md
# Exit 0 here means "completed" or "completed-with-warnings", both safe to ship
If you need to distinguish soft warnings from hard failures:
# Bash: parse the final JSON
output=$(pforge run-plan docs/plans/Phase-NN.md --json)
status=$(echo "$output" | jq -r '.status')
case "$status" in
completed) echo "Clean."; exit 0 ;;
completed-with-warnings) echo "Advisories, review the run log."; exit 0 ;;
failed) reason=$(echo "$output" | jq -r '.statusReason'); echo "FAILED ($reason)"; exit 1 ;;
aborted) echo "ABORTED, preserved state at .forge/runs/$(echo "$output" | jq -r '.runId')/"; exit 2 ;;
*) echo "UNKNOWN STATUS: $status"; exit 1 ;;
esac
For PowerShell with explicit exit-code branching:
# PowerShell
pforge run-plan docs/plans/Phase-NN.md
switch ($LASTEXITCODE) {
0 { Write-Host "Plan completed" -ForegroundColor Green }
1 { Write-Host "Plan failed - check .forge/runs/" -ForegroundColor Red; exit 1 }
2 { Write-Host "Environment refusal - check pforge smith" -ForegroundColor Yellow; exit 2 }
default { Write-Host "Unknown exit code: $LASTEXITCODE" -ForegroundColor Magenta; exit $LASTEXITCODE }
}
See also
- Chapter 15 — Common Error Messages, symptom → cause → fix triage table.
- Appendix V — Event Catalog, full taxonomy of hub events including error events.
- Appendix W — REST API Reference: Error shape, the HTTP contract in context.
- Appendix U — Provider API Keys, for
NO_API_KEYandNO_REASONING_MODELresolution. - Appendix T —
.forge.jsonhooks, configures the LiveGuardpreDeployhook behindpreDeploy-blocked.