!cat .forge/state.json 2>/dev/null || echo NO_STATE
build-loop — launch the goal loop against the frozen suite
If the first line of this skill rendered as literal backtick syntax instead of JSON or NO_STATE
(org policy disableSkillShellExecution blocks dynamic injection), read .forge/state.json with the
Read tool before doing anything else.
State guard
Route on the injected state above. Refuse out-of-order execution and give the exact remediation
command.
| Injected state |
Action |
NO_STATE |
Refuse: no forge project here. Remediation: run /plugin-forge:forge |
phase: interview |
Refuse: pipeline not started. Remediation: run /plugin-forge:forge |
phase: contract |
Refuse: no approved contract. Remediation: run /plugin-forge:write-contract |
phase: evals |
Refuse: the suite exists but is not armed — nothing binds the builder yet. Remediation: run /plugin-forge:arm-evals (or /plugin-forge:build-evals first if evals/ is missing) |
phase: armed |
Proceed — this is the launch phase |
phase: building |
Refuse to launch a second loop. Check the background forge-build task and report its latest FORGE_EVAL line; if the previous run crashed and state is stuck, run forge-eval doctor, then /plugin-forge:arm-evals if the freeze needs repair |
phase: smoke or phase: verify |
Refuse: the build already went green. Remediation: run /plugin-forge:verify |
phase: ship |
Refuse: this cycle is done. Remediation: run /plugin-forge:ship, or start a new iteration via /plugin-forge:arm-evals after suite changes |
Read plugin_name and plugin_dir from .forge/state.json for everything below.
What forge-build does (so the launch is informed, not blind)
bin/forge-build verifies phase == armed and that the evals/ tree hash still matches
.forge/freeze.json, then:
- Creates an isolated git worktree at
.claude/worktrees/forge-build-<ts>.
- Seeds the worktree with
design/CONTRACT.md, the frozen evals/ tree, templates, and —
critically — .forge/state.json with phase=building plus .forge/freeze.json. The seeding is
what arms the guard hooks inside the child session; without state + freeze in the worktree the
"unbypassable rail" silently never engages. evals-holdout/ is deliberately NOT seeded — the
builder must never see the holdout tranche. Within eval trials, the harness additionally stages
each task's reference/ answers outside the trial workspace.
- Launches a headless builder:
claude -p "/goal ..." --output-format stream-json --verbose with a
scoped --allowedTools list (Read, Edit, Write, Glob, Grep, Bash(forge-eval *),
Bash(claude plugin validate *), and similar — never a blanket grant).
- On success cleans up its worktree; on failure leaves it in place, flagged, for triage.
(
--keep-worktree preserves it either way.)
While phase=building, the PreToolUse guard (guard-evals.sh) exit-2 blocks any Edit/Write/Bash
mutation under evals/, and the Stop-hook sweep (sweep-evals.sh) recomputes the tree sha256
against the freeze every turn — these hold even under --dangerously-skip-permissions. The same
guards apply in the parent project: do not edit evals/ from this session either.
Preconditions checklist (walk it, do not skip)
Trust dialog accepted. The /goal evaluator rides the hooks system; the project must have
passed the trust dialog at least once (open claude interactively in the project if unsure).
Hooks enabled. disableAllHooks at any settings level, or allowManagedHooksOnly in managed
settings, makes /goal unavailable — fall back to forge-build --no-goal. Warn loudly in that
case: with hooks disabled the guard hooks and the Stop-hook sweep are dead too, so the freeze is
only checked after the fact — instruct verify to recompute the tree hash against
.forge/freeze.json before trusting any green result, and get explicit user acknowledgment
before an unattended run without rails.
MCP OAuth preflight. For every LIVE server in CONTRACT.md's live-vs-mock table: OAuth cannot
happen inside claude -p, so authenticate each live server in an interactive session first;
tools marked requiresUserInteraction are force-denied headlessly (plan mocks or drop those
paths); elicitation-using servers need the generated Elicitation auto-answer hook plus
elicitation_answers fixtures in the affected tasks. Mocked servers need nothing.
Worktree base ref decision. worktree.baseRef is "fresh" (branch from the remote default
branch) or "head" (branch from the current local HEAD). Choose "head" when the target
project's fixtures or supporting code are uncommitted/unpushed — "fresh" would build against a
tree that lacks them (forge-build's seeding covers only contract, evals, templates, and forge
state). Choose "fresh" for a clean default-branch baseline. baseRef cannot name a branch; for
a pinned branch, create the worktree manually with git worktree add and point forge-build at it.
permissions.allow for a long unattended run. Skill allowed-tools grants are per-turn, so a
multi-hour run will stall on prompts unless the user pre-approves. Suggest (never silently write)
adding to the target project's .claude/settings.local.json:
{"permissions": {"allow": [
"Bash(forge-eval *)",
"Bash(forge-build *)",
"Bash(git worktree *)",
"Bash(claude plugin validate *)"
]}}
Worktrees share approvals saved to the main checkout's settings.local.json, so approving once
covers the build worktree. The child builder's tools come from forge-build's --allowedTools;
this list keeps the PARENT (this) session from stalling while tailing and re-running gates.
Also run git worktree list and forge-eval doctor if a previous run crashed — leaked
forge-build-* worktrees and stuck phase=building state must be cleared before relaunching.
Launch
Run forge-build via the Bash tool with run_in_background: true — never in the foreground; a real
build loop runs 30+ minutes to hours and a foreground call would die at the Bash tool timeout:
forge-build --watch
While it runs:
- Check the background task's output periodically. Surface every
FORGE_EVAL: line to the user as
it appears (each one is an iteration scoreboard) and report cumulative cost_usd.
- Guard blocks (
BLOCKED by plugin-forge: the eval suite is FROZEN...) in the stream are the rail
working, not an error: the builder tried to "fix" a failing grader. It is instructed to route
such desires into .forge/eval-change-requests.md instead; if requests accumulate, tell the user
a human review + /plugin-forge:arm-evals re-arm may be warranted.
The goal condition (what "done" means, mechanically)
forge-build issues a goal of this shape:
/goal Build the plugin at <plugin_dir> so that `forge-eval run --suite capability --plugin-dir <plugin_dir>`
prints a final FORGE_EVAL line with RESULT=PASS, and `claude plugin validate --strict <plugin_dir>` passes,
WITHOUT modifying anything under evals/ (frozen contract, sha256 in .forge/freeze.json).
Print the full FORGE_EVAL line each iteration. Stop after 40 turns if not achieved.
Three design facts make this work:
The goal evaluator is TOOL-LESS. It judges only the conversation — it runs no commands and
reads no files. The scoreboard line (shared contract, design doc 5.2)
FORGE_EVAL: suite=<name> version=v<N> passed=<X>/<Y> pass^<k>=<0.00> cost_usd=<C> RESULT=PASS|FAIL
printed by the harness as its final stdout line is therefore the only admissible proof: it must
land in the builder's transcript. A builder that stops printing it stalls the loop — the evaluator
can never judge the condition met.
The invariant clause ("WITHOUT modifying anything under evals/") is judged by the evaluator
AND mechanically enforced by the guard hooks — belt and braces.
The turn bound ("stop after 40 turns") is judged from the conversation, so the builder must
report progress against it each iteration.
Suite runs INSIDE the builder session: background Bash + progress file
A real suite (tasks x trials) runs far longer than any foreground Bash tool call allows — a
foreground forge-eval run inside the builder session would time out mid-suite and the iteration
would be lost. The builder session must instead:
- Launch
forge-eval run --suite capability --plugin-dir <plugin_dir> with Bash
run_in_background: true.
- Poll the harness's resumable progress file under the run directory (
runs/<ts>/) between turns.
- When the run completes,
cat the final FORGE_EVAL: line from the harness output into the
transcript so the tool-less goal evaluator can see the proof.
forge-build injects these instructions into the builder prompt. When driving a builder session by
hand (debugging, --no-goal development), follow the same recipe — it is the difference between an
iteration that counts and one that dies at a tool timeout.
Retry budget
- Soft budget — the turn-bound clause. "Stop after 40 turns if not achieved" is held by the
evaluator from the conversation. Adjust per contract complexity via
forge-build's options.
- Hard backstop — the Stop block cap.
/goal is a session-scoped prompt Stop hook, and Claude
Code overrides any Stop hook after 8 consecutive blocks without progress. Every "not achieved yet"
verdict is a block: a builder showing no measurable progress for 8 turns is cut off regardless of
the 40-turn clause. In goal mode, forge-build exports CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=25 into
the child goal session's environment to raise that cap for long builds; a fresh FORGE_EVAL
line with a changing passed=<X>/<Y> is the cleanest form of visible progress.
--max-iterations N bounds the --no-goal outer loop.
StopFailure resilience: snapshot + resume, never respawn
API failures do not fire Stop — they fire StopFailure (matcher values include rate_limit,
overloaded, server_error, ...). A rate-limit spike can therefore kill the loop in a way the goal
evaluator never sees. The recipe (shipped as ${CLAUDE_PLUGIN_ROOT}/templates/hooks/stopfailure-resume.sh):
- A StopFailure hook matched on
rate_limit|overloaded snapshots resume state — session_id,
worktree path, latest FORGE_EVAL line — into .forge/ (see the template header for the exact
path). StopFailure hook output and exit codes are ignored by Claude Code: the hook records; it
cannot block or retry by itself.
- Resume, do not respawn:
claude -p --resume <session_id> from the project directory re-enters
the build worktree with the active goal restored (turn counters reset). A fresh session instead
loses the goal, the iteration context, and the guard-armed worktree association, and re-burns cost
rediscovering all of it.
- forge-build distinguishes StopFailure-terminated runs (resume candidates — the snapshot
exists and the last FORGE_EVAL was progressing) from genuine non-convergence (triage
candidates). Report which one happened; only resume the former.
Outcomes
- RESULT=PASS in the final FORGE_EVAL line: forge-build cleans up its worktree. Report the final
scoreboard line and total cost, then invoke
/plugin-forge:verify — it runs the cheap smoke
matrix first (phase smoke), then the expensive fresh-trial pass^k, holdout tranche, and trigger
suite (phase verify). The build going green is necessary, not sufficient: the honest claim is
"green + holdout + triage", never "correct".
- Non-convergence (turn bound, block cap, or
--max-iterations exhausted): the worktree is left
flagged for inspection. Dispatch the triage agent over the failing runs/<ts>/ trials; it labels
each failure agent-error | grader-error | task-ambiguity. Grader-error findings go through
.forge/eval-change-requests.md and a human re-run of /plugin-forge:arm-evals (version bump +
re-freeze) before relaunching /plugin-forge:build-loop; agent-errors usually mean more budget or
a contract fix.
- StopFailure-terminated: apply the resume recipe above.
- Crashed/stuck (
phase=building with no live process, leaked worktrees): forge-eval doctor.
Cost honesty: a 20–50-task suite at trials=3 plus a 40-turn builder can run tens of dollars per
cycle. forge-eval --max-cost-usd hard-aborts runaway suite runs inside the loop; watch cost_usd
in every FORGE_EVAL line and keep the user informed of the running total.
1---2name: build-loop3description: Launch the isolated build-until-green loop for a forge project. Run when .forge/state.json shows phase=armed, after /plugin-forge:arm-evals. Walks the preconditions checklist (trust dialog, hooks enabled, MCP OAuth preflight for live servers, worktree base ref, permissions.allow for a long unattended run), then starts bin/forge-build in background Bash: a git worktree seeded with the frozen suite plus a headless /goal session that iterates until forge-eval prints the FORGE_EVAL scoreboard line with RESULT=PASS without touching evals/. Tails progress, applies the StopFailure resume-not-respawn recipe on rate_limit/overloaded, and on PASS hands off to /plugin-forge:verify. Use forge-build --no-goal when hooks are disabled.4---56!`cat .forge/state.json 2>/dev/null || echo NO_STATE`78# build-loop — launch the goal loop against the frozen suite910If the first line of this skill rendered as literal backtick syntax instead of JSON or `NO_STATE`11(org policy `disableSkillShellExecution` blocks dynamic injection), read `.forge/state.json` with the12Read tool before doing anything else.1314## State guard1516Route on the injected state above. Refuse out-of-order execution and give the exact remediation17command.1819| Injected state | Action |20|---|---|21| `NO_STATE` | Refuse: no forge project here. Remediation: run `/plugin-forge:forge` |22| `phase: interview` | Refuse: pipeline not started. Remediation: run `/plugin-forge:forge` |23| `phase: contract` | Refuse: no approved contract. Remediation: run `/plugin-forge:write-contract` |24| `phase: evals` | Refuse: the suite exists but is not armed — nothing binds the builder yet. Remediation: run `/plugin-forge:arm-evals` (or `/plugin-forge:build-evals` first if `evals/` is missing) |25| `phase: armed` | Proceed — this is the launch phase |26| `phase: building` | Refuse to launch a second loop. Check the background forge-build task and report its latest FORGE_EVAL line; if the previous run crashed and state is stuck, run `forge-eval doctor`, then `/plugin-forge:arm-evals` if the freeze needs repair |27| `phase: smoke` or `phase: verify` | Refuse: the build already went green. Remediation: run `/plugin-forge:verify` |28| `phase: ship` | Refuse: this cycle is done. Remediation: run `/plugin-forge:ship`, or start a new iteration via `/plugin-forge:arm-evals` after suite changes |2930Read `plugin_name` and `plugin_dir` from `.forge/state.json` for everything below.3132## What forge-build does (so the launch is informed, not blind)3334`bin/forge-build` verifies `phase == armed` and that the `evals/` tree hash still matches35`.forge/freeze.json`, then:36371. Creates an isolated git worktree at `.claude/worktrees/forge-build-<ts>`.382. **Seeds the worktree** with `design/CONTRACT.md`, the frozen `evals/` tree, templates, and —39 critically — `.forge/state.json` with `phase=building` plus `.forge/freeze.json`. The seeding is40 what arms the guard hooks inside the child session; without state + freeze in the worktree the41 "unbypassable rail" silently never engages. `evals-holdout/` is deliberately NOT seeded — the42 builder must never see the holdout tranche. Within eval trials, the harness additionally stages43 each task's `reference/` answers outside the trial workspace.443. Launches a headless builder: `claude -p "/goal ..." --output-format stream-json --verbose` with a45 scoped `--allowedTools` list (Read, Edit, Write, Glob, Grep, `Bash(forge-eval *)`,46 `Bash(claude plugin validate *)`, and similar — never a blanket grant).474. On success cleans up its worktree; on failure leaves it in place, flagged, for triage.48 (`--keep-worktree` preserves it either way.)4950While `phase=building`, the PreToolUse guard (`guard-evals.sh`) exit-2 blocks any Edit/Write/Bash51mutation under `evals/`, and the Stop-hook sweep (`sweep-evals.sh`) recomputes the tree sha25652against the freeze every turn — these hold even under `--dangerously-skip-permissions`. The same53guards apply in the parent project: do not edit `evals/` from this session either.5455## Preconditions checklist (walk it, do not skip)56571. **Trust dialog accepted.** The /goal evaluator rides the hooks system; the project must have58 passed the trust dialog at least once (open `claude` interactively in the project if unsure).592. **Hooks enabled.** `disableAllHooks` at any settings level, or `allowManagedHooksOnly` in managed60 settings, makes `/goal` unavailable — fall back to `forge-build --no-goal`. Warn loudly in that61 case: with hooks disabled the guard hooks and the Stop-hook sweep are dead too, so the freeze is62 only checked after the fact — instruct verify to recompute the tree hash against63 `.forge/freeze.json` before trusting any green result, and get explicit user acknowledgment64 before an unattended run without rails.653. **MCP OAuth preflight.** For every LIVE server in CONTRACT.md's live-vs-mock table: OAuth cannot66 happen inside `claude -p`, so authenticate each live server in an interactive session first;67 tools marked `requiresUserInteraction` are force-denied headlessly (plan mocks or drop those68 paths); elicitation-using servers need the generated Elicitation auto-answer hook plus69 `elicitation_answers` fixtures in the affected tasks. Mocked servers need nothing.704. **Worktree base ref decision.** `worktree.baseRef` is `"fresh"` (branch from the remote default71 branch) or `"head"` (branch from the current local HEAD). Choose `"head"` when the target72 project's fixtures or supporting code are uncommitted/unpushed — `"fresh"` would build against a73 tree that lacks them (forge-build's seeding covers only contract, evals, templates, and forge74 state). Choose `"fresh"` for a clean default-branch baseline. `baseRef` cannot name a branch; for75 a pinned branch, create the worktree manually with `git worktree add` and point forge-build at it.765. **permissions.allow for a long unattended run.** Skill `allowed-tools` grants are per-turn, so a77 multi-hour run will stall on prompts unless the user pre-approves. Suggest (never silently write)78 adding to the target project's `.claude/settings.local.json`:7980 ```json81 {"permissions": {"allow": [82 "Bash(forge-eval *)",83 "Bash(forge-build *)",84 "Bash(git worktree *)",85 "Bash(claude plugin validate *)"86 ]}}87 ```8889 Worktrees share approvals saved to the main checkout's `settings.local.json`, so approving once90 covers the build worktree. The child builder's tools come from forge-build's `--allowedTools`;91 this list keeps the PARENT (this) session from stalling while tailing and re-running gates.9293Also run `git worktree list` and `forge-eval doctor` if a previous run crashed — leaked94`forge-build-*` worktrees and stuck `phase=building` state must be cleared before relaunching.9596## Launch9798Run forge-build via the Bash tool with `run_in_background: true` — never in the foreground; a real99build loop runs 30+ minutes to hours and a foreground call would die at the Bash tool timeout:100101```102forge-build --watch103```104105While it runs:106107- Check the background task's output periodically. Surface every `FORGE_EVAL:` line to the user as108 it appears (each one is an iteration scoreboard) and report cumulative `cost_usd`.109- Guard blocks (`BLOCKED by plugin-forge: the eval suite is FROZEN...`) in the stream are the rail110 working, not an error: the builder tried to "fix" a failing grader. It is instructed to route111 such desires into `.forge/eval-change-requests.md` instead; if requests accumulate, tell the user112 a human review + `/plugin-forge:arm-evals` re-arm may be warranted.113114## The goal condition (what "done" means, mechanically)115116forge-build issues a goal of this shape:117118```119/goal Build the plugin at <plugin_dir> so that `forge-eval run --suite capability --plugin-dir <plugin_dir>`120prints a final FORGE_EVAL line with RESULT=PASS, and `claude plugin validate --strict <plugin_dir>` passes,121WITHOUT modifying anything under evals/ (frozen contract, sha256 in .forge/freeze.json).122Print the full FORGE_EVAL line each iteration. Stop after 40 turns if not achieved.123```124125Three design facts make this work:126127- **The goal evaluator is TOOL-LESS.** It judges only the conversation — it runs no commands and128 reads no files. The scoreboard line (shared contract, design doc 5.2)129130 ```131 FORGE_EVAL: suite=<name> version=v<N> passed=<X>/<Y> pass^<k>=<0.00> cost_usd=<C> RESULT=PASS|FAIL132 ```133134 printed by the harness as its final stdout line is therefore the only admissible proof: it must135 land in the builder's transcript. A builder that stops printing it stalls the loop — the evaluator136 can never judge the condition met.137- **The invariant clause** ("WITHOUT modifying anything under evals/") is judged by the evaluator138 AND mechanically enforced by the guard hooks — belt and braces.139- **The turn bound** ("stop after 40 turns") is judged from the conversation, so the builder must140 report progress against it each iteration.141142## Suite runs INSIDE the builder session: background Bash + progress file143144A real suite (tasks x trials) runs far longer than any foreground Bash tool call allows — a145foreground `forge-eval run` inside the builder session would time out mid-suite and the iteration146would be lost. The builder session must instead:1471481. Launch `forge-eval run --suite capability --plugin-dir <plugin_dir>` with Bash149 `run_in_background: true`.1502. Poll the harness's resumable progress file under the run directory (`runs/<ts>/`) between turns.1513. When the run completes, `cat` the final `FORGE_EVAL:` line from the harness output into the152 transcript so the tool-less goal evaluator can see the proof.153154forge-build injects these instructions into the builder prompt. When driving a builder session by155hand (debugging, `--no-goal` development), follow the same recipe — it is the difference between an156iteration that counts and one that dies at a tool timeout.157158## Retry budget159160- **Soft budget — the turn-bound clause.** "Stop after 40 turns if not achieved" is held by the161 evaluator from the conversation. Adjust per contract complexity via `forge-build`'s options.162- **Hard backstop — the Stop block cap.** `/goal` is a session-scoped prompt Stop hook, and Claude163 Code overrides any Stop hook after 8 consecutive blocks without progress. Every "not achieved yet"164 verdict is a block: a builder showing no measurable progress for 8 turns is cut off regardless of165 the 40-turn clause. In goal mode, forge-build exports `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=25` into166 the child goal session's environment to raise that cap for long builds; a fresh `FORGE_EVAL`167 line with a changing `passed=<X>/<Y>` is the cleanest form of visible progress.168- **`--max-iterations N`** bounds the `--no-goal` outer loop.169170## StopFailure resilience: snapshot + resume, never respawn171172API failures do not fire Stop — they fire **StopFailure** (matcher values include `rate_limit`,173`overloaded`, `server_error`, ...). A rate-limit spike can therefore kill the loop in a way the goal174evaluator never sees. The recipe (shipped as `${CLAUDE_PLUGIN_ROOT}/templates/hooks/stopfailure-resume.sh`):175176- A StopFailure hook matched on `rate_limit|overloaded` **snapshots resume state** — session_id,177 worktree path, latest FORGE_EVAL line — into `.forge/` (see the template header for the exact178 path). StopFailure hook output and exit codes are ignored by Claude Code: the hook records; it179 cannot block or retry by itself.180- **Resume, do not respawn:** `claude -p --resume <session_id>` from the project directory re-enters181 the build worktree with the active goal restored (turn counters reset). A fresh session instead182 loses the goal, the iteration context, and the guard-armed worktree association, and re-burns cost183 rediscovering all of it.184- forge-build distinguishes **StopFailure-terminated** runs (resume candidates — the snapshot185 exists and the last FORGE_EVAL was progressing) from **genuine non-convergence** (triage186 candidates). Report which one happened; only resume the former.187188## Outcomes189190- **RESULT=PASS** in the final FORGE_EVAL line: forge-build cleans up its worktree. Report the final191 scoreboard line and total cost, then invoke `/plugin-forge:verify` — it runs the cheap smoke192 matrix first (phase `smoke`), then the expensive fresh-trial pass^k, holdout tranche, and trigger193 suite (phase `verify`). The build going green is necessary, not sufficient: the honest claim is194 "green + holdout + triage", never "correct".195- **Non-convergence** (turn bound, block cap, or `--max-iterations` exhausted): the worktree is left196 flagged for inspection. Dispatch the triage agent over the failing `runs/<ts>/` trials; it labels197 each failure `agent-error | grader-error | task-ambiguity`. Grader-error findings go through198 `.forge/eval-change-requests.md` and a human re-run of `/plugin-forge:arm-evals` (version bump +199 re-freeze) before relaunching `/plugin-forge:build-loop`; agent-errors usually mean more budget or200 a contract fix.201- **StopFailure-terminated:** apply the resume recipe above.202- **Crashed/stuck** (`phase=building` with no live process, leaked worktrees): `forge-eval doctor`.203204Cost honesty: a 20–50-task suite at trials=3 plus a 40-turn builder can run tens of dollars per205cycle. `forge-eval --max-cost-usd` hard-aborts runaway suite runs inside the loop; watch `cost_usd`206in every FORGE_EVAL line and keep the user informed of the running total.