A wall-mounted enchanted alarm panel inside the Plan Forge shop with rows of small round glass dials, some dials glowing red and amber to indicate fault codes triggered, brass labels riveted under each dial, a smith standing beside it jotting fault notes in an open leather logbook
Appendix X

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.

Exit codes are a contract. CI scripts, GitHub Actions workflows, and on-call automation all branch on them. Once published, an exit code's meaning does not change between releases, new failure modes get new codes. If you script against Plan Forge, branch on the codes in this appendix and treat anything outside the contract as unknown failure, fail safe.

Orientation

Plan Forge exits and errors come from four layers, each with its own conventions:

LayerWhat it returnsWhere the codes live
pforge CLIPOSIX exit codes 0 / 1 / 2§ CLI exit codes
pforge run-plan orchestratorPOSIX 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
Looking for a fix, not a contract? Start at Chapter 15 — Common Error Messages. That table maps symptoms to fixes; this appendix is the exhaustive reference.

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.

CodeMeaningWhen you see it
0Success. 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).
1Generic 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.
2Environment-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.
Exit 2 is not "warning". It means Plan Forge could not establish a known state. Treat it the same as exit 1 in CI gates, it should fail the build, but log it distinctly so on-call can see "environment is bad" vs "command found real problems".

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.

CodePlan statusMeaning
0completedEvery slice passed its validation gate, the completeness sweep was clean, the Review Gate (if configured) approved, and the final commit landed.
0completed-with-warningsPlan landed but the audit-loop or post-deploy hook surfaced advisories. Treat as success in CI but post the warnings to the run log.
1failedA 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.
1abortedThe 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.exitCodefailedIf 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

ReasonWhat it means
gate-failedThe slice's bash validation gate exited non-zero after retries / escalation.
worker-failedThe worker process (the LLM call) returned an error envelope, e.g. API timeout, rate-limit-exhausted, model refused.
worker-signaledThe worker process was killed by a signal. On Windows the native code 0xC000013A (STATUS_CONTROL_C_EXIT) maps here. See § OS subprocess exits.
drift-detectedThe PreToolUse hook caught the worker editing a file listed in the plan's Forbidden Actions.
review-rejectedThe Review Gate (Session 3) explicitly rejected the slice. The reviewer's notes are at .forge/runs/<runId>/review-slice-<N>.md.
escalation-exhaustedAll models in the escalation chain failed. Try a different model with --model or split the slice.
quorum-all-failedQuorum mode: every model in the panel timed out or errored. See QUORUM_ALL_FAILED in the named error catalog.
preDeploy-blockedA 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:

