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
Bootstrap guard
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.
Load context
Read ONLY these files:
.gsd/STATE.md~/.claude/forge-agent-prefs.md(user-global defaults — skip silently if missing).gsd/claude-agent-prefs.md(repo-level shared prefs — overrides user-global).gsd/prefs.local.md(local personal overrides — gitignored, overrides repo prefs).gsd/AUTO-MEMORY.mdfull file (skip silently if missing) — stored asALL_MEMORIESfor selective injection per unit.gsd/CODING-STANDARDS.md(skip silently if missing)
Merge order: later files override earlier ones for any key present. Missing files are skipped silently. Store merged result as PREFS.
Extract effort & thinking from PREFS:
EFFORT_MAP←PREFS.effort(per-phase effort table; default: opus phases =medium, sonnet phases =low)THINKING_OPUS←PREFS.thinking.opus_phases(default:adaptive)
Store as: STATE, PREFS, ALL_MEMORIES, CODING_STANDARDS.
CODING_STANDARDS section extraction — to minimize token usage, extract these named sections from the file for selective injection:
CS_LINT— content of## Lint & Format Commandssection onlyCS_STRUCTURE— content of## Directory Conventions+## Asset Map+## Pattern CatalogsectionsCS_RULES— content of## Code Rulessection only If CODING-STANDARDS.md is missing, all section variables are"(none)".
Extract notifications pref from PREFS:
NOTIFICATIONS_ON← match^notifications:[ \t]*(\S+)(single-line, no\s, no\Z) in the merged prefs text; if absent or noton/off, defaulton. Store asNOTIFICATIONS_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:
Do NOT emit a resume message.# 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.jsonfresh— heartbeat within the last 5 minutes.- If
$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. - Otherwise → a session is genuinely in flight (concurrent Claude instance, or just-reopened within 5 min). Emit one line:
↺ Retomando forge-auto após interrupção...and skip the activation step below — go directly to the dispatch loop. The marker is already set.
- If
Orchestrate — AUTO MODE
Multi-run activation (M004+)
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 theforge/{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).- If
ISO_ERRORSis non-empty AND every repo failed (WORKTREE_DIRempty 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. - If only some repos failed → emit a warning line listing them and continue.
- When
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$MSGand 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:
Then continue to legacy activation (which writes auto-mode-started.txt + alias).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"resume— emit$MSG, setRUN_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:
Without this,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/nullforge-hook.resolveBySessionIdwon't match — heartbeats fall back to legacyauto-mode.jsonandruns/{id}.jsonbecomes 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.
Activate auto-mode indicator (legacy single-run alias)
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:
- Read
.gsd/forge/auto-mode.json— ifactive: true, the loop MUST continue - Re-read all context files:
.gsd/STATE.md, prefs files (all 3 layers),.gsd/AUTO-MEMORY.md,.gsd/CODING-STANDARDS.md - Re-initialize all state variables: merge PREFS, extract EFFORT_MAP and THINKING_OPUS, set
session_units = 0, re-extract CS sections - Continue the dispatch loop from Step 1 immediately
The autonomous loop is active as long as
auto-mode.jsonshowsactive: 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.
Dispatch Loop
Repeat until stop condition:
1. Derive next unit
Compact recovery check — before anything else in each iteration:
cat .gsd/forge/compact-signal.json 2>/dev/null
If the file exists:
- Re-read all context files from disk:
.gsd/STATE.md→ updateSTATE~/.claude/forge-agent-prefs.md,.gsd/claude-agent-prefs.md,.gsd/prefs.local.md→ re-mergePREFS.gsd/AUTO-MEMORY.md→ updateALL_MEMORIES.gsd/CODING-STANDARDS.md→ re-extractCS_LINT,CS_STRUCTURE,CS_RULES
- Re-derive
EFFORT_MAPandTHINKING_OPUSfrom merged PREFS - Reset
session_units = 03a. ResetPUSH_AVAILABLE = nulland re-executeToolSearch("select:PushNotification")at the next opportunity (same "not yet probed" semantics as activation — the probe runs once per context window, not once per process) - Delete the signal:
rm -f .gsd/forge/compact-signal.json - Emit:
↺ Recovery pós-compactação — retomando de: {next_action from STATE.md} - Continue the loop normally (proceed to derive next unit below)
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):
- STATE.md (already loaded) —
next_actionusually tells you directly M###-ROADMAP.md— only if STATE is ambiguous about slices/milestone completionS##-PLAN.md— only if STATE is ambiguous about tasks within a slice
Crash 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.
Effort is resolved in step 1.55 below (after tier resolution), because the per-model capability clamp needs the resolved $MODEL_ID. Do NOT resolve effort here.
Tier resolution (step 1.5) — resolve {tier, model, reason} for this dispatch.
Cross-reference:
shared/forge-dispatch.md § Tier Resolution(algorithm) andshared/forge-tiers.md(canonical tables).
# ── Tier Resolution ────────────────────────────────────────────────────────────
# Step 1: unit-type default
declare -A TIER_DEFAULTS=(
[memory-extract]="light" [complete-slice]="light" [complete-milestone]="light"
[research-milestone]="standard" [research-slice]="standard"
[discuss-milestone]="standard" [discuss-slice]="standard" [execute-task]="standard"
[plan-milestone]="max" [plan-slice]="heavy"
)
TIER="${TIER_DEFAULTS[$unit_type]:-standard}"
REASON="unit-type:$unit_type"
# Step 2: parse frontmatter (execute-task only)
if [ "$unit_type" = "execute-task" ]; then
PLAN_PATH=".gsd/milestones/${M###}/slices/${S##}/tasks/${T##}/${T##}-PLAN.md"
PLAN_TIER=$(node -e "const fs=require('fs');const t=fs.readFileSync('$PLAN_PATH','utf8');const m=t.match(/^---[\s\S]*?---/);if(!m)process.exit(0);const r=(m[0].match(/^tier:\s*(.+)$/m)||[])[1]||'';process.stdout.write(r.trim())")
PLAN_TAG=$(node -e "const fs=require('fs');const t=fs.readFileSync('$PLAN_PATH','utf8');const m=t.match(/^---[\s\S]*?---/);if(!m)process.exit(0);const r=(m[0].match(/^tag:\s*(.+)$/m)||[])[1]||'';process.stdout.write(r.trim())")
PLAN_EFFORT=$(node -e "const fs=require('fs');const t=fs.readFileSync('$PLAN_PATH','utf8');const m=t.match(/^---[\s\S]*?---/);if(!m)process.exit(0);const r=(m[0].match(/^effort:\s*(.+)$/m)||[])[1]||'';process.stdout.write(r.trim())")
# Step 3: apply precedence (first match wins)
if [ -n "$PLAN_TIER" ]; then
TIER="$PLAN_TIER"; REASON="frontmatter-override:$PLAN_TIER"
elif [ "$PLAN_TAG" = "docs" ]; then
TIER="light"; REASON="frontmatter-tag:docs"
fi
fi
# Step 3b: risk escalation (plan-slice only) — risk:high slice escalates heavy → max.
# Same ROADMAP check that triggers the risk radar gate below.
if [ "$unit_type" = "plan-slice" ]; then
ROADMAP_PATH=".gsd/milestones/${M###}/${M###}-ROADMAP.md"
if grep -E "${S##}.*risk:[[:space:]]*high" "$ROADMAP_PATH" >/dev/null 2>&1; then
TIER="max"; REASON="risk-escalation:high"
fi
fi
# Step 4: resolve model — PREFS.tier_models[tier] with fallback to forge-tiers.md defaults
MODEL_ID=$(node -e "
let p={};try{p=JSON.parse(require('fs').readFileSync('.gsd/prefs-resolved.json','utf8'));}catch(e){}
const d={'light':'claude-haiku-4-5-20251001','standard':'claude-sonnet-4-6','heavy':'claude-opus-4-8','max':'claude-fable-5'};
const tier='$TIER';
const validTiers=['light','standard','heavy','max'];
const t=validTiers.includes(tier)?tier:'standard';
process.stdout.write((p.tier_models||{})[t]||d[t]);
")
TIER, MODEL_ID, and REASON are now set. Use $MODEL_ID in the Agent() call below (Step 4). $TIER and $REASON are injected into the dispatch event.
Fable 5 thinking guard: if
$MODEL_IDisclaude-fable-5, 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).
Effort resolution (step 1.55) — resolve $EFFORT for this dispatch. Runs after tier resolution because the per-model capability clamp needs $MODEL_ID.
Cross-reference:
shared/forge-dispatch.md § Effort Resolution(algorithm, scale, clamp table).
# ── Effort Resolution (after Tier Resolution; needs $MODEL_ID) ────────────────────
# Ordered scale (cheap → expensive reasoning): low < medium < high < xhigh < max
# Step 1: unit-type default — PREFS.effort (EFFORT_MAP) wins; fallback to built-in defaults.
declare -A EFFORT_DEFAULTS=(
[plan-milestone]="medium" [plan-slice]="medium"
[discuss-milestone]="medium" [discuss-slice]="medium"
[research-milestone]="medium" [research-slice]="medium"
[execute-task]="low" [complete-slice]="low" [complete-milestone]="low"
[memory-extract]="low"
)
EFFORT="${EFFORT_MAP[$unit_type]:-${EFFORT_DEFAULTS[$unit_type]:-low}}"
EFFORT_REASON="unit-type:$unit_type"
# Step 2: dedicated frontmatter axis (execute-task only) — effort: in T##-PLAN wins, independent of tier:
if [ "$unit_type" = "execute-task" ] && [ -n "$PLAN_EFFORT" ]; then
EFFORT="$PLAN_EFFORT"; EFFORT_REASON="frontmatter-effort:$PLAN_EFFORT"
fi
# Step 3: risk escalation sync — a risk:high plan-slice (TIER bumped to max) also gets max effort.
if [ "$unit_type" = "plan-slice" ] && [ "$REASON" = "risk-escalation:high" ]; then
EFFORT="max"; EFFORT_REASON="risk-escalation:high"
fi
# Step 4: clamp to the resolved model's capability ceiling — prevents HTTP 400s + wasted config.
# haiku/sonnet cap at medium; opus/fable allow the full scale.
EFFORT_CLAMPED=$(node -e "
const rank={low:0,medium:1,high:2,xhigh:3,max:4};
const model='$MODEL_ID';
const cap=(/^claude-(haiku|sonnet)/.test(model))?'medium':'max';
let e='$EFFORT'; if(!(e in rank)) e='medium';
process.stdout.write(rank[e]>rank[cap]?cap:e);
")
if [ "$EFFORT_CLAMPED" != "$EFFORT" ]; then
EFFORT_REASON="${EFFORT_REASON}|clamped:model-cap"; EFFORT="$EFFORT_CLAMPED"
fi
unit_effort="$EFFORT"
unit_effort (and $EFFORT/$EFFORT_REASON for the dispatch event) are now set. 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 Tier Resolution block above resolved for $PLAN_PATH of the first task. Before building prompts, re-run the frontmatter parse (steps 2–3 of Tier Resolution) once per task in the batch so each one carries its own {TIER, MODEL_ID, REASON}. The unit-type default and model lookup steps don't change. 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:
- Idempotency: if
{WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-REVIEW.mdalready exists → skip the gate, proceed tocomplete-slice. - Read
review.{mode,style,rounds,ask_in_auto,engine}via the cascade inshared/forge-review.md § Step 0. Ifmode == disabled→ skip. - Execute the procedure in
shared/forge-review.mdwithMODE = 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.- Engine (
shared/forge-review.md § Engine workflow): seengine: workflowe a toolWorkflowestiver 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 + eventoreview-engine-fallback. O render do Step 6 e os Steps 7a/7b/8 não mudam. - Challenge →
Agent({ subagent_type: 'forge-reviewer', … }) - Defense →
Agent({ subagent_type: 'forge-advocate', … }) - Rebuttal ×
rounds→forge-reviewerin rebuttal mode (DEFENSE injected) - Resolve (Step 5 truth table), write
{S##}-REVIEW.md(Step 6). - CONCEDED items → fix now (Step 7a): dispatch
Agent({ subagent_type: 'forge-executor', … })withUNIT: review-fix/{S##}to fix ONLY the conceded items on the still-unmerged slice branch (skip whenreview.fix_conceded: false). On success mark each**Correção:** aplicada — commit {sha}; on failure markfalhou — deferida para triagem final. No re-review of the fix commit. - OPEN items → posture (Step 7b):
ask_in_auto: defer(default) marks each**Decisão:** deferido → triagem no fim da milestoneand continues WITHOUT pausing — they are guaranteed to surface at the milestone-final triage gate below.pause(opt-in) asks per-slice viaAskUserQuestion. - Append the
reviewevent toevents.jsonl(Step 8).
- Engine (
- The gate never blocks — any
Agent()throw is recorded and the loop proceeds tocomplete-sliceregardless.
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):
- Scan all
{S##}-REVIEW.mdunder.gsd/milestones/{M###}/slices/*/for pending items:Decisão: deferido → triagem no fim da milestone,Correção: falhou — deferida para triagem final, or legacyDecisão: deferido (auto-mode). - Zero pending → skip silently and dispatch
complete-milestonenormally. - Otherwise fire push (call-site 2): use Push helper with message
"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 viaAskUserQuestion(batched up to 4, headerReview M###):Manter abordagem atual/Refatorar agora/Criar follow-up. Refatorar agoraitems → ONEreview-fix/{M###}-triagedispatch toforge-executor(slices already merged — fixes are normal commits). Write every decision back into the R#'s**Decisão:**line;Criar follow-upitems also append to.gsd/KNOWLEDGE.md § Review follow-ups(survivesmilestone_cleanup).- Append the
review-triageevent toevents.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.modefrom the 3-file prefs cascade:PLAN_CHECK_MODE=$(node -e " const fs=require('fs'),path=require('path'),os=require('os'); const wd=process.env.WORKING_DIR||process.cwd(); const files=[path.join(os.homedir(),'.claude','forge-agent-prefs.md'), path.join(wd,'.gsd','claude-agent-prefs.md'), path.join(wd,'.gsd','prefs.local.md')]; let mode='advisory'; for(const f of files){try{const r=fs.readFileSync(f,'utf8');const m=r.match(/^plan_check:[ \t]*\n[ \t]+mode:[ \t]*(\w+)/m);if(m)mode=m[1].toLowerCase();}catch(e){}} if(mode!=='advisory'&&mode!=='blocking'&&mode!=='disabled')mode='advisory'; process.stdout.write(mode); " WORKING_DIR="$WORKING_DIR")Store as
PLAN_CHECK_MODE.If
PLAN_CHECK_MODE == "disabled": skip — do not invoke the plan-checker. Proceed to firstexecute-task.Idempotency check: if
{WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN-CHECK.mdalready exists, skip — do not re-invoke the plan-checker.Aggregate MUST_HAVES_CHECK_RESULTS: Use
$WORKING_DIR(captured in bootstrap viapwd— always forward-slash, Windows-safe). For eachT##-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" doneCapture stdout JSON. Build an array of
{task_id, legacy, valid, errors}. Serialize to JSON asMUST_HAVES_CHECK_RESULTS.Fill the plan-check template from
shared/forge-dispatch.md § plan-checkwith$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 firstexecute-taskregardless of counts.blocking→ enter the Blocking-mode revision loop below.- (
disabledalready 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 — degradação no modo auto (NUNCA conduz)
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:
- Run
forge-planner(batch — unchanged). - Run
forge-plan-checker(advisory — unchanged, handled by the gate above). - Skip the interactive gate entirely. No preview, no
AskUserQuestion, no approval marker. - Proceed directly to
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.modefrom the 3-file prefs cascade:SYMBOL_CHECK_MODE=$(node -e " const fs=require('fs'),path=require('path'),os=require('os'); const wd=process.env.WORKING_DIR||process.cwd(); const files=[path.join(os.homedir(),'.claude','forge-agent-prefs.md'), path.join(wd,'.gsd','claude-agent-prefs.md'), path.join(wd,'.gsd','prefs.local.md')]; let mode='advisory'; for(const f of files){try{const r=fs.readFileSync(f,'utf8');const m=r.match(/^symbol_check:[ \t]*\n[ \t]+mode:[ \t]*(\w+)/m);if(m)mode=m[1].toLowerCase();}catch(e){}} if(mode!=='advisory'&&mode!=='disabled')mode='advisory'; process.stdout.write(mode); " WORKING_DIR="$WORKING_DIR")Store as
SYMBOL_CHECK_MODE.If
SYMBOL_CHECK_MODE == "disabled": skip — proceed to firstexecute-task.Idempotency check: if
{WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-SYMBOL-CHECK.mdalready exists, skip — proceed to firstexecute-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.mdto{WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-SYMBOL-CHECK.md(seeshared/forge-dispatch.md § symbol-checkfor format). Then append thesymbol_checkevent 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-taskALWAYS (advisory). MISSING or AMBIGUOUS symbols are documented inS##-SYMBOL-CHECK.mdfor 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 inplan_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):
- Overwrite the
outcomefield 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 - Surface to user (see Termination Surface Block below — reason:
non-decreasing). - Deactivate run (M005+ pattern):
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 - Stop loop. Do NOT dispatch the first
execute-taskfor this slice. Return.
i. Update state: prev_fail_count = new_fail_count.
After the while loop exits:
If
prev_fail_count == 0:- Append events.jsonl:
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 - Proceed to the first
execute-taskdispatch normally.
- Append events.jsonl:
Else (
round == MAX_PLAN_CHECK_ROUNDSandprev_fail_count > 0):- Append events.jsonl:
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 - Surface to user (see Termination Surface Block below — reason:
exhausted). - Deactivate run (M005+ pattern):
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 - Stop loop. Do NOT dispatch the first
execute-taskfor this slice. Return.
- Append events.jsonl:
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)
Plan-Check Revision Loop
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:
- Round 1 = the initial gate run (step 6 dispatch above). Already captured in
plan_check_counts. - Rounds 2 and 3 = revision iterations triggered by this loop.
- At most
MAX_PLAN_CHECK_ROUNDS = 3rounds total (LOCKED constant — not a pref key).
Backup filenames:
- Before round 2 replanning:
{S##}-PLAN-CHECK-round1.md(backup of round 1 results) - Before round 3 replanning:
{S##}-PLAN-CHECK-round2.md(backup of round 2 results) - Final
{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— reachedMAX_PLAN_CHECK_ROUNDS(3) and still has failures
Pass 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-task
2. Check skip rules
Read 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).
3. Build worker prompt
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.
4. Dispatch
Branch on BATCH size:
BATCH.length == 1(all non-execute-task unit types, plus execute-task when only one task is ready): follow the single-task flow below (unchanged from pre-parallelism behavior).BATCH.length > 1(execute-task only, whenforge-parallelism.jsreturnedmode: parallel): follow the parallel-batch flow in Step 4-P after this section.
Single-task flow (BATCH.length == 1):
Use $MODEL_ID resolved by Tier Resolution (step 1.5) above — do NOT re-read from PREFS Phase-routing table.
Create timeline task — use TaskCreate to show progress in the UI.
Use the icon for the current unit_type:
| unit_type | icon |
|---|---|
| plan-milestone | ⚙ |
| plan-slice | ⚙ |
| discuss-milestone | 💬 |
| discuss-slice | 💬 |
| research-milestone | 🔬 |
| research-slice | 🔬 |
| execute-task | ⚡ |
| complete-slice | ✔ |
| complete-milestone | 🏁 |
| memory extraction | 🧠 |
TaskCreate({
subject: "{icon} [{M###}/{S##}/{T##}] {unit_type} — {one-liner}",
description: "{agent_name} ({model_id})",
activeForm: "{icon} {unit_type} · {agent_name} ({model_id}) · {M###}/{S##}/{T##}"
})
Store the returned taskId as current_task_id. Then immediately mark it as in progress:
TaskUpdate({ taskId: current_task_id, status: "in_progress" })
Selective memory injection — before building the worker prompt, source memory entries from the fragment store via the forge-memory.js API (D9):
# 1. List all available fragment unit IDs
_frag_list=$(node "$FORGE_SCRIPTS_DIR/forge-memory.js" --list --cwd "$WORKING_DIR" 2>/dev/null || echo "[]")
If _frag_list is a non-empty JSON array (fragment store is populated):
- For each
unit_idin the list, read its fragment:
Each fragment has_frag=$(node "$FORGE_SCRIPTS_DIR/forge-memory.js" --read <unit_id> --cwd "$WORKING_DIR" 2>/dev/null)facts[],category,confidence,hits. - Apply filter to
facts[]entries using the same selection logic:- For
execute-task: read keywords fromT##-PLAN.mdtitle + step names. Include facts that share ≥2 keywords with the plan. Prefer categoriesgotchaandconvention. Cap at 8 entries total across all fragments. - For
plan-slice/research-slice: include facts from fragments withcategory=architectureorpatternrelated to the milestone scope. Cap at 8 entries. - For other unit types: include top-5 facts by fragment
confidencescore.
- For
- Collect matching facts into
RELEVANT_MEMORIESstring (same shape as before — one bullet per fact).
If _frag_list is [] or errors (pre-fragment-store workspace — fragment store not yet populated):
- Fall back to
ALL_MEMORIES(loaded from.gsd/AUTO-MEMORY.mdat step 5 of Load context) using the same filter logic above.
If no entries match in either path: set RELEVANT_MEMORIES to (none).
Store as RELEVANT_MEMORIES and use in the worker prompt ## Project Memory section instead of the raw full file.
For human-readable consolidation of the fragment store into
.gsd/AUTO-MEMORY.md, run/forge-doctor --regen-projection(usesforge-memory.js --write-all/forge-projectioninternally). The monolith is no longer the runtime source of truth (D9).
Heartbeat — record active worker before dispatching (M005+: writes via forge-runs.js when multi-run, legacy auto-mode.json fallback):
_now=$(node -e "process.stdout.write(String(Date.now()))")
if [ -n "$RUN_ID" ]; then
# Multi-run: forge-runs.update bumps runs/{id}.json + auto-refreshes legacy alias
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json "{\"worker\":\"UNIT_TYPE/UNIT_ID\",\"worker_started\":$_now,\"last_heartbeat\":$_now,\"active\":true}" > /dev/null
else
# Legacy single-run path
_sa=$(cat .gsd/forge/auto-mode-started.txt 2>/dev/null || node -e "process.stdout.write(String(Date.now()))")
echo "{\"active\":true,\"started_at\":$_sa,\"last_heartbeat\":$_now,\"worker\":\"UNIT_TYPE/UNIT_ID\",\"worker_started\":$_now}" > .gsd/forge/auto-mode.json
fi
Replace UNIT_TYPE/UNIT_ID with the actual values (e.g., execute-task/T01). Multi-run uses each run's own runs/{id}.json as source-of-truth; auto-mode.json is automatically synced to oldest-active by forge-runs.refreshLegacyAlias (eliminates cross-tab race). Legacy mode preserved for pre-M004 workspaces.
Per shared/forge-dispatch.md § Token Telemetry — compute input tokens, dispatch, capture output tokens, append dispatch event (I/O errors MUST propagate):
INPUT_TOKENS=$(node "$FORGE_SCRIPTS_DIR/forge-tokens.js" --inline "$worker_prompt")
Antes de despachar o worker principal, exiba o Spawn Liveness Banner (ver
shared/forge-dispatch.md § Spawn Liveness Banner) com a duração estimada para ounit_typesendo executado (consulte a tabela de duração na seção canônica).
Then call Agent(agent_name, worker_prompt) with a description using the same icon:
- Format:
{icon} {unit_id} · {one-liner} - Examples:
⚙ S01 · authentication foundation⚡ T03 · JWT middleware setup🔬 M001 · e-commerce platform💬 S02 · payment flow decisions✔ S01 · auth slice complete🧠 S01 · extract memories
Wait for the result. Then:
OUTPUT_TOKENS=$(node "$FORGE_SCRIPTS_DIR/forge-tokens.js" --inline "$result")
mkdir -p .gsd/forge/
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"dispatch\",\"unit\":\"${unitType}/${unitId}\",\"model\":\"${MODEL_ID}\",\"tier\":\"${TIER}\",\"reason\":\"${REASON}\",\"effort\":\"${EFFORT}\",\"effort_reason\":\"${EFFORT_REASON}\",\"input_tokens\":${INPUT_TOKENS},\"output_tokens\":${OUTPUT_TOKENS}}" >> .gsd/forge/events.jsonl
Guarded dispatch — apply the Retry Handler section of shared/forge-dispatch.md: Wrap the Agent() call in a try/catch. On throw:
- Capture the exception message into
errorMsg. - Shell out:
node "$FORGE_SCRIPTS_DIR/forge-classify-error.js" --msg "$errorMsg"→ parse{ kind, retry, backoffMs? }. - If
retry === trueANDattempt <= PREFS.retry.max_transient_retries(default 3): incrementattempt, apply backoff, append a retry event (includeinput_tokens: INPUT_TOKENSfrom the retry prompt) to.gsd/forge/events.jsonl, and re-dispatch. Task staysin_progressbetween retries. Heartbeat write is NOT disturbed. - Otherwise fall through to the CRITICAL path below.
Transient errors (
rate-limit,network,server,stream,connection) are handled by the Retry Handler before this block is reached. The CRITICAL path below is only reached when the classifier returnsretry: falseOR retries are exhausted.
CRITICAL — Agent() dispatch failure (permanent / retries exhausted): Do NOT attempt to execute the work inline. Instead:
- Deactivate run (M005+ pattern):
if [ -n "$RUN_ID" ]; then node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json '{"active":false}' > /dev/null else echo '{"active":false}' > .gsd/forge/auto-mode.json fi - Mark the task as in_progress (leave it — signals interruption): skip TaskUpdate
- Stop the loop immediately and tell the user:
⚠ Falha ao despachar subagente para
{unit_type} {unit_id}:{kind}(não surfaçarerrorMsg) Execute/forge-autopara tentar novamente quando a API estiver disponível.
Executing work inline bypasses context isolation and is NEVER acceptable as a fallback.
Heartbeat — clear worker field after Agent() returns (M005+ pattern):
_now=$(node -e "process.stdout.write(String(Date.now()))")
if [ -n "$RUN_ID" ]; then
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json "{\"worker\":null,\"worker_started\":null,\"last_heartbeat\":$_now,\"active\":true}" > /dev/null
else
_sa=$(cat .gsd/forge/auto-mode-started.txt 2>/dev/null || node -e "process.stdout.write(String(Date.now()))")
echo "{\"active\":true,\"started_at\":$_sa,\"last_heartbeat\":$_now,\"worker\":null}" > .gsd/forge/auto-mode.json
fi
4-P. Parallel-batch flow (execute-task only, BATCH.length > 1)
This branch runs ONLY when forge-parallelism.js returned mode: parallel for an execute-task unit. All tasks in BATCH have satisfied depends:[] and non-overlapping writes:[].
a) Per-task resolution — for each task T## in BATCH, already resolved (Per-task resolution step above) so each task has its own {TIER, MODEL_ID, REASON, SECURITY_PATH, PLAN_PATH}. Build a per-task worker prompt using the same substitution rules as Step 3 (templates from forge-dispatch.md, memory filter per-task, coding standards sections, etc.).
b) Create N timeline tasks — emit one TaskCreate per batch member (icon ⚡, one-liner from T##-PLAN.md). Store returned IDs in parallel array task_ids = [id1, id2, ...]. Mark each in_progress via TaskUpdate.
c) Heartbeat — record multi-worker before parallel dispatching (M005+ pattern):
_now=$(node -e "process.stdout.write(String(Date.now()))")
# workers_csv = "execute-task/T01,execute-task/T02,..." built from BATCH
if [ -n "$RUN_ID" ]; then
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json "{\"worker\":\"BATCH:$workers_csv\",\"worker_started\":$_now,\"last_heartbeat\":$_now,\"active\":true}" > /dev/null
else
_sa=$(cat .gsd/forge/auto-mode-started.txt 2>/dev/null || node -e "process.stdout.write(String(Date.now()))")
echo "{\"active\":true,\"started_at\":$_sa,\"last_heartbeat\":$_now,\"worker\":\"BATCH:$workers_csv\",\"worker_started\":$_now}" > .gsd/forge/auto-mode.json
fi
Use a single BATCH:<csv> worker label so the statusline shows the parallel group without special-casing.
d) Compute per-task INPUT_TOKENS — loop and capture each:
INPUT_TOKENS_T01=$(node "$FORGE_SCRIPTS_DIR/forge-tokens.js" --inline "$prompt_T01")
INPUT_TOKENS_T02=$(node "$FORGE_SCRIPTS_DIR/forge-tokens.js" --inline "$prompt_T02")
# ... for each task in BATCH
e) Dispatch ALL N Agent() calls IN ONE RESPONSE MESSAGE — this is the critical Claude Code semantic: multiple tool-use blocks in a single assistant turn execute concurrently. Emit all N Agent() calls inside the same assistant message (not sequential messages).
⚠ CRITICAL — tool-call shape (read before dispatching):
The built-in description of the Agent tool suggests run_in_background: true for "genuinely independent work to do in parallel." That guidance does NOT apply here. In this flow we parallelize BUT we need the results back in the SAME turn to process them and drive the next loop iteration. Violating this has already caused a 3+ hour hang in production where 3 backgrounded executors completed but the orchestrator never picked up their results.
- The parallel semantic in Claude Code is: foreground multi-call = parallel-with-results. Background is fire-and-forget (e.g.,
forge-memorystep 6d) — you do not await it. - Never pass these params on the parallel executor dispatch:
run_in_background,isolation,modeloverride, or any field other thansubagent_type,description,prompt. - The only Agent() call in this whole SKILL that legitimately takes
run_in_background: trueis theforge-memorydispatch in step 6d (single-task path) and its equivalent in step 4-P/j. Executors never. - UI tell: if after dispatching you see
⎿ Backgrounded agentunder any of the N calls, you've already broken the contract. See step (f) fail-fast below.
Example shape (N=3), exact and minimal:
Antes de despachar o batch paralelo de executors abaixo, exiba o Spawn Liveness Banner (ver
shared/forge-dispatch.md § Spawn Liveness Banner) — duração estimada paraexecute-task: ~1–5 min (varia conforme a complexidade da task).
Agent({ subagent_type: "forge-executor", description: "⚡ T01 · <one-liner>", prompt: "<prompt_T01>" })
Agent({ subagent_type: "forge-executor", description: "⚡ T02 · <one-liner>", prompt: "<prompt_T02>" })
Agent({ subagent_type: "forge-executor", description: "⚡ T03 · <one-liner>", prompt: "<prompt_T03>" })
f) Await all results — and fail fast if the shape is wrong. Claude Code returns all N results together in the same turn when step (e) was done correctly. Collect them as results = [{taskId: "T01", result: "..."}, ...] preserving BATCH order.
Fail-fast check (execute BEFORE processing any result): if the tool-result payload for any of the N Agent() calls is the background-dispatch acknowledgement shape (contains "Backgrounded agent" / agent ID without the ---GSD-WORKER-RESULT--- block), the contract was violated in step (e). Treat this as a permanent failure:
- Do NOT wait for background completion notifications — they may arrive later but the dispatch loop is not resumable from a half-state like this.
- Deactivate run (M005+ pattern — records reason in registry for post-hoc audit):
if [ -n "$RUN_ID" ]; then node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json '{"active":false}' > /dev/null else echo '{"active":false}' > .gsd/forge/auto-mode.json fi - Append one
blockedevent per affected task toevents.jsonlwithreason: "parallel_dispatch_backgrounded"andbatch_size: N. - Leave STATE.md at its pre-batch position — when the user resumes via
/forge, heartbeat-stale detection will pick up from there. - Surface a single user-facing message naming the skill file and step (e) as the violation point, and stop the loop.
This branch is a safety net, not a retry path. The right fix is to not background in step (e) — the CRITICAL block above covers that.
g) Guarded dispatch for the batch — wrap the whole N-way dispatch in the same try/catch semantics as the single path. Classification rules:
- If ANY single task throws transiently (
retry: true): currently the simplest contract is to re-dispatch only the failed task withattemptincremented, while accepting the already-returned results for the others. The retry runs as its own single Agent() call immediately after — no need to re-batch. - If ANY task throws permanently (classifier
retry: false, or retries exhausted): apply the CRITICAL path — deactivate auto-mode, surface to user, stop loop. Other batch results are discarded (STATE still reflects the pre-batch position). - Transient/retry events append to
events.jsonlwithunit: "execute-task/T##"(per-task, not per-batch).
h) Output tokens + dispatch events — once all results are back, emit one dispatch event per task (not one per batch), preserving the per-task tier/model/reason/effort fields. Effort resolves per task in batch mode exactly as tier does — each task gets its own $EFFORT_T##/$EFFORT_REASON_T## from the Effort Resolution algorithm (step 1.55) run against that task's $MODEL_ID_T##:
for each task in BATCH:
OUTPUT_TOKENS_T##=$(node "$FORGE_SCRIPTS_DIR/forge-tokens.js" --inline "$result_T##")
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"dispatch\",\"unit\":\"execute-task/T##\",\"model\":\"$MODEL_ID_T##\",\"tier\":\"$TIER_T##\",\"reason\":\"$REASON_T##\",\"effort\":\"$EFFORT_T##\",\"effort_reason\":\"$EFFORT_REASON_T##\",\"input_tokens\":$INPUT_TOKENS_T##,\"output_tokens\":$OUTPUT_TOKENS_T##,\"batch_size\":${BATCH_LENGTH}}" >> .gsd/forge/events.jsonl
The extra batch_size field lets post-hoc analysis separate parallel from sequential dispatches without breaking S03 telemetry readers (which ignore unknown fields).
i) Heartbeat — clear worker field after all Agent() calls return (M005+ pattern):
_now=$(node -e "process.stdout.write(String(Date.now()))")
if [ -n "$RUN_ID" ]; then
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json "{\"worker\":null,\"worker_started\":null,\"last_heartbeat\":$_now,\"active\":true}" > /dev/null
else
_sa=$(cat .gsd/forge/auto-mode-started.txt 2>/dev/null || node -e "process.stdout.write(String(Date.now()))")
echo "{\"active\":true,\"started_at\":$_sa,\"last_heartbeat\":$_now,\"worker\":null}" > .gsd/forge/auto-mode.json
fi
j) Process each result serially — iterate results in order and for each, run the full Step 5 (Process result) + Step 6 (Post-unit housekeeping) pipeline. Specifically:
- For each
{taskId, result}:- Parse
---GSD-WORKER-RESULT---andTaskUpdatebased on status. - Append events.jsonl (Step 6a).
- Update STATE.md (Step 6b) — each task advance is independent; do not skip.
- Append decisions (Step 6c).
- Memory extraction (Step 6d) — dispatch
forge-memoryin the background so memory extraction doesn't block the next unit's dispatch. - Track progress (Step 6e) —
session_units += 1per task completed.
- Parse
- If any task returned
status: partialorstatus: blocked, follow the existing partial/blocked handling (writecontinue.md, stop loop, etc.) — but AFTER processing all otherdoneresults so their work isn't lost.
k) Re-enter the dispatch loop — after all results are processed, loop back to step 1 (derive next unit). The next iteration will usually be another execute-task in the same slice (batch exhausted → possibly a new batch) or a complete-slice if this batch finished all tasks.
5. Process result
Update timeline task — mark the current task based on outcome:
status: done→TaskUpdate({ taskId: current_task_id, status: "completed" })status: partialorstatus: blocked→ leave task asin_progress(shows it was interrupted)
Parse the ---GSD-WORKER-RESULT--- block:
status: done→ proceed to post-unit housekeeping, then immediately continue loop (do NOT pause or ask user)status: partial→ writecontinue.md, update STATE, emit compact signal, fire push (call-site 1): use Push helper with message"Forge {RUN_ID} travou — partial: {resumo do blocker}. Run pausado, requer ação manual.", then deactivate run NOW (node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json '{"active":false}'— see## Deactivate auto-mode indicator), stop loopstatus: blocked→ apply failure taxonomy before stopping; if no auto-recovery or recovery exhausted: fire push (call-site 1): use Push helper with message"Forge {RUN_ID} travou — {classe do blocker}: {resumo}. Run pausado, requer ação manual.", then deactivate run NOW (node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json '{"active":false}'— see## Deactivate auto-mode indicator), stop loop:
Failure Taxonomy (check blocker field in result, first match wins):
| Class | Signals | Auto-recovery |
|---|---|---|
context_overflow |
"context limit", "too long", "token" | Retry one tier up: standard → heavy (opus), heavy → max (fable). If already max → stop loop, surface to user. Apply the Fable 5 thinking guard when escalating to max. |
scope_exceeded |
"out of scope", "too broad", "multiple tasks" | Stop loop. Tell user: "Task scope too broad — ask forge-planner to split T## into smaller tasks." |
model_refusal |
"cannot", "I'm not able", "policy" | Retry once with a different model (sonnet ↔ opus). If fails again → stop loop, surface to user. |
tooling_failure |
"command not found", "permission denied", "ENOENT" | Stop loop. Tell user: "Tooling error — check that required tools are installed and accessible." |
external_dependency |
"API", "network", "not running", "connection refused" | Stop loop. Tell user: "External dependency unavailable — resolve and re-run /forge-auto." |
unknown |
anything else | Stop loop. Surface raw blocker to user. |
Auto-recovery attempts (context_overflow, model_refusal) count as units toward COMPACT_AFTER.
Before any auto-recovery retry: If the failed unit spawned a background task (visible via TaskList with status: in_progress and no owner), call TaskStop({ task_id: <id> }) to terminate it cleanly before dispatching the retry.
Node Repair gate (Layer 3 — disjoint from Layers 1 and 2): Applies ONLY when unit_type == execute-task. Trigger: status: done AND S##-VERIFICATION.md rows show must_have drift (artifacts substantive:false / wired:false, test-quality flags) OR status: partial with must_haves unmet. Agent() throws → Layer 1. status: blocked → Layer 2. Do NOT overlap. See full spec: shared/forge-dispatch.md § Node Repair.
Read prefs:
REPAIR_BUDGET=$(node -e " const fs=require('fs'),path=require('path'),os=require('os'); const wd=process.env.WORKING_DIR||process.cwd(); const files=[path.join(os.homedir(),'.claude','forge-agent-prefs.md'), path.join(wd,'.gsd','claude-agent-prefs.md'), path.join(wd,'.gsd','prefs.local.md')]; let v=2; for(const f of files){try{const r=fs.readFileSync(f,'utf8');const m=r.match(/^repair:[ \t]*\n[ \t]+budget:[ \t]*(\d+)/m);if(m)v=parseInt(m[1]);}catch(e){}} process.stdout.write(String(v)); " WORKING_DIR="$WORKING_DIR")Context-monitor suppression (S03 bridge): read
$(node -e "require('os').tmpdir()")/forge-ctx-${SESSION_ID}.json; if absent/unreadable → treat as non-CRITICAL. Ifseverity == "CRITICAL"→ suppress DECOMPOSE and PRUNE (force RETRY or blocked).Budget check (via helper — review S04 R9; NUNCA improvisar edit de YAML):
PLAN="$WORKING_DIR/.gsd/milestones/{M###}/slices/{S##}/tasks/{T##}/{T##}-PLAN.md" REPAIR_COUNT=$(node "$FORGE_SCRIPTS_DIR/forge-repair.js" --read-budget "$PLAN" | node -e "process.stdout.write(String(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).repair_count))") if [ "$REPAIR_COUNT" -ge "$REPAIR_BUDGET" ]; then : # budget exhausted → fall through to blocked → human else # incrementa ANTES do dispatch (persiste em disco — sobrevive compaction); throw se frontmatter ausente REPAIR_COUNT_NEW=$(node "$FORGE_SCRIPTS_DIR/forge-repair.js" --increment-budget "$PLAN" | node -e "process.stdout.write(String(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).repair_count))") fiClassify:
# is_large_task: derivado DETERMINISTICAMENTE do plano (review S04 R6) — frontmatter # large_task: true|false vence; senão heurística (>5 steps | >=3 artifacts | >250 linhas) IS_LARGE=$(node "$FORGE_SCRIPTS_DIR/forge-repair.js" --is-large-task "$PLAN" | node -e "process.stdout.write(String(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).is_large_task))") # Demais campos: substituir '...' e os zeros pelos sinais REAIS — result block do worker # (failure_shape, worker_explained, must_haves_status), linhas do S##-VERIFICATION.md # (substantive_false, wired_false, missing_artifacts), S##-SYMBOL-CHECK.md (symbol_missing), # nível 4 do verifier (test_quality.{disabled,weak}) e severidade do context-monitor (severity). REPAIR_JSON=$(node "$FORGE_SCRIPTS_DIR/forge-repair.js" --classify \ "$(node -e "process.stdout.write(JSON.stringify({failure_shape:'...',severity:'...',worker_explained:'...',signals:{missing_artifacts:0,substantive_false:0,wired_false:0,symbol_missing:0,test_quality:{disabled:0,weak:0},is_large_task:process.argv[1]==='true'}}))" "$IS_LARGE")")Capture
{strategy, reason}from output.Dispatch strategy (per
shared/forge-dispatch.md § Node Repair):retry→ re-dispatch sameforge-executorwith## Verification Failures+## Repair Hint(reason) injected. Liveness banner: duração estimadaexecute-task.decompose→ idempotency guard: ifT##.1-PLAN.mdexists → skip dispatch. Otherwise:Antes de despachar o forge-planner em decompose mode, exiba o Spawn Liveness Banner (ver
shared/forge-dispatch.md § Spawn Liveness Banner) — duração estimadaplan-slice: ~2–4 min.
After return, re-derive next unit (sub-tasksAgent({ subagent_type: 'forge-planner', prompt: <plan-slice template> + "\n\nMODE: decompose\nTARGET_TASK: {T##}\n\n## Unmet Must-Haves\n{diff list}\n\n## Why it failed\n{result/SUMMARY excerpt}" })T##.1,T##.2… now visible viaforge-parallelism.js— regex extended in T03).prune→ write entry to{WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-CONTEXT.md § Decisions(WORKING_DIR, not CODE_DIR) naming pruned requirement + rationale + task ID. Inforge-autowithreview.ask_in_auto: defer: do NOT pause — register and continue (AUTONOMY RULE).blocked→ fall through to existingblocked → humanpath.
Append repair event:
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"repair\",\"unit\":\"execute-task/{T##}\",\"milestone\":\"${RUN_ID:-{M###}}\",\"slice\":\"{S##}\",\"task\":\"{T##}\",\"strategy\":\"$REPAIR_STRATEGY\",\"repair_count\":$REPAIR_COUNT_NEW,\"reason\":\"$REPAIR_REASON\"}" >> "$WORKING_DIR/.gsd/milestones/{M###}/{M###}-events.jsonl"
6. Post-unit housekeeping
a) Append to per-milestone event log — append one line to {WORKING_DIR}/.gsd/milestones/{M###}/{M###}-events.jsonl (M004+; create dir if missing):
{"ts":"{ISO8601}","unit":"{unit_type}/{unit_id}","agent":"{agent_name}","milestone":"${RUN_ID:-{M###}}","status":"{done|blocked|partial}","summary":"{one-liner}"}
Each entry must be a single line. This is the orchestrator-side record; workers may also write their own entries to the SAME file. Append-only is atomic up to PIPE_BUF (~4KB POSIX / single-write NTFS) — event lines are <512B → safe without lockfile.
Legacy: if running pre-M004 (no {M###} resolved), append to .gsd/forge/events.jsonl global as before.
b) Update per-milestone STATE — advance to next unit position via scripts/forge-state.js --update {M###} --json '{...}'. The global .gsd/STATE.md dashboard is regenerated separately via scripts/forge-dashboard.js (called on boot/exit/phase-change per multi_run.dashboard_refresh_on pref).
c) Append decisions — if key_decisions in result, write to the fragment store via forge-decisions.js --write (stdin JSON):
Partition rule:
- Milestone-bound task (T## inside a slice,
{M###}is set) →unit_id = {M###} - Loose
/forge-taskrun (no milestone,{task-id}is set) →unit_id = {task-id}
FORGE_SCRIPTS_DIR=$([ -f scripts/forge-decisions.js ] && echo scripts || echo "$HOME/.claude/scripts")
DECISIONS_UNIT_ID="${M###:-${task_id:-}}"
if [ -n "$DECISIONS_UNIT_ID" ]; then
printf '%s' "$key_decisions_json" | node "$FORGE_SCRIPTS_DIR/forge-decisions.js" --write --cwd "$WORKING_DIR"
else
echo "[forge-auto] WARNING: no unit_id for decisions — skipping fragment write" >&2
fi
Where key_decisions_json is a JSON object { "unit_id": "$DECISIONS_UNIT_ID", "decisions": [...] } built from the key_decisions field of the worker result. The global .gsd/DECISIONS.md is rebuilt from fragments during complete-milestone (forge-merger, S05). Do NOT write directly to .gsd/DECISIONS.md or any M###-DECISIONS.md file.
d) Memory extraction — dispatch forge-memory agent in the background (run_in_background: true) so the orchestrator can immediately dispatch the next unit without waiting for memory extraction to finish. Rationale: memory extraction averages 20–40s, runs on Haiku (cheap + fast), and the extracted memories only affect the next selective injection — not the current dispatch decision. Running it in parallel with the next unit is the single highest-leverage parallelism win (one extraction per unit, every unit).
Determine which summary file was just written:
execute-task→.gsd/milestones/{M###}/slices/{S##}/tasks/{T##}-SUMMARY.mdplan-slice→.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN.mdcomplete-slice→.gsd/milestones/{M###}/slices/{S##}/{S##}-SUMMARY.mdplan-milestone→.gsd/milestones/{M###}/{M###}-ROADMAP.mdcomplete-milestone→.gsd/milestones/{M###}/{M###}-SUMMARY.md- other → use the result block only
Call forge-memory agent with:
WORKING_DIR: {WORKING_DIR}
UNIT_TYPE: {unit_type}
UNIT_ID: {unit_id}
MILESTONE_ID: {M###}
SUMMARY_CONTENT:
{full content of the summary/plan file read above, or "(none)" if not found}
RESULT_BLOCK:
{full ---GSD-WORKER-RESULT--- block verbatim}
KEY_DECISIONS:
{key_decisions field from result, or "(none)"}
Pass run_in_background: true to the Agent() call. The orchestrator does NOT await this — it proceeds immediately to Step 6e. When the background agent finishes, AUTO-MEMORY.md is updated on disk and will be picked up by the next unit's selective injection filter. If the background agent fails silently, the loss is bounded to that one extraction — the next unit's extraction will still run and AUTO-MEMORY accumulates.
e-reinject) Must-haves re-injection diff (scope_reduction) — runs after memory extraction, before progress tracking. Applies to execute-task units only.
Read scope_reduction.reinject from prefs (3-file cascade, pattern identical to plan_check.mode; default auto). If off → skip this step (PRUNE still registers in CONTEXT — independently of this pref).
REINJECT_RESULT=$(node "$FORGE_SCRIPTS_DIR/forge-repair.js" --reinject-diff \
--plan "$WORKING_DIR/.gsd/milestones/{M###}/slices/{S##}/tasks/{T##}/{T##}-PLAN.md" \
--verification "$WORKING_DIR/.gsd/milestones/{M###}/slices/{S##}/{S##}-VERIFICATION.md" \
--pruned "$PRUNED_IDS" \
--must-haves-status "$MUST_HAVES_STATUS_JSON" 2>/dev/null || echo '{"dropped":[],"capped":false}')
Where PRUNED_IDS = comma-separated IDs from any PRUNE decisions made in this unit's repair routing (empty if none); MUST_HAVES_STATUS_JSON = must_haves_status field from the worker result (if present).
If dropped.length > 0: store for the next unit of the same slice. When building the next unit's worker prompt (Step 3 ## Build worker prompt), append:
## Requisitos pendentes re-injetados
Os seguintes requisitos planejados não foram entregues pela unidade anterior e permanecem em aberto:
{bullet list of dropped items}
{if capped: "⚠ Lista truncada em 10 itens — ver S##-VERIFICATION.md para lista completa."}
Also append this same section to {WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-SUMMARY.md (create section if not present; append if exists). Items pruned via PRUNE are excluded from the diff (already registered in CONTEXT — do not re-inject).
e) Track progress:
session_units += 1
completed_units.append("✓ [M###/S##/T##] {unit_type} — {one-liner} · {agent} ({model})")
7. Pause + checkpoint check
After incrementing session_units:
Rate-limit handoff check — runs BEFORE the pause check. Detects when this account's usage window is exhausted and hands off to another account (account exhaustion is more urgent than a queued pause). Gated by prefs: resolve HANDOFF_IN_AUTO (accounts.handoff_in_auto, default on) and HANDOFF_THRESHOLD (accounts.handoff_threshold, default 90). If HANDOFF_IN_AUTO == off, skip this entire check.
Read the freshest rate-limit bridge the statusline wrote (most-recently-modified forge-ratelimit-*.json in the tmpdir, within 120s — that is this session's; the orchestrator's own statusline renders continuously while the loop runs):
node -e '
const fs=require("fs"),os=require("os"),path=require("path");
const dir=os.tmpdir(); let best=null;
try { for (const f of fs.readdirSync(dir)) {
if(!/^forge-ratelimit-.*\.json$/.test(f))continue;
const p=path.join(dir,f),st=fs.statSync(p);
if(Date.now()-st.mtimeMs>120000)continue;
if(!best||st.mtimeMs>best.m)best={m:st.mtimeMs,p};
}} catch{}
if(!best){console.log(JSON.stringify({available:false}));process.exit(0);}
let rl={};try{rl=JSON.parse(fs.readFileSync(best.p,"utf8"));}catch{}
const wins=[["5h",rl.five_hour],["7d",rl.seven_day]].filter(([,w])=>w&&typeof w.used_percentage==="number");
if(!wins.length){console.log(JSON.stringify({available:false}));process.exit(0);}
wins.sort((a,b)=>b[1].used_percentage-a[1].used_percentage);
const [label,w]=wins[0];
console.log(JSON.stringify({available:true,window:label,used:Math.round(w.used_percentage),resets_at:w.resets_at||null,account:rl.account||null}));
'
- If
available == false→ no usage data (API-key user, or statusline never rendered rate_limits). Skip — fall through to the Pause check. - If
available == trueANDused >= HANDOFF_THRESHOLD→ trigger the handoff: go to## Account Handoff Procedure, passing{window, used, resets_at, account}. That procedure checkpoints, deactivates this run, emits the relaunch instructions, fires a push, and stops the loop. Do NOT continue to the next unit. - Otherwise → fall through to the Pause check.
Pause check — multi-run-aware (M004). Checks the run-scoped pause file first, then the legacy global pause file (for compat):
# M004 scoped: .gsd/forge/pause-{RUN_ID} where RUN_ID is this orchestrator's run id (e.g. M065)
PAUSE_SCOPED=".gsd/forge/pause-${RUN_ID}"
PAUSE_LEGACY=".gsd/forge/pause"
if [ -f "$PAUSE_SCOPED" ] || [ -f "$PAUSE_LEGACY" ]; then
rm -f "$PAUSE_SCOPED" "$PAUSE_LEGACY"
# Deactivate THIS run only — never touches other runs' state
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json '{"active":false}' >/dev/null 2>&1 || \
echo '{"active":false}' > .gsd/forge/auto-mode.json # legacy fallback
fi
RUN_ID was set during activation (see Step "Activate auto-mode indicator" above; multi-run version sets RUN_ID=$ARGUMENTS or derives from STATE.md when called with no args + single-run workspace).
Emit and stop loop:
⏸ Auto-mode pausado após {session_units} unidades.
{completed_units list, one per line}
Execute /forge-auto {RUN_ID} para retomar a partir de: {next_action from STATE.md}
Context checkpoint (only fires if the user explicitly set compact_after in prefs AND session_units >= COMPACT_AFTER):
- Append to events.jsonl:
{"ts":"{ISO8601}","unit":"checkpoint","agent":"orchestrator","milestone":"${RUN_ID:-{M###}}","status":"checkpoint","summary":"{session_units} unidades concluídas"} - Reset counters:
session_units = 0,completed_units = [] - Continue the loop immediately — do NOT stop.
Deactivate auto-mode indicator
Before ANY exit (final report, blocked, partial, or pause), deactivate the marker (M005+ pattern):
if [ -n "$RUN_ID" ]; then
node "$FORGE_SCRIPTS_DIR/forge-runs.js" --update "$RUN_ID" --json '{"active":false}' > /dev/null
node "$FORGE_SCRIPTS_DIR/forge-dashboard.js" --cwd "$WORKING_DIR" > /dev/null || true
else
echo '{"active":false}' > .gsd/forge/auto-mode.json
fi
Account Handoff Procedure
Invoked from Step 7 when the active account's tightest usage window crossed HANDOFF_THRESHOLD. Inputs: {window, used, resets_at, account} (account = this session's FORGE_ACCOUNT, or null for the default Keychain login). This is a sanctioned stop — account exhaustion is a hard external limit, not an AUTONOMY-RULE violation. The handoff is always a relaunch (a running session cannot switch its own account); on-disk state makes /forge-auto resume seamlessly.
1. Checkpoint. Write continue.md for the active slice per ## Continue-Here Protocol (so the next session resumes exactly here). Append an events.jsonl line:
{"ts":"{ISO8601}","unit":"account-handoff","agent":"orchestrator","milestone":"${RUN_ID:-{M###}}","status":"handoff","summary":"janela {window} em {used}% — checkpoint + troca de conta"}
1b. Supervisor sentinel. Write .gsd/forge/handoff-request.json so the forge-run supervisor (if one is driving this headless session) switches accounts and resumes automatically: {"run_id":"{RUN_ID}","account":"{account}","window":"{window}","used":{used},"resets_at":{resets_at|null},"ts":"{ISO8601}"}. Harmless without a supervisor — a plain /forge-auto just leaves the file (cleared on the next supervised run). The supervisor consumes and deletes it.
2. Resolve the next account. List registered accounts and pick a candidate with a token that is NOT the current one:
node "$FORGE_SCRIPTS_DIR/forge-accounts.js" --list --json
From the JSON, candidates = accounts where has_token == true AND name != {account} (when {account} is null, every token-bearing account qualifies). Prefer one with the most days_left if several. The switch command for the chosen NEXT is simply forge-accounts use {NEXT} (in a terminal it launches claude on that account directly).
3. Deactivate this run — see ## Deactivate auto-mode indicator (deactivate $RUN_ID only; never touches other runs). This is what stops the loop; the marker staying recoverable lets the relaunched session resume.
4. Emit the handoff message and STOP the loop.
If a NEXT candidate exists:
⚠ Conta esgotada — janela {window} em {used}%. Checkpoint salvo.
Milestone {RUN_ID} pausado em: {next_action from STATE.md}
Para continuar na conta '{NEXT}', rode no seu terminal:
forge-accounts use {NEXT} ← abre o Claude Code nessa conta
Depois: /forge-auto {RUN_ID} ← retoma do checkpoint automaticamente
If NO alternative account is registered:
⚠ Conta esgotada — janela {window} em {used}%. Checkpoint salvo.
Milestone {RUN_ID} pausado em: {next_action from STATE.md}
Nenhuma conta alternativa registrada. Registre uma e retome:
forge-accounts add <nome> (no terminal; precisa de `claude setup-token`)
forge-accounts use <nome> → abre o Claude Code nessa conta
Depois: /forge-auto {RUN_ID}
5. Fire push (reuse the Push helper): message "Forge {RUN_ID} — conta esgotada (janela {window} {used}%). Checkpoint salvo; troque de conta para retomar."
Secondary trigger (429 on dispatch): if Agent() fails with a usage-limit / quota-exhaustion error (not a transient network/stream error — those go through the Retry Handler), route here instead of the generic CRITICAL stop: run this same procedure with {window: "5h", used: 100, resets_at: null, account} (best-effort, since the bridge may lag the real 429). Everything else is identical.
Final Report (milestone complete)
Isolation cleanup — runs ONLY here (milestone complete), never on pause/blocked/partial exits (the branch/worktree must survive for resume). No-op when ISOLATION_MODE == shared. In branch mode it checks the repo back out to the default branch (the forge/{run} branch is kept for PR/merge by the operator). In worktree mode it removes the worktree only if worktree_cleanup_on_complete: true in prefs:
node "$FORGE_SCRIPTS_DIR/forge-isolation.js" --cleanup --run "${RUN_ID:-<active milestone ID>}" --cwd "$WORKING_DIR" || true
If the cleanup output contains status: "error" entries, surface them in the final report (advisory — do not fail the milestone).
✓ Milestone {M###} completo
Slices entregues:
| Slice | Título | Tasks |
|-------|--------|-------|
| S01 | ... | 3 |
⚖ Review — digest da milestone:
| Slice | Objeções | Corrigidas (concedidas) | Triadas | Follow-ups |
|-------|----------|-------------------------|---------|------------|
| S01 | 5 | 2 | 1 | 0 |
{follow-up lines, if any: "R# path:line — <objeção>" → .gsd/KNOWLEDGE.md § Review follow-ups}
Próximo milestone: /forge-new-milestone <descrição>
The review digest is built from the review / review-triage events in events.jsonl (fallback: scan the **Outcome:** lines of each {S##}-REVIEW.md). Omit the section entirely when the milestone had zero objections.
Fire push (call-site 3): After printing the Final Report above, use Push helper with message "Forge {RUN_ID} — milestone completa. {N} slices entregues." (N = count of slices in the report table).
Worker Prompt Templates
Read ~/.claude/forge-dispatch.md and use the worker prompt template for the current unit_type. Substitute all placeholders with actual values from the loaded context.
Continue-Here Protocol
If a worker returns status: partial:
- Write
.gsd/milestones/M###/slices/S##/continue.md:
---
milestone: M###
slice: S##
task: T##
step: {completed_step}
total_steps: {total}
saved_at: {ISO8601}
---
## Completed Work
{from worker result}
## Remaining Work
{from worker result}
## Decisions Made
{from worker result}
## Next Action
{specific next step to resume from}
- Update STATE.md to point to this task with
phase: resume - Emit compact signal and stop.
On resume: STATE has phase: resume → read continue.md, inline into worker prompt with instruction "Resume from continue.md — skip completed work, start from Next Action."