with one click
forge-auto
Executa o milestone inteiro de forma autonoma ate concluir.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
Executa o milestone inteiro de forma autonoma ate concluir.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Based on SOC occupation classification
Executa exatamente uma unidade de trabalho e para (step mode).
Task autonoma sem milestone — brainstorm, discuss, plan, execute.
Gerencia múltiplas contas Claude e troca entre elas (setup-token). Use ao esgotar a sessão de uma conta.
Qualidade do codebase — lint, nomenclatura. Flags: --fix, --paths.
Configuracoes do Forge — status line, hooks, MCPs.
Diagnostico e correcao do projeto GSD. Flags: --fix, --dry-run.
| name | forge-auto |
| description | Executa o milestone inteiro de forma autonoma ate concluir. |
| allowed-tools | Read, Write, Edit, Bash, Agent, Skill, TaskCreate, TaskUpdate, TaskList, TaskStop, WebSearch, WebFetch |
ls CLAUDE.md 2>/dev/null && echo "ok" || echo "missing"
ls .gsd/STATE.md 2>/dev/null && echo "ok" || echo "missing"
WORKING_DIR=$(pwd)
echo "WORKING_DIR=$WORKING_DIR"
# Resolve runtime scripts dir — prefer local ./scripts (dogfood: edits take effect
# immediately); fall back to ~/.claude/scripts (user-land: installed version).
if [ -f "scripts/forge-parallelism.js" ]; then
FORGE_SCRIPTS_DIR="scripts"
else
FORGE_SCRIPTS_DIR="$HOME/.claude/scripts"
fi
echo "FORGE_SCRIPTS_DIR=$FORGE_SCRIPTS_DIR"
Se CLAUDE.md não existe: Stop. Tell the user:
Projeto não inicializado. Execute
/forge-initprimeiro — isso cria oCLAUDE.mdque restaura o contexto automaticamente ao reabrir o chat.
Se .gsd/STATE.md não existe: Stop. Tell the user:
Nenhum projeto GSD encontrado neste diretório. Execute
/forge-initpara começar.
Read ONLY these files:
.gsd/STATE.md.gsd/AUTO-MEMORY.md full file (skip silently if missing) — stored as ALL_MEMORIES for selective injection per unit.gsd/CODING-STANDARDS.md (skip silently if missing)Resolve PREFS via the canonical engine CLI (ONE call — never a 3-file md merge in-context). The S01 engine (scripts/forge-prefs.js) dual-reads legacy markdown OR new jsonc per layer and applies the exact same user-global → repo-shared → local-personal precedence (last wins) that the old inline prose described. Do NOT read/merge ~/.claude/forge-agent-prefs.md + .gsd/claude-agent-prefs.md + .gsd/prefs.local.md by hand — that is exactly what the CLI does. See shared/forge-dispatch.md § Per-unit prefs resolution for the canonical helper.
PREFS_JSON=$(node "$FORGE_SCRIPTS_DIR/forge-prefs.js" --resolved --explain --cwd "$WORKING_DIR")
PREFS_EXIT=$?
Loud-stop on parse error (M008-CONTEXT decision #2 — the loop ALWAYS stops on a broken config, NEVER degrades to defaults silently):
If PREFS_EXIT != 0:
- `$PREFS_JSON` carries `errors[]` ({file,line,message}) on stdout; the CLI already printed a
human message + "corrija o JSONC…" hint on stderr.
- Deactivate this run (same mechanic as the Agent()-failure halt): set auto-mode.json/runs entry
inactive, then STOP the loop.
- Surface to the operator: arquivo + linha + como-corrigir (from errors[]).
- Do NOT proceed on WORKERS_ENGINE=claude / effort defaults / any fallback value.
warnings[] (advisory schema validation, ⚠ on stderr) do NOT stop — only exit≠0 halts.
The resolved object is {ok, prefs, errors[], warnings[], layers}. Throughout this skill PREFS = .prefs from this one call. Store as: STATE, PREFS (the resolved .prefs object), ALL_MEMORIES, CODING_STANDARDS.
Deprecation warning (once per session): Inspect layers from the PREFS_JSON
already resolved above; do not make another CLI call. If any
layers.<name>.source == "md-legacy", list that layer's files and emit exactly:
⚠ Prefs em markdown legado ainda honradas: <files>. Rode /forge-update para migrar para JSONC (remoção do caminho legado na v2.0).
Do not emit this warning when every layer is jsonc or absent. Load context runs
once per session, so this is naturally one warning per session. Re-warning after
compaction is accepted because Compaction Resilience re-reads Load context.
Extract effort & thinking off the resolved PREFS object (defaults identical to the old inline snippet):
EFFORT_MAP ← PREFS.effort (per-phase effort table; default: opus/planning phases = medium, sonnet/haiku phases = low)THINKING_OPUS ← PREFS.thinking.opus_phases (default: adaptive)CODING_STANDARDS section extraction — to minimize token usage, extract these named sections from the file for selective injection:
CS_LINT — content of ## Lint & Format Commands section onlyCS_STRUCTURE — content of ## Directory Conventions + ## Asset Map + ## Pattern Catalog sectionsCS_RULES — content of ## Code Rules section only
If CODING-STANDARDS.md is missing, all section variables are "(none)".Extract notifications pref off the resolved PREFS object:
NOTIFICATIONS_ON ← PREFS.notifications; if absent or not on/off, default on. Store as NOTIFICATIONS_ON.Initialize:
session_units = 0
COMPACT_AFTER = PREFS.compact_after if set and not "unlimited", else "unlimited"
(0 or "unlimited" disables context checkpoints entirely — this is the default)
completed_units = []
PUSH_AVAILABLE = null # sentinel: not yet probed this session
Probe PushNotification (1x per session, cached):
Run ToolSearch("select:PushNotification") exactly once. If the result contains an entry for PushNotification, set PUSH_AVAILABLE = true; otherwise PUSH_AVAILABLE = false. Never re-probe — use the cached value for all subsequent call-sites. PushNotification is a deferred tool; ToolSearch is the correct detection method (not tool-list introspection).
Push helper (define-once, use-thrice):
To fire a notification at any of the 3 call-sites: if NOTIFICATIONS_ON != "on" OR PUSH_AVAILABLE != true → silent-skip (no error, no log). Otherwise call:
PushNotification({ title: "Forge — {RUN_ID}", message: <mensagem pt-BR> })
Use this helper at every call-site below. Never duplicate the guard logic.
Cleanup orphaned tasks — call TaskList. If any tasks have status: in_progress (leftover from a previous crashed session), mark them completed to keep the UI clean:
TaskUpdate({ taskId: <id>, status: "completed" })
Do this for ALL in_progress tasks before starting the loop. Skip if TaskList returns empty.
Argumentos ignorados — /forge-auto não aceita argumentos. Se o usuário digitou /forge-auto resume ou qualquer outro argumento, ignore-o silenciosamente. O auto-resume é automático via detecção abaixo.
Auto-resume detection — check for a previous interrupted session.
Read auto-mode.json and compute heartbeat freshness in one shot:
AUTO_STATE=$(node -e "
try {
const a = JSON.parse(require('fs').readFileSync('.gsd/forge/auto-mode.json','utf8'));
if (a.active !== true) { process.stdout.write('inactive'); return; }
const last = a.last_heartbeat || a.worker_started || a.started_at || 0;
const age = Date.now() - last;
process.stdout.write(age > 300000 ? 'stale' : 'fresh');
} catch { process.stdout.write('inactive'); }
")
COMPACT_SIGNAL=$(test -f .gsd/forge/compact-signal.json && echo "yes" || echo "no")
Branch on $AUTO_STATE:
inactive — no prior session; proceed normally to activation.stale — previous session died (Ctrl+C, terminal kill, OOM). The marker is lying. Clear it silently (M005+ aware of runs/*.json registry) and proceed normally to activation as a fresh start:
# Clean any active runs in registry first
for f in .gsd/forge/runs/*.json 2>/dev/null; do
[ -f "$f" ] || continue
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$(basename "$f" .json)" --json '{"active":false}' >/dev/null 2>&1 || true
done
echo '{"active":false}' > .gsd/forge/auto-mode.json
Do NOT emit a resume message.fresh — heartbeat within the last 5 minutes.
$COMPACT_SIGNAL == "yes" → Compact recovery path: skip ALL initialization (activation, load context, etc.). Go directly to the dispatch loop. The compact recovery check at the top of iteration 1 will re-read state from disk and delete the signal.↺ Retomando forge-auto após interrupção... and skip the activation step below — go directly to the dispatch loop. The marker is already set.Resolve which run this invocation operates on, based on $ARGUMENTS and the active-run registry. This block runs BEFORE the legacy single-run activation below.
Step 0 — Migrate legacy STATE.md (idempotent; required BEFORE dashboard regen):
If the workspace has a pre-M004 single-run .gsd/STATE.md (no <!-- AUTO-GENERATED --> header) AND no runs/*.json exists yet, migrate the legacy state to per-milestone format. This MUST run before any dashboard regeneration — otherwise the legacy state data is destroyed by the dashboard overwrite.
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --migrate-legacy --cwd "$WORKING_DIR" > /dev/null 2>&1 || true
The script is idempotent: returns {migrated: false, reason: "already dashboard"} if already migrated, or {migrated: false, reason: "no Active Milestone field"} if STATE.md doesn't have legacy format. Either case is a no-op. Successful migration creates M###-STATE.md from the legacy fields (Active Slice/Task/Phase/Auto-mode/Next Action preserved verbatim).
RESOLVE=$(node "$FORGE_SCRIPTS_DIR/forge-cli-helpers.js" --resolve-args --args "$ARGUMENTS" --command forge-auto)
STATUS=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).status)" "$RESOLVE")
RUN_ID=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).run_id || '')" "$RESOLVE")
RUN_KIND=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).kind || '')" "$RESOLVE")
MSG=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).message || '')" "$RESOLVE")
Isolation setup (branch/worktree) — when $STATUS resolves to activate-new, resume, or legacy, apply forge_isolation from prefs BEFORE the per-status registry actions below. For refuse/error, skip entirely — never touch git on a refused invocation. The script is idempotent (re-running on resume is a no-op: already-on-branch / already-exists). In legacy mode (RUN_ID empty), substitute $ISO_RUN with the active milestone ID from STATE.md.
ISO_RUN="${RUN_ID:-<active milestone ID from STATE.md>}"
ISO_RESULT=$(node "$FORGE_SCRIPTS_DIR/forge-isolation.js" --setup --run "$ISO_RUN" --cwd "$WORKING_DIR")
ISOLATION_MODE=$(node -e "process.stdout.write((JSON.parse(process.argv[1]).mode)||'shared')" "$ISO_RESULT")
WORKTREE_DIR=$(node -e "const r=JSON.parse(process.argv[1]);const w=(r.repos||[]).find(x=>x.worktree&&x.status!=='error');process.stdout.write(w?w.worktree:'')" "$ISO_RESULT")
ISO_ERRORS=$(node -e "const r=JSON.parse(process.argv[1]);process.stdout.write((r.repos||[]).filter(x=>x.status==='error').map(x=>x.path+': '+x.error).join('; '))" "$ISO_RESULT")
echo "ISOLATION_MODE=$ISOLATION_MODE"
echo "WORKTREE_DIR=${WORKTREE_DIR:-—}"
echo "ISO_ERRORS=${ISO_ERRORS:-none}"
Isolation rules (CRITICAL — the operator configured this; honor it):
ISOLATION_MODE == shared → WORKER_CWD = $WORKING_DIR. Nothing else to do.ISOLATION_MODE == branch → WORKER_CWD = $WORKING_DIR. Workers commit on the forge/{run} branch the setup just checked out.ISOLATION_MODE == worktree → WORKER_CWD = $WORKTREE_DIR. ALL code reads/writes/commits happen inside the worktree; .gsd/** artifacts ALWAYS stay under $WORKING_DIR (the original workspace — registry, statusline and other tabs depend on it).ISO_ERRORS is non-empty AND every repo failed (WORKTREE_DIR empty in worktree mode, or no repo succeeded in branch mode) → STOP. Surface the errors to the user. Running un-isolated when the operator explicitly configured isolation is NOT an acceptable fallback.ISOLATION_MODE != shared, emit one line so the operator sees isolation took effect: ⛓ Isolation: {mode} → {branch name or worktree path}.Branch on $STATUS:
refuse — emit $MSG (lists active runs + example commands) and stop. Do NOT continue.error — emit $MSG and stop.legacy — zero active runs + no arg + .gsd/STATE.md is single-run legacy format. Run the legacy activation block below (preserves pre-M004 behavior). RUN_ID stays empty; {M###} placeholders below resolve from STATE.md as before.activate-new — register the new run:
SESSION_ID="${CLAUDE_SESSION_ID:-$(node -e "process.stdout.write(require('crypto').randomBytes(8).toString('hex'))")}"
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --add --id "$RUN_ID" --kind "$RUN_KIND" --session "$SESSION_ID" --isolation-mode "$ISOLATION_MODE" --account "${FORGE_ACCOUNT:-}" --cwd "$WORKING_DIR" > /dev/null
echo "$MSG"
Then continue to legacy activation (which writes auto-mode-started.txt + alias).resume — emit $MSG, set RUN_ID (already set). Update the existing registry entry with the new session_id (the previous orchestrator process exited; this is a fresh session that needs to own heartbeat updates) and the freshly-resolved isolation mode:
SESSION_ID="${CLAUDE_SESSION_ID:-$(node -e "process.stdout.write(require('crypto').randomBytes(8).toString('hex'))")}"
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json "{\"session_id\":\"$SESSION_ID\",\"active\":true,\"isolation_mode\":\"$ISOLATION_MODE\"}" > /dev/null
Without this, forge-hook.resolveBySessionId won't match — heartbeats fall back to legacy auto-mode.json and runs/{id}.json becomes stale.For all non-legacy paths, the MILESTONE_DIR for downstream substitution is .gsd/milestones/$RUN_ID/ (if kind=milestone) or null (if kind=task). Where bash blocks below reference {M###}, substitute $RUN_ID ($RUN_ID may be a legacy M### or a timestamp M-<ts>-<slug> ID — the substitution is format-agnostic). Workers receive {M###} resolved in their prompt header via the dispatch templates.
Regenerate dashboard after registry change:
node "$FORGE_SCRIPTS_DIR/forge-dashboard.js" --cwd "$WORKING_DIR" --holder "auto:$RUN_ID" > /dev/null || true
Bootstrap + re-load per-milestone STATE (M004+, CRITICAL — must run before dispatch loop):
The initial ## Load context step above read .gsd/STATE.md, which is now a dashboard (auto-generated, no Active Slice/Task/Phase fields). The orchestrator needs the per-milestone STATE to derive the next unit. Bootstrap if absent (brand-new milestone), then re-load:
if [ -n "$RUN_ID" ] && [ "$RUN_KIND" = "milestone" ]; then
PER_MILESTONE_STATE=".gsd/milestones/$RUN_ID/$RUN_ID-STATE.md"
if [ ! -f "$PER_MILESTONE_STATE" ]; then
# Brand-new milestone: no STATE file. Bootstrap with plan-milestone phase
# so the dispatch loop knows what to do first.
mkdir -p ".gsd/milestones/$RUN_ID"
node "$FORGE_SCRIPTS_DIR/forge-state.js" --create "$RUN_ID" \
--phase plan-milestone \
--next-action "Plan milestone $RUN_ID — decompose into slices via forge-planner" \
--auto-mode on \
--isolation-mode "${ISOLATION_MODE:-shared}" \
--cwd "$WORKING_DIR" > /dev/null
echo "→ Bootstrapped $PER_MILESTONE_STATE (plan-milestone phase)"
fi
# Override the STATE variable from Load context with per-milestone content.
# This is the source of truth for `## Dispatch Loop` to derive next_unit.
STATE=$(cat "$PER_MILESTONE_STATE")
fi
For RUN_KIND=task runs, STATE is not file-backed (tasks live in runs/{id}.json directly per D-M004-12) — /forge-task is the canonical entry for those; this skill only handles milestones.
For legacy mode (STATUS=legacy, RUN_ID=""), STATE was already loaded from .gsd/STATE.md in the original format — no override needed.
Write marker so the status line shows ▶ AUTO. With M005+, all started_at lives in runs/{id}.json (per-run, no sharing). Only legacy mode writes auto-mode.json + auto-mode-started.txt directly:
mkdir -p .gsd/forge
if [ -z "$RUN_ID" ]; then
# Legacy single-run path: write shared files (no contention because legacy ⇒ 1 tab)
_forge_now=$(node -e "process.stdout.write(String(Date.now()))")
echo $_forge_now > .gsd/forge/auto-mode-started.txt
echo '{"active":true,"started_at":'$_forge_now',"worker":null}' > .gsd/forge/auto-mode.json
fi
# Multi-run path: `runs/{id}.json.started_at` was set by forge-runs.add earlier (in Multi-run activation).
# `auto-mode.json` is automatically mirrored from oldest-active by refreshLegacyAlias.
# `auto-mode-started.txt` is NOT written in multi-run — each tab reads its own started_at from runs/.
You are the orchestrator. Execute the dispatch loop until the milestone is complete or a stop condition is hit.
AUTONOMY RULE — CRITICAL: This is FULLY AUTONOMOUS mode. After each unit completes with status: done, proceed IMMEDIATELY to the next unit. Do NOT pause to ask the user if they want to continue. Do NOT ask for confirmation between units. Do NOT summarize progress and wait for input. The ONLY reasons to STOP the loop are: milestone complete, worker returned blocked/partial, or pause requested. Between units, emit the progress line and move on — nothing else. Single sanctioned exception: the review triage gate before complete-milestone (see Dispatch guards) MAY ask the user — every slice is done at that point, so arbitrating deferred review items there does not violate this rule.
COMPACTION RESILIENCE — CRITICAL: Claude Code may auto-compact the conversation context during a long autonomous run. This is NOT a stopping condition. If you detect that your in-memory variables (PREFS, EFFORT_MAP, THINKING_OPUS, session_units, ALL_MEMORIES) appear undefined or missing, context was likely compacted. Recovery protocol — execute immediately without telling the user:
.gsd/forge/auto-mode.json — if active: true, the loop MUST continue.gsd/STATE.md, .gsd/AUTO-MEMORY.md, .gsd/CODING-STANDARDS.md; re-resolve PREFS via the single node "$FORGE_SCRIPTS_DIR/forge-prefs.js" --resolved --cwd "$WORKING_DIR" call (NOT a 3-file md re-merge) — same loud-stop-on-exit≠0 posture as Load contextPREFS = .prefs from that call, extract EFFORT_MAP and THINKING_OPUS, set session_units = 0, re-extract CS sectionsauto-mode.json shows active: true. Context compaction never deactivates it.ISOLATION RULE — CRITICAL: The orchestrator NEVER implements code or modifies project files directly. The tools Write, Edit, and Bash available to the orchestrator exist EXCLUSIVELY for orchestrator bookkeeping: writing STATE.md, events.jsonl, auto-mode.json, auto-mode-started.txt, and continue.md. Any code change, file creation, or implementation step — no matter how small — MUST happen inside a worker dispatched via Agent(). If you find yourself about to use Edit or Write on a project file, or running implementation commands via Bash, STOP immediately: you are violating context isolation. Call Agent() instead.
Repeat until stop condition:
Compact recovery check — before anything else in each iteration:
cat .gsd/forge/compact-signal.json 2>/dev/null
If the file exists:
.gsd/STATE.md → update STATEPREFS ← re-resolve via the single node "$FORGE_SCRIPTS_DIR/forge-prefs.js" --resolved --cwd "$WORKING_DIR" call (.prefs; same loud-stop-on-exit≠0 posture) — NOT a 3-file md re-merge.gsd/AUTO-MEMORY.md → update ALL_MEMORIES.gsd/CODING-STANDARDS.md → re-extract CS_LINT, CS_STRUCTURE, CS_RULESEFFORT_MAP and THINKING_OPUS from the resolved PREFS objectsession_units = 0
3a. Reset PUSH_AVAILABLE = null and re-execute ToolSearch("select:PushNotification") at the next opportunity (same "not yet probed" semantics as activation — the probe runs once per context window, not once per process)rm -f .gsd/forge/compact-signal.json↺ Recovery pós-compactação — retomando de: {next_action from STATE.md}If the file does not exist, skip this block entirely.
From STATE, determine unit_type and unit_id using the dispatch table below.
Dispatch Table (evaluate in order — first match wins):
| Condition | unit_type | Agent | Default model |
|---|---|---|---|
| No active milestone | STOP — tell user "no active milestone" | — | — |
| Milestone has no ROADMAP | plan-milestone | forge-planner | opus |
| Milestone has ROADMAP, no CONTEXT, discuss not skipped | discuss-milestone | forge-discusser | opus |
| Milestone has no RESEARCH, research not skipped | research-milestone | forge-researcher | opus |
| Active slice has no PLAN | plan-slice | forge-planner | opus |
| Active slice has PLAN, no RESEARCH, research not skipped | research-slice | forge-researcher | opus |
| Active slice has incomplete task | execute-task | forge-executor | sonnet |
| All tasks in active slice done, no S##-SUMMARY | complete-slice | forge-completer | sonnet |
| All slices complete, no milestone completion marker | complete-milestone | forge-completer | sonnet |
All slices [x] in ROADMAP and milestone complete | DONE — emit final report and stop | — | — |
To determine which case applies, read (in order, stop as soon as you find the answer):
next_action usually tells you directlyM###-ROADMAP.md — only if STATE is ambiguous about slices/milestone completionS##-PLAN.md — only if STATE is ambiguous about tasks within a sliceCrash detection: Before dispatching execute-task, read T##-PLAN.md. If it contains status: RUNNING, the previous session crashed mid-task. Warn the user:
⚠ Task {T##} was interrupted (status: RUNNING). Re-executing from scratch. Then proceed with dispatch normally (the executor will overwrite the partial work).
Dynamic routing: If T##-PLAN.md contains complexity: heavy, route execute-task to forge-executor on opus.
Engine, tier, domain, effort and alias are all resolved in step 1.5 below by the single forge-dispatch-resolve.js --json call. Do NOT resolve any of them here — this block only runs the prefs loud-stop gate and computes the resolver's file args ($PLAN_PATH, $ROADMAP_PATH).
Prefs gate + resolver args (step 1.45) — run the M008-CONTEXT #2 prefs loud-stop gate, then set $PLAN_PATH/$ROADMAP_PATH. As of M012 S02 the engine decision, tier-chain resolution, domain, effort and alias all collapse into ONE forge-dispatch-resolve.js call made in step 1.5 (a thin caller) — so this block resolves only the file inputs to that call. When ENGINE resolves to codex (routable execute-task/plan-slice) the Claude Agent() dispatch + alias warning are skipped (Codex resolves its own model) — they run only on the Claude path, including the fallback.
Cross-reference:
shared/forge-dispatch.md § Worker Engine Routing— canonical algorithm (single-call resolver, engine-by-route_source table, sidecar state machine, BLOCKER contract, fallback) +scripts/forge-dispatch-resolve.js(S01). This block is the executable mirror; the mechanics are locked there.plan-sliceengine routing is active (S03) —ENGINE == codex && unit_type == plan-sliceroutes to the sidecar--mode plan(read-only, Branch D, see Step 4 below).plan-milestoneis never routed throughworkers:/routing:(stays tiermax/Fable).
# ── Prefs loud-stop gate (M008-CONTEXT #2) — MUST run before the resolver ─────────
# Canonical per-unit prefs resolution — ONE forge-prefs.js --resolved call (dual-reads md OR
# jsonc; NEVER a 3-file `files=[…forge-agent-prefs.md…]` cascade node -e merge, MEM001 M005).
# This explicit gate STAYS even though forge-dispatch-resolve.js also surfaces prefs errors
# (prefs_ok:false → exit 1): a malformed prefs layer must HALT the dispatch, never degrade to a
# fallback value. See shared/forge-dispatch.md § Per-unit prefs resolution.
PREFS_JSON=$(node "$FORGE_SCRIPTS_DIR/forge-prefs.js" --resolved --cwd "$WORKING_DIR")
if [ $? -ne 0 ]; then
# Loud stop (M008-CONTEXT #2): errors[] ({file,line,message}) on stdout, human hint on stderr.
# Deactivate the run (same mechanic as the Agent()-failure halt) + surface arquivo+linha+
# como-corrigir. NEVER degrade to a fallback value on a broken config.
echo "✗ prefs parse error — dispatch halted (see stderr for arquivo:linha)" >&2
exit 1
fi
# CRITICAL loud-stop (M008-CONTEXT #2): a nonzero prefs-CLI exit above HALTS the
# dispatch — the `exit 1` fires when this block runs in a shell. In the
# orchestrator loop, mirror the Load-context guard: deactivate this run
# (set auto-mode.json/runs entry inactive, same mechanic as the Agent()-failure
# halt), surface arquivo+linha+como-corrigir from `errors[]`, then STOP the loop.
# Do NOT proceed on a claude/effort-default / any fallback value.
# Resolve ONLY the args the shared resolver needs. No engine/domain/worker/tier/effort parsing
# happens here anymore — forge-dispatch-resolve.js (step 1.5) owns ALL pure resolution and reads
# the PLAN frontmatter + ROADMAP itself. See shared/forge-dispatch.md § Worker Engine Routing.
PLAN_PATH=""
if [ "$unit_type" = "execute-task" ]; then
PLAN_PATH=".gsd/milestones/${M###}/slices/${S##}/tasks/${T##}/${T##}-PLAN.md"
fi
ROADMAP_PATH=".gsd/milestones/${M###}/${M###}-ROADMAP.md"
# ENGINE / DOMAIN_USED / WORKERS_TIMEOUT / CODEX_MODEL are all resolved by the single
# forge-dispatch-resolve.js call in step 1.5 below (engine-by-route_source decided inside it).
Loud-stop on the per-unit prefs re-resolution above (M008-CONTEXT #2 — NOT a bare comment): if the forge-prefs.js --resolved call at the top of this block exited non-zero, the orchestrator MUST STOP the loop now — exactly as the Load-context guard does: deactivate this run (set auto-mode.json/runs entry inactive, same mechanic as the Agent()-failure halt), surface arquivo + linha + como-corrigir from errors[], and do NOT proceed on WORKERS_ENGINE=claude / effort defaults / any fallback value. The exit 1 inside the guard halts a shell-executed path; this prose halts the orchestrator-interpreted path. warnings[] (advisory) never stop — only exit≠0 halts.
$PLAN_PATH, $ROADMAP_PATH are now set — the file inputs the shared resolver reads. ENGINE, $DOMAIN_USED, $WORKERS_TIMEOUT, $CODEX_MODEL are resolved inside the single forge-dispatch-resolve.js call (step 1.5, engine-by-route_source decided within it). When the resolved ENGINE == codex AND unit_type == execute-task AND BATCH.length == 1, take Branch C in Step 4 (sidecar). When ENGINE == codex AND unit_type == plan-slice, take Branch D in Step 4 (sidecar plan, read-only). Otherwise (ENGINE == claude, or a non-routable unit) fall through to the Agent() dispatch unchanged — the claude path is byte-identical.
Dispatch resolution (step 1.5) — resolve {engine, model, alias, tier, domain, route_source, chain, chain_len, reason, effort, effort_reason} for this dispatch via the single forge-dispatch-resolve.js --json call. This one call folds the former Engine Resolution + Tier Resolution + engine-by-route_source + Effort Resolution + Alias Resolution bash — a thin caller now, all pure resolution lives in the resolver. route_source is still tier_models on the legacy byte-identical path (no routing: block / frontmatter worker: not applied), routing/frontmatter otherwise.
Cross-reference:
shared/forge-dispatch.md § Tier Resolution+§ Worker Engine Routing → Single-call resolver+§ Effort Resolution(algorithm) andshared/forge-tiers.md(canonical tables). The resolver internally callsforge-routing.js(cross-engine chain),forge-model-alias.js(alias), and applies the tier/effort defaults + precedence + risk-escalation + model-cap clamp.
# ── Dispatch resolution (single call to the shared resolver) ─────────────────────
# forge-dispatch-resolve.js reads prefs from $WORKING_DIR (MEM018 — never $CODE_DIR), parses the
# PLAN frontmatter + ROADMAP, and emits the full ordered contract. NEVER reintroduce a bash
# tier/effort default map or a frontmatter/clamp regex here — that pure logic lives ONLY in the
# resolver now (S01). See shared/forge-dispatch.md § Worker Engine Routing.
FORGE_SCRIPTS_DIR=$([ -f scripts/forge-dispatch-resolve.js ] && echo scripts || echo "$HOME/.claude/scripts")
ROUTE_JSON=$(node "$FORGE_SCRIPTS_DIR/forge-dispatch-resolve.js" \
--unit-type "$unit_type" --plan "$PLAN_PATH" --unit-id "$unit_id" \
--milestone "${RUN_ID:-{M###}}" --roadmap "$ROADMAP_PATH" \
--cwd "$WORKING_DIR" --json) # SEMPRE $WORKING_DIR, nunca $CODE_DIR (MEM018)
if [ $? -ne 0 ]; then
# prefs_ok:false → resolver exit 1 (M008-CONTEXT #2 loud-stop — mirrors the prefs gate above;
# both stay). Deactivate the run + surface prefs_errors[]; never proceed on a fallback value.
echo "✗ dispatch resolver halted (prefs error) — see forge-dispatch-resolve.js prefs_errors" >&2
exit 1
fi
MODEL_ID=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).model)" "$ROUTE_JSON")
MODEL_ALIAS=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).alias||'')" "$ROUTE_JSON")
TIER=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).tier)" "$ROUTE_JSON")
REASON=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).reason)" "$ROUTE_JSON")
DOMAIN_USED=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).domain)" "$ROUTE_JSON")
ROUTE_SOURCE=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).route_source)" "$ROUTE_JSON")
CHAIN_LEN=$(node -e "process.stdout.write(String(JSON.parse(process.argv[1]).chain_len))" "$ROUTE_JSON")
ENGINE=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).engine)" "$ROUTE_JSON")
ENGINE_REASON=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).engine_reason)" "$ROUTE_JSON")
EFFORT=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).effort)" "$ROUTE_JSON")
EFFORT_REASON=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).effort_reason)" "$ROUTE_JSON")
WORKERS_TIMEOUT=$(node -e "process.stdout.write(String(JSON.parse(process.argv[1]).workers_timeout))" "$ROUTE_JSON")
CODEX_MODEL=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).codex_model||'')" "$ROUTE_JSON")
THINKING_HEADER=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).thinking_header||'')" "$ROUTE_JSON")
# Raw resolver inputs restored for the failure-taxonomy re-resolution (--next-after / tier escalation).
DOMAIN=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).domain_input||'')" "$ROUTE_JSON")
PLAN_TIER=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).frontmatter_tier||'')" "$ROUTE_JSON")
PLAN_WORKER=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).plan_worker||'')" "$ROUTE_JSON")
MODEL_APPLIED_JSON=$([ -n "$MODEL_ALIAS" ] && printf '"%s"' "$MODEL_ALIAS" || printf 'null')
unit_effort="$EFFORT"
# $ROUTE_JSON.chain carries forward unmodified — consumed by Branch C/D (codex-member cap) and by
# the Failure Taxonomy via `forge-routing.js ... --next-after "$MODEL_ID"` on model_refusal/429/400
# (walks the cross-engine chain → category fallback → ''), BEFORE any cross-tier escalation
# (context_overflow's ladder is separate — re-resolves THROUGH routing at the escalated tier).
# Shadowing warning (risk #3) — routing: configured but not applied (frontmatter/legacy won).
ROUTING_PRESENT=$(node "$FORGE_SCRIPTS_DIR/forge-routing.js" --explain --unit-type "$unit_type" --tier "$TIER" --domain "$DOMAIN_USED" --cwd "$WORKING_DIR" 2>/dev/null | grep -qiE 'routing.*present|present.*true' && echo true || echo false)
if [ "$ROUTE_SOURCE" != "routing" ] && [ "$ROUTING_PRESENT" = "true" ]; then
echo "⚠ routing: configurado mas não aplicado (route_source=$ROUTE_SOURCE) — frontmatter/legado venceu para $unit_type/$unit_id" >&2
fi
TIER, MODEL_ID, MODEL_ALIAS, ROUTE_JSON (with .chain), ROUTE_SOURCE, CHAIN_LEN, DOMAIN_USED, ENGINE, ENGINE_REASON, EFFORT, EFFORT_REASON, WORKERS_TIMEOUT, CODEX_MODEL, THINKING_HEADER, MODEL_APPLIED_JSON, unit_effort, and REASON are now set. On ENGINE == codex for a routable execute-task/plan-slice, take Branch C/D in Step 4 (the resolver already emitted $CODEX_MODEL/$WORKERS_TIMEOUT + $ROUTE_JSON.chain those branches read). Otherwise use $MODEL_ID/$MODEL_ALIAS in the Agent() call. $TIER/$REASON/$DOMAIN_USED/$ROUTE_SOURCE/$CHAIN_LEN/$ENGINE/$EFFORT/$EFFORT_REASON are injected into the dispatch event (additive).
Fable 5 thinking guard: the resolver emits
$THINKING_HEADER(adaptivewhen$MODEL_IDisclaude-fable-5, else empty). When$THINKING_HEADERisadaptive, injectthinking: adaptivein the worker prompt header (or omit thethinking:line) regardless of the phase'sthinking:pref —claude-fable-5returns HTTP 400 on an explicitthinking: disabled(Opus 4.7/4.8 accept it).
unit_effort (and $EFFORT/$EFFORT_REASON for the dispatch event) are set by the resolver above. Inject effort: {unit_effort} and (for opus/fable phases) thinking: {THINKING_OPUS} into the worker prompt header.
Batch determination (step 1.6 — execute-task only): When unit_type == execute-task, the dispatch is no longer strictly single-task. Invoke scripts/forge-parallelism.js to compute a ready batch — a set of tasks in the active slice whose depends:[] are satisfied AND whose writes:[] don't overlap with each other.
SLICE_PLAN=".gsd/milestones/${M###}/slices/${S##}/${S##}-PLAN.md"
MAX_CONCURRENT=$(node -e "
let p={};try{p=JSON.parse(require('fs').readFileSync('.gsd/prefs-resolved.json','utf8'));}catch(e){}
process.stdout.write(String((p.parallelism && p.parallelism.max_concurrent) || 3));
")
BATCH_JSON=$(node "$FORGE_SCRIPTS_DIR/forge-parallelism.js" --slice-plan "$SLICE_PLAN" --max-concurrent "$MAX_CONCURRENT")
echo "$BATCH_JSON"
Parse the JSON. Field semantics:
mode | Meaning | Action |
|---|---|---|
parallel | batch.length ≥ 2 — multiple ready tasks, no writes conflicts | Parallel dispatch path (Step 4 branch B) |
single | batch.length == 1 — modern plan, only one task currently ready | Single dispatch path (Step 4 branch A) |
legacy | At least one task in slice is missing depends or writes frontmatter | Single dispatch with batch[0] — preserves behavior for pre-parallelism plans |
blocked | Pending tasks exist but none have satisfied deps (or all ready tasks were filtered out) | Error — emit reason to user, deactivate auto-mode, stop loop |
none | All tasks complete | Advance STATE, re-derive unit_type (should flip to complete-slice) |
error | Script crash | Stop loop, surface reason |
Store the parsed batch as BATCH = [{id, planPath}, ...]. For non-execute-task unit_types, treat BATCH = [{id: unit_id, planPath: (n/a)}] implicitly — the rest of the flow below is unchanged for them.
When mode == "parallel", emit one line so the user sees the parallelism in action:
⇉ Batch paralelo: T01, T02, T03 (3 independent tasks ready)
When mode == "legacy", emit one line (the first time per slice — not every iteration):
↻ Legacy plan — dispatching sequentially (no depends/writes frontmatter)
Per-task resolution (parallel only): If BATCH.length > 1, the dispatch resolution block above resolved for $PLAN_PATH of the first task. Before building prompts, re-run the single forge-dispatch-resolve.js --json call once per task in the batch (passing that task's $PLAN_PATH) so each one carries its own {TIER, MODEL_ID, MODEL_ALIAS, ENGINE, DOMAIN_USED, ROUTE_SOURCE, CHAIN_LEN, REASON, EFFORT, EFFORT_REASON, DOMAIN, PLAN_TIER, PLAN_WORKER} (the last three — the raw resolver inputs domain_input/frontmatter_tier/plan_worker — feed that task's failure-taxonomy re-resolution). Security gate (below) also loops over each task in the batch.
Risk radar gate (plan-slice only): If unit_type == plan-slice and the slice is tagged risk:high in ROADMAP, check if S##-RISK.md already exists. If not:
mkdir -p .gsd/milestones/{M###}/slices/{S##}
Skill({ skill: "forge-risk-radar", args: "{M###} {S##}" })
This runs the risk assessment in the current context before the plan-slice agent is dispatched. The produced S##-RISK.md will be injected into the worker prompt.
Security gate (execute-task only): If unit_type == execute-task, run this check for each task in BATCH (when BATCH.length > 1, iterate through every batch member; when BATCH.length == 1, run once for the single task).
For each task T## in BATCH: scan the corresponding T##-PLAN.md content for security-sensitive keywords:
auth|token|crypto|password|secret|api.?key|jwt|oauth|permission|role|hash|salt|encrypt|decrypt|session|cookie|credential|sanitize|xss|sql|inject
If any keyword matches AND T##-SECURITY.md does not already exist in that task's directory:
Skill({ skill: "forge-security", args: "{M###} {S##} {T##}" })
The produced T##-SECURITY.md will be injected into that task's worker prompt as ## Security Checklist. Skills run in the orchestrator context — loop them serially (fast enough; each is short) before dispatching the batch in parallel.
Review gate (before complete-slice): If unit_type == complete-slice, run the dialectic review on the slice diff BEFORE dispatching forge-completer (the slice branch gsd/{M###}/{S##} is still unmerged here, so the diff is intact). This is the challenger × defender confrontation:
{WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-REVIEW.md already exists → skip the gate, proceed to complete-slice.review.{mode,style,rounds,ask_in_auto,engine,challenger,challenger_model} via the cascade in shared/forge-review.md § Step 0. If mode == disabled → skip.
review.challenger: claude|codex|gemini) follows shared/forge-review.md § Step 0 + the adapter branch in Steps 2/4 (--engine codex|agy) — single fallback to forge-reviewer when the external CLI is unavailable.challenger: codex|gemini forces engine: agents (the workflow script cannot route an external CLI) — see the precedence block in the spec.shared/forge-review.md with MODE = auto:
Antes de despachar cada agente (Challenge e Defense abaixo), exiba o Spawn Liveness Banner referenciado em
shared/forge-dispatch.md § Spawn Liveness Bannercom duração estimada parareview-challenger/review-advocate.
shared/forge-review.md § Engine workflow): se engine: workflow e a tool Workflow estiver no seu tool list (introspecção — NÃO ToolSearch), os três dispatches abaixo (Challenge/Defense/Rebuttal) são substituídos por UMA invocação Workflow; em tool ausente ou erro → fallback agents com warning + evento review-engine-fallback. O render do Step 6 e os Steps 7a/7b/8 não mudam.Agent({ subagent_type: 'forge-reviewer', … })Agent({ subagent_type: 'forge-advocate', … })rounds → forge-reviewer in rebuttal mode (DEFENSE injected){S##}-REVIEW.md (Step 6).Agent({ subagent_type: 'forge-executor', … }) with UNIT: review-fix/{S##} to fix ONLY the conceded items on the still-unmerged slice branch (skip when review.fix_conceded: false). On success mark each **Correção:** aplicada — commit {sha}; on failure mark falhou — deferida para triagem final. No re-review of the fix commit.ask_in_auto: defer (default) marks each **Decisão:** deferido → triagem no fim da milestone and continues WITHOUT pausing — they are guaranteed to surface at the milestone-final triage gate below. pause (opt-in) asks per-slice via AskUserQuestion.review event to events.jsonl (Step 8).Agent() throw is recorded and the loop proceeds to complete-slice regardless.Fires ONLY when the derived unit is
complete-slice. Boundary is per-slice; standalone/forge-taskkeeps its own step-5.5 review. After the gate, dispatchforge-completernormally.
Review triage gate (before complete-milestone): If unit_type == complete-milestone, run the milestone-final triage (shared/forge-review.md § Step 9) BEFORE dispatching forge-completer — i.e., before the milestone is finalized "de fato" (final close-out, LEDGER entry, cleanup):
{S##}-REVIEW.md under .gsd/milestones/{M###}/slices/*/ for pending items: Decisão: deferido → triagem no fim da milestone, Correção: falhou — deferida para triagem final, or legacy Decisão: deferido (auto-mode).complete-milestone normally."Forge {RUN_ID} — {N} item(ns) de review aguardam sua triagem antes de fechar a milestone." (N = count of pending items). Then print the digest table (slice · R# · path:line · objeção · status) and triage each item via AskUserQuestion (batched up to 4, header Review M###): Manter abordagem atual / Refatorar agora / Criar follow-up.Refatorar agora items → ONE review-fix/{M###}-triage dispatch to forge-executor (slices already merged — fixes are normal commits). Write every decision back into the R#'s **Decisão:** line; Criar follow-up items also append to .gsd/KNOWLEDGE.md § Review follow-ups (survives milestone_cleanup).review-triage event to events.jsonl. The triage never blocks the milestone close-out.This gate is the explicit exception to the AUTONOMY RULE — at this point every slice is done; asking the operator here is the designed arbitration moment that
deferpostponed to. It does not fire on pause/blocked/partial exits — only when the derived unit iscomplete-milestone.
Plan-check gate (between plan-slice and first execute-task):
After a successful plan-slice unit, before dispatching the first execute-task for the same slice, run the plan-check gate:
Read plan_check.mode via the canonical engine CLI (single-knob convenience form — dual-reads md OR jsonc; NEVER a 3-file cascade node -e merge, MEM001 M005):
PLAN_CHECK_MODE=$(node "$FORGE_SCRIPTS_DIR/forge-prefs.js" --resolved --key plan_check.mode --cwd "$WORKING_DIR" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{let m=String(JSON.parse(d).value||'').toLowerCase();process.stdout.write((m==='advisory'||m==='blocking'||m==='disabled')?m:'advisory')}catch(e){process.stdout.write('advisory')}})")
Store as PLAN_CHECK_MODE (default advisory on absence/parse error).
If PLAN_CHECK_MODE == "disabled": skip — do not invoke the plan-checker. Proceed to first execute-task.
Idempotency check: if {WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN-CHECK.md already exists, skip — do not re-invoke the plan-checker.
Aggregate MUST_HAVES_CHECK_RESULTS:
Use $WORKING_DIR (captured in bootstrap via pwd — always forward-slash, Windows-safe). For each T##-PLAN.md:
for plan in "$WORKING_DIR/.gsd/milestones/{M###}/slices/{S##}/tasks"/T*/T*-PLAN.md; do
node "$FORGE_SCRIPTS_DIR/forge-must-haves.js" --check "$plan"
done
Capture stdout JSON. Build an array of {task_id, legacy, valid, errors}. Serialize to JSON as MUST_HAVES_CHECK_RESULTS.
Fill the plan-check template from shared/forge-dispatch.md § plan-check with $WORKING_DIR (not raw CWD — always use the bash-captured variable), {M###}, {S##}, {PLAN_CHECK_MODE}, {MUST_HAVES_CHECK_RESULTS}.
Dispatch:
Antes de despachar o plan-checker, exiba o Spawn Liveness Banner (ver
shared/forge-dispatch.md § Spawn Liveness Banner) — duração estimadaplan-check: ~1–2 min.
Agent({ subagent_type: 'forge-plan-checker', prompt: <filled-template> })
Parse the worker result — extract plan_check_counts: {pass, warn, fail} from the ---GSD-WORKER-RESULT--- block.
Append to {WORKING_DIR}/.gsd/forge/events.jsonl (I/O errors MUST propagate — no silent-fail):
{"ts":"<ISO-8601>","event":"plan_check","milestone":"${RUN_ID:-{M###}}","slice":"{S##}","mode":"{PLAN_CHECK_MODE}","counts":{"pass":N,"warn":N,"fail":N}}
Branch on PLAN_CHECK_MODE:
advisory → proceed to first execute-task regardless of counts.blocking → enter the Blocking-mode revision loop below.disabled already handled in step 2.)Forward-compatibility note: future M004+ may add per-dimension enforcement. The current wire passes through all dimension counts to events.jsonl so future code can filter.
This gate fires ONLY when transitioning from a just-completed
plan-sliceto the firstexecute-taskof the same slice. When deriving the next unit (Step 1) results inexecute-taskAND the previous completed unit wasplan-slicefor the same slice, run this gate. For subsequentexecute-taskdispatches within the same slice, the idempotency check (step 3 above) ensures the gate is a no-op.
Plan-gate degradation (auditable) — forge-auto NEVER conducts the interactive handshake:
forge-auto (MODE = auto) never conducts the plan gate handshake defined in shared/forge-plan-gate.md. This is unconditional over MODE = auto — it applies regardless of the plan_gate.interactive pref value. Setting interactive: always does NOT cause forge-auto to pause and ask.
The path in forge-auto at the plan boundary:
forge-planner (batch — unchanged).forge-plan-checker (advisory — unchanged, handled by the gate above).AskUserQuestion, no approval marker.execute-task.ask_in_auto: defer (default) is the explicit guard — it mirrors review.ask_in_auto: defer from shared/forge-review.md. The AUTONOMY RULE protects the middle of the loop; plan-gate conduct is incompatible with autonomous operation.
Spec authority: shared/forge-plan-gate.md § Degradation by mode.
Symbol-check gate (between plan-slice and first execute-task, after plan-check gate):
After the plan-check gate completes (or is skipped), run the symbol-check gate before dispatching the first execute-task for the same slice. This gate runs via Bash shell-out — NOT via Agent() — so there is no liveness banner and return is immediate. See shared/forge-dispatch.md § symbol-check for artifact format and event schema.
Read symbol_check.mode via the canonical engine CLI (single-knob convenience form — dual-reads md OR jsonc; NEVER a 3-file cascade node -e merge, MEM001 M005):
SYMBOL_CHECK_MODE=$(node "$FORGE_SCRIPTS_DIR/forge-prefs.js" --resolved --key symbol_check.mode --cwd "$WORKING_DIR" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{let m=String(JSON.parse(d).value||'').toLowerCase();process.stdout.write((m==='advisory'||m==='disabled')?m:'advisory')}catch(e){process.stdout.write('advisory')}})")
Store as SYMBOL_CHECK_MODE (default advisory on absence/parse error).
If SYMBOL_CHECK_MODE == "disabled": skip — proceed to first execute-task.
Idempotency check: if {WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-SYMBOL-CHECK.md already exists, skip — proceed to first execute-task.
Run symbol-check for each T##-PLAN.md in the slice:
SYMBOL_CHECK_RESULTS="["
FIRST=1
TOTAL_VERIFIED=0; TOTAL_MISSING=0; TOTAL_AMBIGUOUS=0; TOTAL_UNCHECKED=0; TOTAL_GREENFIELD=0
for plan in "$WORKING_DIR/.gsd/milestones/{M###}/slices/{S##}/tasks"/T*/T*-PLAN.md; do
# --cwd: raiz de busca de código = CODE_DIR (worktree isolation) — WORKING_DIR só vale p/ .gsd/** (review S02 R6)
result=$(node "$FORGE_SCRIPTS_DIR/forge-symbol-check.js" --check "$plan" --cwd "${WORKER_CWD:-$WORKING_DIR}")
if [ $FIRST -eq 0 ]; then SYMBOL_CHECK_RESULTS="$SYMBOL_CHECK_RESULTS,"; fi
SYMBOL_CHECK_RESULTS="$SYMBOL_CHECK_RESULTS$result"
FIRST=0
TOTAL_VERIFIED=$((TOTAL_VERIFIED + $(echo "$result" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.counts.verified||0))")))
TOTAL_MISSING=$((TOTAL_MISSING + $(echo "$result" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.counts.missing||0))")))
TOTAL_AMBIGUOUS=$((TOTAL_AMBIGUOUS + $(echo "$result" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.counts.ambiguous||0))")))
TOTAL_UNCHECKED=$((TOTAL_UNCHECKED + $(echo "$result" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.counts.uncheckable||0))")))
TOTAL_GREENFIELD=$((TOTAL_GREENFIELD + $(echo "$result" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.counts.greenfield||0))")))
done
SYMBOL_CHECK_RESULTS="$SYMBOL_CHECK_RESULTS]"
Aggregate {verified, missing, ambiguous, unchecked, greenfield} totals across all tasks.
Write S##-SYMBOL-CHECK.md to {WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-SYMBOL-CHECK.md (see shared/forge-dispatch.md § symbol-check for format). Then append the symbol_check event to {WORKING_DIR}/.gsd/forge/events.jsonl (I/O errors MUST propagate — no silent-fail):
{"ts":"<ISO-8601>","event":"symbol_check","milestone":"${RUN_ID:-{M###}}","slice":"{S##}","mode":"{SYMBOL_CHECK_MODE}","counts":{"verified":N,"missing":N,"ambiguous":N,"unchecked":N,"greenfield":N}}
Proceed to first execute-task ALWAYS (advisory). MISSING or AMBIGUOUS symbols are documented in S##-SYMBOL-CHECK.md for informational use — they NEVER block the execute-task dispatch.
This gate fires ONLY when transitioning from a just-completed
plan-sliceto the firstexecute-taskof the same slice. Fires AFTER the plan-check gate. The idempotency check (step 3 above) makes it a no-op for subsequentexecute-taskdispatches within the same slice.
Blocking-mode revision loop (activated ONLY when PLAN_CHECK_MODE == "blocking"):
Constants (LOCKED — changing requires a new milestone decision):
MAX_PLAN_CHECK_ROUNDS = 3
State for the loop:
round = 1 (the initial plan-check above was round 1; its result is already in plan_check_counts)prev_fail_count = plan_check_counts.fail (from the step 7 parse result)Append first-round events.jsonl entry (round 1 = the initial gate run):
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"plan_check\",\"milestone\":\"${RUN_ID:-{M###}}\",\"slice\":\"{S##}\",\"mode\":\"blocking\",\"round\":1,\"counts\":{\"pass\":${PASS_COUNT},\"warn\":${WARN_COUNT},\"fail\":${FAIL_COUNT}},\"prev_fail\":null,\"outcome\":\"revised\"}" >> {WORKING_DIR}/.gsd/forge/events.jsonl
(Use the actual parsed counts from step 7. prev_fail: null for round 1 — there is no prior round.)
While prev_fail_count > 0 AND round < MAX_PLAN_CHECK_ROUNDS:
a. Back up the prior PLAN-CHECK.md:
mv {WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN-CHECK.md \
{WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN-CHECK-round{round}.md
This preserves the prior round's results for audit. Round 1 backup → {S##}-PLAN-CHECK-round1.md. Round 2 backup → {S##}-PLAN-CHECK-round2.md.
b. Collect failing dimensions from the backed-up {S##}-PLAN-CHECK-round{round}.md. Parse the verdict table — rows where Verdict == "fail". Extract dimension names and justifications into a list.
c. Increment round: round += 1.
d. Re-dispatch plan-slice with an injected ## Revision Request section:
Agent({
subagent_type: 'forge-planner',
prompt: <plan-slice template from shared/forge-dispatch.md>
+ "\n\n## Revision Request (round " + round + ")\n"
+ "The prior plan scored `fail` on these dimensions:\n"
+ "- {dimension 1}: {justification}\n"
+ "- {dimension 2}: {justification}\n"
+ "...\n"
+ "Revise the slice plan to resolve these failures. Preserve all already-passing dimensions. "
+ "Do NOT reduce scope to hide failures — fix the root cause.\n"
})
Wait for the planner result. If the planner returns status: blocked, terminate immediately (do not enter the non-decreasing check — surfacing the planner failure takes precedence).
e. Re-run the plan-check gate — dispatch forge-plan-checker again using the same template from shared/forge-dispatch.md § plan-check, with {PLAN_CHECK_MODE}: blocking and round: {round} passed in the prompt. This produces a new {S##}-PLAN-CHECK.md (overwriting any prior file — the backup in step (a) already preserved the previous round).
f. Parse new counts → new_fail_count (from the worker result plan_check_counts.fail).
g. Append events.jsonl line (I/O errors MUST propagate — no silent-fail):
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"plan_check\",\"milestone\":\"${RUN_ID:-{M###}}\",\"slice\":\"{S##}\",\"mode\":\"blocking\",\"round\":{round},\"counts\":{\"pass\":${NEW_PASS},\"warn\":${NEW_WARN},\"fail\":${new_fail_count}},\"prev_fail\":${prev_fail_count},\"outcome\":\"revised\"}" >> {WORKING_DIR}/.gsd/forge/events.jsonl
h. Monotonic-decrease check: if new_fail_count >= prev_fail_count, TERMINATE (non-decreasing):
outcome field in the events.jsonl line just written — or append a corrective entry:
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"plan_check\",\"milestone\":\"${RUN_ID:-{M###}}\",\"slice\":\"{S##}\",\"mode\":\"blocking\",\"round\":{round},\"outcome\":\"terminated-non-decreasing\",\"prev_fail\":${prev_fail_count},\"new_fail\":${new_fail_count}}" >> {WORKING_DIR}/.gsd/forge/events.jsonl
non-decreasing).if [ -n "$RUN_ID" ]; then
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json '{"active":false}' > /dev/null
else
echo '{"active":false}' > {WORKING_DIR}/.gsd/forge/auto-mode.json
fi
execute-task for this slice. Return.i. Update state: prev_fail_count = new_fail_count.
After the while loop exits:
If prev_fail_count == 0:
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"plan_check\",\"milestone\":\"${RUN_ID:-{M###}}\",\"slice\":\"{S##}\",\"mode\":\"blocking\",\"round\":{round},\"outcome\":\"passed\"}" >> {WORKING_DIR}/.gsd/forge/events.jsonl
execute-task dispatch normally.Else (round == MAX_PLAN_CHECK_ROUNDS and prev_fail_count > 0):
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"plan_check\",\"milestone\":\"${RUN_ID:-{M###}}\",\"slice\":\"{S##}\",\"mode\":\"blocking\",\"round\":{round},\"outcome\":\"terminated-exhausted\"}" >> {WORKING_DIR}/.gsd/forge/events.jsonl
exhausted).if [ -n "$RUN_ID" ]; then
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json '{"active":false}' > /dev/null
else
echo '{"active":false}' > {WORKING_DIR}/.gsd/forge/auto-mode.json
fi
execute-task for this slice. Return.Termination Surface Block (pt-BR):
Emit to the user when terminating (either non-decreasing or exhausted):
⚠ Plan-check blocking mode: terminando loop de revisão.
Motivo: {non-decreasing — fail não diminuiu entre rodadas | exhausted — rodadas esgotadas sem convergência}
Rodada atual: {round}/3
Dimensões ainda falhando:
- {dim1}: {justification}
- {dim2}: {justification}
...
Ação necessária: edite os T##-PLAN.md para resolver as dimensões listadas acima, depois:
- delete {WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN-CHECK.md
- rode `/forge-next` para reexecutar o gate (ou `/forge-auto` para continuar autônomo).
Os arquivos de backup das rodadas anteriores estão em:
{WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN-CHECK-round1.md
{WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN-CHECK-round2.md (se round >= 2)
Purpose: when plan_check.mode: blocking is set in prefs, the orchestrator does not proceed to execute-task if the plan-check gate finds structural failures. Instead, it enters this revision loop, which repeatedly re-plans and re-checks until the plan is clean or the loop terminates.
Activation: only when PLAN_CHECK_MODE == "blocking". Default (advisory) never enters this loop — the plan-checker result is informational only and the orchestrator proceeds immediately to execute-task.
Round semantics:
plan_check_counts.MAX_PLAN_CHECK_ROUNDS = 3 rounds total (LOCKED constant — not a pref key).Backup filenames:
{S##}-PLAN-CHECK-round1.md (backup of round 1 results){S##}-PLAN-CHECK-round2.md (backup of round 2 results){S##}-PLAN-CHECK.md = the last round's results (whatever round terminates the loop)Termination conditions (both stop the loop and surface to user):
terminated-non-decreasing — new fail count ≥ prev fail count (replanning made things worse or stagnated)terminated-exhausted — reached MAX_PLAN_CHECK_ROUNDS (3) and still has failuresPass condition: fail_count == 0 at any point → outcome: passed → proceed to execute-task.
User-surface contract: on termination, emit the structured pt-BR block above. User must edit plans manually and delete {S##}-PLAN-CHECK.md to reset. The T03 idempotency check will treat the deleted file as a fresh gate trigger on the next /forge-next or /forge-auto run.
events.jsonl outcomes (LOCKED):
"revised" — a revision round completed (plan was re-dispatched and re-checked)"terminated-exhausted" — rounds exhausted without reaching fail == 0"terminated-non-decreasing" — fail count did not decrease between rounds"passed" — fail count reached 0; proceeding to execute-taskRead PREFS for skip_discuss and skip_research. If the current unit type is skipped, advance STATE past it and re-derive (do not count as a unit).
Use the template from ~/.claude/forge-dispatch.md for the current unit_type.
Substitute placeholders:
{WORKING_DIR} <- current working directory (orchestrator workspace — all .gsd/** paths){M###}, {S##}, {T##} <- from STATE{unit_effort}, {THINKING_OPUS} <- resolved effort/thinking for this unit{TOP_MEMORIES} <- RELEVANT_MEMORIES (already filtered in Step 4){CS_LINT} <- CS_LINT section (already extracted){CS_STRUCTURE} <- CS_STRUCTURE section (already extracted){CS_RULES} <- CS_RULES section (already extracted){auto_commit} <- PREFS.auto_commit{milestone_cleanup} <- PREFS.milestone_cleanup{CODING_STANDARDS} <- full CODING_STANDARDS content (for research templates)Isolation header — when ISOLATION_MODE != shared (resolved at activation), append these lines to the worker prompt header, immediately after the WORKING_DIR: line (see shared/forge-dispatch.md § Isolation Header Convention):
ISOLATION: {ISOLATION_MODE}
BRANCH: {resolved branch name, e.g. forge/M-20260601...}
CODE_DIR: {WORKER_CWD}
Isolation rule: all source-code reads, writes, builds and git commits happen inside CODE_DIR on branch BRANCH. All .gsd/** artifact paths stay under WORKING_DIR. Never commit from WORKING_DIR when CODE_DIR differs.
(In branch mode CODE_DIR == WORKING_DIR — include the header anyway so the worker commits on the right branch and never switches back to the default branch.)
Do NOT read artifact files here — templates now pass paths; workers read their own context.
Branch on BATCH size and engine:
ENGINE == codex AND unit_type == execute-task AND BATCH.length == 1: follow Branch C — sidecar codex below (dispatch the detached adapter; fall back to Claude on any failure).ENGINE == codex AND unit_type == plan-slice: follow Branch D — sidecar codex plan below (dispatch the detached adapter in --mode plan, read-only; fall back to a single Claude forge-planner on any failure).BATCH.length == 1 (all non-execute-task unit types, plus execute-task when only one task is ready and ENGINE == claude): follow the single-task flow below (unchanged from pre-parallelism behavior).BATCH.length > 1 (execute-task only, when forge-parallelism.js returned mode: parallel): follow the parallel-batch flow in Step 4-P after this section. If ENGINE == codex in a parallel batch, see the codex note in Step 4-P (each ready task is handled single-task via Branch C; the Claude parallel batch is never mixed with background sidecars).Branch C — sidecar codex (ENGINE == codex && unit_type == execute-task && BATCH.length == 1):
Executable mirror of shared/forge-dispatch.md § Worker Engine Routing → Sidecar dispatch state machine + BLOCKER — cross-engine sidecar safety contract + Fallback. States: started → polling → done | failed. On any failure the work reverts to the next chain member (verified reset first) or the Claude fallback — no 4th recovery layer.
BLOCKER contract (S02-RISK — cross-engine chain such as gpt→claude→gpt dispatches the sidecar more than once per unit): three invariants — (1) state fresh per attempt (xllm-state-{unitId}-attempt-{N}.json, N = $SIDECAR_ATTEMPT, never clobbering a prior attempt); (2) verified reset before the next sidecar attempt — the criterion is exit 0 of forge-surgical-reset.js --reset (the codex-authored change set undone, the pre-dirty snapshot re-hashed intact), not git status --porcelain clean (a pre-existing dirty path is expected to still show after a correct reset); exit 3/2 abort to the Claude fallback (surgical-reset-overlap/verified-reset-failed); (3) hard cap SIDECAR_ATTEMPT ≤ the count of engine == codex members in the resolved chain.
# 0. Increment the per-unit sidecar attempt counter. Starts at 1 for the first sidecar dispatch of
# the unit; hard-capped by the number of engine==codex members in the resolved chain (≤3, S01).
# Persisted in the per-attempt state file so it survives an auto-compact mid-unit.
SIDECAR_ATTEMPT="${SIDECAR_ATTEMPT:-0}"; SIDECAR_ATTEMPT=$((SIDECAR_ATTEMPT + 1))
CODEX_MEMBERS=$(node -e "process.stdout.write(String(JSON.parse(process.argv[1]).chain.filter(m=>m.engine==='codex').length))" "$ROUTE_JSON" 2>/dev/null || echo 1)
FORGE_SCRIPTS_DIR=$([ -f scripts/forge-surgical-reset.js ] && echo scripts || echo "$HOME/.claude/scripts")
if [ "$SIDECAR_ATTEMPT" -gt "${CODEX_MEMBERS:-1}" ]; then
# Cap exceeded → go DIRECTLY to the Claude fallback (R3): no snapshot capture, no state/result-file
# allocation, no sidecar launch. $REASON drives the Fallback block below.
REASON="sidecar-cap-exceeded"
else
# 1. Capture START_SHA + the pre-dirty snapshot in ONE atomic write, via the surgical-reset helper.
# --state-init records {attempt, start_sha, pre_dirty:[{path,hash}], reason, result_file, code_dir}
# in the SAME write (.gsd/** excluded), so the snapshot survives the poll loop / an auto-compact
# (BLOCKER item #2 of the S01 risk card — a snapshot in a shell var is lost the moment the process
# crosses a Bash-tool boundary). CODE_DIR from the isolation header; in shared mode CODE_DIR ==
# WORKING_DIR. The -attempt-$N suffix is the BLOCKER invariant — a second dispatch writes
# …-attempt-2.json, NEVER clobbering …-attempt-1.json (audit preserved, recovery unambiguous).
N="$SIDECAR_ATTEMPT"
XLLM_STATE="$WORKING_DIR/.gsd/forge/xllm-state-${T##}-attempt-$N.json"
mkdir -p "$WORKING_DIR/.gsd/forge/"
START_SHA=$(node "$FORGE_SCRIPTS_DIR/forge-surgical-reset.js" --state-init \
--state "$XLLM_STATE" --cwd "$CODE_DIR" --attempt "$N")
# 2. No pre-dispatch clean-tree guard — the pre-dirty snapshot IS the guard (SUPERSEDED, DECISION 39,
# see S01-CONTEXT.md). A dirty working tree is a SAFE precondition, not a refusal reason: the
# fallback reset only ever touches paths that changed relative to the snapshot; pre-existing dirty
# content is provably untouched (re-hash) or the reset aborts entirely (overlap) rather than guessing.
# 3. Result-file OUTSIDE CODE_DIR (codex must not overwrite it). Patch it into the durable state of
# the CURRENT attempt N via --state-update — a READ-MODIFY-WRITE that preserves start_sha + pre_dirty
# untouched. NEVER a plain printf: a hand-written printf omits pre_dirty, clobbering the snapshot and
# degrading the reset back to whole-tree destruction the moment the Fallback runs.
RESULT_FILE=$(mktemp -t forge-xllm-result.XXXXXX.json)
node "$FORGE_SCRIPTS_DIR/forge-surgical-reset.js" --state-update \
--state "$XLLM_STATE" --result-file "$RESULT_FILE"
fi
When REASON == sidecar-cap-exceeded here, skip the timeline task, dispatch and poll entirely — go straight to the Fallback block below (no sidecar is launched).
TaskCreate with icon ⚡ (same as the Claude execute-task path), model label = codex${CODEX_MODEL:+ ($CODEX_MODEL)}; mark in_progress.run_in_background: true (the 600s foreground ceiling does not apply):
node "$FORGE_SCRIPTS_DIR/forge-xllm.js" --mode execute \
--plan "$PLAN_PATH" --result-file "$RESULT_FILE" --cwd "$CODE_DIR" \
--timeout "$WORKERS_TIMEOUT" \
$([ -n "$CODEX_MODEL" ] && printf -- '--model %s' "$CODEX_MODEL")
$RESULT_FILE (polling state) every ~5–10s: status == "running" → keep polling + liveness check; status == "done" (exit 0) → success; status == "error" / adapter exit != 0 / unparseable JSON → failure (reason = codex-error/codex-exit-nonzero/codex-invalid-json). Orphan: heartbeat updated_at stale beyond ~3× the 3s cadence (~9s) → kill "$pid" (from heartbeat) → failure reason: codex-orphan. The adapter --timeout is the backstop → codex-timeout.done): first re-read the durable state from disk (the poll loop crossed multiple Bash invocations — shell vars are gone), then the orchestrator reads the JSON and writes T##-SUMMARY.md (same format as a Claude worker) + assembles the ---GSD-WORKER-RESULT--- block itself. Codex NEVER touches .gsd/** and NEVER commits. Consume: summary (SUMMARY seed), must_haves_status (into the result block), files_changed_declared (primary source of the file-audit), start_sha/head_sha (audit only — $START_SHA is authoritative). Append synthesized advisory evidence to .gsd/forge/evidence-{unitId}.jsonl from git -C "$CODE_DIR" diff --name-status "$START_SHA" tagged source: codex-sidecar. Then emit the dispatch event with engine:"codex" and rejoin Step 5. Process result exactly as a Claude worker would — downstream verification runs byte-identical:
START_SHA=$(node -pe "JSON.parse(require('fs').readFileSync('$XLLM_STATE','utf8')).start_sha" 2>/dev/null)
CODE_DIR=$(node -pe "JSON.parse(require('fs').readFileSync('$XLLM_STATE','utf8')).code_dir" 2>/dev/null)
RESULT_FILE=$(node -pe "JSON.parse(require('fs').readFileSync('$XLLM_STATE','utf8')).result_file" 2>/dev/null)
mkdir -p "$WORKING_DIR/.gsd/forge/"
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"dispatch\",\"unit\":\"execute-task/${T##}\",\"model\":\"${CODEX_MODEL:-codex-default}\",\"reason\":\"${ENGINE_REASON}\",\"engine\":\"codex\",\"domain\":\"${DOMAIN_USED}\",\"route_source\":\"${ROUTE_SOURCE}\",\"chain_len\":${CHAIN_LEN},\"slice\":\"{S##}\",\"milestone\":\"${RUN_ID:-{M###}}\",\"input_tokens\":0,\"output_tokens\":0}" >> "$WORKING_DIR/.gsd/forge/events.jsonl"
(output_tokens may be 0 — the adapter's token channel is git-derived, not SDK usage; no tier/effort fields on the codex path since Claude Tier/Effort Resolution was skipped.)Failure (any reason): first evaluate Layer-1 transient retry (sidecar parity with the Claude Retry Handler — shared/forge-dispatch.md § Layer-1 transient retry); only on a terminal class / exhaustion / unverified reset does control fall through to the Layer-2 verified-reset + chain walk (advance to another codex/claude member) or the Claude fallback:
# ── Layer-1 transient retry (sidecar parity with the Claude Retry Handler) — runs BEFORE Layer-2 ──
# Strictly upstream of the Layer-2 chain walk below (mirrors how the per-Agent() Retry Handler is
# upstream of the claude-member chain walk). Read error_class off the result JSON (or the adapter-failed
# marker — same field, S02/T01). Absent/unrecognized → terminal: byte-identical to pre-T02 (single-shot
# fallback, never an unbounded retry). codex-timeout / codex-orphan are ALWAYS terminal (a hung/orphaned
# process is never retried in place) regardless of a stale error_class.
FORGE_SCRIPTS_DIR=$([ -f scripts/forge-surgical-reset.js ] && echo scripts || echo "$HOME/.claude/scripts")
RESULT_FILE=$(node -pe "JSON.parse(require('fs').readFileSync('$XLLM_STATE','utf8')).result_file" 2>/dev/null || echo "$RESULT_FILE")
ERROR_CLASS=$(node -pe "JSON.parse(require('fs').readFileSync('$RESULT_FILE','utf8')).error_class || 'terminal'" 2>/dev/null || echo terminal)
case "$REASON" in codex-timeout|codex-orphan) ERROR_CLASS="terminal";; esac
TRC=$(node -pe "JSON.parse(require('fs').readFileSync('$XLLM_STATE','utf8')).transient_retry_count || 0" 2>/dev/null || echo 0)
MAX_TRC=$(printf '%s' "$PREFS_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const r=(JSON.parse(d).prefs.retry||{}).max_transient_retries;process.stdout.write(Number.isInteger(r)&&r>0?String(r):'3')}catch(e){process.stdout.write('3')}})")
BASE_BACKOFF=$(printf '%s' "$PREFS_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const b=(JSON.parse(d).prefs.retry||{}).base_backoff_ms;process.stdout.write(Number.isInteger(b)&&b>0?String(b):'2000')}catch(e){process.stdout.write('2000')}})")
# Sidecar failure policy (§ Sidecar failure policy) — fallback skips Layer-1; pause-ask gates exhaustion below; retry-then-fallback (default) is a no-op guard. Absent/invalid → retry-then-fallback.
POLICY=$(printf '%s' "$PREFS_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{let v=String(((JSON.parse(d).prefs.workers||{}).sidecar_on_failure)||'').toLowerCase();process.stdout.write(['retry-then-fallback','fallback','pause-ask'].includes(v)?v:'retry-then-fallback')}catch(e){process.stdout.write('retry-then-fallback')}})")
TRANSIENT_RETRY=""
if [ "$POLICY" != "fallback" ] && [ "$ERROR_CLASS" = "transient" ] && [ "$TRC" -lt "$MAX_TRC" ]; then
# Layer-1 fires (policy allows it, transient AND under the cap). Branch C reset FIRST — same helper + verified-reset
# criterion as Layer-2 (RC=0 required; RC=3 overlap / RC=2 verify-failed do NOT retry — fall through
# to Layer-2, which owns the abort→fallback accounting; a retry NEVER runs on an unverified tree).
node "$FORGE_SCRIPTS_DIR/forge-surgical-reset.js" --reset --state "$XLLM_STATE"; RC=$?
if [ "$RC" = "0" ]; then
# Exponential backoff base * 2^count (mirrors the Claude Retry Handler step 7). Cross-platform sleep.
DELAY_MS=$(node -pe "$BASE_BACKOFF * Math.pow(2, $TRC)")
node -e "setTimeout(()=>{}, $DELAY_MS)"
# Bump the counter + allocate a fresh result-file via the S01 helper (read-modify-write — NEVER a
# printf, which would clobber pre_dirty/start_sha). Same -attempt-$N state file; SIDECAR_ATTEMPT is
# UNTOUCHED (transient_retry_count ⊥ SIDECAR_ATTEMPT — a Layer-1 retry never consumes a chain member).
RESULT_FILE=$(mktemp -t forge-xllm-result.XXXXXX.json)
node "$FORGE_SCRIPTS_DIR/forge-surgical-reset.js" --state-update \
--state "$XLLM_STATE" --transient-retry-count $((TRC + 1)) --result-file "$RESULT_FILE"
TRC=$((TRC + 1)); TRANSIENT_RETRY=1
mkdir -p "$WORKING_DIR/.gsd/forge/"
printf '{"ts":"%s","event":"sidecar-transient-retry","milestone":"%s","slice":"%s","unit":"execute-task/%s","attempt":%s,"transient_retry_count":%s,"backoff_ms":%s}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${RUN_ID:-${M###}}" "${S##}" "${T##}" "$SIDECAR_ATTEMPT" "$TRC" "$DELAY_MS" >> "$WORKING_DIR/.gsd/forge/events.jsonl"
fi
fi
pause-ask degrade (policy == pause-ask) — forge-auto ALWAYS degrades (AUTONOMY RULE), never pauses. Fires at exactly ONE transition — transient-retry exhaustion: POLICY == pause-ask AND Layer-1 did not re-fire this pass ($TRANSIENT_RETRY empty) AND class is transient AND the counter is at the cap (TRC == MAX_TRC). Terminal classes, sidecar-cap-exceeded, surgical-reset-overlap (RC=3) and verified-reset-failed (RC=2) all leave TRC < MAX_TRC or ERROR_CLASS != transient, so they bypass this gate and reach Layer-2 unchanged. On the trigger, degrade to the fallback action (fall through to Layer-2) and emit one sidecar-pause-degraded event (mirror of shared/forge-dispatch.md § Sidecar failure policy):
if [ "$POLICY" = "pause-ask" ] && [ -z "$TRANSIENT_RETRY" ] && [ "$ERROR_CLASS" = "transient" ] && [ "$TRC" -eq "$MAX_TRC" ]; then
mkdir -p "$WORKING_DIR/.gsd/forge/"
printf '{"ts":"%s","event":"sidecar-pause-degraded","milestone":"%s","slice":"%s","unit":"execute-task/%s","reason":"pause-ask-headless-degrade","transient_retry_count":%s}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${RUN_ID:-${M###}}" "${S##}" "${T##}" "$TRC" >> "$WORKING_DIR/.gsd/forge/events.jsonl"
fi
If $TRANSIENT_RETRY is set (Layer-1 fired, reset verified RC=0): re-enter the Dispatch (detached) + Poll steps for the CURRENT attempt N — reusing the same -attempt-$N state, $START_SHA/pre_dirty and the fresh $RESULT_FILE, WITHOUT re-running Branch C step 0 (so SIDECAR_ATTEMPT is NOT incremented and no new attempt file is allocated — transient_retry_count ⊥ SIDECAR_ATTEMPT). Do NOT run the Layer-2 block below. Otherwise — a terminal class, exhaustion (transient_retry_count == max_transient_retries), or an unverified reset (RC≠0, whose abort→fallback accounting Layer-2 owns) — control falls through to the Layer-2 chain walk below unchanged:
# Re-read is delegated to the helper — $XLLM_STATE points at the …-attempt-$N.json of the CURRENT
# attempt and carries start_sha + pre_dirty (this block may be a later Bash invocation — shell vars
# are gone). BLOCKER item 2 — surgical reset via the helper (scoped to CODE_DIR, .gsd/** excluded),
# EXCEPT sidecar-cap-exceeded (no attempt captured a snapshot → nothing codex-authored to undo). The
# pre-dirty snapshot from step 1 makes it safe to reset even over a pre-existing dirty tree: only the
# codex-authored change set is undone; pre-existing dirty content is re-hashed intact (RC=0) or the
# reset aborts (RC=3 overlap / RC=2 verify-failed). .gsd/** is excluded by the helper's own predicate,
# so it never reverts the orchestrator's own .gsd writes (events.jsonl / evidence) made during the poll.
FORGE_SCRIPTS_DIR=$([ -f scripts/forge-surgical-reset.js ] && echo scripts || echo "$HOME/.claude/scripts")
if [ "$REASON" != "sidecar-cap-exceeded" ]; then
RESET_JSON=$(node "$FORGE_SCRIPTS_DIR/forge-surgical-reset.js" --reset --state "$XLLM_STATE"); RC=$?
# RC=0 → reset verified (only codex-authored changes undone, pre-dirty snapshot intact) → advance.
# RC=3 → OVERLAP: a pre-dirty path's hash diverged (the sidecar ALSO wrote it) — the helper reset
# NOTHING (leftovers stay on disk, visible for the human — never silently discarded).
# RC=2 → the reset ran but post-verification still found a leftover that isn't an intact pre-dirty path.
if [ "$RC" = "3" ]; then
REASON="surgical-reset-overlap" # emit event with the overlap path list from $RESET_JSON; abort chain
elif [ "$RC" != "0" ]; then
REASON="verified-reset-failed" # abort the chain to the Claude fallback — never inherit a dirty tree
fi
fi
# Cross-engine chain walk (Layer 2) — advance to the next member unless the trigger forbids it.
# surgical-reset-overlap / sidecar-cap-exceeded / verified-reset-failed abort straight to the Claude
# fallback (no advance). ADVANCED is the mutual-exclusion latch (R1): chain-advance and the generic
# Claude fallback are mutually exclusive — exactly ONE of the two branches below runs.
FORGE_SCRIPTS_DIR=$([ -f scripts/forge-routing.js ] && echo scripts || echo "$HOME/.claude/scripts")
ADVANCED=""
if [ "$REASON" != "surgical-reset-overlap" ] && [ "$REASON" != "sidecar-cap-exceeded" ] && [ "$REASON" != "verified-reset-failed" ]; then
NEXT_ID=$(node "$FORGE_SCRIPTS_DIR/forge-routing.js" \
--unit-type "$unit_type" --tier "$TIER" --domain "$DOMAIN" \
--frontmatter-tier "$PLAN_TIER" --frontmatter-worker "$PLAN_WORKER" \
--cwd "$WORKING_DIR" --next-after "$MODEL_ID")
# NEXT_ID == '' → chain + category fallback exhausted → the Claude fallback below (never a 4th layer).
if [ -n "$NEXT_ID" ]; then
# Engine of the next member = codex when its family is "gpt", claude otherwise (R4 — there is no
# --engine-of CLI; forge-model-alias.js --family is the sibling mirror's approach).
NEXT_FAMILY=$(node "$FORGE_SCRIPTS_DIR/forge-model-alias.js" --family "$NEXT_ID" 2>/dev/null)
MODEL_ID="$NEXT_ID"
[ "$NEXT_FAMILY" = "gpt" ] && NEXT_ENGINE="codex" || NEXT_ENGINE="claude"
ADVANCED="1" # a live next member exists → advance, do NOT take the generic fallback
fi
fi
if [ -n "$ADVANCED" ]; then
# Chain advanced (mutually exclusive with the generic fallback — R1). Select + persist the next
# member and re-enter the appropriate dispatch path; do NOT emit worker-engine-fallback here.
if [ "$NEXT_ENGINE" = "codex" ]; then
ENGINE="codex" # → re-enter Branch C step 0 with attempt N+1 (SIDECAR_ATTEMPT increments, fresh
# state, verified-clean tree). No generic fallback, no fallback event.
else
ENGINE="claude" # → single-task Claude dispatch with $MODEL_ID (Tier/Effort Resolution runs there).
# No generic fallback, no fallback event.
fi
else
# Chain exhausted (NEXT_ID empty) OR an abort reason (surgical-reset-overlap / sidecar-cap-exceeded /
# verified-reset-failed) forbids advancement → the generic Claude fallback fires exactly ONCE.
ENGINE="claude" # unconditionally Claude before re-entering Tier/Effort Resolution + dispatch
echo "⚠ worker: codex indisponível ($REASON) — usando forge-executor"
mkdir -p "$WORKING_DIR/.gsd/forge/"