CodeToolCause
NO_REASONING_MODELforge_master_askNo model configured and no provider API key detected.
CRITICAL_FIELDS_MISSINGforge_crucible_finalizeSmelt blocked, the draft plan is missing one of: build-command, test-command, scope, gates, forbidden-actions, rollback.
PLAN_ALREADY_EXISTSforge_crucible_finalizeRefused to overwrite an existing hand-authored plan. Pass overwrite: true if intentional.
ASK_QUESTION_MISMATCHforge_crucible_askClient passed a stale questionId. Re-fetch state with forge_crucible_preview.
QUORUM_ALL_FAILEDforge_quorum_analyze, forge_diagnoseEvery model in the panel timed out (60s each) or errored.
NO_API_KEYAny provider-bound toolRequired env var (e.g. XAI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY) is unset and no secret file fallback found.
PLAN_NOT_FOUNDforge_run_plan, forge_plan_statusThe plan file path does not exist or is outside the workspace.
PLAN_PARSE_ERRORforge_run_plan, forge_validateThe plan file is missing required sections (e.g. ## Execution Slices) or has malformed slice headers.
ERR_UPDATE_DURING_RUNforge_self_updateRefused 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 }
StatusMeaningWhen
200OKRequest completed. Body is the tool-specific payload.
400Bad requestMissing or malformed body fields. Example: POST /api/audit/lookup without sha256Prefix.
404Not foundResource 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.
409ConflictState prevents the action. Example: POST /api/self-update while a plan run is in flight returns { "code": "ERR_UPDATE_DURING_RUN" }.
429Rate limitedServer-side rate limit hit. Body includes retryAfterMs. Bridge to Retry-After header in your client.
500Internal errorUncaught 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.
Bridge headers: the REST surface does not yet set WWW-Authenticate, Retry-After, or Content-Location. Clients should derive equivalents from the JSON body (retryAfterMsRetry-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:

CodePlatformMeaning
0xC000013A (3221225786)WindowsSTATUS_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.
130POSIXKilled by SIGINT (Ctrl+C). Same handling as Windows Ctrl+C.
137POSIXKilled by SIGKILL (OOM kill, kernel terminator). Surfaces as statusReason: "worker-signaled" with signal: "SIGKILL" in the slice record.
143POSIXKilled by SIGTERM (graceful shutdown). Same handling.
124POSIXGNU timeout killed the command (gate exceeded its budget).
Bug #82 lineage: calling 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.

CodeOriginCause & fix
ASK_QUESTION_MISMATCHCrucibleClient passed a stale questionId to forge_crucible_ask. Re-fetch with forge_crucible_preview, then retry with the current question id.
auditor-spawn-failedOrchestrator / PostRun hookPostRun auditor hook could not be spawned. Check forgeMaster.auditor.outputPath permissions and the selected model tier; the parent run still exits 0.
CRITICAL_FIELDS_MISSINGCrucible finalizeDraft 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-blockedforge_diff_classify / PreCommit chainThe diff classifier returned blocked for one or more files. Revert or move out-of-scope changes, then retry the commit.
DRIFT_DETECTEDPreToolUse hookWorker tried to edit a file listed in the plan's Forbidden Actions. Revert the change, then re-run the slice.
ERR_UPDATE_DURING_RUNREST 409POST /api/self-update was rejected because a plan is currently running. Abort the run or wait for it to finish.
GATE_COMMAND_FAILEDOrchestratorSlice validation gate exited non-zero. Fix the build or test failure, then resume from the failed slice.
lock-hash-mismatchOrchestrator / PreCommit chainThe plan's lockHash no longer matches the current plan body. Re-harden the plan to regenerate lockHash, then retry.
network-allowlist-violationOrchestratorOutbound call targeted a host outside network.allowed. Add the host to the allowlist or remove the outbound call.
NO_API_KEYProvider toolsNo 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_MODELForge-MasterForge-Master has no model configured and no provider key available. Set forgeMaster.reasoningModel or configure a provider key.
observer-budget-exceededObserver daemonForge-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_EXISTSCrucible finalizeRefused to overwrite an existing hand-authored plan. Read both files, then re-finalize with overwrite: true if you really mean it.
PLAN_NOT_FOUNDforge_run_planPlan path doesn't exist or is outside the workspace. Verify the path and keep plans under docs/plans by convention.
PLAN_PARSE_ERRORforge_validatePlan is missing required sections or has malformed slice headers. Run forge_validate to see the specific gap and repair it.
QUORUM_ALL_FAILEDQuorum modeAll quorum models timed out or errored. Check API keys and network connectivity, then retry; consider --quorum=speed if flagship models are unavailable.
RATE_LIMITEDREST 429Request was throttled. Honor retryAfter or the provider reset window before retrying.
REVIEW_REJECTEDReview GateSession 3 reviewer rejected the slice. Read the review artifact, address the findings, then rerun the slice.
SCOPE_VIOLATIONPreToolUse hookWorker edited a path outside the allowed scope contract. Revert the change and rerun with the correct scope.
STRICT_GATES_REJECTEDOrchestratorStrict gates refused a plan that would otherwise have escalated. Drop --strict-gates or strengthen the failing gate.
tool-deniedOrchestratorA 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_TIMEOUTOrchestratorWorker 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:

EventSeverityWhat it signals
slice-orphan-warningwarnFailed slice's worker deliverables were staged but not committed. Recovery commands at .forge/runs/<runId>/orphans-slice-<N>.json.
drift-detectederrorPreToolUse hook caught a forbidden-file edit. Plan run aborts.
quorum-model-failedwarnIndividual model in a quorum panel timed out or errored. The panel proceeds with remaining responders unless threshold breaks.
gate-retry-exhaustederrorSlice gate failed all retries. Orchestrator marks slice failed, exits 1.
preDeploy-blockederrorLiveGuard hook found a secret or unauthorized env var. Run aborts before the deploy slice executes.
observer:budget-blockedwarnForge-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