| name | faff-graft |
| description | Start building an issue — checks the spec exists, sets up a worktree, commits the spec to the feature branch, and gets out of your way. Trigger for: 'graft ISSUE-XX' / 'start ISSUE-XX' / 'pick up ISSUE-XX' / 'let me build'. |
Faff — Graft
Prerequisite: /faff-prep ISSUE-XX (spec must exist on the issue)
Set you up to build. Checks the spec exists, creates a worktree, commits the spec to the feature branch, and gets out of your way.
Configuration
Load the gateway first. If faff/SKILL.md isn't in context this turn, Read it now — it holds the shared rules + fixed contracts faff applies. graft branches on the fixed review-verdict, spec-readiness, and delivery-outcome contracts; it consumes the review / ship slots' faff-contract:review-verdict / faff-contract:delivery-outcome blocks and pipes them to faff contract <name>.
Worktree Hook
Graft owns the worktree mechanism; the policy (location ~/.faff/worktrees/<repo>/<branch> by default, overridable via .faffrc worktree_root; branch-off-HEAD naming, config-copy, install-skip, per-issue isolation, cleanup-is-housekeeping) is single-sourced in the gateway → Worktree policy. This section just registers the hook that enacts it.
Graft needs a WorktreeCreate hook to set up worktrees. On first use, check .claude/settings.json for a WorktreeCreate hook. If none exists:
- Check if a project-specific wrapper exists at
scripts/setup-worktree.sh — if so, register that
- Otherwise, register the generic hook bundled with the faff skill:
{
"hooks": {
"WorktreeCreate": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/skills/faff-graft/setup-worktree.sh\""
}
]
}
}
Tell the user what you're adding and why. If they have a project-specific setup script, suggest they create a wrapper at scripts/setup-worktree.sh that calls the generic one then adds their extras.
Rendering
All human-facing output this skill emits — PR bodies, human-facing summaries, and park comments, plus any terminal summaries — passes through the configured rendering_adaptor normalise pass before it is printed or written (gateway → Rendering, Universal-routing rule). In particular, enumerable sets render as lists, never ·/comma run-on paragraphs (the prose-skimmability rule), so descriptions and comments are as skimmable as terminal output. Carve-outs (skill source files, .faff/ logs) are exempt.
Input
The user may provide an issue identifier, OR invoke with no arguments.
When no arguments are provided:
- Check the current git branch name for a ticket pattern (e.g.,
ISSUE-69-some-description or PROJ-123-feature)
- Extract the issue ID from the branch name
- If found, use that as the issue identifier — no need to ask the user
- If no ticket pattern found, ask the user for an issue identifier
Process
Step 0: Create step-by-step todos (mandatory — interactive and autonomous)
Before starting any work, use TodoWrite to create one todo per numbered step below — one todo per step, in order. Mark each in_progress when starting it and completed the moment it finishes. This is the forcing function that stops review, AC verification, or any other late step from being dropped when the build phase becomes a habit loop.
Minimum todo set:
- Step 1: Get issue details
- Step 2: Check prep gate
- Step 3: Check for existing worktree
- Step 4: Commit spec to feature branch
- Step 5: Move to In Progress
- Step 6: Present spec and choose path (interactive) / proceed to build (autonomous)
- Step 7: Build
- Step 7.5: Engineering gate ladder (
faff gates run → faff contract quality-gates)
- Step 8: AC verification
- Step 8b: Push branch at build-complete + write the build-progress checkpoint (FAFF-402)
- Step 9: Review phase (pre-PR — no PR exists yet)
- Step 9b: Open the PR (only after review returns
pass; identical interactive + autonomous)
- Step 10: Merge-confidence gate
- Step 11: Post-PR checks (interactive) / auto-merge on green (autonomous)
Do not collapse these into one "implement the feature" todo. Every numbered step below, especially 8 / 9 / 9b / 10, must be a discrete todo that's visibly ticked off. Skipping a step without ticking its todo is a process failure.
Step 1: Get Issue Details
Query the issue tracker for the issue. If cancelled or archived per the shared rule, refuse and stop. Otherwise extract:
- Issue identifier
- Title
- Current status
- Suggested branch name (if the tracker provides one)
- Labels — the issue's label set, captured from this same
get_issue response (no extra round-trip). The autonomous eligibility gate at the tail of Step 2 needs them.
If the issue doesn't exist, tell the user and stop.
Step 2: Check prep gate
Check the issue for an attached spec. Follow the shared Spec discovery rule in the sibling faff/SKILL.md — look in tracker comments, the main description/body, committed docs/ paths, and (git-only mode) the .faff/specs/<issue-id>.md store. A hit in any of those counts as the spec.
- Spec exists: Issue is prepped. Proceed to step 3. Per the shared Spec discovery rule, a hit in the description/body only counts when it is an actual formalised spec — a plain description, however well-defined, is not a spec. (This is the same call
faff next makes: with a spec present it returns graft (proceed); with no spec, prep — consult it per gateway → Next-step transition rather than re-deriving, then act as below.)
- No spec (none of those sources): In interactive mode, yes/no gate: "No spec found in comments, description, docs, or the git-only store. Run
/faff-prep ISSUE-XX first? (y/n)". On confirm, invoke the faff-prep skill via the Skill tool (resolve per gateway → Sibling-skill invocation). On deny, stop.
The gate ensures no one starts building without a validated spec. Per the shared Spec discovery rule, a description is never a spec: if the only thing resembling a spec is the ticket description, treat it as "no spec" and route to /faff-prep. Never build straight from a description, and never skip prep because it "reads clear".
Automation eligibility (interactive). If the issue is not automation-eligible (gateway → Automation eligibility) — lacks faff-automate under the opt-in default, or carries faff-automation-hold — warn — "this ticket isn't automation-eligible; proceeding interactively, eligibility is unchanged until you set it" — then continue. Interactive graft is never blocked by eligibility, and graft never changes the eligibility labels.
Automation-eligibility gate (autonomous — pre-worktree, mechanical). Under the autonomous-mode signal only, at the tail of Step 2 before Step 3 creates a worktree, deterministically resolve eligibility by shelling faff eligible and hard-stop on a false verdict. Interactive graft skips this gate entirely — the WARN above is its whole eligibility behaviour. The verdict is the CLI's, never agent re-derivation of label precedence. The labels fed to faff eligible here are the at-the-gate read — Step 1's get_issue is this gate's label source (gateway → Re-ground before gate), captured from the same fresh fetch and not a snapshot carried from an earlier orchestrator read; so a human who cranked the issue up just before dispatch is judged on the live labels:
faff=$(command -v faff || echo "${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/skills/faff/bin/faff")
[ -x "$faff" ] || faff=$(find ~/.claude -path '*/skills/faff/bin/faff' -type f | head -1)
default=$("$faff" config get automation_default -d opt-in)
verdict=$("$faff" eligible --label "$L1" --label "$L2" ... --default "$default")
true → proceed to Step 3 unchanged.
false, OR any resolution failure (faff binary unresolvable, Step-1 labels unresolved, shell/parse error) → refuse, fail-safe to not-eligible: do not create a worktree (Step 3 never runs), do not commit the spec (Step 4 never runs), log the reason to .faff/runs/<run-id>/ISSUE-XX/graft.md, and return the ineligible skip disposition (recorded as ineligible / not built — never parked, never a build attempt). Never add faff-automate or remove faff-automation-hold; the stop is non-destructive.
This is the mechanical form of the Autonomous-Mode backstop — the same boundary, enforced by the deterministic CLI at a fixed point rather than agent narration. Precedence (faff-automation-hold > faff-automate > automation_default) is the CLI's; a ticket with both faff-automation-hold and faff-automate yields false and is refused. Pass labels as repeated --label flags (not --labels, not comma-joined — the wrong form silently parses zero labels).
Intake-provenance precondition (FAFF-212/223 — both modes, pre-worktree). A different axis from eligibility: this asks "did the work enter through the front door at all?", not "is it allowed to auto-build?" (the eligibility gate above is the other axis). After the prep + eligibility gates and before Step 3 creates a worktree, run the deterministic guard — it reads the CLI-written .faff/provenance/<ISSUE>.json marker, with the spoofable faff-jot-intake label as a migration grandfather only (the label alone is not provenance — FAFF-209), and (FAFF-223) a human-set faff-automate label as the eligibility-gesture basis (write-abstained ⇒ human-set by construction, FAFF-218 — a human who cranked the ticket up has, by that one tracker gesture, vouched for its intake). It makes zero tracker calls: pass the Step-1 labels via --labels (comma-joined; note this guard takes one --labels csv, unlike eligible's repeated --label).
The two modes call the guard differently (FAFF-223). Interactive graft passes --interactive — the human at the keyboard is the sanction (parity with interactive jot), so a hand-created no-marker ticket is never sent to a terminal. Autonomous graft omits --interactive — intake_gate: block stays in force, because there is no human to vouch.
mode=$("$faff" config get intake_gate)
if [ "$interactive" = true ]; then
"$faff" intakecheck <ISSUE> --labels "<comma-joined Step-1 labels>" --interactive
else
"$faff" intakecheck <ISSUE> --labels "<comma-joined Step-1 labels>"
fi
- exit 0 → proceed to Step 3. If the output carried a
[warn] line (no-provenance under warn, a grandfathered legacy label, or — interactive — the human-sanction bypass notice), surface it but continue. warn/off never block, a grandfathered ticket is legitimately passing during migration, and an interactive [warn] is the human-is-the-sanction notice.
- exit 3 (autonomous,
intake_gate: block, and no genuine provenance) → refuse, pre-worktree (Step 3 never runs, no spec committed). Interactive never reaches this — --interactive returns exit 0 with the bypass notice, so there is no block to remedy and no intake-record ceremony to send the human to (a human steady-state remedy is simply setting faff-automate in the tracker — zero-CLI, FAFF-223). Autonomous: return the blocked disposition (a skip, like ineligible — not a build attempt, never parked); log the cause to .faff/runs/<run-id>/ISSUE-XX/graft.md. Either way graft never runs intake-record itself to pass its own check.
(No Stop-hook: intakecheck deliberately does not join FAFF_STOP_HOOKS — a turn-end hook with no "which ticket" signal false-blocks unrelated sessions, reintroducing FAFF-205. The guard fires only here, where the issue ID is known.)
Lights-out admissibility gate (autonomous + lights-out signal only — pre-worktree, mechanical). A third axis (after eligibility "may auto-build?" and intake "entered the front door?"): "is this spec's DoD machine-checkable, so the agent isn't grading its own done?". This is the graft backstop of the FAFF-224 quality-IN gate — beep-boop §4's build-queue filter is the primary; this catches a direct lights-out /faff-graft that bypasses assembly. It runs only under the resolved L4 lights-out signal (resolve it fail-safe off — an unresolvable signal means the gate stays inactive, never spuriously blocking L3); interactive graft and ordinary L3 autonomous graft skip it entirely (the human, or the L3 morning park-review, is the gate). After the eligibility + intake gates and before Step 3 creates a worktree, shell faff admissible on the Step-1 spec body — the spec is committed to docs/ only at Step 4, so pass the tracker spec body via stdin (or --spec -):
if [ "$lights_out" = true ]; then
printf '%s' "$SPEC_BODY" | "$faff" admissible --lights-out --json
fi
- exit 0 (admissible) → proceed to Step 3 unchanged. Surface any
warnings (R3 advisory) but they never block.
- exit 1 (inadmissible), OR any
faff admissible resolution / shell failure → refuse, fail-safe inadmissible: do not create a worktree (Step 3 never runs), commit no spec (Step 4 never runs), log the reasons to .faff/runs/<run-id>/ISSUE-XX/graft.md, and return the inadmissible skip disposition (a skip like ineligible/blocked — never a build attempt, never parked-as-built; it does not enter the run-ledger admitted array). Same fail-safe stance as the eligibility gate ("any resolution failure → refuse").
Step 3: Check for Existing Worktree
(Worktree layout and rules: gateway → Worktree policy. Worktrees live at ~/.faff/worktrees/<repo>/<branch> by default, overridable via .faffrc worktree_root.)
Run git worktree list and check if a worktree for this issue already exists (match on the issue ID in the path).
If a worktree already exists:
- Verify the checked-out branch matches the expected branch name. Warn if not.
- Tell the user the worktree exists and open it.
- Skip to step 5 (status update). Spec was already committed on first graft.
If no worktree exists:
Resume-at-review check (FAFF-402 — autonomous L3/L4 only; interactive skips this, like the FAFF-329 review checkpoint). Before creating a fresh worktree, read the build-complete checkpoint — faff build-progress read "$run_dir" <ISSUE>. On exit 3 (no checkpoint) create a fresh worktree as below. On exit 0, git fetch origin and recompute cur = sha256(git diff origin/main...origin/<branch>):
-
build.diff_hash == cur → RESUME: recreate the worktree from the pushed branch — git fetch origin && git worktree add <worktree-path> <branch> (checkout the existing remote branch, no -b; run this directly, not through the WorktreeCreate hook, which only ever creates a new branch off HEAD). Then skip Steps 4–7 (spec commit / claim / build) and re-run Steps 7.5 + 8 (gate ladder + AC) in the recreated worktree before entering Step 9 — the diff is byte-identical to what was gated, so this is a cheap re-confirm and the expensive build is what's skipped. The issue is already In Progress/In Review, so the Step-5 claim is a no-op (never reverted).
-
mismatch (or the branch no longer resolves on origin) → discard the checkpoint (hint-not-authoritative) and create a fresh worktree as below (rebuild).
-
Use the EnterWorktree tool with the branch name as the worktree name
-
The WorktreeCreate hook (setup-worktree.sh) will automatically:
- Create the git worktree
- Copy gitignored config files (.env, etc.)
- Run the project's setup command if one exists
Worktree-root assert (FAFF-382 — both modes; runs after the worktree is entered, before Step 4). The FAFF-379 preflight verifies the configured isolation root is sane; this assert makes that verified property bind — a build whose worktree landed outside the resolved root is caught rather than built. After EnterWorktree returns (fresh-create or the resume path above), the session cwd is the new worktree; assert it is under the resolved root via the single canonical resolver (the same one the hook and the preflight call, so a normal placement always passes):
faff=$(command -v faff || echo "${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/skills/faff/bin/faff")
actual=$(git rev-parse --show-toplevel)
"$faff" worktree-root --assert "$actual"
- exit 0 → proceed to Step 4.
- exit 1 (worktree outside the resolved root) → autonomous: REFUSE fail-closed, pre-build — do not commit the spec (Step 4 never runs), log the resolved root + actual path to
.faff/runs/<run-id>/ISSUE-XX/graft.md, return the parked disposition. Interactive: WARN with the same message and continue (parity with the eligibility WARN — the human absorbs it).
faff unresolvable, or git rev-parse --show-toplevel fails → skip the assert (log the skip); never hard-fail graft on the assert's own tooling. The assert is a fail-closed backstop, not a new hard dependency.
Step 4: Commit spec to feature branch
Pull the spec content from wherever Step 2's Spec discovery found it — a tracker comment, the committed docs path, or (git-only mode, no tracker MCP) the .faff/specs/<issue-id>.md store faff-prep wrote — and commit it to the feature branch. This is the first commit on the branch — the spec ships with the code it describes. (In git-only mode the .faff/specs/ file is the source; committing it here is what moves the spec into the repo, so the "ships with the PR" property holds with or without a tracker. Note .faff/specs/ lives in the main checkout, not this worktree — so capture the spec content back at Step 2, before the worktree exists, and write that captured content here rather than re-reading the file from inside the worktree.)
Resolve and create the target directory mechanically with the bundled resolver (see the gateway's Spec docs location):
faff=$(command -v faff || echo "${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/skills/faff/bin/faff")
dir=$("$faff" config spec-docs-path --create)
This reads tracking.spec_docs_path from .faffrc and, when unset, applies the default rule (docs/specs if docs/ exists, else doc/specs if doc/ exists, else creates docs/ and uses docs/specs). Commit the spec to:
$dir/YYYY-MM-DD-<issue-id>-<slug>-design.md
Derive <slug> from the issue title (lowercase, hyphens, no special chars). Use today's date for YYYY-MM-DD.
Commit message: docs(<issue-id>): add spec for <issue title>
This commit happens once. If the user re-runs graft on the same issue (existing worktree), skip this step.
Step 4b: Materialise ADR promotions (FAFF-16; body authored by the adr slot — FAFF-196). If faff config get adr.mode ≠ off and the issue carries an ## ADR promotion intent comment (written by faff-prep), materialise each listed decision as an ADR on this feature branch — graft owns the branch, so the ADR ships in the PR alongside the code (the spec's "travels with the code, PR-reviewable" property). For each listed decision:
- Scaffold —
faff adr new --title "<decision>" --issue <ISSUE-XX> [--initiative <name>] (resolve the binary per gateway → resolving the faff executable). The faff adr CLI owns numbering / scaffold / validate.
- Author the body via the
adr slot (default faffter-noon-adr; resolve faff config get slots.adr and invoke per gateway → Sibling-skill invocation) — do not free-hand it. Pass the producer: the decision (Chosen: line + its spec rationale), the spec, the issue, and faff adr list --json (related/superseded ADRs). It returns the Nygard ## Context / ## Decision / ## Consequences body + an advisory confidence:.
- Confidence handling (advisory, never a hard gate — appetite-graded per gateway → Appetite for destruction): on a low-confidence body — at
low/medium appetite, surface it for a human glance before committing (interactive; the human may edit — in autonomous mode there is no human to glance, so this degrades to commit + log the low confidence); at high/full appetite, re-invoke the producer once feeding its self-review back in (one bounded refinement pass), take the improved body, and log if still low. The build is never blocked; the body is always recorded.
3b. Detect contradictions + offer supersession (L3 — FAFF-198; runs unless adr.mode: off). With the new ## Decision body in hand and its number known (both pre-commit), check whether it contradicts a live (non-superseded) ADR, and on confirm link them via the shipped faff adr supersede — never auto-superseding:
- Assemble the candidate set —
faff adr live-decisions --exclude <new-id> [--json] returns every non-superseded ADR except the new one, each with its ## Decision body. Empty set → skip (silent proceed). A faff adr list/read failure → log, skip 3b, continue the build (detection is additive, never build-blocking).
- Run the seam —
detect_contradictions(new_decision, live_adr_decisions) -> [{adr, contradicts, why}]: the only LLM-judgement step here — a bounded, side-effect-free contradiction check (no filesystem write, no supersede, no tracker write). Contradiction is semantic (the two Decisions can't both hold), not lexical. Seam errors/timeout → fail safe: log "contradiction detection unavailable", skip the offer, continue. This exact signature is the FAFF-9 hand-off contract — its future architecture lens calls or replaces this occupant without touching the plumbing (ADR-0005).
- Route each
contradicts:true (the offer-routing table is faff adr's — mechanical, never prose-improvised):
- Interactive,
adr.mode: offer → surface ADR-<adr> + why, prompt 3-way: supersede (run faff adr supersede <old> --by <new-id>) / record-anyway (keep both live, no write) / skip (no write).
- Interactive,
adr.mode: surface → surface <adr> + why only; no supersede prompt or write.
- Autonomous (any
adr.mode ≠ off) → never auto-supersede at any appetite (gateway → Appetite for destruction hard floor; re-pointing a record is a write side-effect): record the candidate conflict {new-id, adr, why} for /faff-wtf (per gateway record-and-file) and proceed. Appetite grades only how prominently it surfaces in the run digest (low/medium quiet, high/full prominent), never whether-to-write.
- After a confirmed supersede, the materialisation commit (sub-step 4) carries the back-ref edit;
faff adr validate passes by construction (the CLI is the sole writer). An old ADR superseded between list and confirm makes faff adr supersede error loudly — surface (interactive) / log (autonomous), don't crash.
- Fill + commit the scaffold's three sections with the authored body (
docs(adr): record <decision> (<ISSUE-XX>)); log the advisory confidence. A confirmed 3b supersede is part of this same commit.
No intent comment, or adr.mode: off → skip (no materialisation, no contradiction detection). (Append-only: faff adr new never clobbers an existing ADR.)
Step 5: Claim the issue (In Progress) + status-monotonicity guard
The issue's In Progress status is the claim — the one coordination point every orchestrator shares (gateway → Issue claim & status monotonicity), so two independent runs don't build it at once. Re-read the issue's live status and its eligibility-label set (faff-automate / faff-automation-hold) in this one fetch — not a Step-1 snapshot — per the Re-ground before gate invariant's co-location rule (gateway): the same claim-time re-read that guards status also confirms the eligibility labels are live, so a hold a human applied between dispatch and claim is honoured. Then:
- If it is already
In Progress / In Review / Done, or its PR is merged → a peer is building it, or it's done → stop. Interactive: tell the user "another run is already building — not starting a second build". Autonomous: skip it as claimed-by-peer (a skip, never a park).
- Else transition it
→ In Progress. That is the claim.
Do this read-and-claim before creating the worktree (Step 3) where practical, so a peer-owned issue is skipped without provisioning a worktree. (On a re-entered existing worktree the issue is already In Progress — a no-op.)
Status-monotonicity guard (gateway → Issue claim & status monotonicity, which binds every status write from here on): only move status forward by rank (Backlog < Todo < In Progress < In Review < Done); never move an issue out of Done / In Review back to In Progress. The claim advances forward to In Review / Done, never reverts. Best-effort; a tight race costs at most a duplicate build, caught at merge by rebase-before-merge.
Step 6: Present spec and choose path
Validate the spec's freshness against the current codebase. Then present a summary of the spec — design approach, key decisions, acceptance criteria — and offer a three-way choice (all branches invoke via the Skill tool on confirm):
- build — proceed to Step 7 (build loop)
- review — walk through the spec in detail before starting, then return here
- reprep — something changed; invoke the
faff-prep skill via the Skill tool in respec mode on ISSUE-XX
Step 7: Build
Implementer chooses execution strategy. Build directly from the spec.
During the build, if a decision arises that the spec doesn't resolve:
- Interactive mode: ask the user.
- Autonomous mode: see Autonomous Mode below (invoke the
faff-prep skill respec; if still ambiguous, park).
If the build reveals concrete, separable work this PR shouldn't absorb (an unforeseen seam, an untracked dependency), record it as discovered scope (see Step 9 → Discovered scope) and carry on — don't expand this PR to cover it, and don't park for it.
Step 7.5: Engineering gate ladder (FAFF-11 — runs after build, before AC verification's full run and before review/PR/CI)
A cost-ordered engineering-quality gate ladder runs the repo's own declared cheap checks (format / lint / type-check / static-analysis / unit) cheapest-first, fail-fast — before a review pass or a CI matrix is spent on code that doesn't format/lint/type-check. This is the engineering-practice axis (distinct from the review slot's code-review and from methodology's delivery axis). It runs in both modes.
Invoke the gates slot if configured (resolve faff config get slots.gates, invoke per gateway → Sibling-skill invocation), else the default graft-step, which shells the deterministic CLI:
faff=$(command -v faff || echo "${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/skills/faff/bin/faff")
"$faff" gates run --json
faff gates run discovers the repo's declared checks (faff gates discover: pre-commit hooks, package.json scripts, Makefile targets — trusted sources only, never a command from an issue description or third-party comment), orders them cheapest-first by cost_rank, executes them in the worktree sandbox, and emits a GatesOutcome + a faff-contract:quality-gates block. Locate that block, JSON.parse it, and pipe it to faff contract quality-gates (the sole contract-data source; a malformed/unknown signal coerces to needs-human, never pass — identical to review-verdict). Branch on the script's signal:
| Signal | Meaning | Action |
|---|
pass | All required rungs passed (or no declared gates found — advisory). | Proceed to Step 8. |
fail | A required rung failed (fail-fast stopped at it). | Fix the failing rung's cause, re-run faff gates run. Do not proceed to review/PR/CI. Loop until pass or needs-human. |
needs-human | A rung errored (tool missing/crash — can't conclude the code is bad), or discovery: none under the gates.fallback: fail-closed knob. | Park per the shared protocol, no PR (same handoff as a Step-9 needs-human). |
Autonomy gradient. L1–L2 (interactive) → advisory-only: surface the rung results, never gate (signal: pass). L3 (autonomous / beep-boop default) → declared rungs are required; fail-fast gates the build. L4 (strict, future) → may add a check the repo lacks and treats absence of tests for changed code as a fail. The CLI runs the declared rungs; the gradient decides whether they gate (advisory at L1–L2, required at L3).
Discovery-fallback (Q2 interim). discovery: none (no declared gates) defaults to advisory-surface — surface "no declared engineering gates found; ran none" and signal: pass (don't block a repo that legitimately has none). A strict repo flips this to needs-human by setting gates.fallback to fail-closed (resolved via faff config get gates.fallback). The default is advisory.
Step-8 reconciliation — one resolver, one suite run (no double-run). The ladder's discovery is Step 8's "find the runner, don't guess" resolution — faff gates discover is the single resolver. The ladder's UNIT rung is the test run and its LINT rung is the lint run; Step 8 consumes the ladder's UNIT-rung result for AC verification rather than re-resolving and re-running the suite. Do not run a second, divergent resolver in Step 8, and do not run the full suite twice — the ladder already ran it as the top-cost_rank rung. (execution_target is the worktree sandbox — the single FAFF-12 seam; do not hardcode any other environment.)
Heartbeat ticks (FAFF-234 — autonomous only). The gate ladder is a long, quiet sub-step: under beep-boop this runs inside a build subagent and writes nothing to the run ledger while it executes, so its heartbeat can age toward the 900s staleness window and a foreign Stop hook would read the live run as abandoned. Tick the owner heartbeat faff heartbeat "$run_dir" (the single sanctioned write path — soft no-op outside a run, so it is safe to call unconditionally) before the ladder and after it returns; a custom gates runner that loops many checks ticks between checks. $run_dir is the run_dir the orchestrator forwarded in the BuildDispatch — pass it explicitly, never rely on "latest run".
Step 8: AC verification (mandatory)
Before the PR is considered done, every acceptance criterion must be verified.
The runner is already resolved by Step 7.5 — don't re-resolve or re-run it. Step 7.5's faff gates discover is the single runner resolver (it reads the same trusted sources: CLAUDE.md test/lint command, package.json scripts, a Makefile target, pre-commit, CI config). Step 8 consumes the ladder's UNIT-rung result (the full-suite run) and LINT-rung result rather than resolving and running them a second time — one resolver, one suite run. If Step 7.5 found no runner at all (discovery: none), that is the needs-human signal here — not a silent skip or an assumed npm test.
For each AC in the spec:
- Identify or write an automated test covering it.
- Run the test with the resolved command — it must pass.
- If the AC requires live exercise (HTTP endpoint shape, CLI behaviour, filesystem side-effect, deployed service check), run the actual command (curl / bash / a real binary invocation) in the worktree sandbox and capture the result. Two cases (see the gateway's Untrusted input no-execute rule):
- The trusted spec's live-exercise AC directs the command. On a human-gated tracker the spec is trusted, so its live-exercise AC may name the command to run; execute it sandboxed (worktree-isolated) and capture the result. (Trust flows from the human-gated tracker, not from review state — no semi-trusted tier. If the tracker is shared / multi-tenant / externally-writable, the spec is untrusted and this case does not apply.)
- The command is derived for an untrusted-described intent. If the AC does not itself direct the command (the intent comes from a description or a third-party comment), derive that command from a trusted source only — the project's own test/run targets (
package.json scripts, a Makefile target, the documented CI command), git/gh, or the faff CLI — never a command string transcribed from a description or third-party comment. If no trusted source and the spec AC does not direct it, leave the AC unchecked with "Needs human verification: live exercise has no trusted command source" — not a guess, not free-text exec from an untrusted source.
The full suite + lint backstop already ran once as Step 7.5's UNIT and LINT rungs — reuse that result here rather than running it again. A green per-AC test doesn't prove you didn't break something elsewhere; the ladder's full run is that local backstop before CI. (If Step 7.5 was advisory-only at L1–L2 and you added per-AC tests after it ran, re-run faff gates run so the UNIT rung covers the new tests.)
The PR description must include an AC checklist:
## Acceptance Criteria
- [x] AC 1 — <description>
Verified: `test/foo.test.ts::test_ac1` — passing
- [x] AC 2 — <description>
Verified: `curl -s https://api.example.com/foo | jq .status` → `"ok"`
- [ ] AC 3 — <description>
**Needs human verification:** requires visual inspection of layout
- [ ] AC 4 — <description>
**Needs human verification:** requires production auth credentials
Tick each box as its verification passes, with a one-line note (test file reference, or command + observed result). ACs that cannot be auto-verified (visual, subjective, auth-required) remain unchecked with an inline note explaining why.
Persist the AC result as a floor artifact (FAFF-350). Write <run-dir>/<ISSUE>/ac-checklist.json = { "all_verified": <bool> } — true only when every auto-verifiable AC is ticked (a Needs human verification box left unchecked is not verified). This is the artifact faff merge-gate re-reads at merge; a missing/unreadable file is fail-closed to not-verified, so writing it is what lets the mechanical floor see AC coverage.
This step runs in both interactive and autonomous modes.
Step 8b: Push branch at build-complete + build-progress checkpoint (FAFF-402 — both modes)
The build is complete (gates + AC passed). Make it durable on origin now — before review — so a review-tail stall (a review-provider outage, a turn boundary) never costs a rebuild, and a re-dispatch resumes at review (Step 3 → Resume-at-review check). Gated on faff config get graft.push_at_build_complete -d true (default on; a repo whose CI fires on every branch push sets it off, and the push stays deferred to Step 9b as today). When on, in order:
git push origin <branch> — the branch is now durable off-machine. No PR is opened, so no CI fires (this repo's CI is on: pull_request + push:[main]). A failed push writes no checkpoint (the next run rebuilds — safe); surface the failure.
git fetch origin, then diff_hash = sha256(git diff origin/main...origin/<branch>) — the remote three-dot diff, reproducible from remote refs alone at resume (no worktree needed). Computed here in prose, never inside the CLI (which stays pure — no git/network).
faff build-progress write "$run_dir" <ISSUE> --build-complete --diff-hash <diff_hash> --branch <branch> — push precedes the write, so a checkpoint always implies a pushed, gated branch.
Opens no PR and never merges — faff merge-gate does not read build-progress (a resume artifact, not a merge-floor assertion).
Step 9: Review phase (mandatory — interactive and autonomous; runs PRE-PR)
Runs after AC verification, before the PR is opened (Step 9b) and before the merge-confidence gate. Review runs pre-PR in both modes — no PR exists yet (gateway → faffter-noon-review "Why pre-PR"): review iterates against the branch/diff (git diff main...HEAD), and the PR is opened only once review reaches pass, so review-fix iterations never burn CI. This step is non-negotiable and runs in both interactive and autonomous modes. Do not skip it on the assumption that the user will review manually, or because the build "felt clean", or because tests passed. The review is the senior-engineer stand-in — it catches scope creep, spec misreadings, and human-judgement items that the test suite can't. In interactive mode it also produces the comment the user reads when deciding whether to merge; without it, the user has nothing to decide against. (Step 0 forces this into the todo list; Step 9b only opens the PR on pass; Step 10's gate makes merge impossible without a pass, and Step 11 verifies it before any merge prompt — so a skipped review can't reach main.)
Invoke the review slot via the Skill tool (resolve per gateway → Sibling-skill invocation; the default faffter-noon-review is a canonical name), passing the diff (git diff main...HEAD), the spec, the test results, and the Step 8 AC checklist. Also forward two orthogonal signals — autonomous and lights_out:
autonomous — graft's own run mode, not a signal to resolve afresh: true whenever graft is itself running autonomously (every beep-boop-dispatched build, L3 or L4 — the same mode that makes Step 6 skip its prompts and drives the whole Autonomous Mode section), false on an interactive graft. Unlike lights_out (an L4 sub-distinction graft detects), autonomous is graft's founding invocation context — forward that context's value directly; a fail-safe unresolvable mode is false. This is the signal a review occupant keys unattended-run behaviour off: an adversarial reviewer escalates a Phase-2 critical to needs-human and loudly annotates a full-chain outage on autonomous:true (a soft critical or a silent skip must not slip through when no human is watching, which is exactly the overnight L3 case). Interactive graft forwards false, so those paths stay advisory.
lights_out — the same $lights_out boolean (level:"L4") graft already resolved once for the admissibility gate (Step 2) and the holdout gate (Step 10), defaulting false when unresolved. This is the single L4-resolution locus fed to a third consumer, not a second independent resolution. It is orthogonal to autonomous (L4 = autonomous ∧ lights_out); forward it alongside for L4-specific review behaviour and future composition. The adversarial slot's MANDATORY chain-outage --lights-out activation no longer rides this forwarded signal (FAFF-401): the review helper self-derives mandatory-ness from the run ledger via the $run_dir it already receives (run-ledger.json level:"L4"), so no model step translates lights_out into the flag. The signal is still forwarded here for slot context and future composition.
Forward both as prose context in the Skill-tool invocation (alongside the diff/spec/Phase-1 result), not CLI flags. The slot's default is faffter-noon-review; the review's passes and how it arrives at a verdict are that skill's concern, not faff-graft's. faff-graft owns only the sequencing around the result.
Heartbeat ticks (FAFF-234 — autonomous only). Review is the longest quiet sub-step — an adversarial second-opinion pass calls a different, slower LLM and can run minutes writing nothing to the run ledger, the exact case that ages a live build out of the staleness window. Tick faff heartbeat "$run_dir" (the single sanctioned write path; soft no-op outside a run) at entry to review, between the adversarial Phase-1 and Phase-2 calls, and after review returns its verdict; for an L4 holdout eval, tick before and after the eval. Pass $run_dir (forwarded in the BuildDispatch) explicitly — never rely on "latest run". Any one tick resets the age to ~0, so the maximum heartbeat age is bounded by the longest single blocking call (one adversarial phase), which is well under 900s.
Resume from a review-progress checkpoint (FAFF-329 — autonomous only). The adversarial Phase-2 second-opinion is the slow sub-step that can end a build subagent's turn mid-flight; when the orchestrator re-dispatches the issue, graft must resume the review, not repeat it (a fresh re-run re-does Phase-1 and a completed Phase-2 — the double-spend). On entry to this step, read the checkpoint and compute the current diff hash:
prog=$("$faff" review-progress read "$run_dir" <ISSUE> 2>/dev/null)
cur_hash=$(git diff main...HEAD | sha256sum | cut -d' ' -f1)
- No checkpoint (read exit 3), or a checkpoint lacking a
phase1.verdict=pass (a malformed / partial record — the CLI refuses to write a Phase-2 status without a prior Phase-1 pass, so this only arises from corruption) → run the review fresh (invoke the slot as below). Treat "no usable Phase-1 verdict" exactly as "no checkpoint" — never dereference an absent phase1.
phase1.verdict=pass AND phase1.diff_hash == cur_hash (the diff-identity guard): the hard Phase-1 verdict still holds for THIS diff → skip Phase-1. If phase2.status=complete, skip the review slot entirely and go straight to finding-disposition + the merge gate; if phase2.status ∈ {pending,in_flight}, invoke the review slot with a resume hint — skip Phase-1, run only the bounded Phase-2.
phase1.verdict=pass AND phase1.diff_hash != cur_hash: the diff moved since Phase-1 passed → discard the checkpoint and run Phase-1 fresh. A checkpoint is a hint, never authoritative over the hard review input — it never skips Phase-1 for a diff it wasn't computed against (git/PR/worktree truth wins on any disagreement, the existing FAFF-201 reconciliation rule).
Forward $run_dir + <ISSUE> to the review slot so it writes its own phase checkpoints (--phase1-pass --diff-hash $cur_hash after a Phase-1 pass; --phase2 in_flight before the slow call; --phase2 complete|skipped_deadline|skipped_unreachable on resolution) — the artifact a later re-dispatch resumes from. Interactive graft skips this wholesale (a human absorbs a stall; the checkpoint mechanism is L3/L4-only).
The review returns one of three signals. The verdict vocabulary, their semantics, and the revert test below are the fixed review-verdict contract in the gateway. faff-graft is the consumer (FAFF-109): it locates the reviewer's faff-contract:review-verdict block, JSON.parses it, and pipes it to faff contract review-verdict — the sole source of contract data — then branches on the script's verdict (a malformed/unknown signal coerces to needs-human, never pass). A reviewer that emits no block falls back to reading its native signal: / ## Findings prose into the same extraction JSON. faff-graft branches on the verdict; it does not redefine it:
| Signal | Meaning | Autonomous action |
|---|
pass | Diff matches spec, ACs covered, no flagged items. | Proceed to merge-confidence gate. Merge on green CI. |
fail | Fixable issues — failing tests, missing coverage, obvious bugs, scope creep. | Iterate autonomously: fix the flagged items, re-run tests, re-run review. Loop until pass or needs-human. |
needs-human | Genuine human judgement required — product call, security/privacy concern, irreversible side effect outside the PR flow, spec gap that respec couldn't close. | Flip PR to draft. Park per the shared park protocol. Do not auto-merge. |
needs-human is reserved for things the merge-confidence gate can't catch. The revert test (if git revert on the merge commit fully undoes the change, it is not needs-human — it is pass or fail) is part of the fixed review-verdict contract in the gateway. See the gateway's Autonomous Mode Contract for the full rule on what escalates vs. what proceeds.
Where findings go — the TRACKER ISSUE, never the PR (FAFF-185). Review runs pre-PR, so no PR exists to comment on. The single canonical findings surface — review verdict, adversarial findings, and dispositions, both modes — is the tracker issue. (faffter-noon-review's "Why pre-PR / report to tracker" intent, honoured.) The adversarial reviewer's log to tracker step posts to this same issue surface, never the PR.
Record the review result — collapse-and-log, not append-per-pass (FAFF-184). A fail→fix→re-review loop re-enters this step, so a naïve "comment per pass" would flood the tracker — exactly the per-micro-step marker the gateway forbids (→ Tracker as the lights-out control plane §2, the granularity rule). So:
- Every pass →
.faff/logs. Accumulate each pass's findings + dispositions in the per-issue log (.faff/runs/<run-id>/ISSUE-XX/graft.md, or .faff/logs/…graft-ISSUE-XX.md outside beep-boop) as the build iterates — a hard-floor write, the full blow-by-blow, never dropped.
- Terminal verdict → a floor artifact (FAFF-350). On the final verdict, also write the reviewer's extraction JSON (
{ signal, findings }) to <run-dir>/<ISSUE>/review-verdict.json — the artifact faff merge-gate re-reads (re-validated through the same faff contract review-verdict rule) at merge. A missing file is fail-closed to missing (≠ pass), so the merge is refused; writing it on a pass is what clears the review leg of the mechanical floor.
- Terminal verdict → one tracker-issue comment. Only on the final verdict (
pass / needs-human) post (or update in place) a single comment on the tracker issue carrying that verdict, a one-line "resolved N findings across M passes" summary, and a pointer to the log. Not one comment per pass — and the adversarial reviewer's per-finding dispositions fold into this same comment's summary (Step 9 → adversarial review's log to tracker), never one comment per finding.
- Locate it deterministically — the comment-identity contract (FAFF-202). "Or update in place" is keyed by a hidden HTML-comment marker pair wrapping the faff-authored body —
<!-- faff-review-findings:<ISSUE-ID> --> … <!-- /faff-review-findings:<ISSUE-ID> -->, keyed by issue id (e.g. FAFF-202), invisible in the rendered comment. List the issue's comments, match on the open marker, then create (0 matches) / update-in-place (1) / oldest-wins reconcile (>1). The locate→splice→reconcile procedure (and its human-edit-safety splice) is single-sourced at gateway → Review-findings comment identity — follow it there; do not restate it.
A clean single-pass build still uses this one-comment shape (verdict + "resolved 0 findings across 1 pass"); needs-human carries the blocking-finding summary, detail in the log.
Review-stage terminal states are pre-PR — no PR is opened for them:
pass → proceed to Step 9b (open the PR), then the merge-confidence gate.
fail → iterate (fix flagged items → re-run tests → re-run review), still pre-PR, no PR opened. Loop until pass or needs-human.
needs-human → surface on the tracker issue as needs-human (the one-comment shape above) and park per the shared protocol without opening a PR. This is a no-PR human handoff — distinct from the pr-open-for-human return, which is reserved for the post-PR causes (CI-red / delivery not-ready/failed) that operate on an already-opened PR. See Return values in Autonomous Mode for the split.
If the review names concrete, separable out-of-scope work — not a fixable defect in this diff — record it as discovered scope (see below) rather than looping on it. fail is for fixable items; out-of-this-PR follow-ups are discovered scope, not a review failure.
This step runs in both interactive and autonomous modes.
Step 9b: Open the PR (after review pass — identical interactive + autonomous)
Reached only when Step 9 returned pass. The single PR-creation point, the same in both modes. When graft.push_at_build_complete is on the branch was already pushed at Step 8b, so this step is gh pr create only (no push); when off, it pushes and opens the PR (today's compound). Either way it opens a regular (non-draft) PR, and CI fires here for the first time — a no-PR branch push never triggered it, so review-fix iterations (and the Step-8b push) burned no CI. The PR body carries the Step 8 AC checklist. Proceed to Step 10.
Discovered scope (record, never file)
While building and reviewing, graft often surfaces concrete, separable work this PR should not absorb — a seam the spec didn't foresee, an untracked dependency an AC exposed, a real out-of-scope concern the review flagged. The implementor lane cannot create backlog tickets (gateway → Agent Lanes); it records these so the orchestrator files them (autonomous: /faff-beep-boop after the build pass; interactive: via the gate in Step 12). This is bottom-up source (b) — see design/planning-loop.md.
What qualifies — concrete, nameable, separable from this PR: a follow-up the build revealed is also needed, a prerequisite the spec assumed but no ticket tracks, a review finding that names real out-of-this-PR work. What does not: fixable-in-PR items (those loop via review fail), unverifiable ACs (human-verify flags, not new work), and vague impressions ("logging's inconsistent") — record those as confidence: vague, which only ever surface, never auto-file.
Capture — append to .faff/runs/<run-id>/ISSUE-XX/discovered-scope.json (outside beep-boop: .faff/logs/YYYY-MM-DD/HHMMSS-graft-ISSUE-XX-discovered-scope.json) during Step 7 (build) and Step 9 (review). One array entry per item:
{ "title": "...", "description": "...", "relationship": "blocker|blocked-by|peer|none",
"source": "build|review", "source_ref": "spec line / review finding", "confidence": "concrete|vague",
"containment": "contained|outward-new-root|null" }
relationship is to the issue being built (blocker = the discovered work must land first; blocked-by = it follows this PR; peer = parallel in the same workstream; none = independent). containment (FAFF-221) is the scope-containment verdict the orchestrator stamps when it files — graft leaves it null (the implementor records, never files); outward-new-root marks an item whose intended parent falls outside the mandate's subtree, which beep-boop §10 then never files (surface-only). Recording is cheap and side-effect-free — it never blocks, loops, or parks the current build.
Step 10: Merge-confidence gate
Merge happens only when all conditions hold:
- Every AC has a passing automated verification (Step 8 — all boxes that can be auto-ticked, are)
- CI is green —
ci-green, not merely "not red". Evaluating the CI condition yields one of three results: ci-green (≥1 applicable check ran and all passed → satisfied), ci-red (≥1 applicable check failed → the CI failed branch below), or no-ci-coverage (the applicable-checks set is empty → the No CI coverage branch below). An empty check set is not green — see Classifying the CI result for detecting it.
- Review step (Step 9) returned
pass
- (L4 lights-out signal only) the per-issue code-blind holdout returns
meets-spec — asserted last, after conditions 1–3 are green (see Holdout gate below). At L1–L3 this condition does not apply and no env is provisioned; the floor is the three above.
Decision:
- All applicable conditions hold (integrity floor passed): conditions 1–3 (plus condition 4 under the L4 signal) are the integrity floor — assert them here; this floor is non-delegable and is never re-run or weakened inside the
ship producer (there is no adaptor — the consumer parses the producer's block directly). Under the L4 lights-out signal, run the Holdout gate below as the last assertion before the ship handoff — a non-meets-spec verdict blocks the merge (see Holdout gate). Re-read the issue's live status / PR state immediately before the ship handoff (multi-orchestrator safety — gateway → Issue claim & status monotonicity): if the PR is already merged or a peer has advanced the issue to Done, do not double-merge and do not revert — treat it as an already-shipped no-op. The merge itself is mechanical, not narrated (FAFF-350). The floor asserted here is now also re-verified by a tool: the merge runs through faff merge-gate — the sole sanctioned gh pr merge path — which re-reads the persisted floor artifacts (Step 8's ac-checklist.json, Step 9's review-verdict.json, and under L4 the holdout verdict) and observes CI itself on the PR head sha, merging only on merge-ok. graft (and the default ship producer) never call gh pr merge directly. So pass --pr/--issue/--run-dir/--level down to the ship producer, which invokes faff merge-gate … --execute; graft's narrated assertion becomes belt-and-braces over the mechanical interlock, never a substitute for it. Otherwise hand off to the ship producer via the Skill tool (configured occupant, or the default faffter-noon-ship; resolve per gateway → Sibling-skill invocation); locate its faff-contract:delivery-outcome block, JSON.parse it, and pipe it to faff contract delivery-outcome (the sole source of contract data — the ship_adaptor slot was retired), and route on the script's outcome (an unmappable or uncorroborated result coerces to failed, never shipped — gateway → Delivery outcome):
shipped → merged/deployed. The worktree becomes eligible for cleanup (housekeeping, per gateway → Worktree policy) — the ship producer never touches it. Any worktree-metadata prune in that cleanup goes through faff worktree-prune --issue <id> (scoped to this run's own worktree — never a repo-wide git worktree prune, which clobbers a concurrent peer; resolve the binary per gateway → Resolving the faff executable). Chained issues unblock. Done.
not-ready:<reason> → the merge was deferred without merging — either the producer's deploy-readiness tier (only a deploy-capable producer yields this; the default never does) or a mechanical delivery-precondition block (not-ready:precondition:<kind> — push / token-scope / merge-method / actions-policy; the default does yield this). Leave the PR open and mergeable, record the reason, and park as retry-later — not a defect, not needs-human. For a precondition:<kind> reason, surface the specific blocker + remedy: in the park comment so the operator can apply the one-time fix and re-invoke /faff-graft to resume.
failed:<reason> → merge conflict or deploy error (or an unmappable result coerced to failed). Treat as a post-build failure: autonomous → one fix attempt if obvious from the error, else park; interactive → surface and ask per Step 11.
- Under concurrent execution (the
faffter-dark-concurrency-parallel executor): merges are serialised and rebase-revalidated — before handing to the ship producer, rebase (or merge main) onto the PR branch and re-confirm CI green on the rebased head. A green that predates main moving is stale and must not merge. See that skill's Rebase-before-merge. (Sequential execution needs no rebase — each build already sees the prior merge.) Run the ADR-collision merge guard (below) on the rebased head first, so a renumber commit is part of the head CI re-confirms.
ADR-collision merge guard (FAFF-368). faff adr new allocates a number at branch-base time, so two concurrent grafts can mint the same ADR number and both merge — reddening faff adr validate on main and every open PR. Close it at the last moment both ADRs are visible on one tree: after the rebase/merge-of-main, before the CI re-confirm (sequential execution runs it on HEAD — a near-always no-op). The renumber is mechanical — the faff adr renumber CLI mutates; graft only detects, invokes, stages, re-validates. Resolve the binary per gateway → Resolving the faff executable.
added=$(git diff --diff-filter=A "$(git merge-base HEAD @{u} 2>/dev/null || git merge-base HEAD main)"..HEAD -- docs/adr/). Empty → RETURN (no ADR in this PR; the overwhelmingly common path — no validate decides the merge, no commit).
- Run
faff adr validate. No duplicate ADR number line → RETURN (numbering clean).
- For each
duplicate ADR number NNNN — f1, f2, …:
- The incoming colliding file is the one in
added. If none of the colliding files is in added → main is broken independently of this PR: BLOCK — park needs-human ("pre-existing duplicate ADR NNNN on the merge target, not introduced by this PR"), leave the PR open, renumber nothing. If more than one colliding file is in added (this PR dup'd within itself) → renumber each-but-one.
refScope=$(git diff --diff-filter=AM <merge-base>..HEAD -- docs/adr/) (this PR's added/modified ADRs). Run faff adr renumber <incoming-file> --to next --ref-scope <refScope>. Non-zero exit → BLOCK needs-human with the CLI's stderr.
- Re-run
faff adr validate → must exit 0; else BLOCK needs-human (never merge a red tree).
git add -A docs/adr/ && git commit -m "docs(adr): renumber <ISSUE-XX> ADR <old>→<new> (merge-time collision)", push, then proceed into the CI re-confirm on the renumbered head. faff merge-gate observes CI on the new head sha — if CI doesn't re-run there it returns no-ci-coverage/not-ready (fail-closed; surface, never a silent merge). The guard is idempotent and convergent under the merge-lock — a racing third merge onto the same next re-fires on the next rebase.
- Review returned
fail: iterate autonomously (fix flagged items, re-run tests, re-run review). This is not a park — it's a loop.
- Review returned
needs-human: flip PR to draft, park per the shared protocol. Leave the PR open with the AC checklist, review comment, and CI status visible.
- CI failed (
ci-red): first separate a flaky/infra failure from a real defect — re-run the failed checks once with no code change (re-trigger, then gh pr checks <pr> --watch). If they pass on the clean re-run, it was transient: proceed to the merge gate and do not spend the autonomous fix attempt on it. If they fail the same way again, it's real: in autonomous mode, one iteration attempt (if the failure looks fixable from the logs); otherwise park. In interactive mode, ask per Step 11. (Persistent infra failures unrelated to the diff — runner outages, missing secrets — park as errored, not as a code defect.)
- No CI coverage (
no-ci-coverage): the applicable-checks set is empty — no PR-triggered check ran (config/workflow/docs-only diff, or a repo with no CI for the changed paths). This is not a green: the floor's CI condition is not satisfied, so the gate must not hand off to the ship producer on the strength of an absent check set. Surface it loudly, naming the gap explicitly ("No applicable CI ran on this PR (zero checks). CI-green could not be established; the diff was NOT validated pre-merge."), then route by mode:
- Autonomous: do not merge. Flip the PR to draft and park
needs-human per the shared protocol (attach the AC checklist, review comment, and the no-ci-coverage reason); return pr-open-for-human. Log the decision so /faff-wtf surfaces it.
- Interactive: require an explicit confirm distinct from the normal "merge now?" — yes/no "No CI validated this PR pre-merge. Merge anyway on your own judgement? (y/n)". On confirm, the present human is the gate — but the
--allow-no-ci flag that carries that judgement is fenced on a real TTY (FAFF-375), and this agent-mediated session is not one, so graft cannot run the merge itself. Instead print the exact command for the human to run in their own terminal — faff merge-gate --pr <n> --issue <ISSUE-XX> --run-dir <run-dir> --level <L> --execute --interactive --allow-no-ci --merge-args "--squash --delete-branch" — and ask them to run it there; then re-read the PR state (the merge-gate already-merged path is idempotent, so a second gate call or a gh pr view confirms the merge landed). On deny, leave the PR open. (The equivalent blocked-floor override — a human choosing to merge past a refused CI/review/holdout leg — is the same shape with --human-override in place of --allow-no-ci: graft prints that command for the human's own terminal rather than running it in-session.)
- Post-merge-only validation (a diff whose only check is push/merge-triggered, e.g. a new
on: push workflow or release pipeline) is a sub-case of no-ci-coverage: annotate the reason no-ci-coverage: validation is post-merge-only and route identically — it gets no merge-eligible fast-path (a separate auto-merge lane for push-triggered diffs is exactly the hole this guards against).
- Re-run does not apply here (there is nothing to re-run); the flaky-re-run path above is for
ci-red only.
Holdout gate (condition 4 — L4 lights-out signal only). Under the L4 lights-out signal (the same resolve-fail-safe that gates the per-run holdout phase — an ordinary L1–L3 run runs no holdout gate and provisions no env), after conditions 1–3 are green and immediately before the ship handoff, assert a per-issue code-blind holdout on the pre-merge worktree. This is the gate/prevent call-site of the L4 evaluator lane — the same holdout_step the per-run phase uses (gateway → faff-beep-boop Per-run holdout phase), invoked here per-issue so a spec-failing feature is blocked before it reaches main, not merely detected after. Reuse that step — do not re-implement the engine.
- Short-circuit (no env when nothing is born-verifiable). First run
faff dod classify --spec <spec>; if it reports zero born-verifiable criteria (scenario/assertion) the evaluator could only return all-needs-human, so provisioning buys nothing — skip provisioning and treat the gate as a holdout-block with reason no born-verifiable criteria (route as below). Otherwise provision at most one env for this issue.
- Run
holdout_step keyed to the issue (key = ISSUE-XX): provision the env slot → assert faff contract env-handle → invoke the evaluator slot code-blind → persist the same in-memory verdict block to both .faff/holdout/ISSUE-XX.json (the global roll-up copy) and <run-dir>/ISSUE-XX/holdout.json (the run-scoped copy faff merge-gate's L4 floor actually reads — write it after the build-complete checkpoint at <run-dir>/ISSUE-XX/build-progress.json exists so it is fresh by construction) → tear the env down on every exit path (success, block, exception; a teardown failure is logged loudly, never changes the verdict, never blocks the queue). Code-blindness is structural, not promised: the evaluator runs in a fresh OS-level process (an evaluate-call helper, the review-call.mjs pattern) handed only the spec text + the env-handle endpoint URL — never a repo path, cwd outside the worktree — so the process never received the codebase. Never invoke the evaluator inline "instructed to ignore the code": in-context code makes code_blind:true false-by-construction.
- Assert the verdict. Read the per-issue block back through the deterministic gate —
faff holdout verdict --issue ISSUE-XX (exit 0 = pass, 1 = block; it re-validates via the same faff contract holdout-verdict rule: code_blind:true ∧ aggregate meets-spec ∧ coherent). Pass only on meets-spec; fails / gaps / needs-human / non-code_blind / malformed / missing all block (fail-closed — a missing or unreadable verdict never passes). The same file later feeds the unchanged faff holdout verdicts --association run roll-up (one artifact, two consumers — never a second store).
- On pass → proceed to the
ship handoff. On holdout-block → do not merge: flip the PR to draft, park per the shared protocol with the verdict's aggregate + violations (or the short-circuit reason) surfaced, and return pr-open-for-human (ledger bucket pr-open — no new bucket; the PR was opened at 9b). Autonomous graft never retries a holdout-block automatically — a spec-failing feature is a human call. Distinguish an env fault from a feature failure: an env-handle status:failed / provisioning error is surfaced as an env fault (escalate to the env lane), never silently as a feature fails.
In interactive mode, this gate fires when the user confirms "merge now" at post-PR time (Step 11). In autonomous mode, it fires automatically at the end of the build flow.
Step 11: Post-PR checks (interactive)
Prerequisite check: before running this step, verify Steps 8, 9, and 9b have all been ticked off in the todo list — AC verification, the (pre-PR) review reaching pass, and the PR opened at 9b. If any is missing, run the missing step now — do not offer a merge gate on top of skipped verification or review. This is the last line of defence against the review-skipped failure mode: even if the build loop dropped Step 9, this check must catch it before any "merge now?" prompt fires. (Reaching this step means the pre-PR review already returned pass at Step 9 — the only way 9b opened a PR — so that verdict is not re-evaluated here; a fresh post-PR concern is handled as a new build iteration, not a re-run of the pre-PR review gate.)
After the PR is posted, wait for CI builds to complete synchronously in the same turn. Based on result and the gate in Step 10:
- Gate passes (auto-mergeable): yes/no "All three gate conditions pass (ACs verified, CI green, review
pass). Merge now? (y/n)". On confirm, hand off to the ship producer, pipe its faff-contract:delivery-outcome block to faff contract delivery-outcome, and route on the resulting outcome (per Step 10). On deny, leave PR open.
- Gate fails on CI: "CI failed. Iterate on this PR? (y/n)". On confirm, keep going. On deny, yes/no "Pick next ticket via
/faff-wtf? (y/n)".
- Gate fails on unverified AC: surface the failing condition(s). Yes/no "Address and iterate? (y/n)". On confirm, iterate. On deny, leave for human. (Review can't fail here — it ran and passed pre-PR at Step 9; a post-PR review concern is handled as a fresh build iteration, not a Step-11 gate.)
All subsequent chain points are yes/no gates (never passive "run /faff-wtf").
How to actually wait for CI
Never say "I'll check CI once it reports" and end the turn. Turns don't resume on their own — the user has to prompt you again, which defeats the point. Either you wait synchronously in-turn, or you tell the user CI is running and hand back control explicitly (without any promise to check later).
Correct patterns:
- Block synchronously (preferred):
gh pr checks <pr> --watch --interval 15 — blocks until all checks reach a terminal state, then exits with non-zero on failure. Wrap in Bash with a generous timeout (CI runs routinely take 5–15 minutes; allow 600000ms / 10 minutes at minimum, up to the Bash tool's max). If checks legitimately take longer than the tool max, poll in a loop: gh pr checks <pr> every 30–60s via Bash, until output shows no pending / in_progress.
- Hand back cleanly: "CI is running. I'm stopping here — re-invoke
/faff-graft or say 'check CI' when you want me to poll." This is the only acceptable way to exit without a CI result. Do not pair this with "I'll check once it reports" — you won't.
Forbidden patterns:
- "Waiting on CI. I'll check once it reports." — you can't. The turn is over.
- "Checking CI in the background." — there is no background.
- Ending the turn without a CI terminal state AND without an explicit handoff.
If a CI wait is taking long enough that blocking the turn feels wasteful, prefer the explicit handoff over a fake promise. Surprising the user with silence is worse than telling them you're stopping.
Classifying the CI result (the three-way evaluation)
Once the wait resolves to a terminal state, classify the result into the ci-green / ci-red / no-ci-coverage outcomes Step 10's condition #2 branches on. The empty-set case is the one the watch loop hides — gh pr checks <pr> exits non-zero with "no checks reported on the … branch" both when checks failed and when no checks exist, so the exit code alone cannot separate them:
1. Wait to a terminal state (gh pr checks <pr> --watch / the poll loop above).
2. Read the rows with `gh pr checks <pr>` (or `gh pr checks <pr> --json` for a machine-readable set).
- ZERO check rows ("no checks reported") .................. no-ci-coverage (NOT ci-red — there is nothing to re-run)
- >=1 row, all passing-terminal .......................... ci-green
- >=1 row, >=1 failing-terminal .......................... ci-red
Branch on the observed row count, never on the exit code alone — conflating "zero checks" with "checks failed" either masks the absence (the vacuous-green bug) or wrongly drives a legitimately CI-less repo into the failure-iterate loop. Pending checks are resolved by step 1 before emptiness is evaluated, so "pending" never reads as empty; a set with ≥1 passing check plus skipped/neutral checks is ci-green (CI ran and is green), not no-ci-coverage (which requires the set to be genuinely empty). Under concurrent execution, a rebased head that yields an empty set is no-ci-coverage, not a stale-green pass.
Step 12: Post graft checks
After build is complete and PR has been raised:
- Discovered scope (only if
concrete items were recorded in Step 9 → Discovered scope): list them and offer a yes/no gate — "Found N out-of-scope item(s) while building: [titles]. File as Backlog tickets? (y/n)". On confirm, file each per the faff-chain-gap-fill recipe (see /faff-tidy → Chain gaps): status Backlog, tag faff-chain-gap-fill via faff label add <issue> faff-chain-gap-fill and its descriptor's write (gateway → Control-label provisioning), the recorded relationship link, and a "discovered during build of ISSUE-XX" provenance line + back-link. On deny, leave them in discovered-scope.json for a later pass. vague items are listed for awareness only — never offered for filing. (Interactive use has no orchestrator above graft, so the human confirming is the orchestrator authorising the file; autonomous runs file via beep-boop instead, never here.)
- Next ticket: yes/no "Pick next ticket via
/faff-wtf? (y/n)". On confirm, invoke the faff-wtf skill via the Skill tool. On deny, stop cleanly.
Autonomous Mode
When invoked autonomously (by /faff-beep-boop), follow the shared autonomous contract (see the sibling faff/SKILL.md) and these specifics:
Entry: assumes issue exists, is not cancelled/archived, is automation-eligible (gateway → Automation eligibility), has a valid spec, and a dedicated worktree is already prepared (per-issue worktree isolation per the gateway → Worktree policy; the concurrency slot relies on it for parallel runs).
Automation-eligibility backstop (first). A not-eligible issue should never reach autonomous graft — prep/tidy won't promote it, so it never enters the build queue. As the build chokepoint, graft nonetheless re-checks mechanically at the Automation-eligibility gate (autonomous — pre-worktree) in Step 2: it shells faff eligible (verdict from stdout, never agent re-derivation) and on false or any resolution failure refuses to build — skips without starting, commits no spec, opens no worktree, and returns the ineligible skip (the orchestrator records it as ineligible, not built; never parked, never a build attempt). Never adds faff-automate or removes faff-automation-hold. See that gate for the exact shell sequence and fail-safe rules.
Lights-out admissibility backstop (FAFF-224). Under the L4 lights-out signal only, graft additionally shells faff admissible on the spec body at the Lights-out admissibility gate in Step 2 (pre-worktree) — the backstop to beep-boop §4's primary build-queue filter, catching a direct lights-out /faff-graft that bypassed assembly. Inadmissible (or any binary/shell failure) → refuse fail-safe: no worktree, no spec commit, return the inadmissible skip. Ordinary L3 and interactive graft skip it (the morning park-review / the human is the gate). See that gate for the shell sequence.
Flow:
- Delivery pre-flight (before building). Run the read-only delivery-precondition probe (the same one the
ship producer runs at ship time — push / merge-method always; token-scope / actions-policy when the spec declares the touched surface, e.g. .github/workflows/*). On a diff-independent block (push or merge-method — these don't need the built diff), do not build: park retry-later with cause not-ready:precondition:<kind> — <detail>; remedy: <remedy> (commit nothing built; apply the faff-parked label via faff label add <issue> faff-parked and its descriptor's write; post the cause + remedy) and return parked — a guaranteed-fail delivery must never waste a build. An indeterminate probe (network/gh outage) is not a confirmed block — proceed to build; the ship-time backstop is the real gate. Diff-triggered checks the pre-flight couldn't see are caught at ship time. Then skip Step 6's build/review/reprep choice and proceed directly to build (Step 7).
- During Step 7, if a decision arises that the spec doesn't resolve, run resolve-attempt first (see Resolve-attempt before park section below). If resolve-attempt proceeds, log to
.faff/runs/<run-id>/ISSUE-XX/resolve-attempt.md and write the audit-trail tracker comment, then continue. If resolve-attempt fails, invoke the faff-prep skill respec. If respec is still ambiguous, park.
- Before invoking respec, apply the gateway's "spec-closed decisions stay closed" rule (see the sibling
faff/SKILL.md Autonomous Mode Contract) — parse for Chosen: / Decision: / Punt: markers, not topic keywords. Only invoke respec when the spec has a real punt, missing external dependency, or cost/irreversibility trigger.
2b. After build, run Step 7.5 (engineering gate ladder) — faff gates run → pipe its faff-contract:quality-gates block to faff contract quality-gates. At L3 (the autonomous default) declared rungs are required and fail-fast: on fail, fix the failing rung and re-run (loop until pass/needs-human), before AC verification, review, or any PR/CI spend; on needs-human (errored rung, or discovery: none under gates.fallback: fail-closed), park per the shared protocol with no PR (maps to the parked bucket).
- After the gate ladder passes, run Step 8 (AC verification) — mandatory; it consumes the ladder's UNIT/LINT-rung results (one resolver, one suite run), not a second run.
3b. Run Step 8b (push branch at build-complete + write the build-progress checkpoint — FAFF-402) so the built work is durable on
origin and a re-dispatch resumes at review instead of rebuilding (Step 3 → Resume-at-review check). Gated on graft.push_at_build_complete (default on).
- Run Step 9 (review phase) — pre-PR, no PR open yet (review iterates against the branch/diff; findings → the tracker issue, never a PR). The terminal-verdict comment is posted-or-updated by the comment-identity contract — locate by its marker pair, create/update/reconcile per gateway → Review-findings comment identity (FAFF-202), the same in autonomous mode. Act on the three-valued signal:
pass → proceed to Step 9b (open the PR).
fail → iterate: fix flagged items, re-run tests, re-run review — still pre-PR, no PR opened. Loop until pass or needs-human (cap at 3 iterations; if still fail after 3, treat as needs-human).
needs-human → surface on the tracker issue as needs-human and park per the shared protocol without opening a PR. Return needs-human (a no-PR handoff, distinct from pr-open-for-human — see Return values).
- Step 9b — open the PR (only reached on review
pass). Open a regular (non-draft) PR — gh pr create only when graft.push_at_build_complete is on (the branch was pushed at Step 8b), or the push+create compound when off. The single PR-creation point; CI fires here for the first time (a no-PR branch push never triggered it), so review-fix iterations never burned CI. A later post-PR gate decides whether to flip to draft.
- Run Step 10 (merge-confidence gate) automatically:
- All applicable conditions hold: wait for CI to reach a terminal state (
gh pr checks --watch), classify the result per Classifying the CI result; on ci-green, under the L4 lights-out signal run the Holdout gate (condition 4) last — a holdout-block flips the PR to draft and returns pr-open-for-human (bucket pr-open) without merging; only on a meets-spec pass (or at L1–L3, where the gate does not apply) hand off to the ship producer (configured occupant or the default faffter-noon-ship) and pipe its faff-contract:delivery-outcome block to faff contract delivery-outcome, then map the outcome to a caller-facing return: shipped → shipped (worktree eligible for cleanup, chained issues unblock); not-ready:<reason> → park retry-later, return pr-open-for-human; failed:<reason> (including an unmappable result coerced to failed) → one fix attempt if obvious from the error, else park, return pr-open-for-human.
- CI failed (
ci-red): one fix attempt if the failure is obvious from the logs; otherwise flip to draft, park. Return pr-open-for-human.
- No CI coverage (
no-ci-coverage): the applicable-checks set is empty — not a green. Do not hand off to the ship producer. Flip the PR to draft and park needs-human per the shared protocol (cause no-ci-coverage, plus : validation is post-merge-only when the only check is push/merge-triggered); return pr-open-for-human. Never auto-merge an empty check set.
- Any unrecoverable error → park and return
errored.
Discovered scope captured during Steps 7/9 stays in .faff/runs/<run-id>/ISSUE-XX/discovered-scope.json and is reported in the discovered_scope return field. graft never files it — beep-boop's file-discovered-scope step does, after the build pass (gateway → Agent Lanes). This is independent of the terminal outcome: a shipped, pr-open-for-human, or parked issue can all carry discovered scope.
Resolve-attempt before park
In autonomous mode, before parking on needs-decision-first / gap-blocked / circular-blocked verdicts (read from .faff/runs/<run-id>/automation-verdicts.md if available, otherwise compute inline per gateway → Automation-routing verdict (fixed)), run a resolve-attempt as specified in gateway → Autonomous Mode Contract → Resolve-attempt before park.
Behaviour per verdict (full rules in gateway):