Fabrik User Guide
Fabrik is an automated Claude Code SDLC driver powered by GitHub Issues and Projects. This guide covers everything you need to set up, configure, and use Fabrik effectively.
For a quick overview see the README. For details on the internal stage lifecycle, see Stage Lifecycle. For the authoritative engine state-machine spec (label semantics, review gate transitions, marker handling), see State Machine.
Table of Contents
- Getting Started
- Configuration Reference
- Workflow Patterns
- Stage Reference
- Plugin & Skills
- Labels Reference
- Permissions
- TUI Dashboard
- Observability
- Webhook Mode
- Troubleshooting
1. Getting Started
Prerequisites
- Go 1.26.1+
- Claude Code CLI installed and authenticated
- GitHub classic personal access token (
ghp_...) withrepo,project, andworkflowscopes- Fine-grained tokens (
github_pat_...) are not supported — GitHub Projects v2 GraphQL requires a classic PAT - Create one at: https://github.com/settings/tokens (select “Tokens (classic)”)
- If a fine-grained token is detected at startup, Fabrik prints a
[warn]message and subsequent API calls include an actionable hint; see GitHub API Returns 401 or Fine-Grained Token Warning
- Fine-grained tokens (
- A GitHub Project (v2) with board columns matching your stage names
Initial Setup
Option A: Install binary (requires gh)
# Requires: gh auth login (with access to handarbeit/fabrik)
cd ~/bin # or any directory on your PATH
gh release download --repo handarbeit/fabrik \
--pattern "fabrik_*_$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').tar.gz" \
-O - | tar xz
# Platform-specific alternatives:
# darwin/arm64: --pattern "fabrik_*_darwin_arm64.tar.gz"
# darwin/amd64: --pattern "fabrik_*_darwin_amd64.tar.gz"
# linux/amd64: --pattern "fabrik_*_linux_amd64.tar.gz"
# linux/arm64: --pattern "fabrik_*_linux_arm64.tar.gz"
Option B: Build from source (requires Go)
# Build
go build -o fabrik .
Then initialize:
# Initialize stage configs, plugin, and config template
./fabrik init
# Creates:
# .fabrik/stages/ — stage YAML configs
# .fabrik/plugin/ — Claude Code plugin
# .fabrik/config.yaml — project config template (edit this)
# Updates:
# .git/info/exclude — adds .fabrik/repos/, .fabrik/worktrees/, .fabrik/debug/,
# and .fabrik/history.json so they don't appear as untracked
# in git status (local excludes, not committed to the repo)
Pass your GitHub Project URL to auto-populate owner, project, and owner_type in
config.yaml. The --user flag enables fully non-interactive setup:
./fabrik init --user you https://github.com/orgs/your-org/projects/5
# or for a personal project:
./fabrik init --user you https://github.com/users/you/projects/3
Without a URL, fabrik init behaves as before: if your terminal is interactive it
prompts for owner, repo, project number, and username; otherwise it writes a
fully-commented template for you to fill in.
GitHub Enterprise Server project URLs are also accepted. Pass --ghes-host (or
set FABRIK_GHES_HOST) alongside a project URL on your GHES instance:
./fabrik init --user you --ghes-host github.example.com \
https://github.example.com/orgs/your-org/projects/5
init runs before .fabrik/config.yaml exists, so the host can only come from the
flag or environment variable at this point — never from config.yaml. The resolved
host is written into the generated config (ghes_host: github.example.com), so you
only need to supply it to init once; every subsequent fabrik invocation picks it
up from config.yaml. See GitHub Enterprise Server
Support below for the full effect of ghes_host.
To refresh plugin skills without touching stages or config (e.g., after upgrading Fabrik):
./fabrik upgrade
Note: Plain
fabrik upgradeerrors when local customizations are detected (i.e., you have edited files under.fabrik/plugin/). Use--forceto overwrite them unconditionally, or--reconcileto get a guided merge prompt. See Upgrade protection for customized skills for details.
Edit .fabrik/config.yaml with your project settings and commit it to git. Add your
GitHub token to a gitignored .env file:
# .env (gitignored — keep secrets here)
# Use a CLASSIC personal access token (ghp_...) — not a fine-grained token (github_pat_...)
# Required scopes: repo, project, workflow
# Create at: https://github.com/settings/tokens (select "Tokens (classic)")
FABRIK_TOKEN=ghp_...
# Optional: run this instance's Claude invocations against a different Claude
# account/subscription. See "Alternate Claude Profile (CLAUDE_CONFIG_DIR)" below.
# CLAUDE_CONFIG_DIR=/home/user/.claude-alt-profile
# Optional: explicit opt-in to API billing instead of subscription billing.
# An ambient ANTHROPIC_API_KEY here is scrubbed by default and never reaches
# Claude invocations on its own — see "Anthropic Auth Namespace Scrub &
# apiKeyHelper Refusal" below.
# FABRIK_ANTHROPIC_API_KEY=sk-ant-...
Note: When a
.git/directory is present, Fabrik refuses to start if.envexists but is not listed in.gitignore(prevents accidental token leaks). In directories without.git/— containers, CI workspaces, or bare directories — this check is skipped and.envis loaded normally without requiring a.gitignoreentry.
Create a Project Board
Create a GitHub Project (v2) for your repository. Add board columns that correspond to
your stage names – the column name must match the name field in each stage YAML file
exactly (case-sensitive). The default pipeline uses:
Backlog -> Specify -> Research -> Plan -> Implement -> Review -> Validate -> Done
Compatibility Notes
Warning: Global Claude Code plugins can interfere with Fabrik’s headless sessions. Do not install global plugins on machines running Fabrik as a service. The
superpowersplugin is a known offender — if installed globally, it injects unexpected behaviour into Fabrik’s Claude sessions and causes duplicate comments on issues.To check for the problematic plugin:
ls ~/.claude/plugins/cache/claude-plugins-official/If
superpowersappears, remove it:rm -rf ~/.claude/plugins/cache/claude-plugins-official/superpowersSee §11 Troubleshooting → Duplicate Comments on Issues for full details.
First Run
With settings in .fabrik/config.yaml:
./fabrik
Or pass settings as flags:
./fabrik \
--owner your-org \
--repo your-repo \
--project 1 \
--user your-github-username
Fabrik polls the project board every 30 seconds by default (configurable with --poll
or poll: in config.yaml).
Idle backoff: When nothing needs work, Fabrik progressively slows down polling to conserve API rate limit budget. The backoff is time-based — after 5 minutes of idle polls, the interval doubles; after 10 minutes it quadruples; after 20 minutes it caps at 5 minutes. Any activity (items dispatched or deep-fetched) immediately resets the interval to the configured value. Press w in the TUI to wake polling instantly if you’ve just made changes on the board.
| Idle duration | Effective interval | At 30s base |
|---|---|---|
| 0–5 min | 1× (configured) | 30s |
| 5–10 min | 2× | 60s |
| 10–20 min | 4× | 120s |
| 20+ min | max (5 minutes) | 300s |
Rate-limit backoff: When Fabrik detects GraphQL API quota pressure, the same interval infrastructure adds a separate rate-limit component that escalates as quota depletes. There is no separate 5-minute ceiling for the rate-limit contributor — it can exceed 5 minutes when the configured base interval is large. The effective poll interval is max(idle interval, rate-limit interval), so whichever is larger governs at any given moment.
| Remaining GraphQL quota | Multiplier | At 30s base | At 60s base |
|---|---|---|---|
| >=10% (incl. sticky zone 20%–50%) | 2× | 60s | 120s |
| >=5% and <10% | 4× | 120s | 240s |
| >=1% and <5% | 6× | 180s | 360s |
| <1% | 10× (cap) | 300s | 600s |
Rate-limit backoff uses two-threshold hysteresis to prevent thrashing: it activates when GraphQL remaining quota drops below 20% of the hourly limit, and clears only when quota rises above 50% of the limit. While quota is recovering (20%–50%, sticky zone), the 2× tier applies. Activity detection (items deep-fetched or dispatched) resets idle backoff but does NOT reset rate-limit backoff — the two concerns are independent. When rate-limit backoff is active, Fabrik logs the effective poll interval each cycle so operators can observe the actual cadence.
Board fetch resilience: FetchProjectBoard automatically retries up to 3 times (with 1s/2s backoff) when GitHub returns an empty response (zero items), which occurs during transient Projects v2 indexer degradation that intermittently returns empty boards for non-empty projects (both returned node count and totalCount are zero in degraded responses). The log line:
[warn] project board fetch returned N items, totalCount=M (attempt X/3) — retrying in case of indexer hiccup
indicates the retry is occurring. It is transient and requires no user action unless it persists across all three attempts (in which case Fabrik accepts the last response and continues, which may appear as a temporarily idle engine).
Move an issue to Specify on the board to start processing it.
Git Repositories and Worktrees
Fabrik always bare-clones each managed repository on first access. Run Fabrik from any directory (no need to be inside a git checkout of a managed repo):
mkdir ~/my-fabrik-dir && cd ~/my-fabrik-dir
fabrik init
./fabrik --owner myorg --project 5 --user me
# No --repo needed — Fabrik processes all repos on the board
Fabrik always bare-clones each managed repository on first access:
- Bare clones are stored at
.fabrik/repos/<owner>-<repo>.git - Worktrees are created at
.fabrik/worktrees/<owner>-<repo>/issue-N/on branchfabrik/issue-N - One worktree manager runs per discovered repository, all sharing the same poll loop
.fabrik/repos/ is excluded from git tracking by default (gitignored). Bare clone directories can be very large and should not be committed to your repository. When you run fabrik init inside a git repo, these paths are also added to .git/info/exclude automatically.
To restrict processing to a single repository, pass --repo owner/repo.
The .fabrik/ directory (config, stages, plugin) always lives in the directory where you run fabrik.
Multi-Repo Support
Fabrik can manage issues across every repository on the board in a single run. To enable multi-repo mode, omit (or comment out) the repo: field in .fabrik/config.yaml — Fabrik then discovers repositories lazily from the project board and processes issues from all of them. Each repository gets its own bare clone at .fabrik/repos/<owner>-<repo>.git and its own set of worktrees, all driven by the same poll loop.
Write-access gating for board-derived repos
Because repos are discovered from board items rather than configured explicitly, tracking an upstream issue on your own board (a normal GitHub Projects workflow) can surface a repo you don’t administer. Fabrik checks the configured token’s write access to each newly discovered repo (piggybacked on the same API call already used for the allow_auto_merge check — no extra request) before seeding any labels or evaluating allow_auto_merge on it:
- A repo the token can write to is managed exactly as before: labels are seeded,
allow_auto_mergeis checked, and its board items are dispatched into the pipeline normally. - A repo the token cannot write to is skipped entirely: no label-mutation requests are made against it, no
allow_auto_mergewarning is printed (its only fix requires admin rights the operator doesn’t have), and its board items are not processed at all — not even read-only. Nearly every stage past Specify/Research needs to push commits and open PRs, which requires exactly the access already found absent, so partial processing would only fail later and less clearly. Fabrik logs a one-time notice naming the repo and reason instead.
The access determination is cached per repo for the life of the process — it costs one API call the first time a repo is seen, never repeated on later polls.
Git Clone Protocol (HTTPS vs SSH)
By default, Fabrik uses HTTPS to bare-clone managed repos (https://github.com/<owner>/<repo>.git). Users who authenticate via SSH keys can switch to SSH clone URLs instead.
Using SSH
Pass --ssh on the command line or add git_ssh: true to .fabrik/config.yaml:
# One-time: use SSH for this run
fabrik --ssh --owner myorg --project 5 --user me
# Persistent: add to .fabrik/config.yaml
git_ssh: true
The environment variable FABRIK_GIT_SSH=true is also supported (useful in CI).
When SSH mode is active, Fabrik uses git@github.com:<owner>/<repo>.git as the clone URL. This requires your SSH key to be registered with GitHub and accessible via ssh-agent.
Startup behavior with SSH mode: When
--ssh/git_ssh: trueis set, Fabrik suppresses the HTTPS credential helper startup check (described below) — it is not applicable. No equivalent SSH agent availability check runs at startup. If your SSH key is absent or unloaded when a clone or fetch runs, git will fail at that point rather than at startup. To verify your agent before running Fabrik:ssh -T git@github.com.
HTTPS Credential Helper Warning
When using HTTPS (the default), Fabrik checks at startup whether a git credential helper is configured. If none is found, you’ll see an advisory warning:
[startup] warn: no git credential helper configured; HTTPS cloning may prompt for credentials.
[startup] warn: configure one (e.g. git-credential-osxkeychain) or use --ssh / git_ssh: true in .fabrik/config.yaml.
This warning is non-fatal. If you use a GitHub token in your environment (FABRIK_TOKEN or GITHUB_TOKEN) and your git is configured to use it (e.g., via a credential helper or token-in-URL), cloning will work without further configuration.
To resolve the warning, either:
- Configure a credential helper:
git config --global credential.helper osxkeychain(macOS), orgit config --global credential.helper store - Or switch to SSH mode with
--ssh/git_ssh: true
Existing Bare Clones
Switching between HTTPS and SSH does not re-clone existing bare repos. Once Fabrik has cloned a repo, it reuses the existing bare clone with its original remote URL. The SSH/HTTPS setting only affects new clones.
If you switch to SSH mode after existing bare clones were created, those clones will continue to fetch via HTTPS. To switch an existing bare clone to SSH, update its remote URL directly:
git -C .fabrik/repos/owner-repo.git remote set-url origin git@github.com:owner/repo.git
URL Rewriting
If you have url.<base>.insteadOf configured in your global ~/.gitconfig (a common way to redirect HTTPS to SSH globally), Fabrik will notice at startup and print an informational message. Git applies URL rewriting transparently, so Fabrik’s HTTPS clone URLs will automatically use your SSH configuration — no additional Fabrik setting is needed.
GitHub Enterprise Server: clone URLs (both HTTPS and SSH) are built against the configured
ghes_hostwhen set, instead ofgithub.com. See GitHub Enterprise Server Support below.
GitHub Enterprise Server Support
Fabrik can run against a GitHub Enterprise Server (GHES) instance instead of github.com. This was verified against a live GHES 3.19.8 instance with full API parity for everything Fabrik uses — no adapter layer, schema introspection, or capability negotiation is involved. It’s pure endpoint/host configuration.
Configuring a GHES host
Set the hostname of your GHES instance (no scheme, no trailing slash) via any of the usual precedence layers:
# Flag
fabrik --ghes-host github.example.com --owner myorg --project 5 --user me
# Environment variable
export FABRIK_GHES_HOST=github.example.com
# .fabrik/config.yaml
ghes_host: github.example.com
Precedence is flag > FABRIK_GHES_HOST > config.yaml’s ghes_host, matching every other setting. A value with a https:///http:// scheme or a trailing / is normalized automatically, so https://github.example.com/ and github.example.com are equivalent.
Absent any GHES configuration, behavior is exactly today’s github.com behavior — this is the default path and does not regress.
Once configured, the GHES host governs:
- REST and GraphQL API endpoints — REST resolves to
https://<host>/api/v3/..., GraphQL tohttps://<host>/api/graphql. These are independently derived, not one path-suffixed from the other — GHES puts them at different paths (/api/v3/graphqlis a 404 on GHES;/api/graphqlis the correct endpoint). - Bare-clone URLs — both the HTTPS and SSH forms (
--ssh/git_ssh: truestill selects which protocol is used; see Git Clone Protocol above). - Commit author noreply email — Fabrik-authored commits use
<user>@users.noreply.<host>instead of<user>@users.noreply.github.com. Note: this mirrors github.com’s own noreply-email convention with the configured host substituted, but has not been verified against a live GHES instance’s actual private-commit-email behavior — GitHub’s published GHES admin documentation does not explicitly confirm this format. If your GHES instance uses a different convention, please file an issue. GH_HOSTin stage-worker subprocess environments — every Claude-invoked stage worker’sghCLI calls (e.g.fabrik-validate’s Pre-Completion Gate, which runsgh pr viewon every invocation) are pointed at the same GHES instance as the engine, via theghCLI’s ownGH_HOSTconvention. See Subprocess Environment.fabrik init’s project-URL parsing — a GHES project URL is accepted alongside github.com URLs when--ghes-host/FABRIK_GHES_HOSTis supplied toinit. See thefabrik initwalkthrough near the top of this guide.
Self-upgrade (--auto-upgrade) stays on github.com, unauthenticated, when a GHES host is configured. Fabrik’s own releases always live at github.com/handarbeit/fabrik, never on your GHES instance. Your configured token authenticates your GHES host, not github.com, so it is never sent with the release-check/download request — sending it would fail outright with “Bad credentials” rather than falling back to unauthenticated. Since handarbeit/fabrik’s releases are public, self-upgrade still works unauthenticated; it’s just subject to github.com’s lower unauthenticated rate limit. See Auto-upgrade below.
Minimum supported version
Fabrik requires GHES 3.19 or later. At startup, when a GHES host is configured, Fabrik queries the instance’s unauthenticated /meta endpoint for installed_version and refuses to start if it’s below the floor:
GHES instance github.example.com reports version 3.18.5, below Fabrik's minimum supported version 3.19 —
upgrade the GHES instance or remove the ghes_host configuration. See docs/USER_GUIDE.md
If the /meta fetch itself fails (network error, auth problem), Fabrik logs a non-fatal warning and continues — connectivity issues surface more clearly moments later when the project board fetch runs. This preflight only applies when a GHES host is configured; github.com deployments never see it.
Known limitations
A few advisory-only, non-gating features still hardcode github.com and have not been updated for GHES:
- The startup board-validation check’s human-facing project board URL link.
- The URL-rewrite and HTTPS-credential-helper startup checks’ SSH-rewrite detection heuristics.
None of these affect correctness — they may just show a github.com-shaped link or an inapplicable advisory message on a GHES deployment. Webhook support and projects_v2_item availability on GHES are also unverified; Fabrik always falls back to polling when webhooks aren’t available, so this is not a blocker.
Auto-upgrade
The --auto-upgrade flag enables Fabrik to upgrade itself automatically. It checks for a newer version in two places:
- At startup — once before the first poll. The check fires after the exclusive file lock is acquired, so concurrent Fabrik instances never race on the upgrade check.
- After 2 consecutive idle polls — the only other trigger. Because upgrading a dev build re-execs the process (
syscall.Exec), this check requires true idleness — no workers in flight, including merge-train workers — so an in-progress Claude invocation is never killed mid-run. A board that is continuously busy (dispatching work, or with a merge-train worker in flight, every single poll) never reaches this check, and therefore never upgrades on its own — see “Staleness reporting on a busy board” below for how that gap is surfaced instead of going unnoticed.
For release builds, Fabrik queries the GitHub Releases API at each check point. If a newer version is found, it downloads the new binary, replaces the running executable, runs fabrik upgrade to refresh plugin skills, and re-execs itself. Version comparison ignores any pre-release/build/pseudo-version suffix (-.../+...) on the running version’s version string — so a go install ...@main or branch install (which reports a pseudo-version like v0.0.72-0.20260716173320-6198e8102f90+dirty in --version) still upgrades correctly when a newer clean release tag is published; before this fix such a suffixed version silently compared as “already up to date” and the daemon would never upgrade (#1074). On macOS, the freshly-downloaded binary is best-effort re-signed (xattr -cr + codesign --force --sign -) before the re-exec, working around an Apple Silicon quirk where a binary materialized via a fresh file write (rather than built in place) can otherwise be killed on exec.
Version-skew warning: while --auto-upgrade is enabled, Fabrik periodically compares the on-disk binary’s version (via <binary> --version) against the running process’s version. If they differ — for example because a re-exec after upgrade failed, or another process replaced the binary on disk (common in a fleet sharing a single ~/go/bin/fabrik) — a warning is recorded in the TUI Warnings panel suggesting kill -HUP <pid> to restart in place and pick up the on-disk binary. The warning clears automatically once the versions match again.
Dev builds (built from source) follow the same two-check approach but use a
different upgrade path. Fabrik detects that it is a dev build (version string starts
with dev) and checks whether it is running from a handarbeit/fabrik source
checkout. If so, it compares the running binary’s embedded commit SHA against the
local HEAD:
- Local commits ahead of the binary: rebuild immediately from the current working
tree (
go build -o fabrik .) without pulling — useful when you have local changes you’ve already compiled once. - Remote
origin/mainahead of HEAD: rungit pull --ff-onlyto update, then rebuild.
After rebuilding, Fabrik runs fabrik upgrade (non-interactive, silent — see below)
and re-execs the new binary. This keeps dev builds current with the same hands-off
experience as release binaries.
Staleness reporting on a busy board: the two upgrade-check points above both
require true idleness before a dev build can rebuild and re-exec (upgrading kills
in-flight workers). A board that dispatches work every poll — or has a merge-train
worker running continuously — never reaches either one, so the daemon can end up
running arbitrarily stale code indefinitely with no signal that anything is wrong.
To close that gap, dev-build source checkouts get a separate, report-only staleness
check that runs independently of idleness: every 30 polls (~15 minutes at the
default 30s --poll interval, including once on the very first poll), Fabrik
compares the running binary’s commit against origin/main — the same comparison
the upgrade path itself uses, so the two can never disagree — and, if it’s behind,
records a warning naming the running SHA, how many commits behind it is, and the
age of the running commit (e.g. “75 commits behind origin/main” was the exact
signal that would have caught the incident this feature was built to prevent).
The warning surfaces in the TUI Warnings panel, in the startup/poll log output,
and in fabrik.log; it clears itself on the next check once the daemon is
restarted onto current code. This check never runs git pull, go build, or
re-execs — it only reports. Applies to dev-build source checkouts only (the same
handarbeit/fabrik-remote gate the upgrade path itself uses); release-binary
installs and non-source-checkout directories see no change and trigger no git
fetch.
Plugin-skills upgrade: automatic vs. standalone behavior: The plugin-skills check runs in two contexts with different behavior.
Automatic check (during
--auto-upgradere-exec or dev-build restart): TTY-sensitive. With no TTY, skills are refreshed silently when no local customizations are detected; when customizations are present, the refresh is skipped and a warning is printed to stderr. With a TTY, you are prompted once to confirm before any refresh is applied; when customizations are present,--force/--reconcileoptions are shown instead of a prompt.Standalone
fabrik upgradesubcommand: Not TTY-sensitive — the command never prompts interactively. When no local customizations are detected, skills are refreshed unconditionally. When customizations are detected, the command exits with an error directing you to--force(destructive overwrite) or--reconcile(guided merge prompt). See Upgrade protection for customized skills for details.In both contexts, when customizations are present and the refresh is skipped, the
[u] custom workflowbadge appears on next TUI startup.
./fabrik --auto-upgrade --owner your-org --repo your-repo --project 1 --user you
Upgrading from v0.0.28 or earlier? Before v0.0.29,
--auto-upgradefetched from the privatehandarbeit/fabrikrepo. The v0.0.29 release switched the upgrade source to the publichandarbeit/fabrikrepo — but this change cannot self-apply: a binary built before v0.0.29 will never auto-receive it. If you are running an older binary, upgrade once manually:gh release download --repo handarbeit/fabrik \ --pattern "fabrik_*_$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').tar.gz" \ -O - | tar xzAfter that,
--auto-upgradewill keep you current automatically.
Startup Board Validation
On startup, Fabrik attempts to fetch the project board and status field so it can compare stage names in your YAML configs against the column names on the board. When that metadata is fetched successfully, any non-cleanup stage missing from the board causes Fabrik to exit with a detailed error listing the mismatched names — catching config drift before any work begins. Extra board columns without a matching stage produce a warning but do not block startup. If Fabrik cannot fetch the board or status field, it logs a warning and continues without blocking startup.
See §11 Troubleshooting → Startup Board Validation Failure if validation succeeds and reports a mismatch.
Board-drift warning scan: In addition to name-matching, Fabrik scans all board items on startup and checks for cards that carry a cleanup-stage complete label (e.g. stage:Done:complete) but whose current board column does not yet reflect the completed stage. For each such item, Fabrik emits a warning log line:
[#N startup] warning: item #N has label stage:Done:complete but board column is "Validate" — board drift detected
This scan is informational only — no auto-advance occurs. Move the card to the correct column manually on the GitHub Project board.
Stage YAML Drift Warning
After loading your stage YAMLs from .fabrik/stages/, Fabrik compares each stage against the embedded default with the same name: field. If the embedded default contains top-level YAML keys that your file does not, Fabrik prints a warning to stderr at startup — but only when adding that key would actually change the stage’s behavior. A missing key whose embedded-default value is behaviorally identical to omitting it (e.g. kill_grace: {sigint: 10s, sigterm: 10s}, which matches the engine’s own inherit-on-omission default) is not reported.
[startup] warning: .fabrik/stages/validate.yaml is missing fields present in v0.0.53 defaults: wait_for_ci, wait_for_reviews. Run `fabrik refresh-stages --apply` to add the missing keys.
This warning is informational only — the engine continues running with your existing config. The missing keys are behavioral options added in a newer binary that your stage file predates.
What to do:
- Run
fabrik refresh-stagesto preview missing keys as a unified diff (no file changes). - Run
fabrik refresh-stages --applyto add the missing keys to your stage YAMLs (additive only — never removes or overwrites existing values). - Run
git diffto review the changes. git committo record the update.
fabrik refresh-stages uses the same value-aware comparison as the startup warning, so it never offers to add a key that drift detection considers a no-op.
Common keys that trigger this warning:
wait_for_ci: true— enables the CI gate on Validate auto-advance (added in v0.0.49)wait_for_reviews: true— enables the reviewer gate on Validate auto-advance (added in v0.0.49)
Keys that do NOT trigger this warning at their default value (safe to omit; the engine treats an absent key identically to the value shown):
kill_grace: {sigint: 10s, sigterm: 10s}— signal escalation grace windows; the engine’s own fallback is 10s/10s, so an absentkill_graceblock behaves identically. A different value (e.g.sigint: 0s, which skips the SIGINT step) is a real behavioral change and still triggers the warning if missing.completion: {type: claude}— the engine defaultscompletion.typetoclaudewhen the key is absent, so this block is a no-op at its default value.
Custom stages (names not present in any embedded default) are silently skipped — no warning is produced for them.
refresh-stages only patches existing files — it does not create new ones. It matches your local .fabrik/stages/*.yaml files against the embedded defaults by name: and adds missing keys to files that already exist locally. It has no “file is missing entirely” path. This matters for stages/examples/backlog.yaml (unmanaged: true), the default Backlog stage introduced alongside the unmanaged flag: new installs get it automatically via fabrik init, which extracts every embedded example file. Existing installs that ran fabrik init before backlog.yaml existed do not get it from refresh-stages — there is nothing locally named Backlog for it to match against. Those installs keep working unchanged (the hardcoded "Backlog" recognition in startup validation is the compat net — see Startup Board Validation); to opt in to the declarative form, re-run fabrik init (no --force needed — it only writes stage files that don’t already exist locally, so backlog.yaml is added without touching your other stage configs or .fabrik/config.yaml).
Instance Lock
Note: When Fabrik starts, it creates a PID lock file at
.fabrik/fabrik.lock. If a second instance attempts to start in the same directory, it reads the lock file, logs an error identifying the running process, and exits immediately. The lock is automatically released when the process exits — including on crash or SIGKILL — so there is no need to manually delete the file after an unclean shutdown.See §11 Troubleshooting → Multiple Fabrik Instances if you encounter a stale lock or need to run multiple instances against different projects.
Graceful Shutdown
Pressing Ctrl-C (bare CLI) or quitting the TUI (q or Ctrl-C — both raise the same SIGTERM) triggers
a clean stop, not a kill:
- Stop admitting new work. Fabrik stops dispatching new stage invocations immediately.
- Pause every in-flight issue durably. Every issue with a live worker gets
fabrik:paused+fabrik:awaiting-inputapplied on GitHub, plus a one-time audit comment naming the stage that was interrupted and instructing you to removefabrik:pausedto resume.stage:<Name>:in_progressis cleared at the same time — the board reflects “parked by a clean stop,” not “still running.” - Commit and push in-progress worktree changes. Any uncommitted work in the worktree is
committed (
chore: partial <Stage> stage progress (incomplete)) and pushed to the remote branch — nothing is silently discarded. - Wind down Claude workers within a bounded deadline. Each worker gets the existing
SIGINT→SIGTERM→SIGKILL escalation (
--kill-grace-sigint/--kill-grace-sigterm), and the entire drain — workers plus the pause-write phase above — is bounded by--drain-deadline(default 30 seconds, comfortably above the kill-grace escalation’s ~20s worst case). If the deadline passes with work still outstanding, Fabrik logs a warning and exits anyway rather than hanging indefinitely.
Resuming is exactly the same as resuming any other paused issue: remove fabrik:paused (and
fabrik:awaiting-input, if you want to skip straight back into dispatch — it also self-clears on the
stage’s next entry). The stage picks back up from the pushed partial-progress commit.
Force-quit is still available as an escape hatch if the clean stop itself gets stuck: press
Ctrl-C (or send SIGINT/SIGTERM) a second time while “shutting down gracefully” is in progress.
This exits immediately (os.Exit(1)) with no further label writes or worktree commits — use it only
when the ordinary clean stop is visibly wedged.
An idle daemon (no workers in flight) stops exactly as fast and quietly as it always did — the pause phase writes nothing when there is nothing to pause.
See --drain-deadline/FABRIK_DRAIN_DEADLINE in §2 Configuration Reference to change the drain bound, and ADR-1393 for the full design record.
Worktree Janitor
Fabrik’s normal worktree cleanup runs only when the Done stage (cleanup_worktree: true) is dispatched for an issue. Several situations strand worktrees outside that path:
- Issues removed from the project board (manually archived, deleted, or cleared by the auto-archive pass) are no longer iterated by the poll loop.
- A narrow restart window between Validate completing and Done being dispatched.
- Stage YAML drift — if the Done stage is renamed or removed, its worktrees are never cleaned up.
The worktree janitor scans .fabrik/worktrees/<owner>-<repo>/issue-N/ directories and reaps orphaned worktrees automatically. It runs:
- Once at startup, after the first successful poll (so board state is hydrated).
- Hourly thereafter (configurable; 0 disables).
A worktree is only reaped when all four conditions hold: the issue is closed; the issue is not on the board at an active stage (or it’s at a cleanup stage that already completed); the worktree is clean (no uncommitted changes); and no worker is currently dispatched for the issue. This conservative gate prevents false positives.
At the end of each scan Fabrik logs:
[janitor] cycle complete: scanned N worktrees, reaped K, skipped M (reasons: ...)
Configuration:
# .fabrik/config.yaml
janitor_interval_hours: 1 # default — set to 0 to disable the janitor entirely
Also controllable via --janitor-interval flag or FABRIK_JANITOR_INTERVAL environment variable.
Note (EC-6): Changing
janitor_interval_hoursat runtime has no effect. Restart Fabrik for a new cadence to take effect.
Log Retention
.fabrik/logs/ grows unbounded by default — each Claude invocation writes uniquely-named log files that are never overwritten. On a busy instance running for weeks, this can reach hundreds of megabytes or more.
The log retention janitor prunes .fabrik/logs/ by age and total size. It runs on the same cadence as the worktree janitor (disabled when janitor_interval_hours: 0):
- Once at startup, after the first successful poll.
- Hourly thereafter (same
janitor_interval_hourscadence).
Pruning runs in three phases per cycle:
- Age prune: delete any file whose mtime is older than
log_retention_days× 24 h (default 14 days).0disables age-based pruning. - Size-cap backstop: after age-pruning, if the total size of
.fabrik/logs/still exceedslog_max_bytes, delete the oldest files (by mtime) until the tree is under the cap. Default 2 GiB.0disables the size cap. - Empty-dir cleanup: remove any now-empty
issue-N/and<owner>-<repo>/directories.
The janitor only ever touches .fabrik/logs/. It never modifies worktrees/, repos/, or debug/. (sessions/ has its own janitor — see Session Janitor below.)
At the end of each scan Fabrik logs:
[log-janitor] cycle complete: scanned N files, removed M (P bytes), age=K size=J
Configuration:
# .fabrik/config.yaml
log_retention_days: 14 # default — set to 0 to disable age-based pruning
log_max_bytes: 2147483648 # default (2 GiB) — set to 0 to disable size-cap pruning
Also controllable via --log-retention-days / --log-max-bytes flags or FABRIK_LOG_RETENTION_DAYS / FABRIK_LOG_MAX_BYTES environment variables.
Note: Because the prune runs on every tick (not only at startup), the size-cap backstop only has to absorb at most one tick-interval of log growth. An instance producing more than
log_max_byteswithin a singlejanitor_interval_hourswindow can transiently exceed the cap until the next tick — the hourly default makes this a non-issue in practice.
Session Janitor
.fabrik/sessions/ stores one .session file per (issue, stage) pair — a pointer to the Claude Code conversation Fabrik resumes from on the next invocation. Nothing else in Fabrik ever deletes these files, so on a long-running instance the vast majority end up pointing at conversations Claude Code has already reaped on its own cleanupPeriodDays schedule (default 30 days) — dead pointers that just accumulate.
The session janitor prunes .fabrik/sessions/ by age. It runs on the same cadence as the worktree and log janitors (disabled when janitor_interval_hours: 0):
- Once at startup, after the first successful poll.
- Hourly thereafter (same
janitor_interval_hourscadence).
Each cycle deletes any .session file whose mtime is older than session_retention_days × 24 h (default 14 days; 0 disables age-based pruning), for both directory layouts Fabrik produces (.fabrik/sessions/issue-N/ for single-repo projects, .fabrik/sessions/<owner>-<repo>/issue-N/ for multi-repo). Now-empty issue-N/ and <owner>-<repo>/ directories left behind are removed as well.
A session file for a stage currently in flight is never deleted, regardless of age: if an issue has a live worker registered, every .session file under that issue’s session directory is skipped for the cycle — not just the file for the worker’s current stage. This is deliberately conservative: it avoids a race where the active stage changes mid-cycle between the age check and the delete.
Pruning is age-based only, not liveness-checking against Claude Code’s own transcript store — see ADR 1136 for the tradeoff. One consequence worth knowing: an issue that sits fabrik:paused (e.g. awaiting human input) for longer than session_retention_days has no in-flight worker, so its session file can be pruned mid-pause even though the underlying Claude Code conversation may still be resumable for up to 30 days. This is by design — the janitor only protects “in flight,” not “paused-but-will-resume” — and the consequence is bounded: the next invocation just starts a fresh Claude session instead of resuming, the same graceful fallback used when a session pointer goes dead for any other reason.
At the end of each scan Fabrik logs:
[session-janitor] cycle complete: scanned N session files, removed M, skipped K (in-flight=I, unresolved-repo=U)
Configuration:
# .fabrik/config.yaml
session_retention_days: 14 # default — set to 0 to disable age-based pruning
Also controllable via --session-retention-days flag or FABRIK_SESSION_RETENTION_DAYS environment variable.
2. Configuration Reference
Settings Overview
Fabrik resolves settings in this order (highest to lowest priority):
CLI flag > shell env var > .env file > .fabrik/config.yaml > built-in default
Use .fabrik/config.yaml for non-secret project settings (commit it to git).
Use .env for secrets only (FABRIK_TOKEN / GITHUB_TOKEN).
.fabrik/config.yaml
Generated by fabrik init. Commit this file — it carries project settings with the repo.
# .fabrik/config.yaml
owner: your-org
# repo: your-repo # omit for multi-repo mode (processes all repos on the board)
project: 1
user: your-github-username
# Optional settings (defaults shown):
# GitHub Enterprise Server hostname (no scheme, no trailing slash). Absent
# (the default) means github.com — byte-identical to today's behavior. See
# "GitHub Enterprise Server Support" above for the full effect and the
# minimum supported version (3.19).
# ghes_host: ""
# Path to stage YAML configs directory.
# stages: ./.fabrik/stages
# Polling interval in seconds. Lower values are more responsive; higher values
# reduce GitHub API usage. Tradeoff: 10s is very responsive but consumes ~360 REST
# requests/hour; 30s (default) is a good balance.
# poll: 30
# Maximum number of parallel Claude sessions. Tune based on your API tier capacity.
# Each active session counts against your Anthropic API concurrency limit.
# max_concurrent: 5
# Maximum stage failures before pausing an issue. When exceeded, fabrik:paused and
# stage:<name>:failed labels are applied. Set to 0 for unlimited retries.
# max_retries: 3
# Auto-advance issues through stages without human card moves on the board.
# When true, completed issues advance to the next stage automatically.
# Per-stage auto_advance: in stage YAML can override this setting per-stage.
# yolo: false
# Check handarbeit/fabrik GitHub Releases for a newer version. Fabrik checks once
# at startup (before the first poll) and again after 2 consecutive idle polls.
# On finding a newer release, it downloads the binary, runs fabrik upgrade, and
# re-execs. Requires internet access to the GitHub Releases API.
# auto_upgrade: false
# Disable the interactive TUI dashboard (enabled by default when a real terminal is detected).
# tui: false
# Save raw Claude output to .fabrik/debug/ for diagnosing unexpected behavior
# or prompt issues. Files are named by issue number and stage.
# debug_output: false
# When true, creates a relative symlink at <worktree>/.env pointing to the
# fabrikDir .env file whenever a worktree is set up. Lets stage code read
# credentials (e.g. ANTHROPIC_API_KEY) without copying secrets. No-op when
# the source .env is absent; never overwrites an existing .env in the worktree;
# also excluded from git stash via the worktree's git exclude file.
# symlink_env: false
# Project version shown in the TUI footer. Auto-inferred from package.json,
# go.mod (returns module path, not semver), Cargo.toml, or pyproject.toml.
# Set explicitly to override auto-inference (e.g., version: "1.2.0").
# version: ""
# When true, enables the Layer 2 cross-repo ref audit: Fabrik snapshots git refs
# of all registered bare repos before and after each Claude invocation; if any ref
# in a repo other than the active issue's own repo changes, the stage is failed and
# paused for inspection. Off by default; the snapshot filters to refs/heads/* and
# refs/tags/* only to avoid false positives from routine fetches.
# worktree_boundary_audit: false
# Worktree janitor scan interval in hours (default 1). The janitor runs once after
# startup and then on this cadence, reaping clean worktrees for closed, off-board
# issues. Set to 0 to disable the janitor entirely.
# janitor_interval_hours: 1
# Log retention janitor settings. Runs on the same cadence as janitor_interval_hours.
# Delete log files older than this many days (default 14). Set to 0 to disable
# age-based pruning.
# log_retention_days: 14
# Total size cap for .fabrik/logs/ in bytes (default 2 GiB). After age-pruning,
# oldest files are deleted first until total size is under this cap. Set to 0
# to disable size-cap pruning.
# log_max_bytes: 2147483648
# Session janitor: delete .fabrik/sessions/ .session files older than this many
# days (default 14). Runs on the same janitor_interval_hours cadence. Never
# deletes a session file for a stage currently in flight. Set to 0 to disable
# age-based pruning.
# session_retention_days: 14
# Required status/check-run context names the ci-gate must see confirmed
# `success` for, on the PR's exact head SHA, before it will clear (ADR-933).
# Keyed by "owner/repo" (works in both single-repo and multi-repo mode).
# Only needed for repos whose required CI signal isn't a normal GitHub Actions
# check run — e.g. a "local-CI-takeover" repo that posts a classic commit
# status out-of-band (GitHub Actions disabled) instead. Without this configured,
# Fabrik cannot tell a required context that never ran apart from a check that
# was correctly skipped, and — for repos in that situation — could otherwise
# record a head as CI-green when the real required signal never posted.
# Leave unconfigured for a normal repo where all required checks are GitHub
# Actions check runs; behavior is completely unchanged in that case.
# required_status_contexts:
# your-org/your-repo:
# - fantasy/local-test
# Fabrik-internal merge train (ADR-059). When "on", yolo Validate completions
# advance into the Queued column for batched landing with a single combined
# Validate instead of merging one PR at a time. Off by default — see the
# "Merge Train" section below before enabling.
# merge_train: off
# Merge-train batch-tuning knobs (only consulted when merge_train: on).
# Maximum Queued items landed in a single batch.
# max_batch_size: 5
# Maximum combined validations per red batch before degrading to landing
# members one-at-a-time. Unset derives 2*ceil(log2(max_batch_size))+1.
# max_bisect_validations: 0
# Maximum main-moved rebase+revalidate cycles before a batch is dissolved
# back to Queued.
# max_train_rebase_cycles: 3
# Runaway guard (ADR-059 §D8): maximum trial-branch creations with zero
# successful lands within the rolling window before pausing all Queued members.
# max_train_trials_per_window: 20
# Runaway guard (ADR-059 §D8): rolling window in minutes over which
# max_train_trials_per_window is measured.
# train_trial_window: 60
# Comment-processing circuit breaker (#1089): maximum non-advancing
# comment-processing invocations for a single issue within the rolling window
# before pausing it with fabrik:paused + fabrik:awaiting-input. Defense-in-depth
# backstop against a self-sustaining comment loop (see incident #1083).
# max_comment_cycles_per_window: 10
# Comment-processing circuit breaker (#1089): rolling window in minutes over
# which max_comment_cycles_per_window is measured.
# comment_cycle_window: 30
# Success-agnostic comment-processing circuit breaker (#1555): maximum
# consecutive comment-processing invocations for the same issue and stage that
# produce no observable progress (no commit, no issue-body update, no
# FABRIK_STAGE_COMPLETE) before pausing — regardless of whether each
# invocation itself exited successfully, and regardless of how the window
# above measures time. Sibling of max_comment_cycles_per_window: that one
# bounds a burst of invocations within a rolling window; this one bounds a
# loop whose invocations are spaced sparser than any window. Deliberately
# higher than max_review_cycles's default (5): both counters observe the
# same dispatch funnel (this one also counts review-reinvoke cycles), but
# only max_review_cycles is refunded on a no-op reinvoke — a bot reviewer
# is meant to be tolerated indefinitely (ADR-1518), so this threshold needs
# enough headroom above ordinary duplicate-bot-delivery churn (observed up
# to 4 consecutive rounds in normal operation) that it doesn't silently
# override that guarantee.
# max_no_op_comment_cycles: 10
Multi-repo mode: When repo: is commented out or omitted, Fabrik processes issues from all repositories on the project board. Use this when your project board spans multiple repos (cross-org collaborations, monorepos with independent sub-repos, or a single board managing several distinct services). To restrict Fabrik to one repository, uncomment and set repo:.
Note: .fabrik/config.yaml should NOT be listed in .gitignore. Fabrik warns
(non-fatal) if it is.
.env File
Keep only secrets here. When a .git/ directory is present, Fabrik refuses to start if .env exists but is not listed in .gitignore (prevents accidental token leaks). In directories without .git/ (containers, CI, bare directories), this check is skipped and .env is loaded normally.
# .env (gitignored)
# Classic personal access token (ghp_...) required — see https://github.com/settings/tokens
FABRIK_TOKEN=ghp_... # Preferred token env var (needs repo, project, workflow scopes)
GITHUB_TOKEN=ghp_... # Fallback token env var
For per-developer identity overrides (when your username differs from config.yaml):
FABRIK_USER=my-personal-username
Command-Line Flags
| Flag | Description | Default |
|---|---|---|
--owner |
GitHub repo owner (org or user) | required |
--repo |
GitHub repo name; omit to enable multi-repo mode (processes all repos on the board) | optional |
--project |
GitHub Project (v2) number | required |
--user |
Your GitHub username — used as the operator identity for lock labels (fabrik:locked:<user>), multi-instance tie-breaking, and engine-generated comment authorship; also @mentioned in awaiting-input notification comments to trigger a GitHub Mobile push when an issue blocks on your input |
required |
--token |
GitHub API token | $GITHUB_TOKEN |
--stages |
Directory containing stage YAML configs | ./.fabrik/stages |
--yolo |
Auto-advance issues through stages without human approval; also auto-merges the linked PR when Validate completes | false |
--auto-upgrade |
At startup and when idle (after 2 idle polls), check GitHub Releases for a newer version and self-upgrade; dev builds (built from source) rebuild from origin/main instead |
false |
--notui |
Disable the interactive TUI dashboard | TUI on by default |
--plugin-dir |
Path to Fabrik plugin directory (overrides .fabrik/plugin/) |
auto-detected |
--poll |
Poll interval in seconds | 30 |
--max-concurrent |
Maximum number of concurrent issue workers | 5 |
--max-retries |
Max failed stage attempts before pausing the issue (0 = unlimited). Counts genuine failures only — a turn-cap preemption never counts here; see --max-slice-retries |
3 |
--max-slice-retries |
Maximum number of turn-cap preemption cycles per stage before pausing (0 = use default of 10; also FABRIK_MAX_SLICE_RETRIES). A large job that resumes across several slices is not a failure and is bounded separately from --max-retries — see the “Max retries” troubleshooting note below |
0 (10 slices) |
--max-resume-failures |
Maximum number of consecutive failed --resume attempts for one (issue, stage) session before Fabrik discards the session pointer and cold-starts the next invocation instead of resuming (0 = use default of 2; also FABRIK_MAX_RESUME_FAILURES). Independent of --max-retries — a resume failure never consumes a retry slot, so the guaranteed cold-start attempt can’t be starved by the failures it exists to recover from. See the “Resume failures” troubleshooting note below |
0 (2 failures) |
--max-tools-denied-retries |
Maximum number of consecutive tool-permission-denial exits per stage before pausing (0 = use default of 3; also FABRIK_MAX_TOOLS_DENIED_RETRIES). Independent of --max-retries — a permission misconfiguration is not a stage failure, so it never consumes a retry slot. See the “Tool permission denied” troubleshooting note below |
0 (3 denials) |
--review-wait-timeout |
Minutes to wait for all requested PR reviewers before advancing (0 = use default of 15; also FABRIK_REVIEW_WAIT_TIMEOUT) |
0 (15 min) |
--max-review-cycles |
Maximum number of review-and-fix cycles per issue (0 = use default of 5; also FABRIK_MAX_REVIEW_CYCLES) |
0 (5 cycles) |
--ci-wait-timeout |
Minutes of CI inactivity (no observable progress) before pausing — not total CI wait time; a suite that keeps reporting fresh check-run activity waits indefinitely, however long it takes (0 = use default of 30; also FABRIK_CI_WAIT_TIMEOUT; ADR-1410) |
0 (30 min) |
--ci-backstop-timeout |
Absolute cap in minutes on how long an item may sit in fabrik:awaiting-ci under any classification, bounding per-poll cost independent of CI duration — sized much larger than --ci-wait-timeout on purpose (0 = use default of 240 = 4h; also FABRIK_CI_BACKSTOP_TIMEOUT; ADR-1410) |
0 (240 min / 4h) |
--post-push-dwell |
Seconds to wait after a PR force-push before clearing the CI gate as “no CI configured” (0 = use default of 90; also FABRIK_POST_PUSH_DWELL). Prevents premature gate-clear in the brief post-push window when GitHub has not yet computed mergeability or started CI for the new SHA. |
0 (90 sec) |
--worker-stale-timeout |
Minutes before a stale worker heartbeat triggers PID-liveness check and handle clearing (0 = use default of 5; must be > heartbeatInterval×2; also FABRIK_WORKER_STALE_TIMEOUT) |
0 (5 min) |
--max-ci-fix-cycles |
Maximum number of CI-fix re-invocation cycles per issue (0 = use default of 5; also FABRIK_MAX_CI_FIX_CYCLES) |
0 (5 cycles) |
--max-rebase-cycles |
Maximum number of rebase re-invocation cycles per issue before pausing (0 = use default of 3; also FABRIK_MAX_REBASE_CYCLES) |
0 (3 cycles) |
--max-enqueue-cycles |
Maximum number of merge-queue re-enqueue cycles per issue before pausing (0 = use default of 5; also FABRIK_MAX_ENQUEUE_CYCLES). Bounds a queue-thrash loop when a yolo PR is repeatedly ejected from GitHub’s merge queue and re-enqueued without merging. |
0 (5 cycles) |
--convergence-budget |
Wall-clock budget for post-Validate yolo PR convergence (Go duration: 30m, 1h; 0 disables the budget entirely; also FABRIK_CONVERGENCE_BUDGET) |
30m |
--auto-merge-strategy |
Merge method Fabrik attempts first, for both GitHub native auto-merge (yolo PRs) and direct merges (e.g. merge-train landings): MERGE, SQUASH, or REBASE; falls back to a repo-allowed method if the configured one is disallowed (also FABRIK_AUTO_MERGE_STRATEGY) |
MERGE |
--merge-queue |
Merge queue routing for yolo path: auto (enqueue when repo requires merge queue) or off (disable enqueue; yolo merges may fail with HTTP 405 on queue-required repos). Also FABRIK_MERGE_QUEUE |
auto |
--merge-train |
Fabrik-internal merge train (ADR-059): on advances yolo Validate completions into the Queued column and lands them in batches with a single combined Validate (the plan/host-agnostic answer where GitHub’s native merge queue is unavailable); off keeps the existing per-PR auto-merge path. Also FABRIK_MERGE_TRAIN |
off |
--max-batch-size |
Maximum Queued items landed in a single merge-train batch, ordered by entry (0 = use default of 5; also FABRIK_MAX_BATCH_SIZE). Trade-off: smaller batches → cheaper worst-case bisection when a batch is red, but fewer of the N² CI savings the train exists to capture; larger batches → more savings, but a costlier worst-case bisection. |
0 (5) |
--max-bisect-validations |
Maximum combined validations per red merge-train batch (the initial red validation plus all halving-bisection trials) before degrading to landing members one-at-a-time (0 = derive 2·⌈log₂(max-batch-size)⌉ + 1, ≈ 7 at the default batch size; also FABRIK_MAX_BISECT_VALIDATIONS). The fallback is logged, never silent. |
0 (derived, ≈7) |
--max-train-rebase-cycles |
Maximum main-moved rebase+revalidate cycles for a merge-train batch before it is dissolved back to Queued (0 = use default of 3; also FABRIK_MAX_TRAIN_REBASE_CYCLES). When an external direct push advances the base branch under an in-flight batch, the trial branch is rebased onto the new base and the combined Validate re-runs before landing; after this many failed catch-up attempts the batch is dissolved (integration PR closed, trial branch deleted, members left untouched in Queued) and a fresh train forms on the next poll. |
0 (3) |
--max-train-trials-per-window |
Runaway guard (ADR-059 §D8): maximum trial-branch creations with zero successful lands within the rolling window before pausing all Queued members (0 = use default of 20; also FABRIK_MAX_TRAIN_TRIALS_PER_WINDOW). Composition-agnostic — counts trials regardless of which batch composition produced them. |
0 (20) |
--train-trial-window |
Runaway guard (ADR-059 §D8): rolling window in minutes over which --max-train-trials-per-window is measured (0 = use default of 60; also FABRIK_TRAIN_TRIAL_WINDOW). |
0 (60 min) |
--max-comment-cycles-per-window |
Comment-processing circuit breaker (#1089): maximum non-advancing comment-processing invocations for a single issue within the window before pausing it with fabrik:paused + fabrik:awaiting-input (0 = use default of 10; also FABRIK_MAX_COMMENT_CYCLES_PER_WINDOW). Defense-in-depth backstop of last resort against a self-sustaining comment loop (see incident #1083) — resets on any forward progress (stage completion, new commit, PR state change, issue-body edit, or manual unpause). |
0 (10) |
--comment-cycle-window |
Comment-processing circuit breaker (#1089): rolling window in minutes over which --max-comment-cycles-per-window is measured (0 = use default of 30; also FABRIK_COMMENT_CYCLE_WINDOW). |
0 (30 min) |
--max-no-op-comment-cycles |
Success-agnostic comment-processing circuit breaker (#1555): maximum consecutive comment-processing invocations for the same issue and stage that produce no observable progress (no commit, no issue-body update, no FABRIK_STAGE_COMPLETE) before pausing with fabrik:paused + fabrik:awaiting-input — regardless of whether each invocation itself exited successfully (0 = use default of 10; also FABRIK_MAX_NO_OP_COMMENT_CYCLES). Sibling of --max-comment-cycles-per-window: bounds a loop whose invocations are spaced sparser than any rolling window can see. Deliberately higher than --max-review-cycles’s default (5) — see max_no_op_comment_cycles above. |
0 (10) |
--claude-wait-delay |
Seconds to wait after Claude exits before recovering buffered output; prevents worker goroutines from blocking when Claude uses run_in_background or the Monitor tool, which can hold stdout open after the main Claude process exits (0 = use built-in default of 30 sec; also FABRIK_CLAUDE_WAIT_DELAY) |
0 (30 sec) |
--janitor-interval |
Hours between janitor runs (closed-issue cleanup, stale-label eviction); 0 disables the janitor; also FABRIK_JANITOR_INTERVAL |
1 |
--log-retention-days |
Delete .fabrik/logs/ files older than this many days; 0 disables age-based pruning; also FABRIK_LOG_RETENTION_DAYS |
14 |
--log-max-bytes |
Total size cap for .fabrik/logs/ in bytes; oldest files deleted first after age prune; 0 disables size-cap pruning; also FABRIK_LOG_MAX_BYTES |
2147483648 (2 GiB) |
--session-retention-days |
Delete .fabrik/sessions/ .session files older than this many days; 0 disables age-based pruning; never deletes a session file for a stage currently in flight; also FABRIK_SESSION_RETENTION_DAYS |
14 |
--kill-grace-sigint |
Grace window between SIGINT and SIGTERM when killing the Claude process group (Go duration: 5s, 10s; empty string = use default of 10s; "0s" = skip SIGINT entirely; also FABRIK_KILL_GRACE_SIGINT) |
"" (10s) |
--kill-grace-sigterm |
Grace window between SIGTERM and SIGKILL when killing the Claude process group (Go duration: 5s, 10s; empty string = use default of 10s; "0s" = skip SIGTERM entirely; also FABRIK_KILL_GRACE_SIGTERM) |
"" (10s) |
--drain-deadline |
Bound on a clean stop’s worker drain — how long Run() waits, after the first SIGINT/SIGTERM, for in-flight workers and the shutdown-pause write phase (see Graceful Shutdown) before returning anyway (Go duration: 30s, 1m; unlike --kill-grace-*, "0s" is not a “wait forever” sentinel — any non-positive value falls back to the default with a warning; also FABRIK_DRAIN_DEADLINE). Should stay comfortably above --kill-grace-sigint + --kill-grace-sigterm, or the drain will preempt the kill escalation before it can complete — a startup warning fires if it doesn’t. |
"" (30s) |
--archive-after |
Grace period since an item settled into Done (its stage:<Done>:complete label was applied) before it is auto-archived off the project board (Go duration: 168h, 24h; "0s" archives immediately once eligible; also FABRIK_ARCHIVE_AFTER) |
"" (168h = 1 week) |
--archive-done |
Auto-archive Done items after --archive-after elapses: on or off; also FABRIK_ARCHIVE_DONE |
"" (on) |
--debug-output |
Save Claude stage output to .fabrik/debug/ |
false |
--symlink-env |
Create a relative symlink at <worktree>/.env pointing to the fabrikDir .env file at worktree setup time. Enables stage code to read credentials (e.g. ANTHROPIC_API_KEY) from .env without copying secrets. No-op when source .env is absent; never overwrites an existing .env in the worktree; also excluded from git stash via the worktree’s git exclude file. Also FABRIK_SYMLINK_ENV |
false |
--ghes-host |
GitHub Enterprise Server hostname (no scheme, no trailing slash), e.g. github.example.com. Governs the REST/GraphQL API endpoints, bare-clone URLs, commit-author noreply email, GH_HOST in stage-worker environments, and the startup minimum-version preflight. Absent (default) means github.com, byte-identical to today’s behavior. Also FABRIK_GHES_HOST. See GitHub Enterprise Server Support. |
"" (github.com) |
Unrecognized config.yaml Key Warnings
go-yaml silently discards any .fabrik/config.yaml key with no matching field on Fabrik’s internal config struct — so a typo, a stale key left over from a past experiment, or an entry for a knob that’s actually CLI/env-only (see the table below) parses cleanly and does nothing, with no error anywhere. At startup, Fabrik now detects every such key and reports it — non-fatal, on stderr:
[startup] warning: .fabrik/config.yaml: unrecognised key "archive_after"
(this knob is CLI/env-only — set FABRIK_ARCHIVE_AFTER or -archive-after)
A key with no matching flag or env var at all (a plain typo, never a real knob) gets only the base warning line, with no suggestion clause. This never fails startup — it’s purely diagnostic, mirroring the Stage YAML Drift Warning’s “warn, don’t block” behavior for the inverse case (an extra key here, vs. a missing one there).
The flag/env suggestion is derived mechanically from Fabrik’s snake_case (config.yaml) ↔ kebab-case (CLI flag) ↔ FABRIK_SCREAMING_SNAKE_CASE (env var) naming convention, verified live against the registered flag set — not a hand-maintained list, so it can’t go stale as new knobs are added. Four already-recognized config.yaml keys break that convention (git_ssh vs. --ssh, tui vs. --notui/FABRIK_TUI (inverted), project vs. FABRIK_PROJECT_NUMBER, janitor_interval_hours vs. --janitor-interval) — for these four only, typo’ing the key in a way that happens to match the other name can produce a misleading “CLI/env-only” suggestion even though a config.yaml key already exists, just spelled differently. See ADR-1544.
Environment Variables
| Variable | config.yaml key |
Description | Default |
|---|---|---|---|
FABRIK_TOKEN |
(secrets only) | GitHub classic personal access token (ghp_...) with repo, project, workflow scopes (preferred) |
required |
GITHUB_TOKEN |
(secrets only) | GitHub classic personal access token (ghp_...) — fallback when FABRIK_TOKEN is unset |
required |
FABRIK_OWNER |
owner |
GitHub repo owner | – |
FABRIK_REPO |
repo |
GitHub repo name; optional — omitting enables multi-repo mode (all repos on the board) | – |
FABRIK_PROJECT_NUMBER |
project |
GitHub Project (v2) number | – |
FABRIK_USER |
user |
Your GitHub username — operator identity for lock labels, tie-breaking, and @mention notifications | – |
FABRIK_STAGES |
stages |
Stage configs directory | ./.fabrik/stages |
FABRIK_YOLO |
yolo |
Auto-advance (true/1/yes) |
false |
FABRIK_POLL |
poll |
Poll interval in seconds | 30 |
FABRIK_MAX_CONCURRENT |
max_concurrent |
Max parallel Claude sessions | 5 |
FABRIK_MAX_RETRIES |
max_retries |
Max retries before pausing (0 = unlimited). Genuine failures only — see FABRIK_MAX_SLICE_RETRIES for turn-cap preemptions |
3 |
FABRIK_MAX_SLICE_RETRIES |
(no config.yaml key) | Maximum number of turn-cap preemption cycles per stage before pausing with fabrik:paused + fabrik:awaiting-input (positive integer; invalid or unset values default to 10). A large job resuming across several slices is not a failure and is bounded separately from max_retries. See --max-slice-retries. |
10 |
FABRIK_MAX_RESUME_FAILURES |
(no config.yaml key) | Maximum number of consecutive failed --resume attempts for one (issue, stage) session before Fabrik discards the session pointer and cold-starts (positive integer; invalid or unset values default to 2). Independent of max_retries — see --max-resume-failures. |
2 |
FABRIK_MAX_TOOLS_DENIED_RETRIES |
(no config.yaml key) | Maximum number of consecutive tool-permission-denial exits per stage before pausing with fabrik:paused + fabrik:awaiting-input (positive integer; invalid or unset values default to 3). Independent of max_retries — see --max-tools-denied-retries. |
3 |
FABRIK_AUTO_UPGRADE |
auto_upgrade |
Self-upgrade at startup and when idle (after 2 idle polls) (true/1/yes) |
false |
FABRIK_TUI |
tui |
Disable TUI dashboard (false/0/no) |
true |
FABRIK_PLUGIN_DIR |
(no config.yaml key) | Override plugin directory | .fabrik/plugin/ |
FABRIK_DEBUG_OUTPUT |
debug_output |
Save raw Claude output for debugging | false |
FABRIK_SYMLINK_ENV |
symlink_env |
Symlink fabrikDir .env into each worktree at setup time (true/1/yes) — see --symlink-env |
false |
FABRIK_REVIEW_WAIT_TIMEOUT |
(no config.yaml key) | Minutes to wait per review cycle at the review gate (whether waiting for requested reviewers to submit or for at least one review to exist) before pausing with fabrik:awaiting-input (positive integer; invalid or unset values default to 15) |
15 |
FABRIK_MAX_REVIEW_CYCLES |
(no config.yaml key) | Maximum number of review re-invocation cycles per issue before pausing with fabrik:awaiting-input (positive integer; invalid or unset values default to 5) |
5 |
FABRIK_CI_WAIT_TIMEOUT |
(no config.yaml key) | Minutes of CI inactivity before pausing with fabrik:awaiting-input — not total CI wait time (positive integer; invalid or unset values default to 30). See §3 CI Gate and ADR-1410. |
30 |
FABRIK_CI_BACKSTOP_TIMEOUT |
(no config.yaml key) | Absolute cap in minutes on how long an item may sit in fabrik:awaiting-ci under any classification, independent of CI duration (positive integer; invalid or unset values default to 240 = 4h). See §3 CI Gate and ADR-1410. |
240 |
FABRIK_POST_PUSH_DWELL |
(no config.yaml key) | Seconds to wait after a PR force-push before clearing the CI gate as “no CI configured” (non-negative integer; 0 or unset uses the default of 90; negative values default to 90). Prevents premature gate-clear during the post-push window when GitHub has not yet computed mergeability or started CI for the new SHA. |
90 |
FABRIK_WORKER_STALE_TIMEOUT |
(no config.yaml key) | Minutes before a stale worker heartbeat triggers PID-liveness check; if the PID is dead the handle is cleared so the item can be re-dispatched (positive integer; invalid or unset values default to 5; must be > heartbeatInterval×2, i.e., > 1 min) |
5 |
FABRIK_MAX_CI_FIX_CYCLES |
(no config.yaml key) | Maximum number of CI-fix re-invocation cycles per issue before pausing with fabrik:awaiting-input (positive integer; invalid or unset values default to 5) |
5 |
FABRIK_MAX_REBASE_CYCLES |
(no config.yaml key) | Maximum number of rebase re-invocation cycles per issue before pausing with fabrik:awaiting-input (positive integer; invalid or unset values default to 3). Lower than CI/review because rebase either succeeds in one shot or needs human judgment for a semantic conflict. |
3 |
FABRIK_MAX_ENQUEUE_CYCLES |
(no config.yaml key) | Maximum number of merge-queue re-enqueue cycles per issue before pausing with fabrik:awaiting-input (positive integer; invalid or unset values default to 5). Bounds a queue-thrash loop (a yolo PR repeatedly ejected from GitHub’s merge queue and re-enqueued without merging), independently of the rebase/CI-fix caps that the ejection sub-paths increment. |
5 |
FABRIK_MERGE_QUEUE |
(no config.yaml key) | Merge queue routing for the yolo path: auto (enqueue when the repo requires GitHub’s native merge queue) or off (disable enqueue; yolo merges may fail with HTTP 405 on queue-required repos). Invalid or unset values default to auto. See --merge-queue. |
auto |
FABRIK_MERGE_TRAIN |
merge_train |
Fabrik-internal merge train (ADR-059): on advances yolo Validate completions into the Queued column for batched landing with a single combined Validate; off keeps the existing per-PR auto-merge path. Invalid or unset values default to off. See --merge-train. |
off |
FABRIK_MAX_BATCH_SIZE |
max_batch_size |
Maximum Queued items landed in a single merge-train batch, ordered by entry (positive integer; invalid or unset values default to 5). Smaller = cheaper worst-case red-batch bisection but fewer N² CI savings; larger = more savings but costlier worst-case bisection. See --max-batch-size. |
5 |
FABRIK_MAX_BISECT_VALIDATIONS |
max_bisect_validations |
Maximum combined validations per red merge-train batch before degrading to one-at-a-time landing (positive integer; invalid or unset values derive 2·⌈log₂(max_batch_size)⌉ + 1, ≈ 7 at the default batch size). The fallback is logged, never silent. See --max-bisect-validations. |
derived (≈7) |
FABRIK_MAX_TRAIN_REBASE_CYCLES |
max_train_rebase_cycles |
Maximum main-moved rebase+revalidate cycles for a merge-train batch before dissolving it back to Queued (positive integer; invalid or unset values default to 3). On exhaustion the batch’s integration PR is closed and trial branch deleted, members remain in Queued, and a fresh train forms next poll. See --max-train-rebase-cycles. |
3 |
FABRIK_MAX_TRAIN_TRIALS_PER_WINDOW |
max_train_trials_per_window |
Runaway guard (ADR-059 §D8): maximum trial-branch creations with zero successful lands within the rolling window before pausing all Queued members (positive integer; invalid or unset values default to 20). See --max-train-trials-per-window. |
20 |
FABRIK_TRAIN_TRIAL_WINDOW |
train_trial_window |
Runaway guard (ADR-059 §D8): rolling window in minutes over which FABRIK_MAX_TRAIN_TRIALS_PER_WINDOW is measured (positive integer; invalid or unset values default to 60). See --train-trial-window. |
60 |
FABRIK_MAX_COMMENT_CYCLES_PER_WINDOW |
max_comment_cycles_per_window |
Comment-processing circuit breaker (#1089): maximum non-advancing comment-processing invocations for a single issue within the window before pausing it with fabrik:paused + fabrik:awaiting-input (positive integer; invalid or unset values default to 10). Defense-in-depth backstop of last resort against a self-sustaining comment loop (see incident #1083). See --max-comment-cycles-per-window. |
10 |
FABRIK_COMMENT_CYCLE_WINDOW |
comment_cycle_window |
Comment-processing circuit breaker (#1089): rolling window in minutes over which FABRIK_MAX_COMMENT_CYCLES_PER_WINDOW is measured (positive integer; invalid or unset values default to 30). See --comment-cycle-window. |
30 |
FABRIK_MAX_NO_OP_COMMENT_CYCLES |
max_no_op_comment_cycles |
Success-agnostic comment-processing circuit breaker (#1555): maximum consecutive comment-processing invocations for the same issue and stage that produce no observable progress before pausing, regardless of invocation success (positive integer; invalid or unset values default to 10). See --max-no-op-comment-cycles. |
10 |
FABRIK_CONVERGENCE_BUDGET |
(no config.yaml key) | Wall-clock budget for post-Validate yolo PR convergence (Go duration syntax: 30m, 1h, 2h30m; 0 disables; invalid values default to 30 min). When the budget expires and the PR has not merged, Fabrik pauses the issue with fabrik:awaiting-input. |
30m |
FABRIK_AUTO_MERGE_STRATEGY |
auto_merge_strategy |
Merge method Fabrik attempts first — both when calling GitHub’s enablePullRequestAutoMerge for yolo PRs and when merging a PR directly (e.g. merge-train landings). If the repository disallows the configured method, Fabrik falls back through the remaining allowed methods (logged) rather than failing. Accepted values: MERGE, SQUASH, REBASE. Invalid or unset values default to MERGE. |
MERGE |
FABRIK_CLAUDE_WAIT_DELAY |
(no config.yaml key) | Seconds to wait after Claude exits before recovering buffered output (non-negative integer; 0 or unset uses the default of 30; invalid values default to 30). Prevents worker goroutines from blocking indefinitely when Claude uses run_in_background or the Monitor tool, which can hold stdout open after the main Claude process exits. |
30 |
FABRIK_JANITOR_INTERVAL |
janitor_interval_hours |
Hours between janitor runs (non-negative integer; 0 disables the janitor) |
1 |
FABRIK_LOG_RETENTION_DAYS |
log_retention_days |
Delete log files older than this many days (non-negative integer; 0 disables age-based pruning) |
14 |
FABRIK_LOG_MAX_BYTES |
log_max_bytes |
Total size cap for .fabrik/logs/ in bytes; oldest files deleted first after age prune (0 disables size-cap pruning) |
2147483648 (2 GiB) |
FABRIK_SESSION_RETENTION_DAYS |
session_retention_days |
Delete .fabrik/sessions/ .session files older than this many days (non-negative integer; 0 disables age-based pruning; never deletes a session file for a stage currently in flight) |
14 |
FABRIK_KILL_GRACE_SIGINT |
(no config.yaml key) | Grace window between SIGINT and SIGTERM when killing the Claude process group (Go duration string: "5s", "10s"; empty or unset = use default of 10s; "0s" = skip SIGINT step entirely) |
"" (10s) |
FABRIK_KILL_GRACE_SIGTERM |
(no config.yaml key) | Grace window between SIGTERM and SIGKILL when killing the Claude process group (Go duration string: "5s", "10s"; empty or unset = use default of 10s; "0s" = skip SIGTERM step, SIGKILL fires immediately after SIGINT) |
"" (10s) |
FABRIK_DRAIN_DEADLINE |
(no config.yaml key) | Bound on a clean stop’s worker drain (Go duration string: "30s", "1m"; empty or unset = use default of 30s; unlike the KILL_GRACE_* variables, "0s" is not a “wait forever” sentinel — any non-positive or invalid value falls back to the default with a warning). See Graceful Shutdown. |
"" (30s) |
FABRIK_ARCHIVE_AFTER |
(no config.yaml key) | Grace period since an item settled into Done before it is auto-archived off the project board (Go duration string: "168h", "24h"; empty or unset = use default of 168h/1 week; "0s" archives immediately once eligible; invalid or negative values fall back to the default) |
"" (168h = 1 week) |
FABRIK_ARCHIVE_DONE |
(no config.yaml key) | Auto-archive Done items after FABRIK_ARCHIVE_AFTER elapses: "on" or "off" (case-insensitive; unrecognized values fall back to "on") |
"" (on) |
FABRIK_ANTHROPIC_API_KEY |
(no config.yaml key) | Explicit opt-in for API billing: when set and non-empty, translated into ANTHROPIC_API_KEY on every Claude worker invocation. The only supported way to obtain API billing through this variable — an ambient ANTHROPIC_API_KEY in the engine’s own environment is scrubbed and never reaches the worker on its own. Never forwarded to the worker itself; a one-time [startup] notice fires when active. See “Anthropic Auth Namespace Scrub & apiKeyHelper Refusal” below. |
– |
FABRIK_ANTHROPIC_ENV_PASSTHROUGH |
(no config.yaml key) | Comma-separated exact variable names to re-inherit unchanged from the engine’s ambient environment into the worker, overriding the Anthropic auth namespace scrub for only those names (e.g. Bedrock/Vertex selectors). Never forwarded to the worker itself; a one-time [startup] notice names which variables were passed through when non-empty. See “Anthropic Auth Namespace Scrub & apiKeyHelper Refusal” below. |
– |
FABRIK_GHES_HOST |
ghes_host |
GitHub Enterprise Server hostname (no scheme, no trailing slash). Absent means github.com, byte-identical to today’s behavior. See GitHub Enterprise Server Support. | "" (github.com) |
Token precedence: --token flag > FABRIK_TOKEN > GITHUB_TOKEN
Worker-Injected Environment Variables
The table above lists variables you set to configure the engine. Separately, on every Claude worker invocation Fabrik exports a small set of facts about that invocation into the worker subprocess’s own environment — visible only inside the running worktree, never read back by the engine. This is a supported extension point for repo-side provisioning: a script in your repo (a test-setup hook, a fixture generator, anything invoked from a stage’s own Bash tool calls) can key off these to provision or namespace a resource per worktree, per issue, or per PR — a database schema, a port, a container, a preview environment — without guessing at Fabrik’s internals. Fabrik only publishes the facts; your repo’s scripts own what happens with them.
| Variable | Value | Presence |
|---|---|---|
FABRIK_ISSUE |
The issue number (bare integer, e.g. 1085) |
Always |
FABRIK_REPO |
The owner/repo this invocation belongs to (the item’s repo — correct for multi-repo setups) |
Always |
FABRIK_WORKTREE |
Absolute path to the issue’s worktree | Always |
FABRIK_ROOT |
Absolute path to the directory containing .fabrik/ (config, stages, plugin) |
Always |
FABRIK_PR |
The linked pull request number | Only once a PR exists — unset (never 0) before then |
Not the same FABRIK_REPO. The FABRIK_REPO in this table is a different variable from the FABRIK_REPO in the config table above, despite the identical name. The config-table FABRIK_REPO is something you set before starting Fabrik, to tell the engine which repo to manage. This worker-injected FABRIK_REPO is something Fabrik sets, per invocation, inside the Claude subprocess only — it names whichever repo the current issue actually belongs to (relevant in multi-repo mode, where the config-table FABRIK_REPO is typically omitted entirely). The worker-injected value always wins inside the subprocess regardless of what the launching shell exported, so there is no functional collision — only a naming one worth knowing about if you’re scripting against both.
FABRIK_PR requires a REST fallback to resolve correctly on a base:<branch> repo (GitHub’s closedByPullRequestsReferences field, which the board-level PR link is normally sourced from, is structurally empty for any PR targeting a non-default base branch) — see docs/stage-lifecycle.md’s “Worker Environment: Invocation Facts” section for the full resolution rule, cost-control gating, and the deliberate partial case (merge-train conflict resolution never sets FABRIK_PR).
These five names are a compatibility surface — once a repo scripts against them, renaming is a breaking change for that repo. See ADR-1288 for the full design rationale, including why this is scoped to exposing facts rather than a worktree_created/worktree_removed lifecycle-hook mechanism.
Stage YAML Reference
Each stage is a YAML file in your stages directory. The filename is arbitrary; the
name field determines which board column it matches.
name: Research # Required. Must match a Project board column exactly.
order: 2 # Required. Lower values processed earlier in the pipeline.
skill: fabrik-research # Plugin skill to use (recommended; alternative to inline prompt).
# When set, Fabrik sends a minimal directive and Claude loads
# the skill methodology via the plugin system.
comment_skill: fabrik-research-comment # Plugin skill for comment review (overrides comment_prompt).
prompt: | # Inline prompt (used when skill is not set; legacy but still supported).
You are a research agent...
comment_prompt: | # Inline comment-review prompt (used when comment_skill is not set).
You are reviewing user comments...
model: sonnet # Optional. Claude model: "opus", "sonnet", etc.
max_turns: 50 # Optional. Max conversation turns per main stage invocation.
comment_max_turns: 15 # Optional. Max turns when processing user comments. Defaults to
# min(max_turns, 15) when max_turns is set, otherwise 15.
# Keeps comment processing bounded independently of stage budget.
max_wall_time: "45m" # Optional. Wall-clock deadline for a single Claude invocation.
# Accepts Go duration strings (e.g. "30m", "1h", "1h30m").
# When the deadline is reached, Fabrik sends SIGINT to the
# Claude process group, waits the kill_grace.sigint window
# (default 10 s), then SIGTERM, then the kill_grace.sigterm
# window (default 10 s), then SIGKILL (reaping any hung
# background children). Output collected before the kill is
# processed normally — if FABRIK_STAGE_COMPLETE appears in
# the streamed output, the stage is marked complete without a
# retry. When absent or zero, no wall-clock cap is applied.
# Recommended: "45m" for Implement and Review stages in
# production use. The hardcoded 15-minute inactivity timeout
# (see below) acts as a universal backstop even when this field
# is not set. Automatically scaled up when fabrik:extend-turns
# pre-grants a larger turn budget for a single invocation (e.g.
# 2x turns -> 2x wall time for that invocation only) — size this
# field for the ordinary case; the extended case gets
# proportionate headroom for free. See fabrik:extend-turns below.
kill_grace: # Optional. Per-signal grace windows for the kill sequence.
sigint: "10s" # How long to wait after SIGINT before escalating to SIGTERM.
# Empty or absent = inherit engine default (10s).
# "0s" = skip the SIGINT step (falls directly to SIGTERM → SIGKILL).
sigterm: "10s" # How long to wait after SIGTERM before sending SIGKILL.
# Empty or absent = inherit engine default (10s).
# "0s" = skip the SIGTERM step (SIGKILL fires immediately after SIGINT).
# Negative values are rejected at stage-config load time.
read_only: true # Optional. Stashes the dirty worktree before Claude runs and
# restores it after. Use for analysis stages that should not
# modify files (e.g., Specify, Research).
post_to_pr: true # Optional. Routes detailed Claude output to the linked PR; a
# brief summary is still posted on the issue. Falls back to
# posting on the issue if no linked PR is found.
create_draft_pr: true # Optional. Pushes the branch and creates a draft PR *before*
# Claude runs. Idempotent if a PR already exists.
mark_pr_ready_on_complete: true # Optional. Marks the draft PR as review-ready after the
# stage completes. Triggers external review bots.
auto_advance: false # Optional. Per-stage override for the global yolo setting.
# true = always auto-advance this stage (even if yolo: false)
# false = never auto-advance this stage (even if yolo: true)
# omit = inherit the global yolo setting
cleanup_worktree: false # Optional. Removes the issue worktree instead of invoking Claude.
# Use for terminal stages (e.g., Done) where no further work
# is needed on the branch.
holding_stage: false # Optional. Marks this stage as an engine-managed batch holding pen
# (e.g., Queued for merge-train). Items are never dispatched
# individually — the engine batch-handles them once per poll cycle.
unmanaged: false # Optional. Declares a "parking column" (e.g., Backlog) that Fabrik
# recognizes but runs no workflow for. Items in an unmanaged column
# are never dispatched and never auto-advanced — they sit until a
# human moves them to a real stage. See §Backlog and the Unmanaged
# Stage Flag below.
wait_for_reviews: false # Optional. When true, Fabrik waits for all requested PR reviewers
# to submit and re-invokes the stage agent via the comment-processing
# path to address submitted inline feedback. Re-invocation is
# unconditional — it fires regardless of the auto-advance setting.
# Auto-advancement to the next stage after re-invocation still requires
# auto-advance to be active. This loop repeats until no reviewers are
# pending (up to FABRIK_MAX_REVIEW_CYCLES cycles). On timeout or cycle
# limit, Fabrik pauses with fabrik:awaiting-input instead of advancing.
# If this repo has no reviewer to request (no CODEOWNERS, no review
# bot), set this to false — that is the supported setting rather than
# waiting on a gate that can never clear on its own. See §3 Pending
# Reviewer Gate for full details, including the self-review escape hatch.
review_authority: advisory # Optional: advisory (default) | authoritative. Only meaningful alongside
# wait_for_reviews: true. advisory clears the gate once reviewers have
# responded, whatever they said. authoritative additionally requires no
# outstanding CHANGES_REQUESTED review and required approvals satisfied.
# yolo/cruise never bypass an authoritative gate — they still control
# timing, not whether the gate is open. See §3 Authoritative Mode.
expected_reviewers: # Optional. Declares unrequested reviewers (self-submitting review bots
- handarbeit-pruefer # like Pruefer, Gemini, CodeRabbit) expected to review PRs from this
# stage without ever appearing in GitHub's requested-reviewer list.
# Only meaningful alongside wait_for_reviews: true. Absent (default):
# unchanged behavior — Fabrik waits the full FABRIK_REVIEW_WAIT_TIMEOUT
# for a self-submitting bot exactly as before this key existed.
# Declared with one or more names: the bot re-prompt ladder engages for
# them, satisfied when any one responds. expected_reviewers: [] (empty
# list): declares explicitly that no unrequested reviewer runs here —
# the gate advances immediately when nothing is requested either,
# instead of waiting out the timeout. See §3 Declaring Expected
# Reviewers for full details, including the required identity format.
wait_for_ci: false # Optional. When true, Fabrik gates auto-advance (and auto-merge for
# Validate+yolo) on CI checks passing on the PR head. At
# FABRIK_STAGE_COMPLETE, Fabrik immediately adds `fabrik:awaiting-ci`
# and withholds `stage:X:complete` until the CI gate clears (covering
# both pending and failed CI states). When CI fails, Fabrik re-invokes
# the stage agent via ci_fix_skill (falls back to comment_skill) with a
# structured report classifying each failed check as NEW REGRESSION or
# pre-existing. Re-invocation repeats up to FABRIK_MAX_CI_FIX_CYCLES
# times. On CI timeout or cycle limit, Fabrik pauses with
# fabrik:awaiting-input. Enabled by default on the Validate stage.
# See §3 CI Gate and CI-Fix Workflow for full details.
ci_fix_skill: # Optional. Plugin skill name for CI-fix re-invocations (the synthetic
# CI failure report comment). Defaults to comment_skill if unset.
allowed_tools: # Optional. REPLACES the default tool set — not additive. When set,
- Read # only these tools are allowed; the default list is not added.
- Grep # When omitted, Fabrik uses a comprehensive default covering common
- Glob # SDLC tools: Read, Edit, Write, Glob, Grep, TodoWrite, Skill, Task,
# Bash(git:*), Bash(gh:*), Bash(go:*), Bash(npm:*), Bash(npx:*),
# Bash(yarn:*), Bash(pnpm:*), Bash(make:*), Bash(cargo:*),
# Bash(python:*), Bash(pip:*), Bash(uv:*), Bash(pytest:*),
# Bash(ls:*), Bash(cat:*), Bash(rm:*), Bash(cp:*), Bash(mv:*),
# Bash(mkdir:*), Bash(find:*).
# Use this to restrict read-only stages (Research, Plan, Specify)
# or to limit Claude to project-specific tools.
disable_adaptive_thinking: true # Optional. Disables Claude Code's adaptive (auto-reduced)
# thinking budget. Default: true.
effort_level: high # Optional. Claude Code thinking effort: low, medium, high, max.
# Default: high. Controls how much the model "thinks" before
# responding. Higher values use more tokens.
completion:
type: claude # Only supported type (default).
Either skill or prompt is required (unless cleanup_worktree, holding_stage, or
unmanaged is true — these three flags mark stages that are never dispatched to Claude,
so no prompt is needed). When skill
is set, Fabrik sends a directive prompt telling Claude to follow the named skill; the
skill provides the detailed methodology via the plugin system. Prefer skill for complex
stages — it supports rich methodology, quality checklists, and scope boundaries. Use
prompt for simple single-purpose stages or quick overrides.
auto_advance and yolo interaction:
There are four cases:
- Global
yolo: trueinconfig.yaml→ all stages auto-advance after completion. - Per-stage
auto_advance: truein stage YAML → this stage always auto-advances, regardless of whether globalyolois true or false. - Per-stage
auto_advance: falsein stage YAML → this stage never auto-advances, even when globalyolo: true. This is a meaningful override — explicitly settingfalseis different from omitting the field. - Per-stage
auto_advance:absent from stage YAML → the stage inherits the globalyolosetting.
A fifth case applies at the issue level: adding the fabrik:yolo label to an issue forces auto-advance for that issue even when auto_advance: false is set in the stage YAML. The fabrik:yolo label also triggers auto-merge of the linked PR when the Validate stage completes (equivalent to running with --yolo globally, but scoped to a single issue).
Thinking budget (disable_adaptive_thinking, effort_level):
These two fields control how much Claude “thinks” before responding. disable_adaptive_thinking: true (the default) turns off Claude Code’s adaptive thinking budget, which would otherwise auto-reduce thinking depth to save tokens. With adaptive thinking disabled, the effort_level field directly controls thinking intensity: low, medium, high, or max. The default effort_level is high (changed from max in v0.0.33 to reduce token usage without sacrificing quality). Use effort_level: max only for your most demanding stages — complex implementation or thorough review work — where higher token cost is justified.
Timeout and inactivity protection (max_wall_time + 15-minute inactivity kill):
Fabrik applies two complementary timeout mechanisms to every Claude invocation to prevent indefinite hangs from stuck background processes:
-
max_wall_time(per-stage, opt-in): A hard wall-clock deadline. When the deadline expires, Fabrik sendsSIGINTto the Claude process group (which includes any background children spawned during the session), waits thekill_grace.sigintwindow (default 10 s), thenSIGTERM, then thekill_grace.sigtermwindow (default 10 s), thenSIGKILLto any surviving processes. Recommended:"45m"for Implement and Review stages in production. Legitimately long stages (e.g., a 90-minute Review on a large PR) should either set a higher limit or leave this field unset. Scaled automatically whenfabrik:extend-turnspre-grants a larger turn budget: the label-gated first invocation of a stage (or comment-review pass) that receives 2× the configured turn budget also getsmax_wall_timescaled by the same 2× factor for that invocation — the deadline stays proportionate to the work granted rather than cutting off a legitimately-progressing extended run on a clock sized for the ordinary case. Size this field for the ordinary (1×) case; you don’t need to inflate it to cover the rare extended one. -
15-minute inactivity timeout (global, always active): If no streamed output is received from Claude for 15 consecutive minutes, the process group is killed using the same SIGINT →
kill_grace.sigintwindow (default 10 s) → SIGTERM →kill_grace.sigtermwindow (default 10 s) → SIGKILL sequence. This catches sessions that are stuck on a hung background task even whenmax_wall_timeis not set — as long as Claude is actively producing output, it continues indefinitely. The threshold is hardcoded and cannot be configured.
In both cases, if FABRIK_STAGE_COMPLETE was emitted before the kill (visible in the streamed assistant turns), the stage is treated as successfully completed without retrying. Both timeouts apply equally to main-stage invocations and comment-processing invocations.
3. Workflow Patterns
How Issues Move Through the Pipeline
- Create an issue and add it to your GitHub Project board.
- Move the issue to a stage column (e.g.,
Specify). - Fabrik picks it up on the next poll, creates a worktree, and invokes Claude Code.
- Claude works in the worktree and posts progress as issue comments.
- When Claude completes the stage, the
stage:<name>:completelabel is applied. - In
--yolomode, the issue is automatically moved to the next stage column. Otherwise, a human reviews and drags the card.
The Specify Stage
The Specify stage is the first active stage. It takes a rough backlog issue and refines it into a clear, unambiguous spec:
- Surfaces missing requirements, ambiguities, and edge cases as questions
- Checks consistency with existing project features and documentation
- Researches prior art and established patterns on the web
- Rewrites the issue body with a structured spec
The user answers questions via comments. Claude incorporates the answers and updates the issue body. Once all questions are resolved, the stage completes and the issue is ready for Research.
Steering with Comments
Fabrik responds to natural language comments you post on an issue. Claude sees the full issue body and all prior comments, so context carries forward.
Effective comment patterns:
- “Please link the PR to this issue” – Claude creates the PR link
- “Let’s use approach B instead” – Claude updates the plan and continues
- “The answer to your question about X is Y” – Claude incorporates your answer
- “Please push and link the PR” – Claude pushes the branch and creates a draft PR
When you post a comment:
- Fabrik reacts with eyes to acknowledge the comment.
- Claude is invoked with the stage’s comment prompt (or a default).
- Claude performs any requested actions.
- If the issue body should be updated, Claude outputs the new body between
FABRIK_ISSUE_UPDATE_BEGINandFABRIK_ISSUE_UPDATE_ENDmarkers. - Fabrik updates the issue body and strips the markers from the posted comment.
- Fabrik reacts with rocket to mark the comment as processed.
The rocket reaction is durable – on restart, Fabrik skips comments that already have it.
Engine-posted comments are identified by two dedup signals: the 🏭 **Fabrik header (primary) and the 🚀 rocket reaction (secondary). Both categories are skipped when scanning for new user comments to process.
Reaction Flow
| Reaction | Meaning |
|---|---|
| Eyes | Comment received and queued for processing |
| Rocket | Comment has been fully processed |
When to Intervene
You do not need to babysit the pipeline. The intended human role is:
- File issues with clear specs in the body (or let Specify refine them).
- Answer questions when Specify or Research surfaces unknowns.
- Move cards (or use
--yoloto automate this). - Comment to steer when the plan goes sideways or you want to redirect.
- Review PRs before merging – Fabrik gets them review-ready, not merge-ready (unless
--yoloorfabrik:yololabel is active, in which case Fabrik auto-merges after Validate).
Yolo Mode and Auto-Merge
Pass --yolo to enable global auto-advance: Fabrik moves issues through every stage automatically without waiting for human approval, and enables GitHub’s native auto-merge on the linked PR once Validate completes. To scope the same behavior to a single issue, apply the fabrik:yolo label — Fabrik auto-advances and auto-merges that issue only.
Prerequisite — Allow auto-merge: GitHub’s native auto-merge must be enabled at the repository level before yolo mode can merge PRs. Enable it under Settings → General → Pull Requests → “Allow auto-merge”, or run:
gh api -X PATCH repos/{owner}/{repo} -F allow_auto_merge=trueWithout this setting, Fabrik will reach Validate complete and attempt auto-merge, but GitHub will reject it. The Fabrik engine emits a
[startup] WARNINGat startup if this setting is disabled on any managed repo.
When Validate completes on a yolo issue, Fabrik calls GitHub’s enablePullRequestAutoMerge API (the same merge-when-ready mechanism available in the GitHub UI) rather than attempting to merge immediately. GitHub holds the merge until all branch-protection requirements are satisfied — required CI checks, required reviews, up-to-date branch — then merges atomically. Fabrik monitors convergence in the background and pauses the issue if the PR does not reach a terminal state within the convergence budget (default 30 min; see FABRIK_CONVERGENCE_BUDGET). See Post-Validate Convergence Monitor (yolo) for full details.
For lighter automation without auto-merge, use fabrik:cruise: it auto-advances through all stages but stops at Validate, leaving the merge decision to you. If both fabrik:cruise and fabrik:yolo are present, cruise takes precedence for both decisions — the PR is not auto-merged and the issue does not advance to Done. It stops at Validate exactly as cruise alone would.
See Stage YAML Reference for the auto_advance field, which controls auto-advance behavior per stage independent of the global --yolo flag.
Merge Queue
When a GitHub repository requires a merge queue (configured under Settings → Branches → Branch protection rules → Require merge queue), yolo-mode PRs are routed through the queue rather than merged directly via GitHub’s native auto-merge.
How detection works: Fabrik detects merge-queue repos automatically from the GraphQL isMergeQueueEnabled field on the linked PR. No operator action is required to enable detection — Fabrik reads the per-PR flag on every poll cycle. The kill-switch --merge-queue off (or FABRIK_MERGE_QUEUE=off) disables queue routing and restores legacy behavior (yolo PRs attempt direct auto-merge, which may fail with HTTP 405 on queue-required repos).
What Fabrik does:
- When Validate completes on a yolo issue and the repo requires a merge queue, Fabrik calls
EnqueuePullRequestinstead ofEnablePullRequestAutoMerge. - The convergence monitor (
checkAutoMergeConvergence) watches the queue state each poll cycle. While the PR is in the queue (settle.Status == PRMergeQueued), Fabrik waits without touching labels or branches. - When the PR is ejected (CI failure or conflict), Fabrik classifies the reason and dispatches the appropriate recovery — CI-fix reinvoke or rebase reinvoke — then re-enqueues after the fix.
- When the PR merges via the queue, Fabrik detects the terminal state and advances the issue to Done.
on: merge_group CI prerequisite:
For GitHub’s merge queue to function, every required CI workflow must include on: merge_group in its trigger list. Without this, GitHub creates a temporary merge-group branch to test the PR against the current base, but the CI workflow never fires against it — the PR sits in AWAITING_CHECKS state indefinitely, and the queue stalls silently.
Example workflow trigger:
on:
push:
branches: [main]
pull_request:
merge_group: # ← required for merge queue
Stall detection (ADR-058 D5): If the PR has been in the merge queue for longer than CIWaitTimeout (default 30 min; FABRIK_CI_WAIT_TIMEOUT) with no merge-group CI ever reporting, Fabrik detects the stall poll-natively and pauses the issue with an instructional comment. This is a genuine liveness dwell — no merge-group CI has ever run, so there is nothing to wait on — the same class of dwell CIWaitTimeout governs everywhere else since ADR-1410; it is unrelated to CIBackstopTimeout, which bounds a different, unconditional per-poll-cost cap:
🏭 Fabrik — merge queue stall detected
The merge queue is enabled but no CI check ever reported for the merge group after 30 minutes. This typically means no workflow has
on: merge_groupconfigured.Action required: add
on: merge_groupto each workflow that must pass as a required check, then re-queue the PR. Removefabrik:pausedto resume.
The stall comment is posted once per event — subsequent polls skip the paused item, so no duplicate comments are produced.
Resume path after fixing CI:
- Add
on: merge_groupto each required-check workflow and push to the default branch. - Re-queue the PR manually (GitHub UI or
gh pr merge --merge --merge-queue <PR#>). - Remove
fabrik:pausedfrom the issue. Fabrik resumes monitoring on the next poll cycle.
Re-enqueue cap: To prevent a queue-thrash loop (enqueue → eject → re-enqueue → eject), each re-enqueue attempt is counted. When the count reaches MaxEnqueueCycles (default 5; --max-enqueue-cycles / FABRIK_MAX_ENQUEUE_CYCLES), Fabrik pauses the issue for human intervention.
Merge Train / Queued
GitHub’s native merge queue (above) is only available on GitHub Enterprise Cloud and org-owned public repos. On the common target — private GitHub Team repos and personal-account repos with strict branch protection — the native queue cannot run, yet these are exactly the repos that suffer the O(N²) rebase-and-retest cascade when several ready PRs land serially: each merge invalidates every other ready PR’s “up-to-date + green” status, forcing a rebase and a full required-check re-run before the next merge.
Fabrik’s internal merge train (ADR-059) is the plan-agnostic, host-agnostic answer. It batches ready PRs, validates the combined batch once, and lands them together — collapsing the cascade. It is opt-in via --merge-train on, FABRIK_MERGE_TRAIN=on, or merge_train: on in .fabrik/config.yaml (flag > env var > config.yaml > default off).
One board column, two landing engines. When merge_train: on, every yolo Validate completion advances into a single Queued board column instead of merging immediately. Each poll, the Queued handler picks the landing engine per repo (a Queued column can hold items from several repos at once), so you see one board model, not two parallel merge paths:
| Repo has a native merge queue? | Landing engine |
|---|---|
Yes (isMergeQueueEnabled) |
GitHub’s native merge queue (ADR-058) — Fabrik enqueues each PR; GitHub does the speculative-parallel batching. |
| No | Fabrik’s internal merge train (ADR-059) — one trial branch per repo, a single combined Validate, then a batched land. |
Both engines drain the same Queued column and advance their members to Done on land; only who batches differs. This means queue-enabled repos still use GitHub’s native queue even under merge_train: on — the train never overrides an available native queue (a direct/train merge on a queue-required branch returns HTTP 405).
Precedence (per repo, highest first):
- Native merge queue present → GitHub’s queue (ADR-058), regardless of
merge_train. - Else
merge_train: on→ the internal train (ADR-059). - Else → legacy per-PR serial auto-merge (the pre-merge-train default).
Operator setup — add the Queued column. The train stages ready PRs on a board column whose name matches a stage with holding_stage: true (named Queued in the default stages). Before enabling the train you must:
- Add a
Queuedcolumn to your GitHub Project board (a new single-select “Status” option). Its name must exactly match the holding stage’sname. - Add the holding stage to your
.fabrik/stages/config if it is not already present (fabrik refresh-stagessurfaces it as a missing default). A minimal holding stage:name: Queued order: 6 # after Validate, before Done holding_stage: true - Enable the train:
--merge-train on,FABRIK_MERGE_TRAIN=on, ormerge_train: onin.fabrik/config.yaml.
Startup requirement. Whenever a
holding_stage: truestage (e.g.Queued) is configured in.fabrik/stages/, its board column is part of the required set, the same board-validation that guards every non-cleanup stage — but the severity of a missing column depends onmerge_train’s value, because that’s what determines whether the column is actually reachable. Withmerge_train: on, a missingQueuedcolumn fails startup — the column is live (the train writes to it) and an unvalidated board would strand merged, closed issues the moment the batch lands, the least recoverable point in the pipeline, since native dependency edges clear on close, not on merge (see #1082, ADR-1421). Withmerge_trainoff/unset — the default, and the state of every freshfabrik init, sinceinitextractsqueued.yamlunconditionally regardless of whether you intend to use the train — a missingQueuedcolumn instead prints a startup warning naming the column and stating it must exist beforemerge_traincan be enabled; Fabrik still boots.merge_trainis a runtime-togglable setting that does not regenerate board columns, so add the column (step 1 above) before flipping it on, even though the warning alone won’t stop you. TheQueuedcolumn itself is terminal-only: it is never a valid column to move items into manually, and a Status ofTodo/Backlog/null means Fabrik ignores the item entirely.
Batch tuning. --max-batch-size (default 5) caps how many Queued items land in one batch, per repo. A red batch (a genuine cross-PR conflict — rare, since every member already passed Validate alone) is isolated by halving bisection, bounded by --max-bisect-validations; the poisoner is ejected and the survivors re-form. If the base branch moves under an in-flight batch (an external push), the trial is rebased and re-validated up to --max-train-rebase-cycles times before the batch dissolves back to Queued. See the flag reference above for all knobs.
Scope note. In v1 the train batches yolo Queued items only. fabrik:cruise and manual-merge items return early at Validate (before the train gate) and never enter the train — batching human-merge items behind an explicit “go” is a planned fast-follow.
Draft PR Workflow
The Implement stage creates a draft PR linked to the issue when create_draft_pr: true is set (the default for Implement). This gives you a place to review incrementally. The Review stage then rebases, reviews, fixes, and pushes — turning the draft into a review-ready PR.
PR body seeding: When the draft PR is created, Fabrik seeds the PR body from context files already written in the worktree:
| Section | Source |
|---|---|
## Summary |
Extracted from the issue spec (.fabrik-context/issue.md) |
## Problem |
Extracted from the issue spec (.fabrik-context/issue.md) |
## Approach |
Extracted from the Plan stage output (.fabrik-context/stage-Plan.md); falls back to a placeholder if absent |
## Verification |
Placeholder text, auto-replaced by an extracted stage summary when available |
Closes #N — engine-generated, always first: Fabrik generates the Closes #N closing keyword as the first line of every PR body it creates. The Implement skill emits a FABRIK_PR_CREATE_BEGIN/END marker block (not a direct gh pr create call), and the engine prepends Closes #N\n\n before calling gh pr create. This guarantees the keyword is always present and always at the top — the first thing a reviewer sees. The skill is explicitly prohibited from writing this line, so it cannot silently drop it.
Example PR body shape:
Closes #841
## Summary
...
## Approach
...
## Verification
...
The Closes #N first line links the PR to the issue so Fabrik can discover PR comments via GraphQL. Every downstream gate (review gate, CI gate, auto-merge) depends on this linkage. If the closing keyword is ever missing from an existing PR body, Fabrik detects it post-Implement and auto-heals by prepending Closes #N to the body.
Verification auto-update: For draft PRs created with create_draft_pr: true, Fabrik updates the ## Verification section only when it can extract a summary block delimited by FABRIK_SUMMARY_BEGIN and FABRIK_SUMMARY_END from stage output. This keeps the PR description current when a stage provides a structured summary for PR-body updates.
How do I ask Fabrik to change something on an open PR?
Leave an inline review thread comment on the specific line(s) you want changed, or write your feedback in the top-level review body — start a review on the PR (either commenting on specific diff lines, or just writing a summary, or both) and submit it, in any of CHANGES_REQUESTED, COMMENTED, or APPROVED state. Fabrik picks up unresolved inline thread comments and unaddressed review bodies from any non-DISMISSED, non-PENDING review — an APPROVED “LGTM, minor nit” is picked up the same as a CHANGES_REQUESTED review, and so is a COMMENTED review whose body is the substantive feedback (this covers review bots — such as Pruefer — that submit findings exclusively as COMMENTED reviews) — and re-invokes the stage agent to address them, commit, and push. A reinvoke that lands no commit (nothing actionable in the review body — a generic “Pull request overview” summary, say) does not spend your FABRIK_MAX_REVIEW_CYCLES budget; see the no-op note under Phase 3 below.
If a bot reviewer (e.g. @copilot review) posts its findings as a plain PR comment with no formal review submitted at all — some bots do this inconsistently — Fabrik still recognizes it as review content to evaluate and address autonomously, rather than waiting for a human to confirm the bot is right first.
This works regardless of wait_for_reviews — re-invocation from review feedback is unconditional, not gated behind that setting. See Pending Reviewer Gate for the full mechanism, including how this interacts with auto-advance and the fabrik:awaiting-review label.
Retry and Escalation
When a stage doesn’t complete (Claude doesn’t output FABRIK_STAGE_COMPLETE):
- Cooldown: Fabrik waits
poll_interval x 10seconds (default 5 minutes) before retrying. - Resume: On retry, Claude resumes the existing conversation session with full context. The worktree is left as-is (no rebase) to preserve Claude’s context.
- WIP commit: Partial work is committed and pushed to preserve progress.
Reading the output of a resumed stage. Because the retry continues the same session rather than starting a new one, its posted comment can legitimately describe work the earlier attempt performed — a test suite run before a turn-cap kill, for example, is reported by the retry without being re-run. Two consequences routinely look like defects but are not:
- A retry may report commands it did not run in that invocation. The same session ran them earlier and still has the results in context. This is continuation, not fabrication.
- A retry that completes in very few turns is the expected shape. The work is already done; the continuation only needs enough turns to write it up. A two-turn completion following a turn-capped attempt is a resume working correctly.
The one case where a retry genuinely cannot know what its predecessor did is when the session file is missing, unreadable, or zero-length, so
--resumeis skipped and a fresh session starts against the existing worktree. This is not currently detected or logged (#1117). See Session Resume instage-lifecycle.mdfor detail.
Troubleshooting: repeated near-instant
$0.00, 0-turn failures on a comment. If an issue’s comment is never processed and the stage logs show the same tiny failure over and over —subtype: "error_during_execution", anerrors[]entry readingNo conversation found with session ID: ...,num_turns: 0,total_cost_usd: 0— the session Claude Code was asked to resume has been reaped from its own history (itscleanupPeriodDaysretention, default 30 days), typically because the issue’s last stage invocation was over a month ago. From this fix onward, Fabrik detects this structurally, deletes the stale session file, and starts a fresh session automatically on the next poll — no operator action needed. On an older build without this fix, the only way to unblock the issue is to manually delete the stale.fabrik/sessions/[<owner>-<repo>/]issue-<N>/<Stage>.sessionfile; moving the item out of its board column and back does not work as a workaround, since an unresolved comment always routes to comment processing regardless of board state.
Troubleshooting: a stage carries
fabrik:claude-limitand dispatch looks idle. If the issue carriesfabrik:claude-limit, the underlying Claude account has run out of usage — this is not a stage failure, and it is not GitHub’s own rate limiting (a separate, unrelated mechanism; see Rate Limit Monitoring below). Fabrik detects this structurally, from the CLI’s own result object (terminal_reason == "blocking_limit") — never from anything a stage’s output happened to say, so a stage whose output merely discusses usage limits cannot trigger this. It does not count this attempt against--max-retries: nostage:<name>:failed, nofabrik:paused, no escalation comment. As soon as one worker observes the limit, Fabrik suspends all new Claude dispatch account-wide — not just for this issue — for a fixed one-hour backoff (structural detection carries no reset time to parse, so this fallback backoff is always used), so a usage-limit window costs one detection and one automatic resume rather than every concurrent item independently rediscovering and waiting out the same limit. The suspension and its expected end are visible in the log (tagclaude-limit) and, in the TUI, as a dedicated banner distinct from the GitHub rate-limit banner (see Claude Usage-Limit Suspension below). It clears automatically — either at the one-hour deadline or as soon as any invocation succeeds, whichever comes first — or you can clear it immediately without restarting the engine by applyingfabrik:clear-claude-limitto any open board item (see Claude Usage-Limit Suspension below). Once the account-wide suspension lifts,fabrik:claude-limitclears on every open issue that carries it, not only on this one’s next invocation.
- Max retries: After
--max-retriesfailures (default 3):
fabrik:pausedandstage:<name>:failedlabels are added- An explanatory comment is posted on the issue
- The issue stops being processed until a human investigates
- A Claude account usage-limit exit (see the
fabrik:claude-limittroubleshooting note above) is exempted from this count entirely — the stage never ran, so it never consumes a retry attempt- A turn-cap preemption (the invocation ran out of turns and will resume on the next dispatch) is likewise exempted — it counts against a separate slice counter instead. See below.
- A consecutive
--resumefailure (the session pointer, not the stage’s own work, is the suspected cause) is likewise exempted, up to--max-resume-failures— see below.- A tool-permission denial (Claude’s own permission layer blocked a mutating tool call — e.g.
Edit,Write, or a write-capableBash) is likewise exempted — a blocked worker is not a failed stage. Bounded separately by--max-tools-denied-retriesinstead. See below.
To resume after escalation: remove the fabrik:paused label. Fabrik will clear the
failed label, reset the retry count, and try again immediately.
Troubleshooting: an issue is paused with a “slice budget exceeded” comment, not a failure comment.
--max-retriesbounds genuine failures only — errors, degenerate output, PR-creation failures. A turn-cap preemption (the invocation hit its per-stage turn budget and stopped; the session resumes on the next dispatch) is not a failure and is bounded separately by--max-slice-retries(default 10). A large job that legitimately needs several slices completes without pausing as long as it stays within that limit; only a job that keeps hitting the turn cap--max-slice-retriestimes in a row is paused. The pause comment names the specific cause and never says “failed to complete” — nostage:<name>:failedlabel is applied, since the stage has not failed. It appliesfabrik:paused+fabrik:awaiting-inputinstead. To resume: either addfabrik:extend-turns(grants a larger per-invocation turn budget, so fewer slices are needed) or split the issue into smaller pieces, then removefabrik:pausedandfabrik:awaiting-input. Removing the labels alone does not reset the slice counter — it is already at the limit, so the very next turn-cap exit re-pauses immediately unless the underlying job size or turn budget actually changes. See #1199 and #1191.
Troubleshooting: a stage keeps failing at 1 turn / $0.0000 after a large prior invocation. A
--resumeinvocation resumes the same session every time, including when the session’s own transcript (grown too large, or otherwise broken) is the cause of the failure — every retry re-triggers the identical condition. Fabrik tracks consecutive failed resume attempts per (issue, stage) session — shared by both the stage-run and comment-review invocation paths, since they resume the same session pointer — and after--max-resume-failuresconsecutive failures (default 2), discards the session pointer and cold-starts the next invocation instead of resuming. This is silent and self-healing: only a log line is written (no issue comment, no label), since the whole point is that the next attempt succeeds without anyone intervening. Only the resume pointer is discarded — committed work on the issue branch is completely unaffected. Consecutive resume failures do not consume a--max-retriesslot (mirroring thefabrik:claude-limitexemption above), so the guaranteed cold-start attempt can never be starved by the very failures it exists to recover from; a successful invocation resets the counter to zero. If the cold-started attempt also fails, that is a genuine failure and flows into the normal--max-retries→fabrik:pausedpath described above. See #1414.
Troubleshooting: an issue carries
fabrik:tools-deniedinstead ofstage:<name>:failed. Claude’s own permission layer denied one or more mutating tool calls during the invocation — reported structurally by the CLI (permission_denialson the result object), never inferred from anything the assistant wrote. This is not a stage failure: the worker was simply unable to write files, so it does not consume a--max-retriesslot. Fabrik posts one explanatory comment naming the denied tool(s) the first time this is detected on an issue (not on every retry), then keeps retrying on the normal dispatch cooldown, bounded independently by--max-tools-denied-retries(default 3) — since no retry can fix a broken permission configuration on its own, this bound is intentionally lower than--max-slice-retries. After the bound, Fabrik pauses withfabrik:paused+fabrik:awaiting-input(neverstage:<name>:failed— the work itself was never judged, only blocked). To resolve: check the permission configuration outside Fabrik’s control — most commonly aPreToolUsehook, or an org/user-levelpermissions“ask” rule with no interactive prompt available in this headless context — then removefabrik:pausedandfabrik:awaiting-inputto resume;fabrik:tools-denieditself clears automatically on the next invocation that isn’t denied. The outcome is identical whether or not the worker also emittedFABRIK_BLOCKED_ON_INPUTin its own output — Fabrik’s classification does not depend on it. See #1523.
Stages Waiting for Input
A stage can signal that it needs user input before it can proceed by outputting
FABRIK_BLOCKED_ON_INPUT. This is different from a failure — the stage is not broken,
it just needs a question answered.
When a stage outputs FABRIK_BLOCKED_ON_INPUT:
fabrik:pausedandfabrik:awaiting-inputlabels are added to the issue- The retry counter is not incremented — this does not count as a failure
- The issue waits silently until the configured Fabrik user (
--user/FABRIK_USER) posts a new comment
When the configured user posts a new comment:
- Fabrik detects the comment and automatically removes both labels
- Comment processing is triggered immediately (no manual card move needed)
- The comment processing run can output
FABRIK_STAGE_COMPLETEto finish the stage directly, without needing an additional stage invocation
This is the intended mechanism for Q&A in stages like Specify — Claude asks a question, the configured user answers it in a comment, and the stage resumes automatically.
When Fabrik adds fabrik:awaiting-input, it also posts a notification comment beginning
with 🏭 **Fabrik** — @<user>: so GitHub delivers a mobile push notification to the
configured operator (set via --user / FABRIK_USER). This ensures you’re alerted even
if you’re not actively watching the issue.
Note: GitHub does not deliver push notifications for activity an account performs on itself. If Fabrik runs as the same GitHub account as the user being @mentioned, no push arrives. To receive mobile pushes, run Fabrik as a dedicated bot account (a different identity than the operator account you configure with
--user). The--filter-userflag (#671) will make this separation easier once implemented.
Operator note: If fabrik:paused is removed manually (without also removing fabrik:awaiting-input), the orphaned fabrik:awaiting-input label will be cleared automatically the next time the stage emits FABRIK_STAGE_COMPLETE — the issue will not remain visibly “awaiting input” after the stage has advanced.
No-Work Short-Circuit
A stage can signal that all remaining pipeline work can be skipped by outputting both
FABRIK_STAGE_COMPLETE and FABRIK_NO_WORK_NEEDED, each on its own standalone line. This
is the short-circuit path for issues where Research conclusively shows that no code, test,
or documentation changes are needed.
FABRIK_NO_WORK_NEEDED alone has no effect. Both markers must appear as standalone
lines in the same stage output — the engine matches each token exactly (the line must
contain only the marker and nothing else).
When both markers are present, the engine:
- Marks the emitting stage complete
- Adds
stage:<name>:completelabels for all subsequent non-cleanup stages (audit trail) - Posts a one-line
_Skipped: no work needed_comment per skipped stage - Moves the issue directly to Done — no PR is created
The primary use case is the Plan stage: after Research finds the issue is already resolved or that no changes are required, Plan emits both markers instead of writing an implementation plan. This avoids the HTTP 422 failure that would occur when Implement tried to create a PR from a branch with nothing to commit.
Any stage can emit FABRIK_NO_WORK_NEEDED, not just Plan. Skill authors should only use it
when they are certain no downstream work is needed.
Mutual exclusivity: FABRIK_NO_WORK_NEEDED cannot be combined with FABRIK_BLOCKED_ON_INPUT. If a question needs answering first, use FABRIK_BLOCKED_ON_INPUT.
Dependency-Based Sequencing (Formations)
Fabrik supports dependency-based sequencing of issues using GitHub’s native “Blocked by” relationships. This enables formations — coordinated sets of issues that execute in parallel where possible and respect ordering constraints automatically.
Setting Up Dependencies
To mark one issue as blocked by another on GitHub.com:
- Open the issue in GitHub
- In the right sidebar, find the Relationships section
- Click “Mark as blocked by”
- Search for and select the blocking issue (same repo or cross-repo)
Repeat for each dependency. This uses GitHub’s native blockedBy GraphQL field (available on all GitHub plans since August 21, 2025).
How Fabrik Detects and Respects Dependencies
Fabrik uses the fabrik:blocked label to track blocked issues. The label lifecycle is fully automatic:
- Detection: Fabrik re-evaluates blocked items periodically via a cooldown timer (every
10 × poll interval, typically ~5 minutes at the default 30s poll). When the timer fires, Fabrik deep-fetches the blocked item and checks whether its dependencies have closed. If GitHub also bumps the blocked item’supdatedAtwhen a dependency closes (common but not guaranteed), unblocking is detected sooner — on the next poll cycle. - First block: When Fabrik first detects that an issue is blocked, it posts a comment listing the open blocking issues and adds the
fabrik:blockedlabel automatically. Fabrik creates this label on first use — no pre-creation needed. - While blocked: The issue is skipped silently each poll cycle (no duplicate comments).
- Automatic unblocking: When all blocking issues are closed, Fabrik removes
fabrik:blockedand resumes processing on the next poll — no human action required.
Key behavior callouts:
- All stages, including the first (Specify), are subject to the dependency gate — if an issue has open blockers when it enters any stage, it pauses with
fabrik:blockeduntil all blockers close. - Independent issues start in parallel — issues with no blockers are dispatched concurrently up to the configured
MaxConcurrentlimit. - Failed issues retry independently — a failure in one formation member does not affect siblings.
- Cross-repo dependencies are supported — a blocking issue can be in a different repository. Fabrik displays cross-repo blockers as
owner/repo#Nin log output.
Formation Recipe
- File all issues for the formation. Write specs at the right level of granularity — each issue should be independently implementable.
- Add blocked-by edges in GitHub using the Relationships sidebar (see above).
- Label all issues
fabrik:yolo— this makes the formation hands-free. Withoutfabrik:yolo, each stage requires a manual card move on the project board. - Move all issues to Specify on the project board. Fabrik will pick them up on the next poll.
- Watch it run — Fabrik gates every stage, including Specify, on dependency resolution. Issues with no blockers start immediately; blocked issues wait with
fabrik:blockeduntil their dependencies close.
Example Formation
graph TD
A["#1 Data model design"] --> C["#3 API layer"]
B["#2 Auth design"] --> C
C --> D["#4 Frontend integration"]
C --> E["#5 Background jobs"]
In this 5-issue formation:
- Issues #1 and #2 start immediately in parallel (no blockers)
- Issue #3 starts after both #1 and #2 are closed
- Issues #4 and #5 start after #3 is closed (in parallel with each other)
Real-world validation: A 9-issue formation with 7 dependency edges was run on the Ambient project — 4 issues started in parallel, all pipeline constraints were respected automatically, completing in ~88 minutes wall-clock time at $31 total cost.
Sub-issue Decomposition
When Plan determines that an issue is too broad for a single Implement cycle — or that work needs to happen in more than one repository — it can fan out the work into focused sub-issues, each of which runs through the full Fabrik pipeline independently. Review and Validate can declare sub-issues too, for the different case of a blocker discovered mid-flight rather than an upfront decomposition — see Mid-flight Spawning from Review/Validate below. Everything in this section (block format, board registration, assignment, dependency wiring) applies identically regardless of which stage declares the spawn.
No user configuration required. Decomposition is Plan’s judgment call based on Research findings. If the issue is well-scoped and single-repo, Plan produces a normal implementation plan. If it requires parallel work or spans multiple repos, Plan declares sub-issues to spawn.
How It Works
The Plan stage emits structured FABRIK_SPAWN_CHILD_BEGIN/END blocks in its output to declare each sub-issue:
FABRIK_SPAWN_CHILD_BEGIN owner/repo
TITLE: Short descriptive title for the child issue
Full scoped spec body for the child — enough context for it to run
its own Specify/Research/Plan stages autonomously.
FABRIK_SPAWN_CHILD_END
These blocks are declarative data, not immediate actions. They persist in the Plan comment and are read by the engine at Implement time — Plan can be revised via comment as many times as needed before the user advances to Implement.
When the parent advances to Implement, the engine’s preImplement step fires before Claude is invoked:
- Validates any
DEPENDS_ON:headers declared in the blocks (see below); an invalid one pauses the parent before any child is created - Confirms this Fabrik instance is actually configured to serve each target repo (see Same-Repo and Cross-Repo below) — if not, the spawn fails loudly rather than silently registering the child on the wrong board
- Creates each child issue in its target repo (same repo or cross-repo) and assigns it to the
user:configured on this instance — every spawned child gets an assignee, unconditionally - Adds each child to the same project board
- Links each child as a
blockedBydependency of the parent - Applies
fabrik:sub-issuelabel to each child (informational) - Wires any declared
DEPENDS_ONsibling edges, once all children exist - Applies
fabrik:children-spawnedto the parent (idempotency guard, applied only after step 7 succeeds)
If step 4’s board-placement call fails for a given child (API error, missing status-field metadata, or no suitable column found), the child, board item, and blockedBy link already exist by that point — only the initial column placement is missing. Rather than stranding the child in Backlog forever, Fabrik sets fabrik:awaiting-placement on it and retries placement on every subsequent poll. The marker clears automatically once placement succeeds, or if the child is observed closed in the meantime. After repeated failures (--max-retries settle passes), the child is escalated instead: fabrik:paused is added, fabrik:awaiting-placement is removed, and an explanatory comment is posted on both the child and the parent. See ADR-062.
Any failure in steps 1–3 — including the board-servability refusal in step 2 — pauses the parent with an explanatory comment and creates no children for the affected batch; there is no partial, silently-registered-but-broken spawn.
Ordering Siblings with DEPENDS_ON
By default, every spawned child is an independent unit — all of them start Specify at the same time, gated only on the parent (a “parallel star”). For a decomposition into sequentially dependent slices — the common case when splitting a single-repo feature into ordered chunks for PR hygiene — Plan can declare that ordering with an optional DEPENDS_ON: header:
FABRIK_SPAWN_CHILD_BEGIN owner/repo
TITLE: Retry-same-input: turn-attempt capture (slice 3/4)
DEPENDS_ON: 2
Full scoped spec body...
FABRIK_SPAWN_CHILD_END
DEPENDS_ON: 2 means “this block depends on block 2 of this same Plan output” — a 1-based index into the blocks as emitted, not an issue number. The engine wires it as an additional blockedBy edge between the two children (alongside the unchanged parent edge), so the dependent child carries fabrik:blocked until its sibling closes — exactly like any other dependency Fabrik tracks. References must point to a strictly earlier block; an out-of-range or non-forward DEPENDS_ON value fails the spawn step loudly (the parent is paused with an explanatory comment) rather than being silently dropped. Without this, ordering could only be expressed as prose in the child’s body (“Depends on: Slice 2”) — invisible to Fabrik’s dependency gate, so nothing would stop the sibling worktrees from running concurrently and colliding on shared files.
After spawning, the parent waits at Implement with fabrik:blocked until all children close. When the last child closes, the parent’s Implement Claude invocation fires — for coordinator-only parents (no own implementation work), Claude completes with nothing to commit, and the engine detects the empty-coordinator case (zero commits ahead of base) and moves the parent directly to Done without attempting a PR.
What You Observe
- During Plan: no sub-issues exist. Plan can be revised freely. A decomposing Plan comment carries a receipt note stating how many sub-issues were declared and that they’ll be created when the issue advances to Implement — so you don’t have to know this section exists to know where they went.
- After advancing to Implement: child issues appear on the project board in Specify, each labeled
fabrik:sub-issue. - A child may transiently show
fabrik:awaiting-placementif its initial board placement failed — it clears automatically once a later poll places it successfully, or escalates tofabrik:pausedwith an explanatory comment after repeated failures. - If the parent carries
fabrik:yoloorfabrik:cruise, each child inherits those labels on creation — a yolo’d cross-repo parent spawns children that flow autonomously through all stages, while cruise children stop at Validate for manual merge. - The parent shows
fabrik:blocked+fabrik:children-spawned; a dependency comment lists all open children. - As children complete, the dependency comment updates in-place (no duplicate comments).
- When all children close:
fabrik:blockedclears, and the parent’s own Implement runs.
Same-Repo and Cross-Repo
Spawn blocks work identically whether the target is the parent’s own repo or a different repo. Research is responsible for identifying which repos are in scope — it emits a ## Repositories section listing all potentially-relevant repos. Plan is constrained to spawn only into repos Research named.
A cross-repo spawn also requires the spawning instance’s own repo:/project: config to actually cover the target repo. Each Fabrik instance polls exactly one project board. A multi-repo instance (no repo: configured) already serves any repo the org grants that board access to — spawning cross-repo into any of those repos just works, the same as it always has. A repo:-scoped instance, however, is declared to serve only that one repo; asking it to spawn into a different repo has no board for the engine to register the child on. Before this was enforced, that scenario silently registered the child onto the spawning instance’s own board anyway — wrong, and invisible, since the child sat correctly in a real column the whole time, just on a board no repo:-scoped instance watching the right repo would ever see. Fabrik now refuses instead: the spawn fails loudly (parent paused, explanatory comment naming both the unservable target and this instance’s own configured repo) rather than silently mis-registering. If you hit this, either point the spawning instance’s own repo:/project: at a board that covers the target repo, or have a differently-configured instance perform the spawn.
Mid-flight Spawning from Review/Validate
Plan is not the only stage that can declare a spawn. Review and Validate — the stages positioned to discover a blocker mid-flight, after implementation is already underway — recognize the same FABRIK_SPAWN_CHILD_BEGIN/END block format, with identical board registration, assignment, and dependency wiring. This exists because an agent with gh available and no sanctioned route to declare a blocker will reasonably reach for gh issue create directly — which Fabrik never observes: no board registration, no assignee, and critically no blocked_by edge, so the parent has no record it is blocked at all and can proceed to merge without the blocker’s fix ever landing.
What differs from a Plan-stage spawn:
- Parsed from the stage’s own output directly, not from a stored comment — Review/Validate post to the linked PR, so there’s no comment for the engine to re-read later the way it re-reads Plan’s.
- Takes effect immediately, not deferred to Implement — since Review/Validate run after Implement, there is no later dispatch step to defer to. The child is created, boarded, assigned, and linked as soon as the block is recognized.
- The receipt note is present-tense: “Spawned 1 sub-issue: owner/repo#N. It has been registered, assigned, and linked as a blocker of this issue” — rather than Plan’s “will be created when this issue advances to Implement.”
- The raw declaration block is stripped from the posted comment, unlike Plan’s — Plan’s comment intentionally keeps its block visible (“N sub-issues declared above”), but the mid-flight receipt note already says what was spawned, so the internal
FABRIK_SPAWN_CHILD_BEGIN/TITLE:/FABRIK_SPAWN_CHILD_ENDsyntax would only duplicate that information if left in. - No separate idempotency label — each stage dispatch’s output is fresh and never replayed, so a block is only ever processed once.
Once a mid-flight spawn is processed, the parent picks up fabrik:blocked on its next dispatch, exactly as any other dependency does — it will not proceed past the blocked stage until the newly spawned child closes.
Validate’s merge decision re-checks dependencies live before landing anything. Signaling completion at Validate means “ready to merge” — on a fabrik:yolo issue, that enables auto-merge or performs a direct merge in the very same dispatch that just processed the spawn. Before doing either, Fabrik re-fetches the issue’s dependency state live and refuses to merge if anything is still open — so even a Validate output that (against the skill’s own guidance below) signals completion in the same turn as a genuine spawn cannot merge past it. The skill still instructs the agent to report BLOCKED rather than complete alongside a spawn (per Validate’s own “Decision: Complete or Block” criteria) — that keeps the pipeline’s state clean and avoids an unnecessary retry cycle — but it is no longer the only thing preventing a premature merge. Review carries no equivalent risk in the first place — completing Review while also spawning a blocker is safe, because the next stage’s dispatch is gated on dependency resolution before it runs.
Recursive Decomposition
There is no depth limit. A child issue’s own Plan can emit spawn blocks, creating grandchildren — they gate the child’s Implement exactly as the parent’s children gate the parent’s Implement.
Pure-Coordinator Pattern
When the parent issue has no implementation work of its own (it exists only to coordinate children), the parent’s Implement runs after children close, finds nothing to do, and completes normally (FABRIK_STAGE_COMPLETE) with no new commits. Rather than attempting a draft PR — which would fail, since there is nothing to diff against the base branch — the engine detects that the parent carries fabrik:children-spawned and has zero commits ahead of base, and treats this exactly like a FABRIK_NO_WORK_NEEDED completion: the parent moves directly to Done without a PR. No special configuration is needed, and Claude does not need to self-detect or emit any marker — this is a deterministic engine-side check, not a prompt-driven one. A parent that does have its own implementation work is unaffected: as long as its own commits land, the normal draft-PR path runs.
Re-triggering a Fresh Spawn
The fabrik:children-spawned label is the idempotency guard. If you need to re-trigger spawning (e.g., after Plan was revised and the old children should be replaced):
- Remove
fabrik:children-spawnedfrom the parent - Close the obsolete child issues manually
- Re-advance the parent to Implement
The engine will then read the latest Plan output and spawn fresh children.
Gotcha: Closed = Resolved
When a child is closed — regardless of whether its PR was merged — the parent treats it as resolved. Closing a child issue without merging its PR unblocks the parent. Do this deliberately; Fabrik has no way to distinguish a clean close from an abandoned one.
Pending Reviewer Gate
Unresolved inline PR review thread comments re-invoke the stage agent to address them — this happens regardless of whether wait_for_reviews: true is set on the stage. What wait_for_reviews: true actually controls is narrower: whether Fabrik waits for all requested PR reviewers to submit their reviews before advancing the issue to the next stage. Re-invocation is a separate, unconditional mechanism triggered purely by the presence of unresolved inline feedback, whether or not the gate is enabled. Auto-advancement to the next stage after re-invocation additionally requires auto-advance to be active (global yolo: true, per-stage auto_advance: true, or the fabrik:yolo label on the issue).
Review re-invocation vs. CI re-invocation — not the same rule. Review re-invocation (this section) fires irrespective of wait_for_reviews, because handleReviewGate falls through to dispatching re-invocation whenever the gate is inactive rather than skipping it. CI re-invocation is different: it genuinely requires wait_for_ci: true, because its caller (handleMergeAndCIGates) guards the CI-fix dispatch on the gate’s own result and has no equivalent fall-through path when the gate is off. Don’t generalize this section’s “unconditional” to CI re-invocation — see CI Gate and CI-Fix Workflow below, which requires wait_for_ci: true.
For the authoritative spec on label lifecycle and gate transitions, see State Machine § 2.9 Review Reinvoke.
The Review and Validate stages ship with wait_for_reviews: true enabled by default.
Enabling the Gate
Add wait_for_reviews: true to the relevant stage YAML:
name: Review
order: 4
wait_for_reviews: true
...
Re-invocation fires whenever unresolved inline review feedback is present, regardless of wait_for_reviews or whether auto-advance is active — enabling the gate above changes only whether Fabrik waits for reviewers before advancing. If you’re manually dragging cards through the board, re-invocation still happens; the issue will not advance automatically after the cycle completes — you still move the card manually.
Label Lifecycle
When the gate is active, Fabrik adds the fabrik:awaiting-review label to the issue. This label:
- Makes the wait state visible on the project board
- Is cleared automatically either when no requested reviewers are outstanding and at least one review has been submitted — this dual condition catches bot reviewers (Copilot, Gemini) that self-trigger via webhook without ever appearing in the formal requested-reviewer list — or when the review-wait timeout expires and Fabrik removes the label before pausing with
fabrik:awaiting-input - Triggers a re-invocation of the stage agent (via the comment-processing path) to address the submitted review feedback
- After re-invocation, if new reviewers are assigned (e.g. bots triggered by a fresh push), the label is re-applied and the cycle continues
Three-Phase Mechanism
The gate uses a three-phase design:
- Phase 1 (always-gate): On stage completion, Fabrik immediately adds
fabrik:awaiting-reviewand skips auto-advance. This fires even before reviewer assignments propagate. - Phase 2 (gate evaluation): On subsequent poll cycles, Fabrik re-fetches the PR with fresh GraphQL data and evaluates the dual condition: the gate clears only when no requested reviewers are outstanding and at least one review has been submitted. Requiring at least one review (not just an empty pending list) is what catches bot reviewers like Copilot and Gemini that self-trigger via webhook without ever appearing in the formal requested-reviewer list — if only the pending list were checked, the gate would race through while bots were still processing. If still pending → wait. If timed out → pause with
fabrik:awaiting-input. -
Phase 3 (re-invocation): When the gate clears with submitted reviews present — or, under
review_authority: authoritative, even while the verdict itself is still unresolved (see Authoritative Mode) — Fabrik re-invokes the stage agent via the comment-processing skill (comment_skill) with the unresolved review feedback as input: unresolved inline review thread comments, and the top-level body of any review whose state is notDISMISSEDorPENDING(CHANGES_REQUESTED,COMMENTED, andAPPROVEDbodies are all treated as potentially actionable — GitHub requires a body forREQUEST_CHANGES/COMMENT, so this is what lets a reviewer’s written explanation trigger a re-invocation on its own, with no inline comments needed; it also means a bot reviewer that submits exclusivelyCOMMENTEDreviews — such as Pruefer — has its findings routed here just like aCHANGES_REQUESTEDreview would be). Each inline thread comment is enriched with its file path and, when available, line number and raw diff-hunk context (line number and hunk may be absent for file-level or outdated comments); a review body carries no such location context, just the reviewer’s text. The agent addresses the feedback, commits, and signalsFABRIK_STAGE_COMPLETE. This re-appliesfabrik:awaiting-review, and the cycle returns to Phase 2 until the gate clears again under the same dual condition — no requested reviewers outstanding and at least one review submitted — or, if that does not happen before the wait limit, Fabrik falls back tofabrik:awaiting-input. As of v0.0.39, re-invocation is unconditional — it fires for any issue with submitted inline feedback, regardless ofwait_for_reviews(when the gate is off, Phases 1–2 above simply never run, and Fabrik goes straight to this Phase 3 dispatch) and regardless of whether auto-advance is active.A reinvoke that lands no commit does not spend
FABRIK_MAX_REVIEW_CYCLES. If the agent evaluates the feedback and finds nothing actionable — a generic “Pull request overview” summary from a bot like Copilot or Gemini, most commonly — and pushes no commit, Fabrik decrements the review-cycle counter it incremented for that dispatch, netting the attempt back to unchanged. This is why widening actionability toCOMMENTED/APPROVEDbodies above doesn’t risk exhausting your review-cycle budget on bot noise: only a reinvoke that actually changes something counts against the limit. Five consecutive no-op reinvokes leave the counter exactly where it started; a genuine finding arriving afterward is still addressed and still counted.
This means there is always at least one extra poll cycle delay after stage completion — typically 30 seconds.
No Actionable Feedback Skip
If a submitted-review batch leaves nothing unresolved to process — no inline PR review thread comments and no unaddressed review body — Fabrik skips re-invocation entirely and advances the issue normally. A DISMISSED or PENDING review’s body is never treated as actionable (a dismissed review is no longer an active verdict; a pending review hasn’t been submitted yet), so neither triggers re-invocation on its own. Every other review state — CHANGES_REQUESTED, COMMENTED, and APPROVED alike — is treated as actionable when its body is non-empty, which, per GitHub’s own requirement that REQUEST_CHANGES/COMMENT carry a body, means most reviews trigger re-invocation even with zero inline thread comments; only a truly empty-bodied APPROVED (“LGTM” with nothing further typed) has nothing to route.
This prevents spurious re-invocation cycles when reviews contain no actionable feedback (inline or written) for the agent to address, while still catching the common case of a reviewer who writes an explanation but leaves no inline comments — including a bot reviewer that only ever submits COMMENTED verdicts.
PR Summary Comment
After each re-invocation cycle, Fabrik posts a summary comment on the linked PR (not the issue) with the following format:
🏭 **Fabrik — stage: {StageName} (review feedback addressed)**
*branch: {branch} | commit: {sha} | main: {mainSHA} | {timestamp}*
{Claude output}
---
**Threads addressed:**
- `path/to/file.go:42` — resolved
- `other/file.go` — resolved
Resolved N review thread(s) across M comment(s).
The “Threads addressed” footer lists each inline thread by file path and line number (when available); file-level or outdated threads show only the file path. This comment is posted only when Claude produces output and a linked PR is found. It carries the 🏭 **Fabrik header so it is not reprocessed as user input on subsequent polls.
Cycle Limit
To prevent infinite loops (e.g., a bot that always posts new reviews after every push), Fabrik caps the number of re-invocation cycles per issue per stage per engine session. When the limit is reached with reviewers still pending, Fabrik pauses the issue with fabrik:awaiting-input and posts a comment explaining the situation.
FABRIK_MAX_REVIEW_CYCLES=3 # Limit to 3 re-invocation cycles per issue per stage (default: 5)
# or equivalently:
fabrik --max-review-cycles=3
The cycle count resets on engine restart.
Timeout Configuration
FABRIK_REVIEW_WAIT_TIMEOUT sets the per-cycle timeout in minutes for the review gate. If the gate is still waiting within the timeout — whether for requested reviewers to submit or simply for at least one review to exist — Fabrik pauses the issue with fabrik:awaiting-input (rather than auto-advancing) so a human can investigate. This timeout also acts as the fallback when no reviews ever arrive — for example, if all bot reviewers fail to post — since the dual-condition gate requires at least one submitted review and would otherwise wait indefinitely.
FABRIK_REVIEW_WAIT_TIMEOUT=30 # Wait up to 30 minutes per cycle for reviewers (default: 15)
# or equivalently:
fabrik --review-wait-timeout=30
Restart Persistence
The timeout is based on the timestamp of when the fabrik:awaiting-review label was added to the issue, which is stored in GitHub’s event history. If Fabrik restarts while waiting, it recalculates the remaining wait time from the label timestamp rather than resetting the clock. The cycle count is in-memory and resets on restart.
No Reviewer Ever Requested
wait_for_reviews: true assumes something will eventually request or submit a review — a human collaborator, CODEOWNERS, or a review bot. On a repo with none of those (a solo maintainer with no CODEOWNERS and no review bot installed, for example), no reviewer is ever requested, so the gate’s dual condition can never clear on its own. Since GitHub forbids a PR author from approving their own PR, the gate can look unsatisfiable from the operator’s side too.
It isn’t: the gate’s actual test is “has a human looked at this?”, not “has a reviewer approved this?” A COMMENTED review submitted by the PR author itself satisfies the gate — GitHub permits a self-COMMENT even though it forbids self-approval. This is the supported manual way to clear the gate when no other reviewer can respond.
When the timeout elapses with no reviewer ever requested, Fabrik’s pause comment says so plainly — it does not claim to have waited on “outstanding reviewers” that don’t exist — and lists five ways to resume:
- Post a review on the PR yourself. A
COMMENTEDself-review satisfies the gate — unless the stage isauthoritativeand the repo requires approving reviews (branch protection reportsREVIEW_REQUIRED), in which case an approval from another account is needed; see Authoritative Mode below. - Set
expected_reviewers: []in the stage YAML to declare that no unrequested reviewer (e.g. a bot) is expected here — an explicitly requested reviewer is still honored. This is the narrower, purpose-built option: it stops the gate from waiting out unrequested reviewers that will never respond, without disabling the gate for a reviewer requested on a later PR. See Declaring Expected Reviewers below. - Set
wait_for_reviews: falsein the stage YAML if this repo has no reviewer at all. This disables the review gate entirely, including for a reviewer requested on a later PR — the supported setting only when no review will ever be relevant on this repo. - Merge the PR manually.
- Remove the
fabrik:pausedlabel to let the engine wait again (useful if a reviewer is actually expected and just hasn’t gotten to it yet).
Fabrik deliberately does not try to detect “no reviewer will ever respond” automatically (e.g. by inspecting CODEOWNERS or requested-reviewer history) — that signal can’t distinguish “no reviewer exists” from “the reviewer is just slow or down,” and guessing wrong in the direction of auto-advancing would merge an unreviewed PR. The gate stays loud-and-conservative; only the messaging tells you honestly what it knows.
Authoritative Mode
Everything above describes the default — advisory — mode: the gate clears once every requested reviewer has responded and at least one review exists, whatever the review actually says. A reviewer requesting changes does not, by itself, stop Fabrik from advancing.
Set review_authority: authoritative on a stage (alongside wait_for_reviews: true) to make a review’s verdict binding, not just its existence:
name: Validate
order: 5
wait_for_reviews: true
review_authority: authoritative
...
In authoritative mode, the gate additionally requires:
- No outstanding
CHANGES_REQUESTEDreview — from any reviewer, human or bot. - Required approvals satisfied — preferring GitHub’s own computed review-decision verdict when your repo’s branch protection defines a review requirement (this already accounts for CODEOWNERS and required-approval counts), and otherwise falling back to Fabrik’s own check that no submitted review is currently
CHANGES_REQUESTED— soauthoritativemode is meaningful even on a repo with no branch protection configured.
This applies at both decision points: the stage-advance gate (the mechanism described above) and the landing/auto-merge gate at Validate completion — a CHANGES_REQUESTED review blocks auto-merge under yolo, not just stage advancement.
authoritative governs merging, never working. An unresolved CHANGES_REQUESTED does not make Fabrik sit idle waiting for a human to resolve it — Fabrik re-invokes the stage to address the reviewer’s feedback (both inline PR comments and the review’s own written explanation), pushes a fix, and waits for the reviewer to re-review. This is the same “review reinvoke” mechanism described above for the default (advisory) mode; authoritative does not disable it. The reinvoke loop is bounded by FABRIK_MAX_REVIEW_CYCLES exactly as before — if a reviewer keeps requesting changes past that limit, then Fabrik pauses for a human (see below). Pausing is the fallback for a loop that fails to converge, not the first response to a change request.
yolo/cruise never bypass an authoritative gate. They still control when Fabrik acts once the gate is satisfied — not whether it’s satisfied:
| Mode | Behavior |
|---|---|
advisory + yolo |
Unchanged — auto-advance and auto-merge as soon as a review exists, regardless of verdict. |
authoritative + yolo |
Auto-merge fires as soon as CHANGES_REQUESTED is cleared and required approvals are met — no human click needed for the merge itself, but Fabrik waits for a still-blocking verdict rather than forcing through it. |
authoritative + cruise |
Cruise’s existing “stop before auto-merge” behavior is unaffected — a cruise item never reaches the authoritative check at all. |
If the verdict never resolves — an unfixable review finding, or a required human who never approves — Fabrik keeps reinvoking on each new round of feedback (the primary response, above) until FABRIK_MAX_REVIEW_CYCLES is reached, at which point it pauses for a human. There is no separate cycle limit for authoritative mode — it reuses the same FABRIK_MAX_REVIEW_CYCLES bound advisory mode’s review reinvoke already has. A plain “reviewer hasn’t responded at all yet” block (nothing to reinvoke on) still falls back to the existing FABRIK_REVIEW_WAIT_TIMEOUT pause, unchanged.
review_authority defaults to advisory (equivalent to leaving it unset) — existing repos and stages are unaffected until you opt in.
Per-issue override. Editing stage YAML opts in every issue on that stage, repo-wide. To make a single high-risk change authoritative (or exempt a single issue from an otherwise-authoritative stage) without touching stage config, apply a review-authority:advisory or review-authority:authoritative label directly to the issue — it overrides the stage’s configured review_authority for that issue only. No review-authority: label means the stage config governs, unchanged. If you apply both labels to the same issue, Fabrik resolves to authoritative (the more restrictive value) and logs a warning; a misspelled or unrecognized value (e.g. review-authority:Authoritative) is ignored with a logged warning and falls back to the stage config — it never silently escalates to authoritative. The label only changes the verdict-strictness once the gate is active — wait_for_reviews: true on the stage is still required for the gate to engage at all, and yolo/cruise still never bypass whatever authority resolves to.
For the full mechanism (verdict source precedence, fetch-failure handling, message wording), see State Machine §6.1.1, ADR-1250, and ADR-1261.
Declaring Expected Reviewers
Every real-world review bot — Pruefer, Gemini, CodeRabbit, Copilot — is unrequested: it polls or receives a webhook and posts a review directly, without ever being formally requested as a reviewer on the PR. From the gate’s point of view, “a bot is a few minutes away from reviewing” and “no bot is coming, ever” look identical: no requested reviewer, no review yet. Left alone, the gate has to wait out the full timeout to find out which one it was — which is exactly what happens on a solo-maintainer repo with no bots configured at all (every issue burns the full wait before pausing for no reason).
expected_reviewers tells Fabrik, per stage, which unrequested reviewers (if any) to expect:
name: Review
order: 4
wait_for_reviews: true
expected_reviewers:
- handarbeit-pruefer
Three states:
| Setting | Meaning |
|---|---|
| (absent — default) | Unchanged behavior: Fabrik waits the full FABRIK_REVIEW_WAIT_TIMEOUT for a self-submitting bot, exactly as before this key existed. Safe to leave as-is; nothing breaks. |
expected_reviewers: [] |
Explicitly declares that no unrequested reviewer runs on this repo. If nothing is requested either, Fabrik advances immediately instead of waiting out the timeout — the advance is logged, not silent. A reviewer actually requested on the PR is still honored regardless; this only narrows waiting for unrequested reviewers, it does not disable wait_for_reviews. |
expected_reviewers: [name, ...] |
One or more declared reviewers. Declaring them makes the bot re-prompt ladder (Phase 1/2) reachable while nothing has been requested and nothing has been reviewed. Re-prompts and timeout messages name them by name. |
What “any one” actually means. The gate’s underlying clearing condition — no reviewer left outstanding, and at least one non-DISMISSED review exists — is unchanged by this feature (out of scope; see State Machine §6.1). That condition is satisfied by a review from any author, not specifically one of the declared names. In the common case this means any one declared reviewer responding clears the gate (that’s the point of declaring more than one — see #1071), but an unrelated human or bot review landing first clears it too. expected_reviewers governs the re-prompt ladder and timeout messaging — not all having to respond, and not requiring the response to come specifically from a declared name — it does not change the clearing check itself.
Identity format. A declared name must be a bare, @-mentionable handle: no leading @, and no trailing [bot] suffix — even though that suffix is exactly what GitHub’s REST API reports as the bot’s actual review author (e.g. Pruefer’s is literally <slug>[bot]). Fabrik strips the suffix internally when matching a declaration against a live review, so declare the bare form:
expected_reviewers:
- handarbeit-pruefer # correct — matches both "handarbeit-pruefer" and "handarbeit-pruefer[bot]"
# - handarbeit-pruefer[bot] # WRONG — @handarbeit-pruefer[bot] does not resolve as a mention
A malformed entry (empty, leading @, or trailing [bot]/[Bot]/[BOT]) fails Fabrik’s startup entirely, with an error naming the offending stage and value — a typo here fails loudly at startup, not silently at 3am.
Declaring a bot does not vouch for it. If you declare expected_reviewers: [some-bot] and some-bot is not actually installed on the repo, Fabrik still runs the full re-prompt-then-timeout sequence and pauses, naming some-bot as the reviewer that never responded — a declaration only tells Fabrik who to expect, never that the reviewer is guaranteed to show up.
What changes once you declare a name. The existing bot re-prompt ladder (§Label Lifecycle above, fabrik:bot-reprompted) — previously reachable only for a formally-requested bot reviewer, which none of the bots people actually run ever are — becomes reachable: at 1× FABRIK_REVIEW_WAIT_TIMEOUT, Fabrik posts an @<name> just checking in comment directly on the PR (there is no formal review request to re-send for a reviewer that was never requested) and applies fabrik:bot-reprompted; if still no response after another full timeout window, Fabrik pauses for human input, naming the reviewer. With multiple declared reviewers, the pause message reports per-reviewer status, e.g. Expected reviewers: `handarbeit-pruefer` reviewed; `gemini-code-assist` did not respond.
Startup notice. On every startup, Fabrik prints a one-line notice for each wait_for_reviews: true stage that has not declared expected_reviewers — informational only, never blocking. Add a declaration (even an explicit expected_reviewers: []) and the notice disappears on the next startup.
Interaction with review_authority: authoritative. The immediate-advance path for expected_reviewers: [] fires regardless of review_authority. This is deliberate, not an oversight: authoritative mode weighs an existing review’s verdict (approvals, CHANGES_REQUESTED) — see Authoritative Mode — and that weighing only ever runs once at least one review has been submitted. When nothing is requested, nothing has been reviewed, and the stage has explicitly declared that no unrequested reviewer is coming, there is no verdict for authoritative mode to weigh; gating the advance on review_authority would just wait out the timeout every cycle for a review that, by the stage’s own declaration, will never arrive — recreating the exact stall this feature exists to eliminate. If you need branch-protection review requirements enforced even when Fabrik expects no reviewer, configure that requirement on the repository itself (GitHub still blocks the actual merge); expected_reviewers and review_authority govern Fabrik’s own wait/verdict bookkeeping, not GitHub’s merge protection.
Per-issue override. Editing stage YAML opts in every issue on that stage, repo-wide. To declare no unrequested reviewer (or a synthetic declared one, for testing) on a single issue without touching stage config, apply an expected-reviewers:none or expected-reviewers:declared label directly to the issue — it overrides the stage’s configured expected_reviewers for that issue only. No expected-reviewers: label means the stage config governs, unchanged (nil stays nil). expected-reviewers:declared resolves to a fixed synthetic reviewer identity (e2e-synthetic-declared-reviewer) intended for testing/e2e use — it never posts a real review, so applying it to a production issue runs out the full re-prompt ladder before pausing. If you apply both labels to the same issue, Fabrik resolves to declared (the more restrictive value — it imposes waiting, unlike none’s immediate advance) and logs a warning; a misspelled or unrecognized value is ignored with a logged warning and falls back to the stage config — it never silently escalates to declared. The label only changes which reviewers are expected once the gate is active — wait_for_reviews: true on the stage is still required for the gate to engage at all.
CI Gate and CI-Fix Workflow
When a stage has wait_for_ci: true set, Fabrik gates auto-advance (and auto-merge for Validate+yolo) on CI checks passing on the PR head. If checks fail, Fabrik re-invokes the stage agent with a structured failure report so it can fix the regression and push again. Re-invocation is unconditional — it fires for any issue with wait_for_ci: true and a failing CI check, regardless of whether auto-advance is active.
For the authoritative spec on label lifecycle and gate transitions, see State Machine.
The Validate stage ships with wait_for_ci: true enabled by default.
Enabling the Gate
Add wait_for_ci: true to the relevant stage YAML:
name: Validate
order: 5
wait_for_ci: true
...
CI checks are only evaluated on the PR head SHA — Fabrik makes two REST calls per poll: one to get the head SHA via the linked PR, and one to fetch the check runs. If no check runs exist and GitHub’s mergeable_state is either empty or "unknown" (meaning no legacy Commit Status is blocking merge), the gate clears immediately. If mergeable_state is non-empty and not "unknown" (a legacy Commit Status or external status check is actively blocking merge), the gate holds even with no check_runs — Fabrik falls back to CIWaitTimeout escalation to avoid blocking forever. This case has no check-run signal to observe progress on at all, so it stays elapsed-time-based even though the check-runs-present case below does not (ADR-1410).
For a repo whose required CI signal is a classic commit status rather than a normal GitHub Actions check run, configure required_status_contexts (per-repo, in .fabrik/config.yaml, ADR-933) so the gate can distinguish a required context that never ran from one that was correctly skipped.
Label Lifecycle
When a wait_for_ci: true stage emits FABRIK_STAGE_COMPLETE, Fabrik immediately adds the fabrik:awaiting-ci label to the issue — it means “CI gate active” and covers both pending and failed CI states. stage:X:complete is withheld until checkCIGate confirms all checks pass (conjunctive gate, ADR 032). This label:
- Makes the CI-blocked state visible on the project board; present whether checks are still running or have failed
- Is cleared automatically when all CI checks pass, or when a genuine liveness dwell elapses (removed before pausing with
fabrik:awaiting-input) — since ADR-1410, a confirmed CI failure does not clear this label via a timeout; it instead triggers the CI-fix re-invocation above, and the label stays applied while that plays out - Triggers the
itemMayNeedWorkcache bypass — CI results change independently of the issue’s GitHubupdatedAt, so items withfabrik:awaiting-cibypass the staleness cache and are re-evaluated on every poll - R5 post-push guard: within the current engine run, if the PR has previously had check runs, empty check run results after a push are treated as a post-push registration delay (GitHub takes a few seconds to register new runs) rather than “no CI configured” — the gate does not prematurely clear
The CI-Fix Report
When CI fails and Fabrik re-invokes the stage agent, it passes a synthetic CI failure comment with the following structure:
🏭 **Fabrik — CI Fix Required**
CI checks failed on the PR head. Fix **NEW REGRESSION** failures before proceeding.
### Failed checks
| Check | Status | Classification |
|-------|--------|----------------|
| Go tests | failure | **NEW REGRESSION** |
| Go vet | failure | pre-existing (also failing on base branch) |
### Base branch CI status (for comparison)
| Check | Status |
|-------|--------|
| Go tests | success |
| Go vet | failure |
- NEW REGRESSION — the check passes on the base branch but fails on this PR. The agent should fix these.
- pre-existing — the check also fails on the base branch. The agent should not attempt to fix these.
The agent should fix NEW REGRESSION failures, commit, push, and not emit FABRIK_STAGE_COMPLETE. Fabrik re-evaluates CI on the next poll and advances the issue once all checks pass.
Two-Prong CI Gate
The CI gate operates on two different paths depending on whether the item is being auto-merged or just being polled:
Merge guard (Validate+yolo auto-merge path):
- Checks CI before attempting the merge in
attemptMergeOnValidate() mergeable_stateshortcut (v0.0.52):attemptMergeOnValidatequeries GitHub’smergeable_statefirst; ifcleanorunstable, the gate clears immediately and proceeds to merge — per-check classification is skipped entirely- While CI is pending: returns an error (no label applied — avoids churn)
- On CI failure: adds
fabrik:awaiting-ci, returns error (skips merge) - On timeout (pending too long): pauses with
fabrik:paused+fabrik:awaiting-input
Catch-up loop (all other paths):
- Evaluates CI on every poll for items with
fabrik:awaiting-cion stages withwait_for_ci: true mergeable_stateshortcut (v0.0.52):checkCIGatequeries GitHub’smergeable_statefirst; ifcleanorunstable,addCompleteLabelAndRemoveCIruns immediately — stalefabrik:awaiting-ciis cleared,stage:<X>:completeis added, and per-check classification is skipped entirely- On pending:
fabrik:awaiting-ciis already present (applied at FABRIK_STAGE_COMPLETE); no additional label applied, re-evaluates next poll — indefinitely, as long as check-run state keeps changing (ADR-1410) - On failure:
fabrik:awaiting-ciapplied idempotently; dispatches CI-fix re-invocation unconditionally, regardless of how long the gate has been open (ADR-1410, R3 — never a timeout) - On a genuine liveness-dwell timeout: pauses with
fabrik:paused+fabrik:awaiting-input. For the pending-but-stalled case, the dwell is measured from the last observed check-run change (in-memory, resets to “no progress observed” on restart — a cold cache never escalates blind); for the required-check-never-starts and legacy-Commit-Status cases (R3 above), it’s measured from whenfabrik:awaiting-ciwas first applied (durable across restarts, viaFetchLabelAppliedAt) — see ADR-1410. - Absolute backstop: independent of the above,
fabrik:awaiting-ciis also unconditionally capped atFABRIK_CI_BACKSTOP_TIMEOUT(default 4h) regardless of classification, bounding per-poll cost — see Timeout Configuration below.
Rationale for the
mergeable_stateshortcut: GitHub’smergeable_statereflects branch protection rules — it already aggregates required check status, reviewer approvals, and protection constraints. Non-required check_run failures (e.g., cleanup workflow jobs, notification steps) do not block merges per branch protection, so Fabrik’s gate must not block on them either. Consultingmergeable_statefirst avoids over-aggressive blocking caused by raw per-check classification that cannot distinguish required from non-required checks.
CI Gate Recovery
Beyond the normal pending→failure→fix→pass loop, the CI gate handles four additional edge cases:
R1 — Merged PR: If the linked PR is merged while fabrik:awaiting-ci is active, Fabrik clears the gate immediately, removes fabrik:awaiting-ci and the withheld stage:X:complete state, and advances the issue to Done. No manual action required.
R2 — Closed (not merged) PR: If the PR is closed without merging, Fabrik posts an explanatory comment and pauses the issue with fabrik:awaiting-input. The gate is not cleared — an operator must decide the next step (reopen the PR, create a new one, or close the issue).
R3 — Required check that never starts: If a required check is expected but never produces a check run (e.g., converted to workflow_dispatch after the PR was created), the gate would wait indefinitely. After CIWaitTimeout elapses with the PR in an OPEN+BLOCKED state and no check runs ever having run, Fabrik pauses the issue via pauseForRequiredNeverRunningCheck. Apply fabrik:revalidate after fixing the CI configuration to re-run Validate. This case has no check-run signal to observe progress on at all, so — like the merge-guard fallback above — it stays elapsed-time-based under CIWaitTimeout (ADR-1410), unlike the check-runs-present pending case.
R4 — Paused-item auto-recovery: Items paused with fabrik:paused and either fabrik:awaiting-ci or fabrik:awaiting-review are monitored by a separate recovery loop (runValidatePRTerminalAdvance) that bypasses the boardcache (which may have stale PR state). When the linked PR merges, the recovery loop automatically clears the gate, removes the pause labels, and advances the issue to Done — no manual unpause is required. This handles the case where a PR is merged externally (e.g., by a human or GitHub auto-merge) while the issue sits paused under either gate.
R4b — Convergence-paused auto-recovery: Items paused by convergence budget exhaustion carry fabrik:paused + stage:Validate:complete but no gate label (fabrik:awaiting-ci or fabrik:awaiting-review). When their linked PR is subsequently merged externally, the same runValidatePRTerminalAdvance pass detects the merge, removes fabrik:paused and fabrik:awaiting-input, and advances the issue to Done — no manual intervention required. See Post-Validate Convergence Monitor (yolo) for how items reach this paused state.
Merge-Conflict Gate
In addition to checking CI status, the catch-up loop also checks the mergeable field of the linked PR. When GitHub reports mergeable: false (confirmed — not null or pending) while the item is in the CI-await window, Fabrik applies the fabrik:rebase-needed label and dispatches a rebase re-invocation. Claude is instructed to git fetch, rebase the branch onto the base branch, resolve any conflicts, and force-push. The label clears automatically on the next poll when GitHub flips mergeable back to true.
The rebase cycle cap is controlled by --max-rebase-cycles / FABRIK_MAX_REBASE_CYCLES (default 3). When the cap is exceeded, Fabrik posts a comment, keeps fabrik:rebase-needed applied so the reason for the pause remains visible, and pauses the issue with fabrik:paused + fabrik:awaiting-input. The pause comment instructs the operator to resolve the conflict manually, then remove both fabrik:paused and fabrik:rebase-needed to resume. The default of 3 is intentionally lower than the CI-fix and review defaults (both 5): a rebase either succeeds in one automated pass or it has a semantic conflict that requires human judgment. More than a handful of automated attempts without success almost always means a human needs to look at it.
CI-Fix Skill
By default, CI-fix re-invocations use the stage’s comment_skill. To use a dedicated skill for CI fixes, set ci_fix_skill:
name: Validate
order: 5
wait_for_ci: true
ci_fix_skill: fabrik-validate-ci-fix # Custom skill for CI-fix re-invocations
comment_skill: fabrik-validate-comment # Used for regular comment processing
...
When ci_fix_skill is not set, comment_skill is used. When neither is set, the stage’s primary skill is used.
Cycle Limit
To prevent infinite loops (e.g., a non-deterministic flaky test that keeps failing), Fabrik caps the number of CI-fix re-invocation cycles per issue per stage per engine session:
FABRIK_MAX_CI_FIX_CYCLES=3 # Limit to 3 CI-fix cycles per issue per stage (default: 5)
# or equivalently:
fabrik --max-ci-fix-cycles=3
The cycle count resets on engine restart, and is also reset by clearFailedStage() when the user manually unpauses a failed issue.
Timeout Configuration (ADR-1410: liveness, not elapsed time)
Fabrik distinguishes CI that is slow from CI that is dead, and bounds only the latter. This changed as of ADR-1410 — see below for what an existing FABRIK_CI_WAIT_TIMEOUT setting means now.
- CI checks failed: never a timeout. A confirmed failure always triggers the CI-fix re-invocation above, however long
fabrik:awaiting-cihas been present. This is a verdict, not a wait. - CI checks still pending, and observably progressing (a check-run status changed, or a fresh commit was pushed, since the last poll): Fabrik waits indefinitely. An 18-minute suite and a 3-hour suite are both fine — nothing about their duration counts against a deadline.
- CI checks still pending, with no progress observed for
FABRIK_CI_WAIT_TIMEOUTminutes: Fabrik pauses the issue withfabrik:awaiting-input. This is the genuine stall case — something stopped reporting. - A required check that never starts, or a legacy Commit Status actively blocking merge with no check runs at all: also governed by
FABRIK_CI_WAIT_TIMEOUT, unchanged — there is no check-run signal in these cases to observe progress on, so a plain elapsed-time dwell remains the right instrument (see R3 above).
Known gap — the pending-and-stalled case above is not currently reached via the normal catch-up loop. The merge gate (
checkMergeabilityGate) unconditionally claims any item with check runs still pending before the CI gate is ever evaluated, soFABRIK_CI_WAIT_TIMEOUTnever actually fires for “checks pending but frozen” in practice — onlyFABRIK_CI_BACKSTOP_TIMEOUT(below, default 4h) escalates that case today. If you lowerFABRIK_CI_WAIT_TIMEOUTexpecting a stalled-but-pending PR to escalate sooner, it won’t; lowerFABRIK_CI_BACKSTOP_TIMEOUTinstead. See ADR-1410’s “Architectural discovery” section for the full detail.
FABRIK_CI_WAIT_TIMEOUT=60 # Pause after 60 minutes of no observed CI progress (default: 30)
# or equivalently:
fabrik --ci-wait-timeout=60
If you already had FABRIK_CI_WAIT_TIMEOUT set: the name, flag, and default (30 minutes) are unchanged — you do not need to update anything. What changed is what the number means: before ADR-1410, it capped the PR’s total time in fabrik:awaiting-ci regardless of whether CI was alive; now it only measures inactivity. If you had tuned it up to accommodate a slow test suite, you likely no longer need that override — a suite of any length now completes normally as long as it keeps reporting.
Absolute backstop — FABRIK_CI_BACKSTOP_TIMEOUT (new, ADR-1410, R5): a separate setting bounds how long an item may sit in fabrik:awaiting-ci under any classification, including a suite that is technically still progressing. This exists purely to cap the per-poll cost of a held item (a deep-fetch plus a live check-run refresh every poll), not to bound CI duration — so it defaults much larger than the liveness dwell above:
FABRIK_CI_BACKSTOP_TIMEOUT=480 # Absolute cap of 8 hours (default: 240 = 4h)
# or equivalently:
fabrik --ci-backstop-timeout=480
Tune this down only if you want a hard ceiling on how long any single PR can occupy fabrik:awaiting-ci, independent of whether it’s making progress.
Keep FABRIK_CI_BACKSTOP_TIMEOUT well above FABRIK_CI_WAIT_TIMEOUT. Nothing enforces this ordering structurally — if the backstop is set at or below the liveness dwell, settleAwaitingCIScan’s unconditional backstop fires before the CI-gate liveness dwell ever gets a chance to distinguish a stalled CI run from one that’s actively progressing, silently reintroducing the #342 spurious-pause behavior this redesign fixed. Fabrik logs a [startup] warning (not a hard failure) if the resolved values are misordered — watch for it after changing either setting.
Restart Persistence
The R3/legacy-Commit-Status elapsed dwells and the CIBackstopTimeout backstop are measured from the fabrik:awaiting-ci label’s creation timestamp (fetched via the GitHub API), making them durable across engine restarts. The check-run-pending liveness dwell instead tracks progress in memory; a restart resets it to “no progress observed yet,” which is the safe default — it never escalates blind on a cold cache, only re-observes on the next poll. The CI-fix cycle count is also in-memory and resets on restart.
CI Auto-Advance Workflow (removed)
The fabrik-advance.yml GitHub Actions workflow template previously generated by
fabrik init has been removed. The engine’s wait_for_ci: true stage option is
the canonical CI-gate path (see the section above).
If your repo has a .github/workflows/fabrik-advance.yml installed by an earlier
fabrik init, delete it and ensure wait_for_ci: true is set in your Validate
stage YAML. The Validate stage ships with wait_for_ci: true by default.
Post-Validate Convergence Monitor (yolo)
When Validate completes on a yolo issue, Fabrik enables GitHub’s native auto-merge on the linked PR and applies the fabrik:auto-merge-enabled label. GitHub then handles the merge atomically once all branch-protection requirements are met. Fabrik monitors the PR’s convergence in the background until it reaches a terminal state (merged or closed).
Yolo vs. cruise in high-contention repos
fabrik:yolo— full automation, including auto-merge. Best for low-contention repos or issues where merge timing doesn’t matter. GitHub’s native auto-merge queues the PR and merges it as soon as branch-protection requirements are met.fabrik:cruise— automation through all stages, human merge decision. Use in repos with strict merge policies, main-branch freezes, or where you want to review the final diff before merge regardless of CI status.
If both labels are present, cruise takes precedence for both decisions — the PR is not auto-merged and the issue does not advance to Done. It stops at Validate exactly as cruise alone would; adding yolo to a cruise issue never widens automation.
Convergence budget
Fabrik gives the PR a wall-clock window (default: 30 minutes) to reach a terminal state after auto-merge is enabled. The budget starts when the fabrik:auto-merge-enabled label is applied and is durable across engine restarts (the label’s creation timestamp is fetched from the GitHub events API).
FABRIK_CONVERGENCE_BUDGET=1h # Wait up to 1 hour (default: 30m)
FABRIK_CONVERGENCE_BUDGET=0 # Disable the budget — wait indefinitely
During the convergence window, Fabrik:
| PR state | Action |
|---|---|
| Merged or closed | Removes fabrik:auto-merge-enabled; advances to Done |
| Auto-merge was user-disabled | Posts comment; removes fabrik:auto-merge-enabled; pauses for input |
| Mergeability unknown (GitHub computing) | Waits (no action) |
| Merge conflict detected | Dispatches rebase re-invocation; re-enables auto-merge after successful push |
| Budget expired | Calls pauseForConvergenceFailed (see below) |
When the budget expires
Fabrik posts a structured comment with the current PR state, then pauses the issue:
🏭 Fabrik — convergence budget exhausted
The linked PR did not merge within the convergence budget.
| Field | Value |
|-------|-------|
| PR | #123 |
| mergeable_state | blocked |
| commits behind base | 2 |
| elapsed | 32m14s / 30m budget |
| CI | ✅ Build passed · ❌ Tests failed |
To resume, resolve the blocking condition, then remove fabrik:paused and
fabrik:awaiting-input. Fabrik will re-enable auto-merge and start a new
convergence window.
What to do when the issue is paused:
- CI failure: Fix the failing check. Push to the PR branch. Remove
fabrik:pausedandfabrik:awaiting-input. Fabrik re-enables auto-merge on the next poll and starts a fresh 30-minute window. - Merge conflict: Resolve the conflict, force-push the branch (Fabrik will have attempted an automated rebase; check if it already succeeded). Remove the pause labels.
- Review required: Request and obtain reviewer approval. Remove the pause labels once approved.
Merge strategy
By default, Fabrik uses a merge commit (MERGE). To use squash or rebase instead:
FABRIK_AUTO_MERGE_STRATEGY=SQUASH # Squash commits into one
FABRIK_AUTO_MERGE_STRATEGY=REBASE # Rebase commits onto base branch
Accepted values: MERGE, SQUASH, REBASE. The repository must have the corresponding merge method enabled in its settings.
Single-owner PR terminal advance (
runValidatePRTerminalAdvance— ADR-056 D2): All Validate-stage PR terminal transitions — PR merged, PR closed-without-merge, convergence-paused recovery (R4b), gate-labelled (fabrik:awaiting-ci,fabrik:awaiting-review) or unlabelled — are handled by a single authoritative dispatcher. This eliminates the family of bugs where one code path advanced correctly but a sibling path didn’t. Items carryingfabrik:auto-merge-enabledare the sole exclusion: they are handled exclusively by the convergence monitor above while that label is present.
SHA-change Auto-Revalidation
When a PR receives a new push after stage:Validate:complete has been applied, Fabrik automatically detects the SHA change on the next poll and re-dispatches Validate — no operator action required.
What Fabrik clears before re-dispatching:
stage:Validate:completefabrik:auto-merge-enabledfabrik:awaiting-cifabrik:awaiting-review
Validate then runs on the new HEAD SHA, with the full CI gate and convergence-monitor flow.
Legacy items (empty completion SHA): Issues that completed Validate before v0.0.69 was deployed do not have a recorded completion SHA. The SHA-change scan is a no-op for these — Fabrik cannot distinguish a pre-feature item from an item whose SHA was intentionally cleared. Use fabrik:revalidate as the manual fallback: applying the label clears stage:Validate:complete, stage:Validate:failed, fabrik:paused, fabrik:awaiting-input, fabrik:awaiting-ci, and fabrik:auto-merge-enabled, then re-runs Validate on the current HEAD SHA. See fabrik:revalidate in the labels reference.
4. Stage Reference
Default Pipeline
| Stage | Order | Purpose |
|---|---|---|
| Backlog | -1 | Parking lot. Declared unmanaged: true (stages/examples/backlog.yaml) — recognized by startup validation, never dispatched. |
| Specify | 0 | Refine rough issues into clear, unambiguous specs. |
| Research | 1 | Explore codebase, surface technical findings and questions. |
| Plan | 2 | Design implementation approach with task checklist. |
| Implement | 3 | Write code and tests, commit frequently, push to branch. |
| Review | 4 | Rebase, review, fix issues, push. Posts output on PR. |
| Validate | 5 | Run tests, verify requirements, confirm PR is ready. |
| Done | 99 | Terminal state. Cleanup stage removes worktree. Item remains on the board. |
Done Stage and Archiving
When an issue reaches Done, Fabrik:
- Removes the worktree — frees disk space from the issue’s working copy
- Adds
stage:Done:complete— marks cleanup as finished
Auto-archive: Once an item has sat in Done with stage:<Done>:complete for at least --archive-after (default 168h / one week, also FABRIK_ARCHIVE_AFTER), Fabrik archives it off the project board. The week-long default is deliberately conservative — a prior implementation archived items the instant they reached Done, so completed work appeared to vanish before anyone could see it land; the grace period exists to keep completed work glanceable on the board for a while before it disappears from view. Archived items are not deleted — they remain accessible via the project board’s “Archive” view in GitHub, and can be unarchived there if needed. Set --archive-done off (also FABRIK_ARCHIVE_DONE=off) to disable auto-archival entirely and leave Done items on the board indefinitely.
Closed-issue auto-advance: If an issue is closed externally (for example, a PR merge closes it) while its board item sits at any column other than Done, Fabrik automatically advances it to Done. This process has the following properties:
- Unconditional: It runs on every poll — regardless of yolo, cruise, or plain manual mode.
- No labels required: It requires no
stage:<name>:completelabel or any other label at all. - Broadly applicable: It applies to any non-Done column whose resolved stage isn’t a cleanup, holding, or gate-checked stage, including columns with no matching stage config at all (for example, a stranded item in Backlog).
- No manual board move is required.
- Validate is excluded: A closed issue at Validate is handled separately by the existing PR-terminal-advance/auto-merge path described above.
Customizing Stages
# Initialize with defaults
./fabrik init
# Edit stage configs and skills
vim .fabrik/stages/research.yaml
vim .fabrik/plugin/skills/fabrik-research/SKILL.md
# Or point to a custom stages directory
./fabrik --stages ./my-custom-stages ...
You can add, remove, or reorder stages. Stages must have name, order, and either
skill or prompt. The name must match a board column and order values define
the sequence.
5. Plugin & Skills
Install the Fabrik PM Plugin
The Fabrik PM plugin is a Claude Code plugin for your interactive session — not for the workers. It gives Claude ambient awareness of your Fabrik-managed project so you can ask questions like “what’s on the board?”, “why is #42 stuck?”, or “what’s in progress?” without leaving Claude Code.
Distinct from the worker plugin:
fabrik-workflowsis embedded in the Fabrik binary and deployed automatically to.fabrik/plugin/byfabrik initandfabrik upgrade. You do not installfabrik-workflowsmanually.
Install
Run these three commands in your Claude Code interactive session:
/plugin marketplace add handarbeit/fabrik
/plugin install fabrik@fabrik
/reload-plugins
What you get
After installing, your interactive Claude Code session gains:
| Component | Description |
|---|---|
fabrik skill (qualified: fabrik:fabrik) |
Auto-activates when .fabrik/ is detected or Fabrik is mentioned. Provides ambient PM awareness — ask about board state, issue status, stuck items, etc. |
fabrik-setup skill (qualified: fabrik:fabrik-setup) |
One-time onboarding walkthrough for setting up Fabrik from scratch in a new project. |
/fabrik:status command |
Board snapshot: shows what’s in each pipeline stage, which workers are running, and which worktrees have uncommitted changes. |
Keeping the plugin up to date
When the Fabrik repo ships a newer version of the plugin, refresh your local install:
/plugin marketplace update fabrik
/plugin install fabrik@fabrik
/reload-plugins
Alternatively, enable auto-update via the plugin UI: /plugin → Marketplaces tab → select fabrik → Enable auto-update. When enabled, Claude Code updates the marketplace and reinstalls the plugin automatically at startup.
Entwurf Plugin
The Entwurf plugin (entwurf) is a separate, optional Claude Code plugin that turns a project into buildable specifications, one expert role at a time. It interviews the three people a project needs — the product owner, the technical architect, and the experience lead — each about their own side of the product, and never asks anyone to answer for a role they don’t hold. The product-owner path needs no terminal, git, or technical background. Output is a standard GitHub Spec Kit specs/NNN-feature-name/ folder — byte-compatible with upstream Spec Kit, so it can be handed to Fabrik, or to any development team, without translation. It is not installed by fabrik init and carries no relationship to the engine’s own worker plugin.
Skills
| Skill | Purpose |
|---|---|
onboard |
A 30–45 minute interview about the project as a whole — the problem it solves, who it serves, what’s in and out of scope, hard constraints, and domain vocabulary. Writes .specify/memory/constitution.md, the ground rules every later spec inherits. Run once at the start of a project. |
write-spec |
Turns a feature described in plain language into a structured spec — user stories, requirements, success criteria, and a graded quality checklist — under specs/NNN-feature-name/. |
clarify |
Reads an existing spec, finds what’s vague or missing, and asks up to five targeted questions one at a time, writing each answer straight into the document. |
architecture-foundations |
Interviews the architect about what is already fixed — existing systems, regulatory obligations, platform commitments — plus the shared domain model with per-field ownership, interface contracts, shared state vocabularies, and the authority model. Runs early and is re-run as specs accumulate, because a shared model created near the beginning is what stops features drifting apart. |
experience-baseline |
Interviews the experience lead about conditions of use per surface class, the independent status dimensions and their states, the component and content vocabulary, the accessibility floor, and interaction budgets. Decided once for the whole product. |
architecture-baseline |
Converts user-facing guarantees into system guarantees — stack, cross-cutting mechanisms, non-functional budgets, build order, and the fitness functions that make the baseline binding rather than advisory. Ratifies. |
The three baseline skills assume the person being interviewed is the expert, so they invert the product-owner rule: rather than avoiding questions only an engineer could answer, they never default a decision inside the expert’s own domain, and always flag a default made in a neighbouring one. They write .specify/memory/architecture.md, experience.md, a decision register, and per-stage quality gates — all additive to upstream Spec Kit’s layout (see the plugin’s NOTICE.md).
Install
In an interactive Claude Code session:
/plugin marketplace add handarbeit/fabrik
/plugin install entwurf@fabrik
The plugin’s primary audience installs it through the Claude desktop app’s Cowork UI instead (Cowork → Customize → Plugins → Add marketplace → handarbeit/fabrik → install Entwurf); the commands above are the developer-facing equivalent. See the plugin’s own GETTING-STARTED.md for the full non-technical walkthrough.
Attribution
The write-spec and clarify skills are adapted from github/spec-kit (MIT licensed); onboard is a Fabrik-original interview skill mapped onto Spec Kit’s constitution output shape. See the plugin’s own NOTICE.md for the full license text and a detailed account of what was kept, removed, and changed from upstream.
How Skills Work
Each stage references a skill – a markdown file that contains detailed methodology
for how Claude should approach the stage. Skills are packaged as a Claude Code plugin
in .fabrik/plugin/ and loaded via --plugin-dir on each Claude invocation.
The default skills are:
| Skill | Purpose |
|---|---|
fabrik-specify |
Requirements clarification, consistency checks, prior art research; preserves the Problem section verbatim (never compressed) and places it first in the spec |
fabrik-research |
Codebase exploration, technical analysis, constraint discovery; includes a Documentation Impact section for user-facing features |
fabrik-plan |
Implementation design, task checklist, decision documentation; includes explicit doc update tasks in the checklist for user-facing features |
fabrik-implement |
Code writing, testing, committing, pushing; updates USER_GUIDE.md and/or README.md in the same PR for user-facing features — never defers documentation to a follow-up issue |
fabrik-review |
Code review, fix issues, rebase, prepare PR |
fabrik-validate |
Final verification, test suite, requirements check |
Review and Validate: post-rebase dependency install
After each rebase onto the base branch, both the fabrik-review and fabrik-validate skills read the project’s CLAUDE.md for a dependency-install command (e.g. pnpm install, cargo build, go mod download) and run it before executing the test suite. On failure, the skill emits FABRIK_BLOCKED_ON_INPUT and the issue pauses — a dependency-install failure requires human attention before the stage can complete. If no install command is found in CLAUDE.md, the step is silently skipped. This closes a class of stale-dependencies test failures that occurred when main advanced past a dependency bump between the time the branch was created and the time Review or Validate ran.
Validate: pre-completion mergeability gate
Before emitting FABRIK_STAGE_COMPLETE, the fabrik-validate skill checks the linked PR’s mergeability via gh pr view --json mergeable,mergeStateStatus. When the mergeable value is CONFLICTING or mergeStateStatus is DIRTY — indicating a merge conflict or a rebased-away branch — the skill emits FABRIK_BLOCKED_ON_INPUT instead of FABRIK_STAGE_COMPLETE. The issue pauses with fabrik:awaiting-input rather than marking Validate complete on a non-mergeable PR. To resume: resolve the conflict, push the fixed branch, then remove fabrik:paused and fabrik:awaiting-input.
Before that mergeability check, the skill’s Pre-Completion Gate also decides whether to rebase the branch onto its base at all. Rebasing pushes, and a push restarts CI, so an unconditional rebase on every Validate invocation can livelock a repo whose required-check duration exceeds its merge interarrival time, or eject a PR already sitting in a merge queue. The skill skips the rebase when the branch is already up to date with its base (nothing to gain), when the linked PR is in a merge queue or the check can’t confirm it isn’t (pushing there would eject the PR), or when every one of the PR’s required checks has already succeeded against the current base branch tip — measured by comparing the base branch’s own commit time against the earliest start time among those required checks (not completion time — a long-running check can finish after the base has moved again, so only the start time proves the check began against a tree at least as new as the current base; and the earliest, not the most recent, since one fresh required check says nothing about a sibling required check that hasn’t rerun), not by reading branch-protection config, since a repo can allow merging on a stale check regardless of how up-to-date-ness is enforced. It still rebases in the one case that needs it: behind its base, not queued, with no fresh-enough successful check run to fall back on. Either way, the stage summary states which outcome applied (rebased, skipped-in-queue, skipped-detection-failed, skipped-up-to-date, or skipped-ci-fresh), so a skipped rebase reads as a deliberate decision, not an omission. See ADR-1364.
Customizing Skills
Skills live in .fabrik/plugin/skills/<skill-name>/SKILL.md. Edit them to change
how Claude approaches each stage. Changes take effect on the next Claude invocation.
The stage YAML references the skill by name:
skill: fabrik-research # loads .fabrik/plugin/skills/fabrik-research/SKILL.md
Upgrade protection for customized skills
Fabrik tracks what it last installed using a .fabrik/plugin/.installed-version
fingerprint file (SHA256 of all embedded skill files). This enables a three-way
comparison on every upgrade:
| State | Condition | Behavior |
|---|---|---|
| Up to date | disk == installed == embedded | No-op |
| Upgrade available | disk == installed, embedded changed | Auto-refresh (non-TTY) or y/N prompt (TTY) |
| Custom workflow | disk != installed | Block refresh; show [u] custom workflow badge |
| First run (migration) | .installed-version absent |
Seeds fingerprint from current disk; no refresh this cycle |
When customizations are detected, Fabrik refuses to overwrite them silently. Use one of:
fabrik upgrade --force # Overwrite all customizations (destructive)
fabrik upgrade --reconcile # Print a Claude Code prompt for guided reconciliation
The --reconcile output is a prompt you can paste into Claude Code to diff your
customizations against the new embedded version and preserve non-conflicting changes.
In the TUI, pressing u when the [u] custom workflow badge is shown opens the
same three options interactively.
Skill vs Prompt
skill:references a plugin skill file (recommended). The skill contains rich methodology, quality checklists, and scope boundaries.prompt:is an inline prompt string in the YAML (legacy, still supported). Useful for quick customization but harder to maintain for complex stages.
When skill is set, Fabrik sends a minimal directive:
You are operating as the Fabrik Research agent for issue #42.
Follow the instructions in the fabrik-research skill exactly.
The skill is auto-loaded by Claude Code via the plugin system.
Built-in Skill: /cut-release
The /cut-release skill automates the Fabrik release process — pre-flight checks, release notes, commit/tag/push, and documentation issue filing — in a single invocation.
Invocation
/cut-release # Auto-suggests next patch bump; confirms version with you
/cut-release v0.0.12 # Uses the explicit version; no confirmation prompt
Pre-flight checks
Before doing anything, /cut-release:
- Verifies the working tree is clean (
git status --porcelain). Stops if dirty. - Pulls latest main (
git pull origin main). - Runs
go build ./...andgo test -race ./.... Stops if either fails.
The build/test gate is mandatory — broken releases are worse than delayed ones.
Version determination
- Reads the latest tag via
git describe --tags --abbrev=0. - If you provided an explicit version, validates it: must be valid semver with a
vprefix and greater than the current tag. - If no version is provided, auto-suggests the next patch bump (e.g.
v0.0.11→v0.0.12) and confirms with you before proceeding.
Change gathering
Runs git log <last-tag>..HEAD --oneline and groups commits into four categories:
| Category | What goes here |
|---|---|
| Features | New user-facing capabilities |
| Fixes | Bug fixes |
| Improvements | Enhancements to existing features |
| Internal | Refactoring, test improvements, CI — summarized briefly, not enumerated |
Merge commits and Co-Authored-By lines are ignored.
Release notes format
Writes release-notes.md in the repo root:
# Fabrik <version>
## Features
- Description of feature (#issue)
## Fixes
- Description of fix (#issue)
## Improvements
- Description of improvement (#issue)
## Internal
- Summary of internal changes
## Upgrading
\```bash
# Auto-upgrade from a running Fabrik instance
# Fabrik checks for new releases each poll cycle and upgrades automatically with --auto-upgrade
# Or download directly
gh release download --repo handarbeit/fabrik --pattern '*<os>_<arch>*' -O - | tar xz
\```
Empty category sections are omitted. The release-notes.md file must be committed before the tag push because the GitHub Actions release workflow (release.yml) uses it to populate the GitHub Release body.
Commit, tag, and push
Once the release notes are written, /cut-release proceeds without a confirmation prompt (pre-flight already passed):
git add release-notes.md- Commits with message:
Release notes for <version> - Creates the tag:
git tag <version> - Pushes branch and tag together:
git push origin main <version> - Reports the triggered GitHub Actions run URL.
Never force-push tags. If a tag already exists at that version, /cut-release stops and tells you.
Documentation issue
After a successful push, /cut-release automatically files a GitHub issue:
- Title:
Update docs for v<version> - Labels:
documentation,fabrik:yolo - Board position: Added to the Fabrik PM project board and moved to the Specify column
The fabrik:yolo label means Fabrik will pick it up automatically and run it through the pipeline. You don’t need to do anything else for documentation updates after cutting a release.
Plugin version auto-bump
Claude Code’s /plugin update detects a new plugin release by comparing the cached plugin.json version field against the version at ref: main in marketplace.json — same version number means no refresh fires, even if the plugin’s source changed. To prevent users from getting stuck on stale cached content, cut-release.sh may commit a version bump to plugin/fabrik/.claude-plugin/plugin.json before it tags the release:
-
When it fires: after the release-notes commit is pushed and before the tag is created, the script diffs each plugin listed in
marketplace.json(excluding its own manifest) against the previous release tag. For each one that changed, it patch-bumps thatplugin.json’sversionfield (e.g.0.2.0→0.2.1) and commits it as@arbeithand, in a separate commit alongside an appended changelog line in that release’srelease-notes/<version>.md, e.g.:- Auto-bumped fabrik plugin to 0.2.1 (source changed since v0.0.75) - When it doesn’t fire: if
plugin/fabrik’s source is unchanged since the previous tag, or there is no previous tag (first-ever release), no extra commit is made and nothing is appended to the release notes. - Scope: every plugin listed in
marketplace.jsonwith agit-subdirsource is bumped — the list is derived from that file rather than hardcoded, so listing a plugin is all that is needed to include it. All bumped manifests land in one commit, each with its own changelog line.plugin/fabrik-workflowsis out of scope: it is embedded directly into the Fabrik binary and distributed viafabrik init/fabrik upgrade, uses independent content-hash-based change detection, and is not affected by the/plugin updatestaleness issue this bump exists to fix. - Bump type: patch-only. Minor/major version bumps for
plugin/fabrikremain a manual responsibility. -
Escape hatch: pass
--no-plugin-bumpto skip this step entirely, e.g. for a hotfix release where you don’t want the extra commit:scripts/cut-release.sh v0.0.68 --no-plugin-bump
Don’t be surprised to see a second @arbeithand commit on main after a release that touched plugin source — this is expected, not a sign something went wrong.
Release-artifact verification guard
The GitHub Actions release workflow (.github/workflows/release.yml, via .goreleaser.yaml) independently verifies every built binary before it is published — a check that runs in CI, separate from and in addition to cut-release.sh’s own local build/test gate.
What it checks: for each binary in the release matrix (currently darwin/linux × arm64/amd64), tools/verify-release-artifact reads the binary’s embedded Go build info and asserts:
vcs.modifiedis nottrue— the binary was built from a clean tree, not one with uncommitted changes.vcs.revisionequals the pushed tag’s target commit — the binary was built from the tagged commit, not some other one.
Where it runs: as a goreleaser per-build hook (builds[].hooks.post), immediately after each target’s binary is compiled and before it is archived, checksummed, or published. If any one of the four targets fails either check, the entire goreleaser release invocation aborts — no GitHub Release, checksums, or Discussions announcement is created for that run.
What a failure means: the CI logs name the offending binary’s path, its actual vcs.revision/vcs.modified, and the expected revision. This most likely means something in the CI environment (not your local machine) mutated or failed to clean the working tree before goreleaser’s build step, or the build ran against the wrong commit. Investigate the workflow run’s logs; do not assume a local cut-release.sh re-run will fix it, since that script doesn’t build what ships.
This guard exists because v0.0.75 shipped with vcs.modified=true — a dirty-tree build that was undetectable except via go version -m on the downloaded binary. A guard trip does not mean re-cutting v0.0.75 — proxy.golang.org has already cached that version’s zip and checksum, and republishing different content under the same tag would break every consumer that already resolved it. If the guard trips on a future release, fix the underlying cause and cut a new version; never force through or re-tag. See also: issue #1073, which gitignored goreleaser’s own dist/ output directory, since an untracked dist/ at build time was independently identified as a plausible (though at the time unconfirmed) cause of the same symptom — this guard is the safety net for if that fix turns out to be incomplete.
The cut-release.sh allowlist for uncommitted release-notes/<version>.md, plugin/known_embedded_versions.go, and plugin/fabrik/.claude-plugin/plugin.json (documented inline at the script’s DIRTY= check) was reviewed alongside this guard: all three files are committed by the script itself before the tag is pushed, so none can reach the CI-built artifact — the allowlist is unrelated to what this guard protects against and was left unchanged.
Built-in Skill: /audit-documentation
The /audit-documentation skill compares recently shipped features against USER_GUIDE.md, README.md, and docs/index.md, then files GitHub issues for gaps and closes existing documentation issues whose gaps are now covered.
Invocation
/audit-documentation # Scan issues closed in the last 30 days
/audit-documentation --since v0.0.20 # Scan issues referenced in commits since a tag
What it does
- Discovers source issues — finds recently closed issues using the selected mode (see below).
- Filters — excludes issues labeled
documentationand issues that are infrastructure/tooling-only with no user-facing behavior change. - Reads the docs — reads
USER_GUIDE.md,README.md, anddocs/index.mdin full. - Analyzes gaps — for each filtered issue, checks whether the feature is adequately described in the docs. Groups related issues into single gap entries. Errs on the side of filing — false positives are refined by the Specify stage; false negatives cause documentation drift.
- Clears the deck — for each existing open
documentationissue whose gap is now clearly and completely covered, closes it with a comment. - Files new gap issues — for each newly identified gap, creates a GitHub issue labeled
documentationandfabrik:yolo, and places it in the Specify column on the PM board.
Modes
Date-based mode (default): Fetches closed issues via gh issue list --state closed --search "closed:>$CUTOFF_DATE" --limit 200.
Tag-based mode (--since <tag>): Two passes — extracts #NNN references from commit messages since the tag, then scans merged PRs for Closes #NNN references.
Output
After running, /audit-documentation prints a structured summary:
## Audit Documentation — Summary
**Period**: <date range or tag range scanned>
**Issues scanned**: <N>
**Issues excluded**: <N>
- <issue#>: <title> — <reason>
**Documentation gaps found**: <N>
**New gap issues filed** (<N>):
- #<number>: <title>
**Existing documentation issues closed** (<N>):
- #<number>: <title>
**Existing documentation issues left open** (<N>):
- #<number>: <title> — <reason left open>
If no gaps are found, it reports: “No documentation gaps found — docs appear current.”
Known limitations
- Rate limits: May apply for repos with hundreds of closed issues.
- Tag-based mode coverage: Misses issues whose PRs used
Fixesor bare URLs instead ofCloses #NNNin the PR body.
Plugin Development
For developing the plugin itself, use --plugin-dir to point at your working copy:
./fabrik --plugin-dir ./plugin/fabrik-workflows ...
6. Labels Reference
For the authoritative label lifecycle spec — when each label is added, cleared, and what transitions it guards — see State Machine.
Fabrik-Managed Labels
Fabrik auto-seeds all managed labels on the configured repository at startup — creating them if missing, and enforcing the correct color on existing labels, so the GitHub UI shows meaningful hover text and the board color scheme stays consistent.
| Label | Purpose |
|---|---|
fabrik:locked:<user> |
Issue being processed by this user’s instance |
fabrik:editing |
Issue body being updated (comment processing) |
fabrik:paused |
Processing paused (max retries exceeded or manual) |
fabrik:awaiting-input |
Stage paused waiting for user input; auto-clears on a new comment from the configured user, or when a subsequent FABRIK_STAGE_COMPLETE is emitted (clears any orphaned label that survived a manual fabrik:paused removal) |
fabrik:awaiting-review |
Set when a wait_for_reviews: true stage completes with outstanding reviewer requests; cleared when no requested reviewers are outstanding and at least one review has been submitted (then re-invocation fires unconditionally), or when the FABRIK_REVIEW_WAIT_TIMEOUT elapses (then issue is paused with fabrik:awaiting-input). With review_authority: authoritative (default: advisory), the same clearing condition additionally requires no outstanding CHANGES_REQUESTED review and required approvals satisfied — see §3 Authoritative Mode. With expected_reviewers: [] declared and nothing requested, the gate skips waiting entirely and clears immediately — see §3 Declaring Expected Reviewers |
fabrik:awaiting-ci |
Applied immediately when a wait_for_ci: true stage emits FABRIK_STAGE_COMPLETE; means “CI gate active” and covers both pending and failed CI states; stage:X:complete is applied only when CI passes — not when this label is cleared by timeout (conjunctive gate, ADR 032). Triggers itemMayNeedWork cache bypass so CI results are re-evaluated on every poll. Cleared when all checks pass, or when a genuine liveness dwell elapses (then issue is paused with fabrik:awaiting-input) — a confirmed CI failure does not clear this via a timeout; it triggers CI-fix re-invocation instead and the label stays applied (ADR-1410). See §3 CI Gate. |
fabrik:rebase-needed |
Set when GitHub reports the linked PR as mergeable: false on a wait_for_ci: true stage — typically because another PR merged into the base branch during the CI-await window. The engine dispatches a rebase re-invocation instructing Claude to git fetch && git rebase origin/<base>, resolve conflicts conservatively (watching for semantic collisions like duplicated ADR numbers), and force-push. The label clears when GitHub flips mergeable back to true. Triggers itemMayNeedWork cache bypass because base-branch advances don’t bump the item’s updatedAt. |
fabrik:blocked |
Issue is waiting for one or more blocking issues to close; added and removed automatically by the engine (Fabrik creates this label on first use — no pre-creation needed) |
fabrik:awaiting-placement |
Set on a spawned child issue when its initial project-board Status placement fails (the child, board item, and blockedBy link already exist — only the column placement is missing). Retried every poll from board.Items directly. Cleared on successful placement, or when the child is observed closed. Escalates to fabrik:paused after --max-retries failed attempts, with an explanatory comment posted on both child and parent (ADR-062). |
fabrik:awaiting-member-close |
Set by the merge-train singleton-landing path (landSingleton) when a member issue’s CloseIssue call fails after its PR has already merged and the board moved to Done — most likely on a non-default base branch, where GitHub’s Closes #N never auto-fires. Retried every poll. Cleared once the issue is confirmed closed (by Fabrik or by GitHub’s own auto-close). Escalates to fabrik:paused after --max-retries failed attempts, with an explanatory comment posted on the issue (ADR-061). |
fabrik:api-key-helper-detected |
Set when a stage invocation is skipped because the worktree’s own .claude/settings.json sets apiKeyHelper — a repo-resident setting Fabrik cannot see until the worktree exists. Does not count against max_retries; no fabrik:paused or stage:<name>:failed applied. Clears automatically once apiKeyHelper is removed from the file and a later invocation reaches Claude successfully — no manual removal needed. See Anthropic Auth Namespace Scrub & apiKeyHelper Refusal. |
fabrik:tools-denied |
Set when Claude’s own permission layer denies one or more mutating tool calls during an invocation, detected structurally from the CLI’s permission_denials result field. Does not count against max_retries; bounded instead by its own --max-tools-denied-retries counter (default 3), at which point fabrik:paused + fabrik:awaiting-input are applied — never stage:<name>:failed. Clears automatically on the next invocation that isn’t itself classified as tools-denied. See the “Troubleshooting: an issue carries fabrik:tools-denied” note under Retry and Escalation. |
fabrik:awaiting-runaway-alert |
Set when the merge-train runaway guard (ADR-059 D8) pauses a Queued member (fabrik:paused + fabrik:awaiting-input applied unconditionally) but its AddComment alert call fails. Retried every poll by a settle scan, independent of fabrik:paused’s presence, until the alert succeeds or a fallback comment lands. Cleared only once some explanation is confirmed delivered — a persistent comment-post outage leaves the marker in place indefinitely rather than erasing the last diagnostic signal. See §6.18 Runaway Guard Alert Retry (ADR-1533). |
fabrik:awaiting-landing-verification |
Set immediately after a Done transition attributable to a merge (merge-train batch/singleton landing, or the ordinary auto-merge path) succeeds. The post-Done settle scan confirms the credited PR actually reached MERGED. Clears automatically once confirmed. See §6.19 Post-Done Landing Verification (ADR-1616). |
fabrik:credited-pr:<N> |
Set alongside fabrik:awaiting-landing-verification, but only by the two merge-train landing paths, recording which PR (the integration/singleton PR, distinct from the member’s own closed-not-merged PR) was credited for the Done transition. Clears whenever fabrik:awaiting-landing-verification clears. See §6.19. |
fabrik:landing-verification-failed |
Set only on a confirmed non-merge of the credited PR — the issue is simultaneously reopened and moved back to Validate. Not self-clearing — remove manually once the credited PR is re-landed and re-verified. See §6.19. |
stage:<name>:in_progress |
Stage actively running |
stage:<name>:complete |
Stage completed successfully |
stage:<name>:failed |
Stage hit max retries |
User-Set Labels
| Label | Effect |
|---|---|
model:opus |
Override Claude model to Opus for this issue |
model:sonnet |
Override Claude model to Sonnet for this issue |
effort:low |
Override thinking effort to low for this issue |
effort:medium |
Override thinking effort to medium for this issue |
effort:high |
Override thinking effort to high for this issue |
effort:max |
Override thinking effort to max for this issue |
fabrik:paused |
Manually pause processing (add to pause, remove to resume) |
fabrik:yolo |
Force auto-advance for this issue even when auto_advance: false; also triggers auto-merge of the linked PR when Validate completes. If the linked PR was already manually merged when auto-merge runs, Fabrik treats it as a success and advances the issue to Done normally. |
fabrik:cruise |
Auto-advances through all stages like fabrik:yolo but stops at Validate — no auto-merge, no move to Done. If both fabrik:cruise and fabrik:yolo are present, cruise takes precedence for both decisions: the PR is not auto-merged and the issue does not advance to Done. |
fabrik:unrestricted |
Pass --dangerously-skip-permissions instead of --permission-mode dontAsk for this issue; bypasses the default tool allowlist entirely. Use only when a stage needs tools outside the default set or when the default posture prevents required work. Caution: removes all tool restrictions. |
fabrik:extend-turns |
Pre-grant 2× max_turns for the first stage invocation while the label is present, with max_wall_time scaled by the same 2× factor for that invocation (so the extra turn budget gets proportionate wall-clock headroom instead of a deadline sized for the un-extended case — see max_wall_time above). Auto-extends to 3× when actual progress is detected during an invocation (Implement: new git commit (HEAD SHA changed) OR (baseline was clean AND working tree is now dirty — uncommitted file edits by Claude); Review: new git commit or resolved reviewer thread count; Validate: new comment; other stages: no progress signal); each such extension re-invokes with a fresh max_wall_time window at the un-scaled deadline. The label persists across all stages — apply it once and every stage from the current one through Done will benefit. It is removed automatically when the Done stage cleanup runs. No-op when max_turns is 0 (unlimited). The displayed turn counter denominator always reflects the effective budget. |
fabrik:revalidate |
Force re-entry of the Validate stage. Removes stage:Validate:complete, stage:Validate:failed, fabrik:paused, fabrik:awaiting-input, fabrik:awaiting-ci, fabrik:auto-merge-enabled, then itself; Validate then re-runs on the current HEAD SHA. Use this to recover from a stuck-Validate state in one action — no need to manually clear individual gate labels. Applied to non-Validate issues: removed with a warning, no work dispatched. Safe to apply while Validate is in-flight — the label is held until the worker exits, then processed normally. |
base:<branch> |
Override the base branch for this issue (e.g. base:develop). Fabrik will fork from, rebase onto, and target PRs at <branch> instead of the repository default. Apply before Research; adding mid-pipeline is unsupported and may produce unexpected results. Branch names containing / are supported (e.g. base:release/1.x). If the named branch does not exist on the remote, Fabrik falls back to the default branch and posts a comment on the issue. |
review-authority:advisory |
Override the stage’s configured review_authority to advisory for this issue only. Only meaningful alongside wait_for_reviews: true. See Authoritative Mode. |
review-authority:authoritative |
Override the stage’s configured review_authority to authoritative for this issue only. Only meaningful alongside wait_for_reviews: true. See Authoritative Mode. |
expected-reviewers:none |
Override the stage’s configured expected_reviewers to none for this issue only. Only meaningful alongside wait_for_reviews: true. See Declaring Expected Reviewers. |
expected-reviewers:declared |
Override expected_reviewers to a fixed synthetic reviewer for this issue only; testing/e2e use — never posts a real review. Only meaningful alongside wait_for_reviews: true. See Declaring Expected Reviewers. |
fabrik:clear-claude-limit |
Clear an active account-wide Claude usage-limit suspension without restarting the engine. Apply to any open board item — it does not need to be one already carrying fabrik:claude-limit, since the suspension is account-wide, not per-issue. Read and consumed on the next poll: clears the suspension immediately and removes itself. See Claude Usage-Limit Suspension. |
Model label precedence: model:<name> label > stage YAML model field > default.
Effort label precedence: max > high > medium > low. If multiple effort: labels are present, the highest-ranked value wins and a warning is logged.
Review authority label precedence: no review-authority: label → stage YAML review_authority governs. Exactly one recognized label (advisory or authoritative) → it overrides the stage config for this issue only. Both labels present → resolves to authoritative (the more restrictive value) and a warning is logged. A malformed or unrecognized suffix is ignored with a logged warning and falls back to the stage config.
Expected reviewers label precedence: no expected-reviewers: label → stage YAML expected_reviewers governs (nil stays nil). Exactly one recognized label (none or declared) → it overrides the stage config for this issue only. Both labels present → resolves to declared (the more restrictive value — it imposes waiting, unlike none’s immediate advance) and a warning is logged. A malformed or unrecognized suffix is ignored with a logged warning and falls back to the stage config.
Advanced: base:<branch> timing
The base:<branch> label is read on every stage invocation, so a change takes effect from the next stage onward. However, Fabrik cannot retroactively re-create past work — the issue branch is forked once, commits accumulate on it, and the PR is opened once. The later you apply or change the label, the more of that history is already locked to the original base.
| When applied | Effect |
|---|---|
| Before any stage runs | Clean. The issue branch is forked from the specified base, all commits stack on the right base, the PR targets the right base. This is the intended path. |
| After Specify, before Research | Fairly clean. The worktree exists but no commits yet. The next stage’s updateWorktreeFromMain rebases onto the new base — usually no conflict. |
| After Research, before Implement | Similar — Research is read-only, so no commits. Rebase onto the new base works. |
| After Implement (commits exist, no PR yet) | Messy. The issue branch has commits forked from the old base. Rebase onto the new base may produce conflicts that Claude must resolve. The PR, when created, will target the new base correctly. |
| After PR creation | Fabrik automatically updates the PR’s base branch when the base:<branch> label changes mid-pipeline. No manual intervention required. |
Natural language in the issue body is NOT parsed. Writing “use the develop branch” in the Problem section has no effect on Fabrik’s base-branch selection. The base:<branch> label is the only structured signal.
If the named branch does not exist on the remote, Fabrik falls back to the repository’s default branch and posts a comment on the issue explaining the fallback. Fabrik keeps refs/remotes/origin/HEAD in sync with the remote’s current default branch on every fetch, so a default-branch change on the remote (after Fabrik initially cloned the repo) is detected automatically on the next stage invocation.
7. Permissions
Fabrik passes --permission-mode dontAsk to every Claude Code invocation. In this mode Claude does not prompt for tool permissions interactively. This ensures Fabrik works correctly in headless mode and shared environments regardless of the user’s ~/.claude/settings.json. Fabrik does not require users to pre-configure permissions in their Claude Code settings.
allowed_tools is a call-time permission filter, not an availability filter. A tool absent from the allowed set is still offered to the model in its tool schema; under --permission-mode dontAsk, absence from the allowed list is not a guarantee the tool call is rejected either (see the Worktree Boundary Enforcement caveat below and #1372). The only mechanism that removes a tool from what the model is even offered is --disallowedTools, a separate, construction-time exclusion.
ScheduleWakeup, Workflow, Monitor, and CronCreate are unconditionally suppressed via --disallowedTools on every invocation, regardless of stage config, allowed_tools, or whether fabrik:unrestricted is set. Each tool promises delivery to a future turn, session, or external callback — a scheduled wakeup, a completion notification, a streamed report, or a future cron fire — that a headless Fabrik stage cannot honor: there is no running session to receive it. Without suppression, a stage worker calls one of these tools, the turn ends, the stage exits without FABRIK_STAGE_COMPLETE, and the next retry deterministically re-derives the identical stall (Workflow additionally risks spending session budget on background subagents that never report back). See ADR-1365.
Default allowed-tool set (used when a stage does not specify allowed_tools):
| Category | Tools |
|---|---|
| File operations | Read, Edit, Write, Glob, Grep |
| Task management | TodoWrite, Skill, Task |
| Git & GitHub | Bash(git:*), Bash(gh:*) |
| Build systems | Bash(go:*), Bash(npm:*), Bash(npx:*), Bash(yarn:*), Bash(pnpm:*), Bash(make:*), Bash(cargo:*) |
| Python | Bash(python:*), Bash(pip:*), Bash(uv:*), Bash(pytest:*) |
| Shell utilities | Bash(ls:*), Bash(cat:*), Bash(rm:*), Bash(cp:*), Bash(mv:*), Bash(mkdir:*), Bash(find:*) |
allowed_tools replaces the defaults — it is not additive. When a stage sets allowed_tools, only those tools are permitted; the default list above is not merged in. This is intentional: Research, Plan, and Specify stages set allowed_tools to a read-only subset to prevent Claude from writing files during those stages.
fabrik:unrestricted bypasses everything — passes --dangerously-skip-permissions instead, granting Claude full tool access. Use this label only when a stage requires tools outside the default set (e.g. deno, bun, or other non-standard toolchains).
Note on personal ~/.claude/settings.json: Under dontAsk mode, tools the user has explicitly allowed in their own permissions.allow remain allowed in addition to Fabrik’s list — this does not break the isolation goal, it just means personal extras carry through. If your organization deploys managed settings (MDM/enterprise level), a deny rule in managed settings cannot be overridden by --allowedTools — that is outside Fabrik’s control.
Worktree Boundary Enforcement
Fabrik enforces two independent layers of worktree isolation on every Claude invocation (implemented in engine/boundary.go):
Layer 1 — Tool-permission scoping: At invocation time, bare Edit and Write entries in the allowed-tools list are replaced with Edit(<workDir>/**) and Write(<workDir>/**). If Claude attempts to edit or write a file outside the issue’s worktree, the tool call receives a permission error — but the stage continues running; only the specific tool call is rejected.
Layer 2 — Cross-repo ref audit (opt-in, default off): Before the extension loop begins, Fabrik snapshots the git refs of all registered bare repos. After the loop completes, a second snapshot is taken and compared. If any ref in a repo other than the active issue’s own repo has changed, a boundary violation is raised: fabrik:paused and stage:<name>:failed are applied, a comment is posted listing the specific refs that were mutated, and no automatic cleanup occurs.
Note: The Layer 2 audit is off by default. The snapshot filters to
refs/heads/*andrefs/tags/*only, so routinegit fetchagainst sibling bare clones does not trigger false-positive violations. To enable it, use one of:# .fabrik/config.yaml worktree_boundary_audit: true# CLI flag ./fabrik --worktree-boundary-audit ... # Environment variable FABRIK_WORKTREE_BOUNDARY_AUDIT=true ./fabrik ...
What to do when a stage fails with a boundary violation: Check whether the stage’s prompt or skill is attempting to write files outside the issue’s worktree — for example, writing to fabrikDir (the Fabrik config directory) or a sibling worktree. The comment posted on violation lists the specific bare-repo refs that changed, which identifies which repo was touched.
fabrik:unrestricted bypasses both layers. When this label is present, applyWorktreeBoundary is skipped (tool-path restrictions are not applied) and the cross-repo ref snapshot is not taken (the audit does not run). Use with caution — this removes all path-level isolation guarantees, not just tool-permission restrictions.
For the authoritative engine-level spec, see Stage Lifecycle — Worktree Boundary Enforcement.
8. TUI Dashboard
The interactive terminal dashboard is enabled by default when running in a real terminal. To disable it, use --notui:
./fabrik --notui --owner your-org --repo your-repo --project 1 --user you
Layout
The TUI shows a compact header with poll status, an In Progress pane showing active
Claude sessions, a scrollable History pane with completed jobs, a Warnings panel
surfacing actionable pre-flight issues (below History), and a status bar (footer)
displaying REST and GraphQL rate limit stats. When the GraphQL budget is exhausted
or rate-limit-induced probe failures are detected, a prominent red alert banner
appears immediately below the header with a countdown to when polling resumes.
When plugin skills in .fabrik/plugin/ are outdated relative to the embedded
defaults, a [u] skills out of date badge appears in the header, reminding the
operator to press u to upgrade. When Fabrik detects that the on-disk skills
differ from the last installed version (indicating operator customizations), the
badge changes to [u] custom workflow and pressing u opens a three-option
dialog instead of a simple y/N prompt (see Customizing Skills).
Keyboard Shortcuts
| Key | Action |
|---|---|
Tab |
Cycle focus: In Progress → History → Warnings → (repeat); the Warnings pane is skipped whenever it’s empty (hidden), so focus never lands on an invisible pane |
Up/Down or j/k |
Navigate items within the focused pane |
l |
Open fabrik watch for selected issue (live Claude output, stage tabs, PR/CI status) |
s |
Stop selected active job and apply fabrik:paused (In Progress pane only; shows [y/N] confirmation first; posts an audit comment on the issue) |
enter |
Toggle inline detail panel (History pane); toggle expanded detail (Warnings pane) |
r |
Resume Claude session for selected history item (history pane only, item must not be active) |
Escape |
Close open dialogs; with no dialog open, triggers quit confirmation |
n / N |
Cancel quit or clear-all confirmation dialogs |
c |
Delete selected history entry |
C |
Clear all history (with confirmation) |
F |
Fix selected warning (Warnings pane only) — tears down TUI, runs the fix command in the foreground, re-renders on exit |
D |
Dismiss / undismiss selected warning (Warnings pane only) — sticky across restarts; dismissed entries are hidden by default |
S |
Show / hide dismissed warnings (Warnings pane only) |
a |
Open abtop AI session monitor (shows token usage, context window, rate limits for all Claude sessions) |
u |
Upgrade plugin skills. When the [u] skills out of date badge is shown (embedded changed, no local edits), pressing u shows a y/N confirmation and applies the upgrade. When the [u] custom workflow badge is shown (local edits detected), pressing u opens a three-option dialog: [1] Reconcile — quits the TUI and prints a Claude Code prompt to stderr for merging customizations; [2] Overwrite — prompts you to type OVERWRITE to confirm destructive reset; [3] Cancel — dismisses the dialog. Active stage invocations pick up new files on next run. |
w |
Wake: reset idle backoff and poll immediately |
ctrl+r |
Force refresh: drain in-flight workers and restart in place (same as SIGHUP — see Recovering from Wedged State) |
? |
Toggle help panel (keybindings and labels reference) |
q |
Quit |
Help Panel (?)
Pressing ? opens an overlay that displays all keybindings and a labels reference. The labels reference covers every label Fabrik uses:
| Label | Meaning |
|---|---|
fabrik:yolo |
Auto-advance through all stages and auto-merge PR on Validate completion |
fabrik:cruise |
Auto-advance through all stages without auto-merging the PR |
fabrik:paused |
Issue processing is paused |
fabrik:awaiting-input |
Stage is blocked waiting for user input |
fabrik:awaiting-review |
Stage completed; waiting for outstanding PR reviewer requests; clears when no requested reviewers are outstanding and at least one review has been submitted, then re-invokes the stage agent to address feedback |
fabrik:locked:<user> |
Issue is locked for editing by the specified user |
stage:<name>:in_progress |
Named stage is currently running |
stage:<name>:complete |
Named stage completed successfully |
stage:<name>:failed |
Named stage failed (hit max retries) |
model:<name> |
Override the model for this issue (e.g. model:opus) |
effort:<level> |
Override thinking effort for this issue (low, medium, high, max) |
fabrik:unrestricted |
Bypass default permission posture; passes --dangerously-skip-permissions instead |
fabrik:extend-turns |
Pre-grant 2× max turns budget (with matching 2× max_wall_time) for the first stage invocation while present; auto-extends to 3× (unscaled deadline) when progress is detected; persists across all stages until Done cleanup; the turn counter denominator reflects the effective budget |
base:<branch> |
Override base branch for this issue (e.g. base:develop); Fabrik forks from, rebases onto, and targets PRs at this branch |
review-authority:<mode> |
Override the stage’s configured review_authority for this issue only (advisory, authoritative); only meaningful alongside wait_for_reviews: true |
expected-reviewers:<mode> |
Override the stage’s configured expected_reviewers for this issue only (none, declared); only meaningful alongside wait_for_reviews: true |
Dismiss the panel by pressing ? again or Esc.
In terminals that support OSC 8 hyperlinks (Ghostty, iTerm2, WezTerm, Kitty), issue numbers (#NNN) in the In Progress and History panes are hyperlinks. Cmd+click (macOS) or Ctrl+click (Linux) on a #NNN to open the corresponding GitHub issue in your browser; Cmd+click / Ctrl+click the board title in the footer to open the project board. Use keyboard navigation for selection and scrolling.
What’s Displayed
In Progress: Issue number, stage name, elapsed time, a live turn counter ([N/M turns] or compact [N/M] when terminal width is tight), issue title, and latest status message. The turn counter updates in real time as Claude advances through turns; the denominator reflects the effective per-invocation budget. With fabrik:extend-turns, the first invocation may use a denominator of 2× stage.MaxTurns, while subsequent extension invocations revert to stage.MaxTurns each. Review-reinvoke jobs appear here alongside regular stage jobs — they emit JobStarted/JobCompleted events and display with the issue number, stage name, and elapsed time, just like a normal stage invocation.
Pressing Enter on an active item opens the inline detail panel, which shows Issue, Stage, Elapsed, and a live Turns: N/M counter for in-progress stages.
History: Issue number, stage name, success/fail icon, duration, timestamp, turns used, cost, and issue title. Status icons:
| Icon | Meaning |
|---|---|
✓ |
Stage completed successfully |
✗ |
Stage failed (hit max retries) |
? |
Stage blocked / awaiting user input |
↻ (retry) |
Stage retrying after a genuine incomplete attempt |
↻ (turn limit) |
Invocation exhausted its turn budget — not a failure; the same Claude session resumes and picks up where it left off |
💬 |
Issue has unprocessed comments |
A turn-capped invocation is distinguished from a genuine error using the Claude CLI’s
own result classification (subtype: "error_max_turns"), not by inferring it from turn
counts. It renders with the same ↻ icon as a retry, since it is incomplete and
resumable rather than a fault — ✗ (error) is reserved for invocations that did not
complete for some other reason.
Warnings panel: Sits below History and surfaces actionable pre-flight issues that Fabrik detected at startup (e.g. allow_auto_merge disabled on a managed repo, stage-config drift). The panel header is colour-coded: dim grey for zero outstanding warnings, yellow for 1–2, red for 3+. The panel’s appearance when there are no active warnings depends on whether there are dismissed warnings:
- No active and no dismissed warnings: The panel renders nothing at all (zero height), so History retains maximum vertical space.
- No active warnings but dismissed entries exist: The panel collapses to a single hint line:
Warnings: none (N dismissed, press tab + S to show).
Tab into the Warnings panel to take action:
- F — run the fix command in the foreground (tears down the TUI, executes, re-renders)
- D — dismiss the selected entry (sticky: survives restarts; dismissed entries are hidden by default)
- S — show/hide dismissed entries
- Enter — expand/collapse the full detail text for the selected entry
Warning entries persist to .fabrik/warnings.json and survive auto-upgrade restarts. Dismissed entries are preserved in the file until the underlying condition clears, at which point they are removed entirely. This includes the condition’s subject going away entirely (e.g. a repo leaving the project board, or a stage being renamed) — a per-poll or startup sweep clears those automatically too, without requiring a restart (see docs/state-machine.md §7.11).
History Persistence
Job history is saved to .fabrik/history.json (project-local, alongside .fabrik/config.yaml) and restored on restart.
9. Observability
fabrik watch — Per-Issue TUI
The recommended way to monitor a running issue is fabrik watch:
fabrik watch 42
# or with explicit credentials:
fabrik watch 42 --owner myorg --repo myrepo --token ghp_...
This opens a real-time terminal UI that shows:
- Issue title, labels, and current stage
- Live Claude output — streams from the
.logfile as Claude writes it (no polling) - Live turn counter —
turn N/Mshown inline in the PR/CI row while a stage is active; updates in real time as Claude advances through turns (turn Nwhen the stage has no turn limit) - Stage history — completed stages with duration and cost
- PR status — linked PR number, open/draft/merged state
- CI check results — compact pass/fail/pending summary
- Comment count — updates on each GitHub poll
Keyboard shortcuts
| Key | Action |
|---|---|
↑ / ↓ or k / j |
Scroll live output |
g / G |
Jump to top / bottom of output |
left / h |
Switch to previous stage tab |
right / l |
Switch to next stage tab |
i |
Open interactive Claude session in the issue’s worktree (resumes current stage) |
esc |
Quit (same as q) |
q / ctrl+c |
Quit |
The session ID for the active Claude session is shown in the header when available.
Credentials
fabrik watch reads owner, repo, and poll interval from .fabrik/config.yaml (see §2). Without a GitHub token, it shows local log output and stage history but skips GitHub API calls (PR/CI/comments).
Log Files
Session logs are saved to .fabrik/logs/<owner>-<repo>/issue-<N>/ (relative to the project working directory) as NDJSON (one JSON event per line). Each stage produces a .log file written continuously during execution — fabrik watch follows this file live. To view a log manually:
ls -lt .fabrik/logs/<owner>-<repo>/issue-42/
# Render a completed log as human-readable text
cat .fabrik/logs/<owner>-<repo>/issue-42/<stage>-<timestamp>-<nanos>.log | fabrik stream-filter | less -R
Logs are namespaced by repository: .fabrik/logs/<owner>-<repo>/issue-<N>/.
fabrik stream-filter reads NDJSON or JSON array Claude output from stdin and renders thinking blocks, text responses, and tool calls as human-readable text.
Poll Log
Fabrik writes engine-level output to .fabrik/fabrik.log in the project working directory. This file is truncated on each startup and contains only the current run.
The poll log captures:
- Deep-fetch decisions (which issues were shallow-skipped vs. fully fetched)
- Dispatch events (which issues were dispatched to workers and why)
- Upgrade checks and idle-upgrade transitions
- GitHub API rate limit stats per poll cycle
[#N extend-turns]verdict lines — emitted on everydetectProgresscall (pass or fail) without--debug-output; useful for diagnosing why a stage did or didn’t receive a turn extension
The poll log is written in both TUI and non-TUI modes. It is most useful for post-mortem debugging of engine-level behavior — for example, diagnosing why an issue was not picked up during a poll cycle.
Auto-migration from ~/.fabrik/
On the first startup after upgrading from a pre-v0.0.32 release, Fabrik automatically migrates session files and stage logs from ~/.fabrik/sessions/ and ~/.fabrik/logs/ to <cwd>/.fabrik/sessions/ and <cwd>/.fabrik/logs/. The migration is idempotent (files whose destination already exists are skipped) and handles cross-device filesystems. Empty source directories are removed after migration.
Debug Output
Enable --debug-output to save Claude’s raw output to .fabrik/debug/ in the
working directory. Useful for diagnosing prompt issues or unexpected behavior.
Subprocess Environment
Claude Code is invoked with the engine’s parent-process environment as its base,
but it is not inherited unfiltered: Fabrik scrubs the Anthropic/Claude-Code auth
namespace by default (see below) so no ambient credential can silently redirect
billing, then shadows a small, explicit set of keys it cares about
(CLAUDE_CODE_EFFORT_LEVEL, CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING, the
FABRIK_* invocation facts, GH_TOKEN/GITHUB_TOKEN, GH_HOST) so that Fabrik’s
own value for those specific keys always wins over an ambient one, no matter which
happened to be set first. GH_HOST is only emitted when a GHES host is configured
(ghes_host / FABRIK_GHES_HOST) — see GitHub Enterprise Server
Support; it is a Fabrik-computed override, not
part of the Anthropic auth namespace, so it is never subject to the scrub described
below. Everything else — anything outside the scrubbed namespace and
not on the shadowed-keys list — passes through untouched. If you want a
stage-specific variable to reach Claude reliably regardless of what’s ambient,
pass it explicitly via your stage YAML or a shell wrapper script rather than
relying on shell export order.
Anthropic Auth Namespace Scrub & apiKeyHelper Refusal
Claude Code resolves its API key before consulting any subscription path. An
ANTHROPIC_API_KEY (or several other Anthropic/Claude-Code environment
variables) present in the engine’s own environment — for example, added to a
managed repo’s .env by a human or an agent doing something locally reasonable —
would otherwise silently redirect every Fabrik stage invocation from subscription
billing to metered API billing, with no log line, no label, and no board signal.
Fabrik closes this by scrubbing the Anthropic/Claude-Code auth namespace out of
every worker’s environment by default, and gating any exception behind an
explicit, FABRIK_-prefixed variable.
What’s scrubbed. Every inherited variable whose name starts with ANTHROPIC_
is removed unconditionally — default-deny over the whole namespace, not a
deny-list of currently-known-bad names, so a newly introduced upstream
ANTHROPIC_* billing variable is denied automatically with no Fabrik upgrade
required. This also scrubs non-auth ANTHROPIC_* variables you might otherwise
expect to pass through (ANTHROPIC_MODEL, ANTHROPIC_CONFIG_DIR, etc.) — a
deliberate consequence of scrubbing the namespace rather than an enumerated list.
A specific, enumerated set of CLAUDE_CODE_* auth-selector variables
(CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR, CLAUDE_CODE_OAUTH_TOKEN and its
file-descriptor variant, and the CLAUDE_CODE_USE_BEDROCK/_VERTEX/_FOUNDRY/
_ANTHROPIC_AWS/_ANTHROPIC_GOOGLE_CLOUD/_MANTLE/_GATEWAY provider
selectors) is also removed — CLAUDE_CODE_* as a whole is too broad a
general-configuration namespace to wildcard-scrub, since it already carries
Fabrik’s own non-auth CLAUDE_CODE_EFFORT_LEVEL/CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING.
A variable that merely mentions “anthropic” without matching one of these exact
names (e.g. a hypothetical FANTASY_ANTHROPIC_API_KEY) is left untouched — the
scrub matches on the parsed variable name, never a substring.
Explicit API-billing opt-in. API billing is not forbidden — some
environments (corporate deployments especially) have no Claude subscription
available. Set FABRIK_ANTHROPIC_API_KEY in .env to opt in explicitly:
FABRIK_ANTHROPIC_API_KEY=sk-ant-...
Fabrik translates this into ANTHROPIC_API_KEY on every worker invocation. This
is the only supported way to get ANTHROPIC_API_KEY into the worker — an
ambient ANTHROPIC_API_KEY already in the engine’s environment is scrubbed and
never reaches the worker on its own, regardless of this setting. When active, a
one-time startup notice confirms it (the value itself is never logged):
[startup] notice: FABRIK_ANTHROPIC_API_KEY is set — Claude invocations will be billed to the Anthropic API rather than a subscription. See docs/USER_GUIDE.md.
Passthrough escape hatch, for the long tail. FABRIK_ANTHROPIC_API_KEY
covers the common case; FABRIK_ANTHROPIC_ENV_PASSTHROUGH covers everything
else non-subscription auth might need — Bedrock, Vertex, or a future
provider-selector this doc hasn’t caught up with yet. It names an exact,
comma-separated allow-list of variables to re-inherit from the engine’s ambient
environment unchanged, overriding the scrub for only those names:
FABRIK_ANTHROPIC_ENV_PASSTHROUGH=CLAUDE_CODE_USE_BEDROCK,ANTHROPIC_AWS_API_KEY
Bedrock and Vertex are examples of a growing list — Claude Code has at least
seven CLAUDE_CODE_USE_* provider selectors today, not just these two — so treat
this as “name whatever selector your provider needs,” not a fixed set. Note that
most Bedrock/Vertex credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
GOOGLE_APPLICATION_CREDENTIALS, etc.) already fall outside the scrubbed
namespace and need no passthrough entry at all — only the ANTHROPIC_*/
CLAUDE_CODE_*-namespaced selector variables do. A name listed but absent from
the ambient environment is a harmless no-op; a name listed but outside the
scrubbed namespace (e.g. PATH) is likewise a no-op — it was never going to be
removed. When the list is non-empty, a one-time startup notice names which
variables were passed through (never their values), and warns that invocations
may not be subscription-billed as a result:
[startup] notice: FABRIK_ANTHROPIC_ENV_PASSTHROUGH passes through CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_AWS_API_KEY — Claude invocations may not be subscription-billed as a result. See docs/USER_GUIDE.md.
No line means the passthrough list is empty — the scrub applies to every
ANTHROPIC_*/enumerated CLAUDE_CODE_* variable with no exceptions.
apiKeyHelper is refused, not scrubbed. apiKeyHelper is a settings.json
key, not an environment variable — a command Claude Code shells out to for
credentials — so no amount of environment scrubbing can prevent it from
supplying an API key. Fabrik refuses to start if apiKeyHelper is set anywhere
in the resolved Claude Code settings chain: the managed-policy layer
(/Library/Application Support/ClaudeCode/managed-settings.json on macOS,
/etc/claude-code/managed-settings.json on Linux), the user layer
($CLAUDE_CONFIG_DIR or ~/.claude, settings.json/settings.local.json), and
the project layer (.claude/settings.json/.claude/settings.local.json in the
directory Fabrik itself runs from). The startup error names the specific file
and layer; remove apiKeyHelper from it and restart. A missing or unreadable
settings file is not an error — only a present, parseable apiKeyHelper key is
fatal.
Separately, since a managed repo’s own checked-in .claude/settings.json isn’t
visible until Fabrik has already checked it out into a worktree, each invocation
also checks the worktree’s own settings files (settings.json and
settings.local.json). If either sets apiKeyHelper, that
invocation is skipped (not run) and the issue is labeled
fabrik:api-key-helper-detected with an explanatory comment — this does not
count against max_retries, and self-clears once a human removes
apiKeyHelper from the repo and a later invocation reaches Claude successfully.
There is no support for actually using apiKeyHelper with Fabrik; it is refused
outright.
Alternate Claude Profile (CLAUDE_CONFIG_DIR)
CLAUDE_CONFIG_DIR is not special-cased by Fabrik — it is simply one instance of
the general passthrough rule above (it is not ANTHROPIC_/CLAUDE_CODE_*-namespaced,
so the auth scrub above never touches it), which is what makes this pattern work
at all. Setting it in the engine’s environment points every Claude Code
invocation at an alternate credentials/config directory — a different account,
subscription, and billing quota than the engine’s own default profile. Fabrik
does not set or otherwise manage this variable; it only reads it once, at
startup, to emit the notice below.
This is the pattern to use when you want one Fabrik instance’s stage invocations to bill against a different Claude account than the one the engine itself runs as — for example, running several instances across several accounts to spread usage-limit exposure.
Set it in .env (already .gitignore-enforced, per the note above):
CLAUDE_CONFIG_DIR=/home/user/.claude-alt-profile
It survives --auto-upgrade re-execs: a re-exec restarts the same binary from
scratch, which re-runs .env loading at startup, so the value is picked up again
exactly as on a fresh start.
To confirm it took effect, check the startup output (stderr and .fabrik/fabrik.log)
for a line naming the resolved directory:
[startup] notice: CLAUDE_CONFIG_DIR is set to "/home/user/.claude-alt-profile" — Claude invocations will use this profile directory instead of the default account. See docs/USER_GUIDE.md.
No line means CLAUDE_CONFIG_DIR was unset — every invocation runs against the
default profile. Fabrik does not validate the directory’s contents or credential
state; a misconfigured or unauthenticated profile fails wherever Claude Code
itself would fail, not at Fabrik startup.
Worker Session Naming (--name)
Every worker invocation carries a --name fabrik:<owner>/<repo>#<issue>:<stage>
flag (e.g. fabrik:handarbeit/fabrik#1284:Implement), giving each Fabrik worker a
self-describing identity in ps output — ps aux | grep -- "--name fabrik:" finds
every worker on the host, and the sentinel itself names which repo, issue, and stage
each one is serving. The flag is gated on a one-time startup probe of the installed
claude binary’s --help output; on older binaries that predate the flag, it’s
omitted and workers run exactly as before. It is observability-only — the engine
never reads or branches on it. See stage-lifecycle.md § Worker Session Naming
for the full as-built mechanism.
Rate Limit Monitoring
Fabrik reports GitHub API rate limit stats in each poll cycle (visible in the TUI status bar (footer) or plain-text log output):
- REST API: requests remaining / limit
- GraphQL API: points remaining / limit
The GraphQL query uses a two-phase fetch to minimize rate limit consumption:
- Shallow board scan — fetches all items with lightweight fields (
updatedAt, labels, status). Items whoseupdatedAthasn’t changed since the last poll are skipped entirely. -
Targeted detail fetch — runs only for items that passed the shallow filter.
fabrik:blockeditems do not receive a forced deep-fetch on every poll. Instead, they are re-evaluated via the standardprocessedSetcooldown (every10 × poll interval, typically ~5 minutes at the default 30s base) — when the cooldown fires, Fabrik deep-fetches the blocked item to check whether its dependencies have closed. If GitHub bumps the blocked item’supdatedAtwhen a dependency closes, unblocking may also be detected sooner — on the next poll cycle.Items with
fabrik:awaiting-inputorfabrik:awaiting-reviewdo not force a deep-fetch. New user comments bump the issue’supdatedAt; PR review submissions bump the linked PR’supdatedAt— both are captured by the shallow scan, so the targeted detail fetch fires naturally when there is actually something new to see.Items in board columns that have no corresponding stage config (e.g. Backlog, Triage, or custom parking-lot columns) are skipped from
FetchItemDetailsentirely — their probe state is updated (keepingStatuscurrent foritemMayNeedWork) but no deep-fetch is triggered. This reduces per-poll GraphQL cost on boards with large unconfigured column populations.
Typical cost is ~5–30 points per poll depending on active items, well within the 5,000 points/hour limit.
Footer GraphQL Indicator Color
The TUI footer’s GraphQL indicator (remaining/limit countdown) colors itself from a
projected-exhaustion estimate, not raw percentage remaining. Fabrik estimates the
burn rate (points consumed per minute) from consecutive polls, projects that rate
forward to the window reset, and compares the projection against Remaining:
- Green — projected consumption stays comfortably below
Remaining. - Yellow — projected consumption is within 25% of
Remaining. - Red — projected consumption would exceed
Remainingbefore the window resets.
This means a low percentage remaining with little time left in the window can still be green: if the window is about to reset, the budget resets with it. Conversely, a seemingly-healthy percentage with a high burn rate and a long time left before reset can turn yellow or red. On startup, before enough polls have landed to estimate a rate, and whenever the engine is idle, the indicator is green — it never flashes red before there is a burn rate to project from.
Cold-Start Cache Population
On first startup, Fabrik bootstraps the board cache from the same lightweight probe data used for polling — closed Done items are seeded as terminal without a full deep-fetch, eliminating the cold-start spike where each item would otherwise require an individual deep-fetch to determine its state. This reduces cold-start GraphQL cost by ~80–90%. The bootstrap runs once before the first poll and is invisible to the operator beyond the faster startup time.
Rate-Limit Exhaustion Alert Banner
GitHub tracks two separate API budgets — REST/core and GraphQL — and Fabrik reports exhaustion of either one distinctly, naming the bucket that actually ran out. When the GraphQL budget drops below 20% and probe failures begin, or when the REST budget hits a hard near-zero threshold, Fabrik suspends polling and displays a red alert banner immediately below the TUI header:
⚠ GraphQL rate limit exhausted — polling suspended. Resumes in 12m (14:30 local time).
⚠ REST rate limit exhausted — polling suspended. Resumes in 18m (21:38 local time).
If both buckets are exhausted at the same time, the banner names both in a single combined line and quotes whichever bucket’s reset comes sooner:
⚠ REST and GraphQL rate limits exhausted — polling suspended. Resumes in 5m (21:25 local time).
The countdown ticks every second and shows the local time when the named bucket’s quota resets. Each bucket recovers and clears its own banner independently — a REST recovery never dismisses a banner raised by GraphQL exhaustion, and vice versa; if both are exhausted, the banner stays until both clear. You do not need to restart Fabrik — it recovers on its own. On recovery from near-zero GraphQL exhaustion, Fabrik fires an immediate probe rather than waiting for the next scheduled poll tick, so processing resumes as soon as the budget is restored. The footer’s GraphQL remaining/limit readout always reflects the same underlying stats the banner uses for GraphQL, so the two can never disagree about GraphQL’s state.
Claude Usage-Limit Suspension
This is a separate mechanism from GitHub rate-limit monitoring above — it tracks the Claude account’s own usage limit (session or weekly), not GitHub’s API budget. Board polling, settle scans, and label reconciliation are unaffected; only the start of new Claude invocations is gated.
When any worker’s Claude invocation exits because the account’s usage limit was hit — detected structurally from the CLI’s own result object, never from output text a stage happened to write — Fabrik suspends the start of every new Claude invocation across all issues, not just the one that hit the limit, and displays a banner distinct from the GraphQL one:
⚠ Claude usage limit hit — dispatch suspended. Resumes in 1h (22:20 local time).
Structural detection carries no reset time to parse, so this is always a fixed one-hour suspension — a full order of magnitude longer than the normal 5-minute dispatch cooldown — rather than retrying on the normal cooldown. The suspension clears in any of three ways:
- Automatically, once the one-hour deadline passes or as soon as any invocation succeeds, whichever comes first — no operator action required.
- On operator request, by applying
fabrik:clear-claude-limitto any open board item (it does not need to be one already carryingfabrik:claude-limit— the suspension is account-wide, not per-issue). Fabrik reads the label on the next poll, clears the suspension immediately, and removes the label. This is the lightest-weight recovery from a suspected false positive — no engine restart required. - Via a full restart (
ctrl+r/SIGHUP) — see Recovering from Wedged State — which also drops the suspension, but along with all other in-memory state. Prefer option 2 unless something else is also wedged.
Already-running Claude invocations are never interrupted by this; the suspension only
prevents new ones from starting. Once the account-wide suspension lifts (by any of the
three paths above), a per-poll settle scan removes fabrik:claude-limit from every open
issue that still carries it — not only from the issue that happens to be dispatched next.
See the fabrik:claude-limit troubleshooting note above for the per-issue label/comment
behavior layered on top of this account-wide gate.
10. Webhook Mode
Webhook mode is an optional feature that dramatically reduces GraphQL usage and cuts event latency from up to 30 seconds to near-zero. When enabled, Fabrik receives GitHub events within seconds of them occurring instead of waiting for the next poll tick.
How It Works
Fabrik spawns gh webhook forward as a background subprocess. This tool connects outbound to GitHub’s SSE stream and forwards every matching event as an HTTP POST to a local listener on 127.0.0.1:<port>. No public endpoint, no ngrok, no infrastructure changes. Each event payload is authenticated with HMAC-SHA256 before Fabrik acts on it.
Events are translated into immediate poll wakeups — the existing board-fetch and filter logic handles the rest. The poll loop continues running as a safety net (up to once every 60 minutes when the webhook stream is healthy) so missed or delayed events never strand work. Board-column changes that aren’t delivered as events are caught by a lightweight status sweep that runs every 10 minutes by default (see --status-poll).
Prerequisites
cli/gh-webhookextension —gh webhook forwardis not a built-inghsubcommand; it ships as a separate extension that must be installed once:gh extension install cli/gh-webhookVerify installation:
gh webhook --help(should print usage, notunknown command "webhook"). Fabrik requiresgh≥ 2.32.0, enforced at startup with a clear error if the version is too old.gh auth loginwith a token that hasadmin:repo_hookwrite scope (classic PAT) or equivalent repo admin access. Fine-grained tokens may lack this scope.-
admin:org_hookscope for org-level webhook subscription (see Multi-Repo Boards). If your token lacks this scope, Fabrik falls back to per-repo subscriptions automatically. To add it:gh auth refresh -h github.com -s admin:org_hook.admin:orgis not sufficient. The two scopes are independent — holding org-admin rights does not grant webhook management through the API. Withadmin:orgbut notadmin:org_hook, org mode fails at startup withorg-level webhook failed (permission error)and Fabrik falls back to per-repo. GitHub reports this explicitly if you probe it directly:$ gh api /orgs/<org>/hooks gh: This API operation needs the "admin:org_hook" scope.Repo-level hooks are unaffected — the
reposcope already includesadmin:repo_hook. - Repo admin access on each repository on your board, or org-admin access for org-level subscription.
If any prerequisite is missing, Fabrik logs a clear warning and falls back to polling-only mode. Webhook mode failure is always non-fatal.
[!NOTE] Orphan webhook cleanup: At startup, Fabrik automatically deletes any previous-session webhook subscriptions whose
config.urlmatches thegh webhook forwardforwarder endpoint (https://webhook-forwarder.github.com/hook). This prevents theHook already existsHTTP 422 error that occurs when theghsubprocess crashed mid-session and the old subscription was never torn down. Operators do not need to manually delete stale webhooks between restarts.
[!WARNING] Single-user constraint: GitHub allows only one active
gh webhook forwardsubscription per repository or organization at a time. If a second Fabrik instance (or any other tool) attempts to subscribe while one is already active, GitHub returns an HTTP 422Hook already existserror. Fabrik retries the subscription a few times and, after repeated 422 failures, permanently falls back to poll-only mode for that instance — it will not receive real-time events.Operator guidance: Coordinate across your team so that only one Fabrik instance runs with
--webhooksat a time. Other instances can run in poll-only mode (omit--webhooks) and will still process issues, just without real-time event delivery. For the longer-term solution that lets multiple operators share a board without conflicting webhook subscriptions, see issue #543 (planned per-user assignee filter).
Enabling Webhook Mode
Three equivalent ways to enable:
CLI flag:
fabrik --webhooks
Environment variable:
FABRIK_WEBHOOKS=true fabrik
.fabrik/config.yaml:
webhooks: true
Configuration Options
| Option | Flag | Env var | Config YAML | Default |
|---|---|---|---|---|
| Enable webhooks | --webhooks |
FABRIK_WEBHOOKS |
webhooks: true |
off |
| Listener port | --webhook-port=<N> |
FABRIK_WEBHOOK_PORT |
webhook_port: <N> |
0 (OS-assigned) |
| Event filter | --webhook-events=<csv> |
FABRIK_WEBHOOK_EVENTS |
webhook_events: [...] |
all supported events |
| Reconcile interval | --reconcile-interval=<N> |
FABRIK_RECONCILE_INTERVAL |
— | 0 (180 s) |
By default, Fabrik subscribes to: issue_comment, issues, pull_request, pull_request_review, pull_request_review_comment, check_run, check_suite, and projects_v2_item (board column changes — see note below).
Health States and TUI Indicator
The TUI footer shows a webhook health indicator when webhook mode is enabled:
| Indicator | State | Meaning |
|---|---|---|
● webhook (N) (green) |
Healthy | Last light reconcile found no drift. Full-board idle cap: 60 min. |
○ webhook (N) (blue) |
Starting Up | Subprocess launched; persists until first reconcile tick (default ~3 minutes). Full-board idle cap: 60 min. |
◌ webhook (N) (yellow) |
Unhealthy | Reconcile detected drift, or echo-check miss threshold reached. Idle cap: 5 min (same as polling-only). |
N is the total number of webhook events received since startup. You can also verify the stream by checking fabrik.log for [webhook] tag lines.
Mutation Echo-Check
During active pipeline runs, Fabrik tracks outgoing mutations (issue comments, label changes, PR updates) and expects a matching inbound webhook event within 15 seconds. If three consecutive mutation echoes are missed within a 60-second rolling window, the stream is marked Unhealthy — even if the reconcile loop has not yet fired.
This complements the reconcile loop: the reconcile loop detects drift on a ~3-minute cadence during quiet periods; the echo-check detects silent stream failures within seconds during active periods when mutations are occurring. No user action is required — the echo-check is automatic.
Health transition log lines show the trigger reason:
health state: starting_up → healthy— first reconcile found no drifthealth state: healthy → unhealthy (N item(s) drifted)— reconcile detected drifthealth state: unhealthy → healthy (drift reconciled)— reconcile recoveryhealth state: healthy → unhealthy (echo-check miss threshold)— echo-check triggered
Push-Wake Gate Latency
When --webhooks is enabled, gate clearance events (Copilot/Gemini review submission, CI check completion) wake the poll loop within ~2 seconds of the webhook arriving — rather than waiting for the next poll tick, which can be up to 60 minutes when the stream is healthy. This near-instant response is the expected behavior, not a symptom to troubleshoot. If gate clearance feels slow when webhooks are enabled, check that the stream is in a Healthy state (green indicator).
Expected GraphQL Load Reduction
On an active board that would poll every 30 seconds without webhooks, enabling webhook mode reduces steady-state full-board GraphQL polling to at most once every 60 minutes — roughly a 120× reduction in GraphQL points consumed while idle. A lightweight Layer 2 status gate checks the project updatedAt timestamp each poll cycle at very low GraphQL cost to catch board-column changes. During active periods, polls are triggered immediately by webhook events instead of waiting for the tick.
In-Memory Board Cache
Fabrik always maintains an in-memory cache of board state — the cache is the unified source of truth for downstream engine logic and reactive observers (push-unblock, stage-change, etc.) regardless of whether webhooks are configured.
How the cache stays fresh:
| Mode | Refresh paths |
|---|---|
| Webhooks disabled | Every poll cycle: shallow board fetch from GitHub → Reconcile into the cache. Layer 2 status gate also runs each poll for project Status changes (which never flow over webhooks). |
| Webhooks enabled | Webhook deltas (issues, pull_request, issue_comment, etc.) update the cache between polls. Every poll still does a shallow Reconcile as the drift-detection safety net for missed events. The light-reconcile loop (~3 min) drives stream-health classification. |
Project Status note: GitHub webhooks do not deliver project-board Status (column) changes for repo-level project subscriptions, and only conditionally for org-level subscriptions with the right permissions. The Layer 2 status gate (updatedAt check + lightweight status batch) runs each poll cycle regardless of webhook health and is the only authoritative delivery path for Status drift.
Stream-health failover (webhook mode): If the webhook stream goes unhealthy (reconcile detected drift or echo-check miss threshold triggered), the cache pauses and reads fall back to the live GitHub API. When the stream recovers, the cache reconciles from GitHub before resuming. The per-poll Reconcile remains active throughout and continues to keep state correct.
projects_v2_item (Board Column Changes)
Fabrik attempts to subscribe to projects_v2_item events, which fire when an issue is moved between board columns. If gh webhook forward does not support this event type for your installation, Fabrik logs a warning and continues without it. In this case, board-column changes are caught by the Layer 2 status gate (runs each poll cycle via updatedAt check), or on the next full reconcile if the stream is unhealthy. All other event types continue to work normally.
Per-repo webhooks never deliver projects_v2_item events. This is a GitHub platform constraint, not a Fabrik limitation. GitHub only sends board-column events to org-level webhook subscriptions. If Fabrik is in per-repo mode (either because org-level subscription failed, or because the board spans multiple owners — see Multi-Repo Boards), projects_v2_item events will never arrive regardless of configuration. Board-column changes in per-repo mode are caught by the Layer 2 status gate each poll cycle.
Security
- The HTTP listener binds exclusively to
127.0.0.1and is never reachable from outside your machine. - Every incoming payload is verified with HMAC-SHA256 before Fabrik acts on it. A malicious local process cannot spoof events without the secret.
- The webhook secret is generated from
crypto/rand(32 bytes) at startup and is never written to disk or logged. - If HMAC verification fails repeatedly (5 consecutive failures within 2 minutes), Fabrik automatically rotates the secret and restarts the subprocess. After 2 failed rotation cycles, webhook mode is disabled for the session with a clear log message.
Multi-Repo Boards
On multi-repo boards, Fabrik uses a single gh webhook forward subprocess:
- If your token has org-admin access, Fabrik uses
--org=<org>to subscribe to all repos in the org with one subscription. New repos in that org are covered automatically — when a new repo appears, the subprocess is briefly restarted, but the org-level subscription covers all repos in the org so no events are missed. - Otherwise, Fabrik subscribes per-repo. When a new repo appears on the board, the subprocess is briefly restarted with the updated repo list (the safety-net poll covers any events missed during the restart).
Mixed-owner boards disable org mode entirely. Org-level subscription requires every managed repo on the board to share the same owner. If your board spans two orgs or users (for example, during a repo migration), Fabrik silently falls back to per-repo webhooks for all repos — even if your token has admin access to both orgs. Board-column changes are then caught only by the Layer 2 status gate each poll cycle. To avoid this: migrate all repos to the target org simultaneously, or run two separate Fabrik instances (one per org) during the transition.
projects_v2_itemis never delivered, in either mode.gh webhook forwarddoes not support that event type at all, so board-column changes are always caught by the Layer 2 status gate rather than by webhook — org mode does not change this. Fabrik logs the fact at startup:[webhook] WARNING: projects_v2_item event not supported by gh webhook forward — board-column changes caught by safety-net poll onlySwitching to org mode to get faster column updates will not work; the status gate is the only delivery path.
Known limitation — one webhook session per GitHub account.
gh webhook forwardappears to support only one active forwarding session per authenticated user, not one per repo. Running several Fabrik instances under the same GitHub identity therefore gives webhooks to only one of them; the rest must run withwebhooks: falseand rely on polling. This is observed behaviour — it is not documented upstream incli/gh-webhook— so verify it against your own setup before designing around it.Related: Fabrik passes one
--repo=flag per managed repo, butgh webhook forwarddeclares--repoas a single value, so only the last one takes effect. On a multi-repo board without org mode, this means only one repo is actually subscribed, while the webhook stream still reports healthy. Check.fabrik/fabrik.logfor[webhook] event:lines and confirm you see every repo you expect. Tracked in issue #1142.
Per-repo failure isolation. When Fabrik is in per-repo subscription mode, auth-shaped quick exits (permission errors, missing admin:repo_hook scope, etc.) are conservatively attributed to all repos in the current subscription set. Each repo tracks its own consecutive failure count; a repo is quarantined for the session once it reaches the threshold (3 consecutive auth-shaped exits). Because failures are attributed across all repos, a single misconfigured repo will cause all repos to reach the threshold together over time. Quarantined repos fall back to the safety-net poll (same cadence as polling-only mode). To recover: fix the underlying issue (re-add token scopes, restore repo access) and restart Fabrik. The next startup re-attempts subscription for all repos, including any previously quarantined ones.
Startup delay on multi-repo boards: Webhook subscriptions are established after the first board poll completes (up to one poll interval, typically 30 seconds). During this window the TUI shows the blue “starting up” indicator and the safety-net poll is in effect. This is expected — the webhook manager waits until the repo list is known before subscribing.
Migration / Permission Changes
Fabrik probes org mode each time the webhook subprocess starts or restarts. If the probe fails with a permission error (the subprocess exits within 10 seconds with an auth-shaped error), org mode is disabled for the rest of the session — Fabrik switches to per-repo subscriptions and does not retry. A Fabrik restart always clears this sticky state and re-probes org mode from scratch.
- You just gained org-admin access (or migrated repos to a new org you own): no state needs clearing. Restart Fabrik and it will detect org-level subscription automatically on the next startup.
- You lost org-admin access mid-session: the running subprocess continues in org mode until it exits or restarts. On the next restart cycle, the probe fails with a permission error and Fabrik falls back to per-repo mode for the remainder of the session. Restart Fabrik to immediately settle into per-repo mode.
- Per-repo mode after a permission failure: a prior org-mode probe failed with an auth error (token lacks
admin:orgor org-admin access). Fabrik will not retry org mode until restarted.
Troubleshooting Webhook Mode
“prerequisite check failed” — gh is missing or the cli/gh-webhook extension is not installed. Install gh at https://cli.github.com, then run gh extension install cli/gh-webhook.
Stream shows yellow “unhealthy” — The subprocess may have exited or GitHub isn’t delivering events. Check fabrik.log for [webhook] error lines. Run gh auth status to verify your token has admin:repo_hook scope.
poll-only mode active — webhook subscription disabled; restart Fabrik to retry — This log line (visible in the TUI or fabrik.log) appears when the 422 circuit-breaker has permanently disabled the webhook manager after 3 consecutive HTTP 422 errors. Common causes: another Fabrik instance already holds the webhook subscription (Hook already exists), your token lacks admin:repo_hook scope, or the repo has reached GitHub’s webhook quota. Fix the underlying issue, then restart Fabrik — the next startup clears the circuit-breaker and re-attempts subscription. The 422 circuit-breaker is preceded in the log by: WARNING: webhook subscription permanently failed after N consecutive HTTP 422 errors — switching to poll-only mode. Check 'gh auth status' and repo webhook quota, then restart Fabrik.
HMAC failures logged — Usually caused by a gh auth refresh that invalidated the webhook secret. Fabrik auto-rotates; if failures persist after 2 cycles, restart Fabrik.
projects_v2_item warning — Board column changes are caught by the Layer 2 status gate (runs each poll cycle via updatedAt check). Not a problem for most workflows.
Log format changes (v0.0.54) — Operators with grep/alert rules on log patterns should update for these changes:
- New reconcile log format:
[reconcile] light reconcile: N item(s) drifted (...) — reconciling cache— emitted when the periodic reconcile tick detects cache drift and triggers a full cache refresh. - Removed
auto-advance catch-up:prefix — Routine stage-transition log lines no longer carry this prefix. If you have monitoring rules or scripts that grep forauto-advance catch-up:, update them — that string no longer appears in Fabrik logs.
11. Troubleshooting
GitHub API Returns 401 or “Fine-Grained Token” Warning
Fabrik requires a classic personal access token (ghp_...). Fine-grained tokens (github_pat_...) are not supported because GitHub Projects v2 GraphQL — which Fabrik uses for status updates and board queries — is not available to fine-grained PATs.
Symptoms:
- Startup warning:
[warn] Fine-grained personal access tokens (github_pat_...) do not support GitHub Projects v2 GraphQL... - Error on first API call:
GitHub API returned 401: ... If you used a fine-grained access token...
Fix:
- Go to https://github.com/settings/tokens and select “Tokens (classic)”
- Generate a new token with scopes:
repo,project,workflow - Update
FABRIK_TOKENin your.envfile with the newghp_...token
Startup Board Validation Failure
On every startup, Fabrik fetches the project board and compares stage names in .fabrik/stages/*.yaml to the column names on the board. If any non-cleanup stage is missing from the board, Fabrik exits with an error listing the mismatched names.
To fix: ensure stage YAML name fields match board column names exactly (case-sensitive). If you renamed a column on the board, update the matching stage YAML. Extra board columns (with no matching stage) produce a warning but don’t block startup.
Stage YAML Drift Warning
At startup, Fabrik prints a warning when a .fabrik/stages/*.yaml file is missing top-level keys that exist in the embedded defaults for the same stage name — but only when adding the key would change the stage’s effective behavior. A missing key whose embedded-default value is behaviorally identical to omission (e.g. kill_grace: {sigint: 10s, sigterm: 10s}, completion: {type: claude}) is not reported.
[startup] warning: .fabrik/stages/validate.yaml is missing fields present in v0.0.53 defaults: wait_for_ci, wait_for_reviews. Run `fabrik refresh-stages --apply` to add the missing keys.
This is informational — the engine continues running. It means your stage file predates behavioral options added in a newer binary.
To resolve: run fabrik refresh-stages --apply to add the missing keys to your stage YAMLs, then git diff and git commit to record the update. See §1 Stage YAML Drift Warning for a full explanation and list of common keys.
Issue Not Being Picked Up
- Confirm the board column name exactly matches a stage YAML
name(case-sensitive). - Confirm the issue is on the project board.
- Check for
fabrik:locked:<other-user>label (another instance has it). - Check for stuck
fabrik:editinglabel (remove manually if stale). - Check the poll log – the issue may be filtered by
updatedAtcaching. Restart Fabrik to force a fresh scan.
Stage Keeps Retrying
A stage that never outputs FABRIK_STAGE_COMPLETE retries after each cooldown. Causes:
- Claude needs user input – answer the questions in the issue comments.
- Missing context – add detail to the issue body.
- Bug in the skill – check the SKILL.md in
.fabrik/plugin/skills/.
After --max-retries failures, the issue is paused with fabrik:paused. Remove the
label to resume.
Stale Worktrees
Worktrees are at .fabrik/worktrees/<owner>-<repo>/issue-N/ on branch fabrik/issue-N. On each stage
invocation, Fabrik rebases onto the latest base branch (or the branch specified by a
base:<branch> label), unless it’s a retry, which preserves the worktree as-is to
maintain Claude’s context. If the rebase conflicts, it’s silently aborted and Claude
works from the current base.
To manually clean up:
git worktree remove --force .fabrik/worktrees/<owner>-<repo>/issue-N
git branch -D fabrik/issue-N
Killed or Interrupted Sessions
Claude sessions are stored at .fabrik/sessions/issue-N/<stage>.session (relative to the project working directory). On retry, Fabrik resumes from the session file. The Implement stage commits frequently to minimize lost work.
To force a fresh session:
- Remove:
rm .fabrik/sessions/issue-N/<stage>.session - Remove
stage:<name>:completelabel if incorrectly applied - Fabrik starts a fresh session on next poll
Comment Reprocessed After Restart
The rocket reaction is the durable “processed” marker. If a comment gets reprocessed after restart, it means the rocket was not applied before shutdown (killed mid-flight). This is expected for in-flight comments.
Issue Body Not Updated
If you see FABRIK_ISSUE_UPDATE_BEGIN/END markers in stage comments, the markers
are being processed correctly – the issue body is updated and the markers are stripped
from the posted comment. If the markers appear verbatim, update Fabrik to the latest
version.
Post-to-PR Output Missing
For stages with post_to_pr: true:
- Confirm the issue has a linked PR (via “Development” section or
Closes #Nin PR body) - If no PR is found, output falls back to the issue
Raw JSON in Comments
If you see raw JSON dumped in issue/PR comments, the output was too large for the JSON
parser or the format changed. Fabrik now posts an error message instead of raw JSON.
Check .fabrik/logs/<owner>-<repo>/issue-N/ for the full output.
Multi-User and Multi-Instance Operation
Multiple users and multiple Fabrik instances can safely share a project board.
Comment processing: Fabrik processes comments from any author, not just the
configured --user. Human colleagues, code-review bots (Copilot, Gemini), and
other Fabrik instances all trigger comment processing. Three categories are
skipped: comments that start with 🏭 **Fabrik (Fabrik’s own output), comments
that already carry a 🚀 rocket reaction (already processed), and non-actionable
bot service-notices — e.g. a quota-exhaustion or rate-limit banner from a
review bot — which are watermarked as processed without ever spawning a worker
or receiving a reply, so they can’t re-trigger the bot into a reply loop.
Multi-instance locking: The fabrik:locked:<user> label is still the
concurrency guard, but acquisition now uses a lock-then-verify protocol:
- An instance adds its
fabrik:locked:<username>label. - It waits 2 seconds to let a competing instance place its own lock.
- It re-fetches the issue’s labels.
- If no other
fabrik:locked:*label is present → proceed. - If a competing lock exists → lexicographic tie-breaking: the instance whose username sorts lower alphabetically keeps its lock and proceeds; the instance whose username sorts higher removes its lock and skips this issue for this poll cycle. On the next poll, the winner’s lock is already visible, so the loser skips cleanly without retrying.
This guarantees that exactly one instance processes each issue at a time, even when multiple instances poll simultaneously.
Duplicate Comments on Issues
If a single stage run produces multiple comments on the issue (each with the
🏭 Fabrik — stage: header), the cause is almost always globally-installed
Claude Code plugins interfering with Fabrik’s headless sessions.
The superpowers plugin is a known offender — its SessionStart hook causes
Claude to spawn parallel Agent subagents, each producing separate output that
Fabrik posts as individual comments.
Fix:
- Remove interfering global plugins:
rm -rf ~/.claude/plugins/cache/claude-plugins-official/superpowers - Check for other global plugins:
ls ~/.claude/plugins/cache/claude-plugins-official/ - Fabrik’s behavior should be governed only by the repo’s CLAUDE.md and the Fabrik plugin — not personal global plugins
Prevention: Avoid installing global Claude Code plugins on machines that run Fabrik.
Multiple Fabrik Instances
Running two Fabrik processes against the same project board causes duplicate dispatches, wasted API credits, and conflicting comments. Each instance has its own in-memory deduplication, so they can’t detect each other.
Starting with v0.0.30, Fabrik acquires an exclusive file lock on
.fabrik/fabrik.lock at startup. If another instance is already running for
the same project, it exits with a clear error. The lock is automatically
released if the process crashes.
To check for stale instances: pgrep -la fabrik
unknown command "webhook" for "gh" in Webhook Mode
Symptom: Fabrik logs lines like:
[webhook] [gh] unknown command "webhook" for "gh"
[webhook] gh webhook forward exited: exit status 1 — restarting in 1s
Cause: gh webhook forward is not a built-in gh subcommand — it is the cli/gh-webhook extension, which must be installed separately.
Fix:
gh extension install cli/gh-webhook
gh webhook --help # verify: should print usage
Then restart Fabrik. The extension is a one-time install and persists across gh upgrades.
Hook already exists (HTTP 422 from gh webhook forward)
Symptom: After each restart attempt, Fabrik echoes the gh error to the log:
[webhook] [gh] Error: error creating webhook: HTTP 422: Hook already exists (https://api.github.com/repos/<owner>/<repo>/hooks)
After three consecutive 422 failures, Fabrik emits:
[webhook] WARNING: webhook subscription permanently failed after 3 consecutive HTTP 422 errors — switching to poll-only mode. Check 'gh auth status' and repo webhook quota, then restart Fabrik.
Fabrik then continues in poll-only mode for the remainder of the session with no real-time events.
Cause: GitHub enforces a single-user constraint on gh webhook forward: only one active subscription can exist per repository or organization at a time. A second Fabrik instance (or another tool) that tries to subscribe while one is already active receives this 422 error.
Fix: Coordinate across your team so that only one Fabrik instance runs with --webhooks at a time. All other instances should run in poll-only mode (omit the --webhooks flag). They will still process issues on their normal poll interval — they just won’t receive real-time event notifications.
For a longer-term solution that allows multiple operators to share a board without conflicting over the webhook subscription, see issue #543 (planned per-user assignee filter).
Plugin Not Loading
If Claude doesn’t seem to follow the skill instructions:
- Verify
.fabrik/plugin/exists (runfabrik initif not) - Check that the stage YAML has
skill:set (notprompt:) - For development, use
--plugin-dirto point at your working copy - Verify Claude Code version supports
--plugin-dir
Recovering from Wedged State
Cache-stale bugs can occasionally leave Fabrik’s in-memory state stuck — an issue that should advance doesn’t, or a label that should clear doesn’t. The safest escape hatch is a SIGHUP restart, which drops all in-memory state and re-execs the same binary in place, without tearing down the terminal session or TUI.
If the only symptom is a suspected false-positive Claude usage-limit suspension, prefer applying fabrik:clear-claude-limit to any open board item instead — see Claude Usage-Limit Suspension. It clears just that one piece of state without dropping the board cache, observers, or mayNeedWork map a full SIGHUP restart discards. Reach for SIGHUP when something else is also wedged, or the lighter clear doesn’t help.
How to trigger:
If you are using the TUI dashboard, press ctrl+r — this sends SIGHUP to the Fabrik process and triggers the same restart without leaving the TUI to open another terminal window.
From the command line:
# Find the PID (shown at startup: "[startup] fabrik PID: <N>")
kill -HUP <fabrik-pid>
# Or by name if only one instance is running
pkill -HUP fabrik
What happens:
- Fabrik logs
[signal] received SIGHUP — restarting in place to clear in-memory statetofabrik.log. - In-flight Claude workers are cancelled (same drain as Ctrl-C).
fabrik:locked:<user>labels are removed from any issues that were mid-run.- The lockfile (
.fabrik/fabrik.lock) is released. syscall.Execreplaces the process image in place — the PID stays the same, the parent shell and TUI remain attached, and a brief TUI redraw may occur.- The new process starts with a clean in-memory cache, Store, observers, and
mayNeedWorkset.
Terminal restore: The terminal alt-screen is properly released before the process
is replaced or exits — no reset command is needed after a SIGINT, SIGTERM, or SIGHUP.
This applies to all signal-driven exit paths that go through Fabrik’s signal handlers,
including SIGHUP re-exec.
Plugin-skills upgrade: SIGHUP restart proceeds non-interactively through the
plugin-skills upgrade check — when no local customizations are detected, skills are
refreshed automatically without prompting (a brief status line is written to stderr),
so the restart never hangs waiting for user confirmation. When local customizations are
present, the refresh is skipped entirely to preserve your edits; the [u] custom workflow
badge appears on next TUI startup instead of silently overwriting your changes.
What is cleared:
- In-memory board cache and item Store
- Registered observers (wake-channel, mayNeedWork, invocation, stage-change, push-unblock)
mayNeedWorkmap
What is preserved:
- All GitHub-side state: labels, comments, PR status, stage history — these live on GitHub, not in memory
- Rocket-reaction durability: comments that were fully processed before the SIGHUP already carry a 🚀 reaction; the new process skips them
- Worktrees and uncommitted work in
.fabrik/worktrees/
In-flight Claude runs: Any Claude session interrupted by the SIGHUP drain did not emit FABRIK_STAGE_COMPLETE, so the new process will re-dispatch those stages. The rocket-reaction guard prevents double-posting of already-processed comments. Expect the interrupted stage to restart from its session file.
Log truncation: fabrik.log is opened with O_TRUNC on each startup, so the pre-restart log is cleared. If you need the pre-restart log for diagnostics, copy it first:
cp .fabrik/fabrik.log .fabrik/fabrik-pre-restart.log
kill -HUP <fabrik-pid>
Windows: SIGHUP is a Unix signal. This feature is silently absent on Windows.