Imported from vh2224/forge-agent (
skills/forge-next/SKILL.md). Install upstream withnpx skills add vh2224/forge-agent --skill forge-next. Copyright stays with the author.
Nos despachos sidecar, siga shared/forge-bidirectional-sidecar.md § Identidade do sidecar exibida na conversa: anuncie a solicitação pré-disparo e repasse literalmente as linhas [forge-sidecar] do stderr.
Personal selection - before any operational load
Read shared/forge-personal-context.md from the repo or FORGE_HOME before running
the adapter or reading any run/state/handoff. When forge-context-boundary returns
personal_checkpoint.status other than ok, report partial continuity and preserve
artifacts; do not silently claim resume persisted. Resolve scripts and call
forge-cli-helpers.js --resolve-args --args "<explicit ID or empty>" --cwd "<cwd>".
An empty argument uses only personal bindings; none, refuse or error stops
before isolation/dispatch. No STATE, global alias, marker, ledger or legacy fallback.
Multiple candidates require a selected ID; attention-required requires reconciliation.
Set RUN_ID/RUN_KIND from the result, WORKING_DIR from the personal snapshot's project.
A selected task routes to forge-task --resume ID; do not run a milestone unit for it.
Bootstrap an activate-new milestone before binding or loading its state. For an
explicitly supplied ID, bind with --intent explicit-resume before loading existing
state or handoff artifacts. Inspecting an ID does not bind. Reconcile stale/missing evidence or
pending decisions before dispatch; preserve prior acceptances. Global locks/census
remain concurrency guards, never personal selection inputs.
All references below to STATE mean .gsd/milestones/{RUN_ID}/{RUN_ID}-STATE.md.
RUN_ID is mandatory; historical empty-ID/legacy branches below are unreachable.
After run/isolation registration, bind idempotently to capture validated worktree aliases.
After every durable handoff (partial/blocked/pause/account/review/compact), and after
advancing the next unit, saveCheckpoint per the shared contract before deactivation.
On complete-milestone, explicitly reconcile pending/nextAction and capture resolved
lastResult from final state/SUMMARY; process inactivity alone is never completion.
FORGE_SCRIPTS_DIR=$([ -f scripts/forge-cli-helpers.js ] && echo scripts || echo "${FORGE_HOME:-$HOME/.forge-agent}/scripts")
PERSONAL_ARG="$ARGUMENTS"
case "$PERSONAL_ARG" in next|step) PERSONAL_ARG="" ;; esac
RESOLVE=$(node "$FORGE_SCRIPTS_DIR/forge-cli-helpers.js" --resolve-args --args "$PERSONAL_ARG" --cwd "$(pwd)") || exit 1
STATUS=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).status)" "$RESOLVE")
case "$STATUS" in resume|activate-new) ;; *) echo "$RESOLVE"; exit 1 ;; esac
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")
WORKING_DIR=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).project || '')" "$RESOLVE")
[ -n "$RUN_ID" ] && [ -n "$WORKING_DIR" ] || exit 1
# Route task IDs to forge-task --resume before this milestone-only block.
if [ "$RUN_KIND" = "milestone" ] && [ "$STATUS" = "activate-new" ]; then
PER_MILESTONE_STATE="$WORKING_DIR/.gsd/milestones/$RUN_ID/$RUN_ID-STATE.md"
if [ ! -f "$PER_MILESTONE_STATE" ]; then
mkdir -p "$WORKING_DIR/.gsd/milestones/$RUN_ID" || exit 1
if ! node "$FORGE_SCRIPTS_DIR/forge-state.js" --create "$RUN_ID" --phase plan-milestone --next-action "Plan milestone $RUN_ID" --cwd "$WORKING_DIR" > /dev/null; then
echo "Milestone bootstrap failed for $RUN_ID; stop before binding or dispatch." >&2
exit 1
fi
fi
fi
if [ -n "$PERSONAL_ARG" ]; then
if ! node "$FORGE_SCRIPTS_DIR/forge-personal-context.js" --bind --project "$WORKING_DIR" --id "$RUN_ID" --intent explicit-resume --json; then
echo "Personal resume failed for $RUN_ID; preserve artifacts and recover explicitly." >&2
exit 1
fi
fi
After an explicit bind, display --snapshot and reconcile attention-required before any adapter or isolation call. This guard grants no new consent for pending decisions.
Parse arguments
From $ARGUMENTS:
- Empty,
next, orstep→ STEP MODE (execute one unit, stop) auto→ tell the user: "Use/forge-autopara modo autônomo." and stop.- A valid work ID selects explicit resume in STEP MODE; invalid arguments stop.
Bidirectional delivery
After the resolver allowance gate, a Claude sidecar or any artifact unit on
Codex uses shared/forge-bidirectional-sidecar.md and
scripts/forge-unit-sidecar.js. This branch precedes the historical Codex-only
Branch C/D conditions below. Read that shared contract in full before dispatch;
keep the controller-selected unit and snapshot. Supported artifact units are
research-milestone, research-slice, discuss-milestone, discuss-slice,
plan-milestone, complete-slice, complete-milestone and plan-check. Execution and
slice planning on Claude use the same entrypoint with their distinct contracts.
Native delivery remains native. Unsupported auxiliary units stop specifically.
After successful housekeeping, acknowledge the unit using the loop adapter's
complete command before requesting next; never discard/reset its snapshot.
Bootstrap guard
ls CLAUDE.md 2>/dev/null && echo "ok" || echo "missing"
test -d "$WORKING_DIR/.gsd" || exit 1
[ -n "$WORKING_DIR" ] || exit 1
echo "WORKING_DIR=$WORKING_DIR"
# Resolve runtime scripts dir — prefer local ./scripts (dogfood: edits take effect
# immediately); fall back to ${FORGE_HOME:-$HOME/.forge-agent}/scripts (user-land: installed version).
if [ -f "scripts/forge-parallelism.js" ]; then
FORGE_SCRIPTS_DIR="scripts"
else
FORGE_SCRIPTS_DIR="${FORGE_HOME:-$HOME/.forge-agent}/scripts"
fi
echo "FORGE_SCRIPTS_DIR=$FORGE_SCRIPTS_DIR"
# Same resolution for the shared reference specs — the installer COPIES shared/*.md
# into ${FORGE_HOME:-$HOME/.forge-agent}/shared/, so a bare relative `shared/X.md`
# is a dead path in every consumer
# project. See the path convention note right below this block.
if [ -f "shared/forge-review.md" ]; then
FORGE_SHARED_DIR="shared"
else
FORGE_SHARED_DIR="${FORGE_HOME:-$HOME/.forge-agent}/shared"
fi
echo "FORGE_SHARED_DIR=$FORGE_SHARED_DIR"
# ── Routing contract (multi-LLM) ────────────────────────────────────────────
# The rules the SESSION itself must obey — resolver decides the engine, sidecar
# gets what routes to it, fallback only via `worker-engine-fallback` — live in
# the project's instruction file, refreshed here so a stale copy never governs a
# run. Idempotent, splices only its own marked block, and prints only changes
# and refusals. Advisory: a failure here is reported, never a reason to stop.
node "$FORGE_SCRIPTS_DIR/forge-instructions.js" --sync --cwd "$WORKING_DIR" --no-create --quiet 2>&1 \
|| echo "⚠ routing contract sync falhou (advisory — o loop segue)"
Path convention — binding for the whole skill. Every reference below written as
shared/<name>.md MUST be read from $FORGE_SHARED_DIR/<name>.md. shared/ in prose is
the canonical name of the spec, never a literal path to open. A spec you could not open
is a hard stop for the step that needs it — never a cue to improvise the procedure from
memory. Same rule for scripts/<name>.js → $FORGE_SCRIPTS_DIR/<name>.js.
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/milestones/{RUN_ID}/{RUN_ID}-STATE.md- Canonical memory projection (
forge-projection.renderMemory) for selective injection. .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) reads the jsonc catalog per layer; legacy Markdown without jsonc hard-stops — see shared/forge-prefs-cutover.md. It 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.jsonc + .gsd/claude-agent-prefs.jsonc + .gsd/prefs.local.jsonc 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.
- Surface to the operator: arquivo + linha + como-corrigir (from errors[]).
- When any `errors[]` entry has `code == "legacy-md-without-jsonc"`, re-emit that entry's
`errors[].message` VERBATIM, without paraphrasing. Use `shared/forge-prefs-cutover.md § Canonical message`
as the message contract; STOP without retry or handoff loops (headless-safe).
- STOP — do NOT dispatch the unit. 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.
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)
Store as: STATE, PREFS (the resolved .prefs object), ALL_MEMORIES, CODING_STANDARDS.
Cleanup orphaned tasks — call TaskList. If any tasks have status: in_progress (leftover from a previous session), mark them completed before creating new tasks:
TaskUpdate({ taskId: <id>, status: "completed" })
Skip if TaskList returns empty.
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)".
Isolation setup (branch/worktree)
Apply forge_isolation from prefs before dispatching the unit. Idempotent — re-running on every /forge-next invocation is safe (already-on-branch / already-exists). $ISO_RUN is the active milestone ID from STATE.md:
ISO_RUN="$RUN_ID"
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")
ELEVATED=$(node -e "process.stdout.write(String(JSON.parse(process.argv[1]).elevated||false))" "$ISO_RESULT")
ELEV_REASON=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).elevation_reason||'')" "$ISO_RESULT")
echo "ISOLATION_MODE=$ISOLATION_MODE WORKTREE_DIR=${WORKTREE_DIR:-—} ISO_ERRORS=${ISO_ERRORS:-none}"
[ "$ELEVATED" = "true" ] && echo "⚠ require_worktree: elevado a worktree ($ELEV_REASON) → CODE_DIR=${WORKTREE_DIR:-?}"
Isolation rules (CRITICAL — the operator configured this; honor it):
shared→WORKER_CWD = $WORKING_DIR. Nothing else to do.branch→WORKER_CWD = $WORKING_DIR. Workers commit on theforge/{run}branch the setup just checked out.worktree→WORKER_CWD = $WORKTREE_DIR(bootstrap value). In a multi-repo workspace, once$PLAN_PATHexists the resolver selects the explicit primary repo and emits the completerepo_roots/writable_rootsscope. A fully attributed cross-repo plan is supported; an ambiguous or incomplete plan is refused..gsd/**artifacts ALWAYS stay under$WORKING_DIR.ISO_ERRORSnon-empty AND no repo succeeded → STOP and surface the errors. Running un-isolated when the operator configured isolation is NOT an acceptable fallback.- When mode != shared, emit one line:
⛓ Isolation: {mode} → {branch name or worktree path}. workers.require_worktreeelevation is static-at-activation (never mid-run);auto(default) elevatesshared→worktreeonly whenexecute-taskresolves to an external write engine (codex/gpt/gemini);truealways elevates;falsenever elevates. Read-only paths (Branch D plan-slice, review challenger) are exempt. Warn-and-proceed — never blocks; false-positive acceptable, false-negative not. Keepshared:workers.require_worktree: false.
Orchestrate — STEP MODE
You are the orchestrator. Execute the dispatch loop exactly once, then stop.
1. Derive next unit
Read shared/forge-lifecycle.md section Milestone selection authority.
Use the existing controller result in auto mode, or its read-only --select CLI
in step mode. Keep the returned milestone/slice/unit identity; do not infer a
second selection from STATE, ROADMAP or PLAN prose.
Depends-aware task pick (execute-task only): forge-next is strictly sequential — never dispatches more than one task — but it must still respect depends:[] declared in T##-PLAN.md frontmatter. Without this, forge-next would try to run tasks in STATE-declared order even when a predecessor is incomplete, producing broken dispatches.
After the dispatch table resolves unit_type == execute-task, ask forge-parallelism.js which task to pick. The script, invoked with --max-concurrent 1, returns the first pending task in plan order whose depends:[] are satisfied (by T##-SUMMARY.md existence). Legacy plans (any task missing depends/writes frontmatter) fall back to the first pending task in plan order — preserving pre-parallelism behavior exactly.
SLICE_PLAN=".gsd/milestones/${M###}/slices/${S##}/${S##}-PLAN.md"
BATCH_JSON=$(node "$FORGE_SCRIPTS_DIR/forge-parallelism.js" --slice-plan "$SLICE_PLAN" --max-concurrent 1)
PICK_MODE=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).mode)" "$BATCH_JSON")
PICK_ID=$(node -e "const r=JSON.parse(process.argv[1]);const b=r.batch||[];process.stdout.write(b[0]?b[0].id:'')" "$BATCH_JSON")
Handle PICK_MODE:
singleorlegacyorparallel— usePICK_IDasunit_id(override STATE's T## if different; the picker knows best).parallelmode can still happen here because the script computes the full ready set — just takebatch[0]. The user only sees one dispatch.none— all tasks complete; re-derive (should flip tocomplete-slice).blocked— surface to user:⚠ Dispatch bloqueado: todas as tasks pendentes dependem de unidades não concluídas. Motivo: {reason}. Stop without dispatching.error— stop and surface the error.
If STATE's next_action referenced a different T## than PICK_ID, emit one line so the user sees the swap:
↷ Pulando para {PICK_ID} (STATE apontava para {STATE_T##}, mas {STATE_T##} depende de tasks ainda pendentes)
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.
Resolver args (step 1.45). As of M012 S02 the entire dispatch resolution (engine decision + tier-chain + domain + effort + alias) collapses into ONE call to forge-dispatch-resolve.js (made in step 1.5 below). This step resolves only the file args that call consumes: $PLAN_PATH (execute-task frontmatter source) and $ROADMAP_PATH (domain tag + risk-escalation source). Everything else — $ENGINE, $DOMAIN_USED, $WORKERS_TIMEOUT, $CODEX_MODEL, $MODEL_ALIAS, $TIER, $EFFORT — is emitted by the resolver. Thin caller of shared/forge-dispatch.md § Worker Engine Routing (canonical). forge-next is sequential — no parallel-batch — so this is simpler than forge-auto; the resolver contract (vars, reasons, event) is otherwise identical to the forge-auto mirror.
Cross-reference:
shared/forge-dispatch.md § Worker Engine Routing(single-call resolver, route_source table, prefs reader, sidecar state machine, fallback) +scripts/forge-dispatch-resolve.js(S01). Any change lands there first, then propagates here.
The runtime gate is the delivery boundary. After it allows the unit, WORKER_MODE == native
uses the canonical Agent() form below; the Codex projection alone rewrites that form to
spawn_agent(). WORKER_MODE == sidecar loads shared/forge-sidecar-next.md on demand for
the supported execute/plan adapters. DISPATCH_ENGINE remains adapter and telemetry metadata;
it never selects native versus sidecar delivery.
# ── Resolver args (all pure resolution folded into forge-dispatch-resolve.js — step 1.5) ──
# Loud-stop reminder (M008-CONTEXT #2 — NOT a bare comment): prefs were already resolved AND
# loud-stop-guarded by the ONE forge-prefs.js --resolved call at Load context. If that resolution
# exited non-zero the run has ALREADY stopped there; there is no silent-fallback dispatch path.
# No engine/domain/worker/tier/effort parsing happens here anymore — the shared resolver reads
# the PLAN frontmatter + ROADMAP itself. Resolve only the *file* args it needs:
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"
$PLAN_PATH, $ROADMAP_PATH are now set — the file inputs the shared resolver reads. $ENGINE/$ENGINE_REASON/$DOMAIN_USED/$WORKERS_TIMEOUT/$CODEX_MODEL and the runtime verdict are resolved inside the single forge-dispatch-resolve.js call (step 1.5). The Step 4 dispatch branches on $WORKER_MODE.
Dispatch resolution (step 1.5) — resolve {engine, model, alias, tier, domain, route_source, chain, chain_len, reason, effort, effort_reason} plus the canonical runtime verdict through the single forge-dispatch-resolve.js --json call. The consume-once tier cursor remains a next-invocation checkpoint: its worker engine is supplied to this same resolver before dispatch, but the cursor is deleted only after an allowed verdict. A refusal therefore preserves the durable advance for the operator's next attempt. No cursor path dispatches or retries in the current invocation.
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.
Step dispatch refusal boundary (main and review-fix): the per-run state and any tier cursor
already point at the unit that has not started. Print the stable resolver diagnostics and end this
/forge-next invocation. Do not create a timeline task, launch another worker, consume the cursor,
or perform the unit inline. The non-zero shell exit is the stop signal that returns control to the
operator; it does not mutate the already-durable cursor/state.
dispatch_refusal_stop() {
printf '✗ %s\n%s\n' "$DISPATCH_REASON_CODE" "$DISPATCH_HINT" >&2
return 2
}
# ── 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 "${FORGE_HOME:-$HOME/.forge-agent}/scripts")
# Step 4b consume-once cursor is read before resolution so its worker identity passes through the
# same runtime guard. It remains on disk until the verdict is allowed; refusal/error preserves it.
TIER_CURSOR_FILE="$WORKING_DIR/.gsd/forge/tier-cursor-${RUN_ID:-legacy}-${unit_type}-${unit_id}.json"
CURSOR_MODEL=""; CURSOR_ENGINE=""; RESOLVER_WORKER_ARGS=()
if [ -f "$TIER_CURSOR_FILE" ]; then
CURSOR_MODEL=$(node -pe "(JSON.parse(require('fs').readFileSync('$TIER_CURSOR_FILE','utf8')).model)||''" 2>/dev/null)
CURSOR_ENGINE=$(node -pe "(JSON.parse(require('fs').readFileSync('$TIER_CURSOR_FILE','utf8')).engine)||''" 2>/dev/null)
if [ -n "$CURSOR_MODEL" ]; then
[ -z "$CURSOR_ENGINE" ] && { case "$(node "$FORGE_SCRIPTS_DIR/forge-model-alias.js" --family "$CURSOR_MODEL" 2>/dev/null)" in gpt) CURSOR_ENGINE=codex;; *) CURSOR_ENGINE=claude;; esac; }
RESOLVER_WORKER_ARGS=(--worker-engine "$CURSOR_ENGINE")
fi
fi
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" \
--host-runtime claude "${RESOLVER_WORKER_ARGS[@]}" --cwd "$WORKING_DIR" --json) # host canônico; renderer projeta codex. SEMPRE $WORKING_DIR, nunca $CODE_DIR (MEM018)
if [ $? -ne 0 ]; then
# prefs_ok:false → resolver exit 1 (M008-CONTEXT #2 loud-stop; the Load-context prefs gate stays too).
echo "✗ dispatch resolver halted (prefs error) — see forge-dispatch-resolve.js prefs_errors" >&2
exit 1
fi
# Single-parse (diagnóstico 2026-08-23): ONE emitter replaces the 19 per-field
# `node -e "JSON.parse(...)"` spawns this block used to run. The emitter sets:
# MODEL_ID, MODEL_ALIAS, TIER, REASON, DOMAIN_USED, ROUTE_SOURCE, CHAIN_LEN,
# ENGINE, DISPATCH_ENGINE, ENGINE_REASON, EFFORT, EFFORT_REASON, WORKERS_TIMEOUT,
# CODEX_MODEL, SIDECAR_MODEL, THINKING_HEADER, DOMAIN, PLAN_TIER, PLAN_WORKER,
# ROUTING_PRESENT, MODEL_APPLIED_JSON, unit_effort, HOST_RUNTIME, WORKER_ENGINE,
# RESOLVED_WORKER_ENGINE, WORKER_MODE, DISPATCH_ALLOWED, DISPATCH_REASON_CODE,
# DISPATCH_HINT, DISPATCH_DECISION, SIDECAR_DECLARED — all eval-safe single-quoted.
eval "$(printf '%s' "$ROUTE_JSON" | node "$FORGE_SCRIPTS_DIR/forge-dispatch-resolve.js" --shell-exports)"
if [ "$DISPATCH_ALLOWED" != "true" ]; then
dispatch_refusal_stop
exit 2 # terminal step boundary; no timeline, worker, cursor consume, or inline fallback
fi
if [ "$DISPATCH_DECISION" = "advisory" ] && [ -n "$DISPATCH_HINT" ]; then
printf '⚠ %s: %s\n' "$DISPATCH_REASON_CODE" "$DISPATCH_HINT" >&2
# Advisory is observation only: the resolved runtime/routing fields remain unchanged.
fi
# Step 4b consume: $TIER_CURSOR_FILE is deleted here, adjacent to the spend and only after an
# allowed verdict — a refusal never costs the operator the advance. Consume-once: the file is gone
# before the model it carries is used. Resolver evidence stays in ROUTE_JSON; the cursor overwrites
# the selected model/mode carriers and still executes exactly one unit this invocation.
if [ -n "$CURSOR_MODEL" ]; then
rm -f "$TIER_CURSOR_FILE"
MODEL_ID="$CURSOR_MODEL"; MODEL_ALIAS=$(node "$FORGE_SCRIPTS_DIR/forge-model-alias.js" --id "$CURSOR_MODEL" 2>/dev/null)
REASON="tier-chain-cursor:$CURSOR_MODEL"; ENGINE="$CURSOR_ENGINE"; ENGINE_REASON="tier-chain-cursor:$CURSOR_ENGINE"; DISPATCH_ENGINE="$CURSOR_ENGINE"
[ "$CURSOR_ENGINE" = "codex" ] && SIDECAR_MODEL="$CURSOR_MODEL"
fi
# $ROUTE_JSON.chain carries forward unmodified — consumed by the Failure Taxonomy via
# `node "$FORGE_SCRIPTS_DIR/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; see
# the Failure Taxonomy below and shared/forge-dispatch.md § context_overflow).
# Step 4-shadow: shadowing warning (risk #3) — routing: configured but not applied (advisory, stderr).
# $ROUTING_PRESENT comes from the contract itself — no second routing read.
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 (chain), ROUTE_SOURCE, CHAIN_LEN, DOMAIN_USED, ENGINE, ENGINE_REASON, EFFORT, EFFORT_REASON, WORKERS_TIMEOUT, CODEX_MODEL, SIDECAR_MODEL, THINKING_HEADER, unit_effort, HOST_RUNTIME, WORKER_ENGINE, RESOLVED_WORKER_ENGINE, WORKER_MODE, DISPATCH_ALLOWED, DISPATCH_REASON_CODE, DISPATCH_HINT, and REASON are now set (the allowed tier cursor may have overridden only the attempt carriers). Branch on $WORKER_MODE in Step 4. The runtime axes and existing tier/routing/effort axes are additive fields in every dispatch event.
Thinking guard (Fable 5 + Opus 5): the resolver emits
$THINKING_HEADER(adaptivewhen$MODEL_IDisclaude-fable-5, orclaude-opus-5with resolved effortxhigh/max; 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: disabledat any effort, andclaude-opus-5returns HTTP 400 whendisabledis paired with effortxhigh/max(Opus 4.7/4.8 accept it at any effort).
unit_effort (and $EFFORT/$EFFORT_REASON for the dispatch event) were set by the resolver above (§ Effort Resolution — unit-type default + frontmatter axis + risk-escalation sync + model-cap clamp). The prompt may carry effort: {unit_effort} as diagnostic metadata and, for opus/fable phases, thinking: {THINKING_OPUS}. That text never satisfies Claude's effort binding; only the fingerprinted agent-frontmatter observation at the native adapter does.
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, scan T##-PLAN.md content for security-sensitive keywords using the canonical word-boundary pattern + narrow exception list in shared/forge-dispatch.md § Security Gate — Keyword Pattern (formula-once source — do not restate the regex here).
If the base pattern matches (and no exception suppresses it) AND T##-SECURITY.md does not already exist in the task directory:
Skill({ skill: "forge-security", args: "{M###} {S##} {T##}" })
The produced T##-SECURITY.md will be injected into the execute-task worker prompt as ## Security Checklist.
Cross-run claim gate (execute-task e review-fix): If unit_type == execute-task, run the
enforcing cross-run write lease before dispatching. Spec autoritativa: shared/forge-claim-gate.md
— the decision table (§ Step 3), the canonical invocation (§ Step 2), B2 e a escalação (§ Step 4) vivem
lá, uma vez só; este bloco só invoca por referência.
RUN_ID ausente (step mode sem run registrada, mesmo caso da linha 895) → não há RunRecord próprio
para registrar o claim; pular o gate e ecoar o motivo — não há counterpart universe a confrontar sem
uma run própria:
if [ -z "$RUN_ID" ]; then
echo "ℹ Claim gate pulado: RUN_ID ausente (step mode sem run registrada) — sem RunRecord próprio para registrar o claim."
else
# --ready-alternatives: o ready set real da slice, já computado em BATCH_JSON (Step 1) — nunca uma
# segunda enumeração. Legacy (`BATCH_JSON.mode == legacy`, sem .details.readyCount) → 0 (piso D3).
READY_ALTERNATIVES=$(node -e "let r;try{r=JSON.parse(process.argv[1])}catch(e){r={}}; const rc=(r.details&&typeof r.details.readyCount==='number')?r.details.readyCount:0; process.stdout.write(String(Math.max(0, rc-1)))" "${BATCH_JSON:-{}}")
GATE_JSON=$(node "$FORGE_SCRIPTS_DIR/forge-claim-gate.js" --claim-and-check \
--run "$RUN_ID" \
--unit "execute-task/$unit_id" \
--source plan-writes \
--plan "$PLAN_PATH" \
${UNIT_CODE_DIR:+--code-dir "$UNIT_CODE_DIR"} \
--ready-alternatives "$READY_ALTERNATIVES" \
--cwd "$WORKING_DIR" \
--json)
GATE_EXIT=$?
# `--code-dir` só quando o resolvedor de código já correu para esta unidade — B2. Neste ponto do
# fluxo (antes do Step 4) o resolvedor per-unit ainda não rodou; `$UNIT_CODE_DIR` fica vazio salvo
# se um passe anterior já o deixou setado (idempotência entre invocações). A flag omitida NÃO é
# uma degradação silenciosa: o claim carrega `code_dir: null`, o escopo contra cada counterpart vira
# `unknown` e `unknown` continua em escopo — o gate fecha por precaução, nunca abre por suposição.
# Fail-closed (§ Fail-closed) — exit != 0 ou stdout não-JSON é sempre block/gate-unavailable, loud.
if [ "$GATE_EXIT" -ne 0 ] || ! printf '%s' "$GATE_JSON" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{JSON.parse(d);process.exit(0)}catch(e){process.exit(1)}})"; then
echo "⛔ Claim gate indisponível (exit $GATE_EXIT) — tratando como block/gate-unavailable. Nenhum dispatch." >&2
# MODE == interactive: surface direto, sem run a desativar (§ Step 4 passo 3).
else
DECISION=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).decision||'')" "$GATE_JSON")
CAUSE=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).cause||'')" "$GATE_JSON")
ESCALATION=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).escalation||'')" "$GATE_JSON")
NOT_COVERED=$(node -e "process.stdout.write(JSON.stringify(JSON.parse(process.argv[1]).not_covered||[]))" "$GATE_JSON")
echo "ℹ Claim gate not_covered: $NOT_COVERED"
fi
fi
Mapeamento por decisão (MODE == interactive — shared/forge-claim-gate.md § Step 3, agido por
referência, nenhuma tabela restatada aqui):
O gateamento aqui é afirmativo, e essa é a diferença de polaridade em relação ao forge-auto
(onde a task fica em BATCH por default): o dispatch só é alcançado pela linha proceed. $DECISION
só é atribuída no ramo else do fence acima — o ramo fail-closed a deixa não-atribuída de
propósito, e valor não-atribuído (ou fora do conjunto de quatro) não casa com nenhuma das linhas
abaixo, portanto nunca alcança a instrução de despachar. Não importar a forma do forge-auto aqui é
deliberado.
O que alcança o dispatch é advised_action, nunca decision. O eixo advisory/enforcing é
resolvido pelo módulo a partir de parallelism.claim_gate (spec § Step 0, § Enforcement) — o
consumidor nunca relê a pref. advised_action == dispatch → despachar; qualquer outro valor
(inclusive vazio/desconhecido) → não despachar. As quatro linhas abaixo governam a mensagem,
não o dispatch. Quando suppressed_action está presente (postura advisory), prefixar o eco com
⚠ [advisory] — a cerca computou a recusa e não agiu, e isso é dito, nunca silenciado.
proceed→ dispatch normal (Step 4 segue).defer→ se$BATCH_JSON(ready set real da slice) tem outra task além desta, dispatchar ELA nesta invocação (eco nomeando a troca: "↷ Claim gate: {T##} adiada (colisão com run {counterpart}) — despachando {OUTRO_T##} no lugar"); senão surface "unidade adiada por claim overlap com run {counterpart} — re-rode /forge-next ou resolva a colisão" e parar (step mode já retorna ao operador de toda forma).block→ surface IMEDIATO ao operador, sem--waitlongo (o operador está presente — esperar em silêncio é pior UX que avisar): nomear counterpart, causa e paths (.counterparts[].paths), e as saídas legítimas deshared/forge-claim-gate.md § Step 4.refuse→ surface da causa nomeada —undeclared-writes(plano semwrites:— repair no plano),overlap(medido — nomear paths e counterpart) epathless-conceded-item(item concedido sem path — D7) são causas distintas e NUNCA substituídas uma pela outra — sem dispatch, esperar não resolve.escalationpresente (wait-ceiling/defer-cap) → mesma mensagem do spec § Step 4; step mode não tem loop a desativar (sóMODE == autodesativa a run).
not_covered é ecoado ao operador em toda execução do gate (linha acima) — o Overlap advisory abaixo
permanece intacto e distinto deste gate (um é sinal pós-hoc de toque; este é cerca pré-dispatch, cuja
execução é governada por parallelism.claim_gate).
Overlap advisory (before complete-slice): grave o toque desta run e confronte com as demais runs ativas — o sinal existe para ser visto antes do merge.
node "{WORKING_DIR}/scripts/forge-touch.js" --record "{RUN_ID}" --cwd "{WORKING_DIR}" || true
node "{WORKING_DIR}/scripts/forge-overlap.js" --check --cwd "{WORKING_DIR}" || true
Imprima o veredicto ao operador e siga. O sinal é advisory: nunca bloqueia o complete-slice, nunca ordena runs, nunca faz merge. Verdict inconclusive significa "não havia o que comparar" e não deve ser lido como limpo.
Review gate (before complete-slice): If unit_type == complete-slice, run the dialectic review on the slice diff BEFORE dispatching forge-completer (the run branch forge/{run} is still unmerged here — it stays unmerged until the operator integrates it — 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,challenger,challenger_model}via the cascade inshared/forge-review.md § Step 0. Ifmode == disabled→ skip.- Challenger routing (
review.challenger: claude|codex|gemini) followsshared/forge-review.md § Step 0+ the adapter branch in Steps 2/4 (--engine codex|agy) — single fallback toforge-reviewerwhen the external CLI is unavailable. challenger: codex|geminiforcesengine: agents(theworkflowscript cannot route an external CLI) — see the precedence block in the spec.
- Challenger routing (
-
Execute the procedure in
shared/forge-review.mdwithMODE = interactive:Antes de despachar cada agente (Challenge e Defense abaixo), exiba o Spawn Liveness Banner (ver
shared/forge-dispatch.md § Spawn Liveness Banner) com 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', … })— passDEFENSE_FILE(crash rail,shared/forge-review.md § Step 3); a defense that comes back missing/short/scoreboard-only is salvaged from that file beforereview-advocate-unavailablemay be emitted. - Rebuttal ×
rounds→forge-reviewerin rebuttal mode (DEFENSE injected) - The
model:offorge-advocate/forge-reviewercomes exclusively from resolved$ADVOCATE_ALIAS/$CHALLENGER_MODEL; literals are a violation detected byforge-review-audit.js. - Resolve (Step 5 truth table), write
{S##}-REVIEW.md(Step 6). - CONCEDED items → fix now (Step 7a): bind
RF_UNIT_IDto{S##}(or{M###}-triageat the milestone triage boundary), then consumeshared/forge-review.md § Step 7a → Runtime resolver gatebefore the claim gate. The shared section owns the runtime posture; do not reproduce its host/worker leg table here. Its executable caller seam in step mode is:
Only after this allowed verdict, run the cross-run claim gate per that same shared section +RF_ROUTE_JSON=$(node "$FORGE_SCRIPTS_DIR/forge-dispatch-resolve.js" \ --unit-type review-fix --unit-id "$RF_UNIT_ID" --milestone "${RUN_ID:-{M###}}" \ --host-runtime claude --cwd "$WORKING_DIR" --json) [ $? -eq 0 ] || { echo "✗ review-fix resolver halted — fixer not launched" >&2; exit 2; } RF_EXPORTS=$(printf '%s' "$RF_ROUTE_JSON" | node "$FORGE_SCRIPTS_DIR/forge-dispatch-resolve.js" --shell-exports) [ $? -eq 0 ] || { echo "✗ review-fix resolver exports invalid — fixer not launched" >&2; exit 2; } eval "$RF_EXPORTS" if [ "$DISPATCH_ALLOWED" != "true" ]; then dispatch_refusal_stop exit 2 # return to operator; this is not review-agent-unavailable fi if [ "$DISPATCH_DECISION" = "advisory" ] && [ -n "$DISPATCH_HINT" ]; then printf '⚠ %s: %s\n' "$DISPATCH_REASON_CODE" "$DISPATCH_HINT" >&2 fi RF_ROUTE_JSON_SAVED="$RF_ROUTE_JSON"; RF_HOST_RUNTIME="$HOST_RUNTIME" RF_WORKER_MODE="$WORKER_MODE"; RF_DISPATCH_ALLOWED="$DISPATCH_ALLOWED" if [ "$RF_WORKER_MODE" = "sidecar" ] && [ "$RESOLVED_WORKER_ENGINE" != "claude" ] && [ "$RESOLVED_WORKER_ENGINE" != "codex" ]; then DISPATCH_REASON_CODE="unsupported-sidecar-unit" DISPATCH_HINT="review-fix sidecar existe só para as engines claude e codex (engine: ${RESOLVED_WORKER_ENGINE:-vazia}); ajuste host/worker e execute /forge-next novamente." dispatch_refusal_stop exit 2 fishared/forge-claim-gate.md(--unit "review-fix/{S##}",--concededfrom the CONCEDEDpath:lineitems, and its decision table, never restated here). WithRF_WORKER_MODE == sidecar(engine claude|codex) and decisionproceed, runshared/forge-review.md § Sidecar review-fix branch(boundaryslice, ormilestone-triagefor Step 9) fromRF_ROUTE_JSON_SAVED; the adapter publishes the per-R# lines and a failure defers the items to Step 9. WithRF_WORKER_MODE == native, build review-fix throughbuildNativeInvocationfrom the saved route and active capabilities; invoke its returned tool/arguments unchanged and validate the result withforge-review-fix.js --accept-native. A resolver refusal ends this step invocation before worker creation; an actual review-worker throw remains advisory under the unavailability rule below. - OPEN items (Step 7b, interactive): each OPEN objection is put to the user via
AskUserQuestion—Manter abordagem/Refatorar agora(dispatches areview-fixunit for the accepted items) /Criar follow-up(creates an item pershared/forge-review.md § Item capture, sourcereview/{S##}/{R#}, plus the pointer line in.gsd/KNOWLEDGE.md § Review follow-ups) — and the decision is written back into{S##}-REVIEW.md. - Append the
reviewevent toevents.jsonl(Step 8).
- Engine (
-
The gate never blocks on review-worker unavailability — any
Agent()throw is recorded and the step proceeds tocomplete-sliceregardless. A runtime resolver refusal is an enforcing pre-dispatch boundary and returns control to the operator instead.- On throw, follow
shared/forge-review.md § Agent unavailability (review-agent-unavailable): retry first viashared/forge-dispatch.md § Retry Handler; if the agent stays unavailable, emitreview-agent-unavailable(review-advocate-unavailable|review-challenger-unavailable) — never the CRITICAL failure path.
REGRA CRÍTICA: o orquestrador NUNCA produz veredito de review no lugar de um agente indisponível — nem defesa, nem réplica, nem julgamento de objeção alheia. A única ação permitida é registrar a indisponibilidade e escalar ao humano (interativo) ou deferir à triagem final (auto).
- Política deste modo (
MODE = interactive): advogado indisponível — só depois de a salvage doDEFENSE_FILEnão render nenhum veredito (shared/forge-review.md § Step 3) — ⇒ objeções sobem cruas ao humano viaAskUserQuestion(Step 7b), sem veredito fabricado, com Rebuttal PULADO e a ressalva de adversarialidade reduzida no artefato. Challenger indisponível ⇒{S##}-REVIEW.mdmínimo registrando a indisponibilidade (proibido renderizar como limpo — ausência de review não é aprovação).
- On throw, follow
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.
Slice git guard (around complete-slice): complete-slice never integrates a branch — no unit of the loop does; integration is the operator's act on the delivered forge/{run} branch (agents/forge-completer.md § Git boundary — complete-slice). Snapshot the checkout before dispatching, verify after the worker returns:
# before Agent("forge-completer", ...)
node "$FORGE_SCRIPTS_DIR/forge-slice-git-guard.js" --snapshot --cwd "$CODE_DIR" --gsd-dir "$WORKING_DIR/.gsd" --run "$RUN_ID" --unit "complete-slice/{S##}" > /dev/null
# after it returns
node "$FORGE_SCRIPTS_DIR/forge-slice-git-guard.js" --verify --cwd "$CODE_DIR" --gsd-dir "$WORKING_DIR/.gsd" --run "$RUN_ID" --unit "complete-slice/{S##}"
The snapshot is written to .gsd/forge/ on purpose — shell variables do not survive between Bash calls. --gsd-dir and --run are not optional here (item #112): --cwd is the CODE_DIR, so without --gsd-dir the baseline lands inside the worktree — against the .gsd/**-stays-in-the-workspace convention, and as an untracked file that can make cleanupWorktreeOne report skipped (dirty); and without --run two runs closing the same {S##} in one workspace share a file, so one run's baseline answers for the other. The baseline is spent by the verify — call --snapshot immediately before every dispatch, never once for several verifies, or the second one reports inconclusive (never clean). Exit 3 = violation (moved checkout, advanced default branch, or new merge commit): print it LOUDLY to the operator with the offending detail, append a slice-git-violation event, and do not push — nothing is pushed yet, so git reset --hard on the default branch still recovers it. Verdict inconclusive means nothing was measurable (no git repo, unresolved default branch) and must not be read as clean. The guard never blocks the loop; it reports.
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. In pure forge-next sessions OPEN items were already decided live per-slice, so this usually finds nothing and skips silently — it exists for mixed sessions (slices run under forge-auto with ask_in_auto: defer, milestone closed via forge-next): scan all {S##}-REVIEW.md for pending deferido/falhou — deferida items, triage each via AskUserQuestion, dispatch ONE review-fix/{M###}-triage for the Refatorar agora items (Criar follow-up items create an item per shared/forge-review.md § Item capture, source review/{S##}/{R#}, plus the pointer line in .gsd/KNOWLEDGE.md § Review follow-ups), write decisions back, append the review-triage event. Never blocks the close-out.
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.modevia the canonical engine CLI (single-knob convenience form — reads the jsonc catalog per layer; legacy Markdown without jsonc hard-stops — seeshared/forge-prefs-cutover.md; 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:'disabled')}catch(e){process.stdout.write('disabled')}})")Store as
PLAN_CHECK_MODE(defaultdisabledon absence/parse error — flipped 2026-08-23: 21/21 measured advisory runs never changed the flow;advisory/blockingare opt-in). -
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":"{M###}","slice":"{S##}","mode":"{PLAN_CHECK_MODE}","counts":{"pass":N,"warn":N,"fail":N}} -
Branch on
PLAN_CHECK_MODE:advisory→ proceed to the plan gate (interactive) → symbol-check gate → 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 (interactive) (after plan-check gate, before symbol-check gate):
Roda o handshake interativo do plan gate (spec autoritativa: shared/forge-plan-gate.md) no boundary do forge-next: após o forge-plan-checker retornar plan_check_counts e escrever {S##}-PLAN-CHECK.md (plan-check gate acima) e antes do symbol-check gate / primeiro execute-task. forge-next é sempre interativo → MODE = interactive. forge-auto NÃO executa este gate (MODE = auto → degradação auditável; ver shared/forge-plan-gate.md § Degradation by mode).
Binding forge-next (conforme shared/forge-plan-gate.md tabela de consumidores):
| Campo | Valor |
|---|---|
| UNIT | plan-slice/{S##} |
| PLAN_GLOB | {S##}-PLAN.md + tasks/*/T##-PLAN.md |
| MODE | interactive (forge-next é sempre interativo) |
| Approval marker | {S##}-PLAN-GATE.md |
| GATE_MARKER path | {WORKING_DIR}/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN-GATE.md |
R4 (batching de findings — resolvido para planos estruturados): planos do
forge-nexttêmplan_check_countsreais e podem ter múltiploswarn/failpor dimensão e task. Regra operacional: findingsfailsão SEMPRE perguntas individuais; findingswarnpodem ser agrupados em UMAAskUserQuestion(até 4 por call) somente quando compartilham a mesma dimensão OU a mesma task-id — caso contrário, perguntas separadas. Cada finding agrupado mantém sua própria resolução registrada individualmente no marker.
Não-aninhamento de plan mode: o
forge-nextroda no contexto do orquestrador, que não carrega plan mode herdado. O gate usa somenteAskUserQuestion— NÃO usaEnterPlanMode/ExitPlanMode. Vershared/forge-plan-gate.md § Plan-mode non-nesting.
NUNCA usar
{S##}-PLAN-CHECK.mdcomo marker de aprovação — esse arquivo pertence aoforge-plan-checker(agente advisory separado).
Skip conditions (verificar antes de qualquer bloco bash):
{S##}-PLAN-GATE.mdjá existe comstatus: approved→ pular (resume idempotente pós-compactação, não re-pergunta o operador). Prosseguir diretamente ao symbol-check gate.plan_gate.interactive == off→ pular o gate inteiro; comportamento batch-advisory atual intocado (sem preview, semAskUserQuestion, sem marker).
Gate Step 0 — Read da pref plan_gate: (canonical engine CLI)
Both knobs read via the canonical engine CLI (single-knob convenience form — reads the jsonc catalog per layer; legacy Markdown without jsonc hard-stops — see shared/forge-prefs-cutover.md; NEVER a 3-file cascade node -e merge, MEM001 M005). Defaults byte-identical to the old inline cascade: interactive=always (whitelist always|auto|off), ask_in_auto=defer (whitelist defer|off).
INTERACTIVE=$(node "$FORGE_SCRIPTS_DIR/forge-prefs.js" --resolved --key plan_gate.interactive --cwd "$WORKING_DIR" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{let v=String(JSON.parse(d).value||'').toLowerCase();process.stdout.write(['always','auto','off'].includes(v)?v:'always')}catch(e){process.stdout.write('always')}})")
ASK_AUTO=$(node "$FORGE_SCRIPTS_DIR/forge-prefs.js" --resolved --key plan_gate.ask_in_auto --cwd "$WORKING_DIR" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{let v=String(JSON.parse(d).value||'').toLowerCase();process.stdout.write(['defer','off'].includes(v)?v:'defer')}catch(e){process.stdout.write('defer')}})")
Semântica da pref interactive:
| Valor | Comportamento |
|---|---|
always (default) |
Conduzir o gate em todo plano — preview + aprovação sempre, mesmo all-pass. |
auto |
Conduzir só quando warn > 0 ou fail > 0. Auto-aprovar silenciosamente se warn==0 && fail==0. |
off |
Pular o gate inteiro — comportamento batch-advisory atual. Ir direto ao symbol-check gate. |
Gate Step 0a — Idempotency / GATE_MARKER
GATE_MARKER="$WORKING_DIR/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN-GATE.md"
if [ -f "$GATE_MARKER" ] && grep -qF "status: approved" "$GATE_MARKER" 2>/dev/null; then
echo "Plan gate already approved — skipping (resume after compaction)"
# Prosseguir diretamente ao symbol-check gate
fi
Regras de skip (após ler a pref e verificar idempotência):
# skip: interactive off
if [ "$INTERACTIVE" = "off" ]; then
# Pular gate — comportamento batch-advisory atual
# Prosseguir ao symbol-check gate
fi
# auto-approve: interactive=auto + all-pass
if [ "$INTERACTIVE" = "auto" ] && [ "${plan_check_counts_warn:-0}" -eq 0 ] && [ "${plan_check_counts_fail:-0}" -eq 0 ]; then
mkdir -p "$(dirname "$GATE_MARKER")"
cat > "$GATE_MARKER" << 'EOF'
---
status: approved
approved_at: {ISO8601}
consumer: forge-next
unit: plan-slice/{S##}
---
Plan auto-approved (all-pass, interactive: auto). Execution may proceed.
EOF
GATE_EDITS=0
# Append plan-gate event (outcome: skipped — auto-approve silencioso)
printf '%s\n' "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"plan-gate\",\"milestone\":\"{M###}\",\"unit\":\"plan-slice/{S##}\",\"mode\":\"interactive\",\"interactive\":\"$INTERACTIVE\",\"outcome\":\"skipped\",\"warn\":${plan_check_counts_warn:-0},\"fail\":${plan_check_counts_fail:-0},\"edits\":0}" >> "$WORKING_DIR/.gsd/forge/events.jsonl"
# Prosseguir ao symbol-check gate
fi
# interactive=always (ou auto com warn/fail > 0) → conduzir o gate
Gate Step 1 — Preview do plano
Ler {S##}-PLAN.md do disco — preview = arquivo em disco, não conteúdo cacheado.
SLICE_PLAN_FILE="$WORKING_DIR/.gsd/milestones/{M###}/slices/{S##}/{S##}-PLAN.md"
Exibir um resumo informacional (sem pergunta ainda):
- Título do milestone + slice (de
{S##}-PLAN.mdfrontmattertitle) - Número de tasks na slice
- Contagem de
must_haves(truths + artifacts + key_links) por task - Dependências de ordenação entre tasks (campo
dependsde cadaT##-PLAN.md) - Para cada
T##: título,tier,effort,depends(lendo os task plans)
O operador lê o plano e se prepara para a revisão de findings no Gate Step 2.
Gate Step 2 — Lapidação de findings (R4: batching estruturado para forge-next)
Ler os findings de {S##}-PLAN-CHECK.md (dimensões com verdict warn ou fail).
Ordem de apresentação: fail primeiro (severidade decrescente), depois warn.
R4 (resolvido para forge-next):
- Findings
fail→ SEMPRE pergunta individual (arbitragem item-a-item — severidade alta demais para agrupar). - Findings
warn→ podem ser agrupados em UMAAskUserQuestion(até 4 por call) somente quando compartilham a mesma dimensão OU a mesma task-id; caso contrário, perguntas separadas. Cada finding agrupado mantém sua própria resolução registrada individualmente no marker.
Para cada finding individual (ou grupo de warns relacionados), invocar AskUserQuestion:
Header: "Plano {S##} — <nome da dimensão> [fail|warn]"
Body: "<justificativa de uma linha do checker para aquela dimensão/task>"
Options: ["Manter — aceitar assim", "Corrigir no ato", "Deferir — vira item no backlog"]
Registrar a resolução de cada finding individualmente (para inclusão no marker):
Manter→ aceitar o finding sem mudança; anotar no marker como{dimensão}: mantido.Corrigir no ato→ prosseguir para Gate Step 3 (edição livre) com intenção de corrigir.Deferir→ cria um item pershared/forge-review.md § Item capture(sourceplan-gate/{S##},origin: auto,status: inbox, semfile— esta junção não tem um) e anota no marker como{dimensão}: deferido → {I-id} — {title}.
Se não houver findings warn/fail (all-pass) e INTERACTIVE == always → pular Gate Step 2 (nada a lapidar); ir direto para Gate Step 3 (edição livre opcional).
Gate Step 3 — Edição livre (escape hatch)
Inicializar contador de edições: GATE_EDITS=0 (se ainda não definido).
Ao entrar no Gate Step 3: GATE_EDITS=$((GATE_EDITS + 1)) (conta cada visita ao step, incluindo re-entradas via "Editar mais").
Oferecer ao operador uma janela de edição não-estruturada:
AskUserQuestion({
header: "Edição livre do plano",
body: "Edite {S##}-PLAN.md e/ou os T##-PLAN.md no seu editor agora. Confirme quando terminar.",
options: ["Confirmar — relerei o plano", "Pular — plano está bom"]
})
Confirmar→ reler{S##}-PLAN.mde todos osT##-PLAN.mddo disco e exibir a versão atualizada ao operador. O orquestrador NÃO usa cache — lê o arquivo atual. Ir para Gate Step 4 (re-validação).Pular→ ir direto para Gate Step 5 (aprovação).
Gate Step 4 — Re-validação pós-edição (LOOP sobre PLAN_GLOB)
Após edição (caminho Confirmar do Gate Step 3), re-validar o schema de todos os planos da slice.
PLAN_GLOB_FILES=$(find "$WORKING_DIR/.gsd/milestones/{M###}/slices/{S##}" -maxdepth 1 -name "{S##}-PLAN.md"; \
find "$WORKING_DIR/.gsd/milestones/{M###}/slices/{S##}/tasks" -name "T*-PLAN.md" 2>/dev/null)
REVALIDATION_BLOCKING=false
for plan in $PLAN_GLOB_FILES; do
REVALIDATION_STDERR=$(mktemp)
REVALIDATION=$(node "$FORGE_SCRIPTS_DIR/forge-must-haves.js" --check "$plan" 2>"$REVALIDATION_STDERR")
REVALIDATION_EXIT=$?
if [ $REVALIDATION_EXIT -ne 0 ] && [ $REVALIDATION_EXIT -ne 2 ]; then
IO_ERR=$(cat "$REVALIDATION_STDERR")
LEGACY=false; VALID=false
ERRORS="[\"IO error from forge-must-haves.js: $IO_ERR\"]"
else
if ! node -e "JSON.parse(process.argv[1])" "$REVALIDATION" 2>/dev/null; then
IO_ERR=$(cat "$REVALIDATION_STDERR")
LEGACY=false; VALID=false
ERRORS="[\"Non-JSON stdout from forge-must-haves.js (exit $REVALIDATION_EXIT): $IO_ERR\"]"
else
LEGACY=$(node -e "process.stdout.write(String(JSON.parse(process.argv[1]).legacy))" "$REVALIDATION")
VALID=$(node -e "process.stdout.write(String(JSON.parse(process.argv[1]).valid))" "$REVALIDATION")
ERRORS=$(node -e "process.stdout.write(JSON.stringify(JSON.parse(process.argv[1]).errors))" "$REVALIDATION")
fi
fi
rm -f "$REVALIDATION_STDERR"
if [ "$LEGACY" = "false" ] && [ "$VALID" = "false" ]; then
REVALIDATION_BLOCKING=true
# Surface schema error as a blocking finding for this file
AskUserQuestion({
header: "Erro de schema no plano",
body: "O arquivo $plan tem erros de schema que impedem a aprovação:\n$ERRORS\nCorrigir o plano (edit + releitura) ou abortar.",
options: ["Corrigir agora", "Abortar — replanejar"]
})
# "Corrigir agora" → voltar ao Gate Step 3, depois re-rodar Gate Step 4
# "Abortar" → não escrever marker; re-despachar forge-planner; encerrar o gate
fi
done
Re-validação é SIGNIFICATIVA para forge-next: planos estruturados (
must_haves:YAML) retornam{legacy:false, valid:true/false}.legacy==false && valid==falseem qualquer arquivo da PLAN_GLOB → finding bloqueante. Aprovação só concedida após todos os arquivos atingiremvalid==true(oulegacy==true).
Aprovação só prossegue para Gate Step 5 se REVALIDATION_BLOCKING=false ao final do loop.
Gate Step 5 — Approval handshake
Após os findings serem endereçados e a re-validação passar, apresentar o gate de aprovação final.
Não-aninhamento de plan mode: NÃO usar
EnterPlanMode/ExitPlanModeaqui. O gate usa somenteAskUserQuestion. Vershared/forge-plan-gate.md § Plan-mode non-nesting.
AskUserQuestion({
header: "Aprovar plano {S##}",
body: "Plano revisado e validado. Aprovar para iniciar a execução?",
options: ["Aprovar — iniciar execução", "E
*Truncated - read the full file at https://github.com/vh2224/forge-agent/blob/22f2834d053fd133a6cadb14baf326cd46989b5e/skills/forge-next/SKILL.md.*
