| name | faff-beep-boop |
| description | Chew through ready work unattended — overnight or fire-and-forget. Default: full pipeline (tidy → prep queue drain → build queue drain). Parks anything ambiguous so /faff-wtf can surface it in the morning. Trigger for: 'beep boop' / 'overnight' / 'fire and forget' / 'run the backlog' / 'unattended build'. |
Faff — Beep-Boop
Unattended end-to-end runs of the faff suite. Drives the other faff skills in autonomous mode — no prompts, no human in the loop, parks anything ambiguous, logs everything to .faff/runs/….
This skill is the orchestrator. It does not reimplement prep, build, or tidy — it invokes the existing faff sub-skills with the autonomous-mode signal set.
Methodology lens. The methodology slot always runs — it defaults to faffter-noon-methodology-thematic when .faffrc sets none, and its pick-ordering / build-queue outputs are Required (gateway → The methodology slot), so the build-queue order always comes through it. What's conditional is only the display banner: the run summary's first line is Methodology: [skill-name] and an opinionated lens (e.g. faffter-dark-methodology-agile-delivery) re-sequences by its own logic (value × risk × dep-aware) rather than the thematic default's baseline order. When the slot is the silent thematic default, the banner is omitted and the order is whatever the default's pick-ordering returns (priority + chainable unlock value) — beep-boop renders the slot's order and never names a baseline of its own; the slot is never skipped, because its outputs are load-bearing. No WIP gating — autonomous queues are unbounded regardless of methodology. Admission stays governed by the verdict gate (admit fire-and-forget + likely-fire, route out the other four verdicts).
Configuration
Load the gateway first. Beep-boop is the autonomous entry point; if faff/SKILL.md isn't in context this turn, Read it now — it holds the shared rules + fixed contracts faff applies. It gates the queue on the fixed automation-routing admission rule; every skill it delegates to (tidy, prep, graft + their slots) inherits the gateway ambiently.
Beep-boop uses these slots from .faffrc when set:
concurrency — the build-pass executor. Default faffter-noon-concurrency-sequential (one build at a time); swap to faffter-dark-concurrency-parallel for capped, worktree-isolated concurrency with rebase-before-merge.
spec, review, ship — passed through to the sub-skills; beep-boop doesn't use them directly.
Invocation
Two forms:
| Form | Behaviour |
|---|
/faff-beep-boop | Full pipeline (default). Tidy → prep queue drain → build queue drain. The whole shebang. |
/faff-beep-boop ISSUE-XX ISSUE-YY … | Explicit list. Skips discovery; operates on the listed issues only. |
All forms run non-interactively. No yes/no gates. The whole point is unattended execution.
All forms also accept two optional cost-budget flags that can be combined; the run stops when either fires. See ## Budget flags below — which also documents the opt-in --converge flag (within-run convergence — drain execution-discovered scope in the same run).
Budget flags
A run's budget is a BudgetEnvelope (FAFF-36): a set of ceilings across four dimensions — wall-clock (until), build-attempts (max_attempts), tokens, cost — and one at-ceiling outcome (stop | narrow | escalate, default stop). Any subset of ceilings; an unset dimension is unbounded. The envelope is resolved once at run start from the .faffrc budget: block, with the --until/--max flags overriding it. The contract FAFF-38/FAFF-225 consume is ceiling + outcome; how spend is accounted is a swappable producer behind it (the default is faff budget check's transcript-sum).
| Dimension | CLI flag | Config key | Caps | Semantic |
|---|
until | --until HH:MM | budget.until | Wall-clock time | Stop dispatching once local time reaches HH:MM. 24-hour; a past time is next-day (so 06:00 started at 23:00 means 06:00 tomorrow). |
max_attempts | --max N | budget.max_attempts | Build attempts | Stop once N attempts launched. Counts every build-queue dispatch regardless of outcome (Shipped / PR-open / Parked / Errored). Not routed-out (no /faff-graft invocation), not prep dispatches. |
tokens | — | budget.tokens | Token spend | Stop once this run's measured token spend reaches N. Measured from the session transcript; estimate fallback labelled (estimate) when the transcript is unavailable. |
cost | — | budget.cost | Dollar cost | cost = tokens × budget.price_per_mtok. Disabled unless price_per_mtok > 0. (Real CI/compute metering is out of scope — cost is tokens×price until a CI-introspection producer lands.) |
budget:
until:
max_attempts:
tokens:
cost:
at_ceiling: stop
price_per_mtok: 0
max_attempts is a backstop, not an L4 governor. A tally answers a cost question, not a doneness one — ticket count is uncorrelated with project size, and stalling a healthy run at attempt N is exactly what an unattended run must not do. So the governors divide by job: run-done terminates a run because the work is done, Sentry interrupts one going wrong, budget backstops rogue spend. Accordingly, at L3 any ceiling (including max_attempts alone) is a legitimate cost idiom with at_ceiling: stop; at L4 (faff lights-out) a spend/time ceiling (tokens / until / priced cost) is the mandatory launch precondition — a max_attempts-only envelope is refused at preflight (it may ride along only as an extra backstop) — and the run mints at_ceiling: escalate when config leaves it unset, so a backstop that binds surfaces as a structured needs-human rather than a silent stop (set at_ceiling: stop for L3's quiet stop).
The check — faff budget check (FAFF-36)
At each between-units checkpoint (below), call faff budget check — a pure CLI (no tracker/network call, parity with faff next) that reads the run-ledger + config + the run's local transcripts and emits a BudgetState JSON:
{ "spent": { "elapsed_ms", "attempts", "tokens", "cost" },
"tokens_source": "transcript" | "estimate",
"breached": [<dimension>, …], # breached ≠ [] is the terminating signal
"outcome": "stop" | "narrow" | "escalate" | "none" }
- Token accounting. Tokens are this-run's spend: the orchestrator transcript (keyed off
$CLAUDE_CODE_SESSION_ID — never the mtime-newest file, which can be a different session) plus every child agent-*.jsonl modified ≥ run start (subagent grafts dominate run spend and live in separate files), summed and baselined at run start (tokens_at_start). A missed late child file undercounts but never overcounts (guard-rail semantics). When no transcript is readable (CLAUDE_CODE_SKIP_PROMPT_HISTORY / missing), it falls to estimate = attempts × est_tokens_per_attempt and sets tokens_source = estimate — surface that label so a user knows the token figure was estimated, not metered.
- At-ceiling outcomes (on
outcome when breached ≠ []): stop (default) — stop dispatching, in-flight finishes, admitted-undispatched land under ## Unreached (budget hit), Stop reason = budget-hit(<dims>); narrow — dispatch only the methodology's cheapest remaining admissible subset, re-check after each, fall through to stop when nothing fits; escalate — stop and emit a structured needs-human escalation, Stop reason = budget-escalated(<dims>) (the L4 path wants this, not a silent drain).
When the check fires
Between units, never mid-unit (a turn's usage lands in the transcript only once the turn closes — so token accounting is exact only at these boundaries). The checkpoints: after every prep return (before the next prep candidate), after every build return / before every launch in parallel mode (before the next build issue in the wave), and at every wave boundary (before re-assembling the next queue). Phase scope: until gates both phases (a mid-prep breach skips the build phase; a mid-wave breach skips re-entry); max_attempts / tokens / cost gate build only.
In-flight units finish naturally. There is no mid-issue cancellation — a /faff-graft run in progress when the budget fires completes normally and lands in its terminal state (which then appears in Shipped / PR-open / Parked / Errored as usual). Under the parallel executor, up to concurrency_max issues may finish after the budget fires; that's expected. max_attempts fires on launched attempts, not returned terminals (identical in sequential mode; in parallel it avoids the overshoot of waiting for the Nth terminal).
The interrupt — faff sentry check
Budget backstops rogue spend; Sentry is the live derailment interrupt — it stops a run going wrong (thrash, no-progress, repeated-identical-failure, escaped side-effect, wall-clock, liveness). At every between-units checkpoint (the same boundaries the budget check fires) also run faff sentry check --json --run-dir <run-dir>: a pure evaluator that reads the run's append-only surface (events, ledger, heartbeat, a consumed faff budget check) without mutating it and emits DerailmentVerdicts + an intervention continue | pause | abort. Consume its verdict — never re-implement trigger math (attempt counting, staleness) in loop prose; running outside the supervised context is what makes it trustworthy.
The consult always runs; acting on it is mint-scoped — a run is L4 iff its ledger was faff lights-out-minted (level: "L4"). Only L4 acts; a non-L4 run logs + surfaces the verdicts (shared telemetry + the threshold-calibration feed) and takes no dispatch action. Don't fork the consult itself on level (shared prose drifts) — only the handling does. An aborted L4 run is resumable by design, so a disputed abort is a paused night, not lost work — tune the sentry.* thresholds, not the model. continue → proceed either way; the trips an L4 run acts on:
faff sentry check trip | L4 (lights-out-minted) — acts | Non-L4 — advisory |
|---|
| consult fails (non-zero / unparseable) | fail closed — stop dispatching, surface needs-human "kill-switch evaluator down" | log the failure, continue |
pause | park the implicated issue(s) the verdict names (thrash / repeated-failure are issue-scoped) via the shared park protocol; continue the queue | log + surface, proceed |
abort | run faff sentry abort --run-dir <run-dir> (ledger → aborted-resumable), launch nothing further, surface the verdicts | log + surface, proceed |
Unreached issues
Issues that reached build-ready (spec present, verdict-admitted, partitioned by conflict analysis) but were never dispatched because the budget fired land in a new Unreached (budget hit) bucket in the run summary. They are not parked — they retain Todo + spec state and the next run picks them up (no tracker comment; their state is unchanged from run start). Un-prepped Backlog candidates whose prep dispatch never fired because --until cut prep short do not appear in Unreached — they stay in Backlog (the prep-queue summary just has smaller counts), and the next run picks them up.
Within-run convergence (--converge, opt-in — FAFF-87). By default, execution-discovered scope is filed at end-of-run for the next run (step 10, file-and-defer) — depth grows ~one layer per run. The opt-in --converge flag (or convergence.enabled: true; the flag overrides config) instead closes the loop inside one run — each wave's discovered scope is filed mid-run (step 8.0), prepped, folded into the queue, and built this run until both bottom-up tributaries run dry; the wave-boundary stop is delegated to faff run-done (8.5), depth bounded by dryness + budget + value/dedup gating (never a fixed count — convergence.max_waves, default 6, is only a reported runaway backstop). L4 discipline, off by default; the file-and-defer path is unchanged without it.
Full pipeline (default)
Two independent phases. The prep queue drains fully first. Then the build phase runs as one or more waves: each wave assembles a build queue from the current Todo+spec set, drains it, and re-checks for work newly unlocked by issues that just shipped. The prep queue always runs to completion regardless of whether any wave ends up non-empty. Overnight prep is valuable on its own.
0a. PRD-admissibility pre-check (L4 lights-out only)
Under the L4 lights-out signal only (an ordinary L3 run skips this entirely — L3 behaviour is unchanged), run this before faff lights-out mints the ledger, so a refused PRD never leaves an orphaned run-ledger. faff lights-out is a pure CLI and cannot invoke an LLM, so the prose layer owns this pre-step. Resolve the binary per gateway → Resolving the faff executable.
- Resolve the container.
faff prd list --json. Exactly one PRD → use its container. Zero → no-PRD case: skip to step 1 (tidy). Multiple → keep those whose status is Active/Frozen; if still more than one → REFUSE (prd-ambiguous: multiple active PRDs, configure tracking.container to disambiguate), mint nothing.
- Resolve the PRD path.
faff prd path <container>. File missing → no-PRD case (skip, as for zero PRDs).
- Validate slot liveness. Resolve
faff config get slots.prd (default faffter-noon-prd). Unreachable → REFUSE (prd-slot-unreachable), fail-closed.
- Invoke the
prd slot via the Skill tool (resolve per gateway → Sibling-skill invocation), passing the resolved PRD file path from step 2 (the slot reads that one file). It emits one faff-contract:prd-readiness block.
- Pipe to the contract. Locate the block,
JSON.parse it, pipe to faff contract prd-readiness. Branch: exit 0 + admissible → PROCEED (carry creative_licence forward); exit 0 + not-ready → REFUSE (prd-not-ready: <reason>, escalate, surface in /faff-wtf); exit 1 (violations) or exit 2 (fail-loud / missing block) → REFUSE (fail-safe).
- Mint with the licence. Call
faff lights-out --prd-creative-licence <value> with the creative_licence token; it stores prd_creative_licence in the ledger (absent flag → null).
A REFUSE here exits before minting — no run-ledger is written, and the cause surfaces for /faff-wtf. The no-PRD case proceeds to step 1 normally, with no prd-admissibility gate applied.
1. Tidy pass
Invoke the faff-tidy skill via the Skill tool (resolve per gateway → Sibling-skill invocation) in autonomous mode. Applies the auto-actions (archive dead weight, reparent obvious orphans, strip dead references, canonicalise overlooked specs, clear stale park labels) and tags stale-spec / superseded-spec issues so the prep queue picks them up in step 2. Logs remaining findings for morning review.
2. Prep queue build
Gather every issue that is:
- Not cancelled or archived (shared rule)
- Automation-eligible — skip anything not automation-eligible (gateway → Automation eligibility: no
faff-automate under the opt-in default, or a faff-automation-hold)
- Not explicitly blocked
- In Backlog or similar pre-Todo state
- Lacking a valid spec (no spec, or spec marked stale)
- Flagged by the tidy pass as a prep candidate — issues tagged stale-spec (need refresh) or superseded-spec (need fresh spec). These are active issues already in Todo with a spec that's no longer valid; prep's stale-refresh or fresh-spec autonomous paths decide whether they rejoin the build queue or park for human attention.
Decide membership via faff next, not by re-deriving these criteria by hand (gateway → Next-step transition): map each issue's fetched state to the flags and consult faff next — an issue belongs in the prep queue iff it returns next: prep. The bullets above are the human-readable shape of that same transition.
This is the prep queue. The eligibility-exclusion here (and at build-queue assembly / wave re-entry) is an efficiency early-exit, not the guarantee — prep and graft are the chokepoints that actually enforce eligibility (gateway → Automation eligibility), so a not-eligible issue can't slip through even if a filter is missed. Items skipped here never enter the run-ledger admitted array, so runcheck is unaffected.
3. Prep queue drain
For each candidate, invoke the faff-prep skill via the Skill tool in autonomous mode. Possible returns per skills/faff-prep/SKILL.md autonomous section:
refreshed — spec updated, issue stays in Todo (contributes to build queue)
promoted — fresh high-confidence spec, moved to Todo (contributes to build queue)
promoted-needs-review — medium-confidence spec attached (rating retained) and moved to Todo; it joins the candidate set but its verdict is needs-decision-first, so it routes out of the build queue and surfaces in the morning brief rather than auto-building
ineligible — issue is not automation-eligible (gateway → Automation eligibility: no faff-automate under opt-in, or a faff-automation-hold); skipped without speccing or promotion. Never enters the admitted ledger and is not counted as parked — surfaced in the run summary's On-hold bucket, not Parked
parked — low confidence, contract violation, or architectural change needed; tracker tagged, log written
errored — treated as parked for reporting
Runs until the prep queue is empty. Never short-circuits on build-queue state.
4. Build queue assembly
Collect every issue that meets readiness (in Todo, with no open external blockers — in-queue dependencies are handled by conflict analysis as collision groups, not exclusions) and has a spec discoverable per the shared Spec discovery rule (gateway) — tracker comments, tracker description/body, or committed docs/. Any hit counts. This includes:
- Issues already in Todo at the start of the run (spec likely on the tracker)
- Issues freshly moved to Todo by the prep queue (spec on the tracker by construction)
Do not require a repo-side spec file at this stage — faff-graft commits the spec to docs/ only at the start of the build. An absent spec file under the configured Spec docs path (default docs/specs/*-<issue>-*.md) is not grounds for exclusion; the tracker is the pre-build source of truth.
Gate eligibility via faff next first (gateway → Next-step transition): consult faff next per candidate; only issues returning next: graft are build-eligible (prep rejoins the prep queue, skip-ineligible→On-hold, needs-human→routed-out, blocked/done/none→excluded). faff next's --blocked carries external blockers only — in-queue dependencies stay with conflict analysis. The labels + status fed to faff next / faff eligible here are sourced from a read taken at assembly, never a same-session snapshot from before the run started (gateway → Re-ground before gate) — a human who cranked an issue up between the queue-build and now must not be judged on the pre-edit labels. Then, on the graft-eligible set:
Re-scan the live comment thread first (mandatory — gateway → Automation-routing verdict (fixed) → Live-thread reconciliation). Before computing or trusting a cached verdict for any spec-gated candidate, fetch its comments and scan everything posted after the spec (faff-prep → Scenario B Step 2a: Challenge / Resolution / Context / Noise). A Resolution (a human picking an option, answering a **Punt:**, closing an open decision) or a Challenge (a new constraint contradicting the spec) means the attached spec's retained rating is stale — the human steered the decision on the control surface. Route that candidate through narrow prep (the faff-prep skill, autonomous) to fold the resolution in and re-rate, then compute the verdict on the refreshed spec — never the pre-resolution snapshot. A cached verdict from the tidy pass only reflects the thread as of tidy; any comment since supersedes it. (This is the step whose absence stranded a medium-spec issue whose open **Punt:** a human had already resolved by comment — it routed out as needs-decision-first instead of re-rating to high → fire-and-forget.) This re-scan is beep-boop's discharge of the steer-loop re-read in gateway → Human curation is authoritative assertion 2 — the wave honours a human's mid-flight edit, never an over-ridden snapshot.
Compute the automation-routing verdict for every spec-gated candidate. The verdict is normally already in .faff/runs/<run-id>/automation-verdicts.md from the tidy pass in step 1 — read it from there to avoid recomputation, but only after the live-thread re-scan above clears it as current (a post-tidy Resolution/Challenge invalidates the cache entry and forces the narrow-prep refresh). Any candidate not in that cache — an issue prep promoted during a wave (step 8), or one whose spec was refreshed since the tidy pass — is computed inline at assembly and written back to the cache; the top-of-run cache only covers the issues tidy saw. Admit only fire-and-forget and likely-fire verdicts to the build queue. Issues routed out (the other four verdicts) are captured for the run summary's "Routed out" section — they appear in /faff-wtf's next morning brief with the verdict-specific diagnosis.
Claim-before-admit (multi-orchestrator safety — FAFF-82). Before appending a verdict-admitted issue to admitted, re-read its live tracker status (gateway → Issue claim & status monotonicity). If a peer already claimed it — status already In Progress / In Review / Done set by someone else — give it the claimed-by-peer disposition: do not append it to admitted (so runcheck's admitted − outcomes == ∅ invariant stays true), and do not park it (a peer is building it). Surface it in the run summary's "Claimed by peer" line. This one re-read also re-reads the issue's eligibility-label set (faff-automate / faff-automation-hold) — the canonical co-location for the status + eligibility-label freshness members (gateway → Re-ground before gate), one fetch, no extra round-trip — so the labels the verdict/eligibility decision consumed are confirmed live at admit, not carried from an earlier same-session read. The same re-read runs at wave re-entry re-assembly (step 8.4), so an issue freed by a crashed peer (or cranked up between waves) is re-admittable later. (graft's own Step 5 claim is the backstop — it re-checks and skips if a peer claimed the issue between assembly and dispatch.)
Lights-out admissibility filter (FAFF-224 — primary; lights-out signal only). Under the L4 lights-out signal only (resolve fail-safe off — an unresolvable signal skips this), after routing admits and before appending to admitted, pipe the candidate's spec body to "$faff" admissible --lights-out --json — the mechanical quality-IN floor refusing a spec whose DoD structure is not machine-checkable (so the build agent can't grade its own "done"). Admissible (exit 0) → admit unchanged (surface any R3 warnings, they never gate). Inadmissible (exit 1), or any binary/shell failure → fail-safe: do not append to admitted (so runcheck's admitted − outcomes == ∅ holds); give it the inadmissible:<reasons> routed-out disposition for the morning brief — never parked-as-built. This is the primary call-site; graft's Step-2 backstop catches a direct lights-out /faff-graft. An ordinary L3 run skips this filter entirely.
Record to the run ledger. Append every admitted issue to admitted, and write each routed-out issue's routed-out outcome immediately, in .faff/runs/<run-id>/run-ledger.json (see Run ledger). The ledger is what step 11's runcheck audits — keep it current as the queue is assembled and drained.
Exclude anything parked during the prep queue (no valid spec or flagged for human attention). Also exclude anything not automation-eligible (gateway → Automation eligibility) — though by construction a not-eligible item can't reach here (prep/tidy never promoted it to Todo); this is the defence-in-depth early-exit.
5. Conflict analysis
Run once over the build queue. See Conflict analysis below.
6. Build pass
Hand the conflict-analysis partition to the concurrency slot (see Build-pass execution below), which drives the faff-graft skill in autonomous mode per issue — sequentially by default, or concurrently when the parallel executor is configured — respecting the partition (independents in parallel where the executor supports it, serial within collision groups). When models.build_by_confidence is configured (FAFF-334, per-issue build-model routing), annotate each partition entry with its already-read spec confidence — the same rating the routing verdict above already read, so no new tracker read — so the executor can resolve faff models build-for <confidence> per issue at dispatch; with the matcher unset, omit the annotation and the executor resolves models.build once per run exactly as today. Independents are ordered per the configured methodology's pick-ordering (gateway → Ordering & judgement delegation); beep-boop states no ordering of its own. The thematic default supplies priority + chainable unlock value when no methodology is set; an opinionated lens supplies its own (value × risk × dep-aware).
7. Wave drain
Keep building until the wave's build queue is drained or everything remaining is parked. Each build return is aggregated. This is the inner drain loop — when a wave's queue is exhausted, control passes to step 8 (wave re-entry).
8. Wave re-entry
After the wave drains, re-check the tracker for work newly unlocked by issues that just shipped. Faff's promotion rule is that only specced items live in Todo, but Todo can hold specced-and-blocked items too — so a chain unlock can land in either bucket. Wave re-entry scans both, consults faff next per newly-unblocked item (gateway → Next-step transition) to decide its step — prep routes through narrow prep, graft rejoins build-queue assembly, skip-ineligible/needs-human route out — rather than prose-deciding. Every newly-unblocked item that returns prep routes through narrow prep. Prep is the single mechanism that handles spec generation, in-place refresh, and the Backlog→Todo move; the orchestrator does no tracker state moves of its own.
-
File this wave's discovered scope (--converge only; skipped by default — as is the --converge branch of step 5; with the flag absent step 8 is exactly the default loop). Before the budget check, run the step-10 filing mechanism over this wave's discovered-scope.json files only, so concrete, contained, non-duplicate items become Backlog tickets in time for step 8.2's re-query. Track the count of new concrete items filed (filed_this_wave) for the step-5 non-convergence backstop. Eligibility is not bypassed — a filed ticket re-enters only if it passes step 8.3's automation-eligibility filter: under the opt-in default it lacks a human-set faff-automate (the filing path can't write it — FAFF-218), so it surfaces On-hold for crank-up (file-and-defer, reached mid-run); under opt-out it is built this run, draining the tributary in-run.
-
Budget check + Sentry consult. Run faff budget check and faff sentry check (see Budget flags → The check / The interrupt) — both fire at this and every between-units checkpoint. For budget: if breached ≠ [], act on outcome: stop/escalate → exit to reporting with Stop reason: budget-hit(<dims>) (or budget-escalated(<dims>)), escalate additionally emitting the structured needs-human signal; narrow → continue this wave with only the methodology's cheapest remaining subset, re-checking after each, falling through to stop when nothing fits. The wave re-entry step is the last point at which the budget gate fires for the run; on a stop/escalate exit the run ends cleanly with any unreached issues reported under ## Unreached (budget hit) in the summary. Under --converge a breach here short-circuits the loop before re-entry; scope not yet built falls back to the step-10 file-and-defer form. For Sentry: act on the intervention per The interrupt (mint-scoped — L4 acts, non-L4 logs + surfaces).
-
Re-query Backlog AND Todo issues per the shared ignore rule, excluding anything already touched by an earlier wave (shipped / PR-open / parked / errored — these stay in their bucket; once parked in this run, always parked in this run).
-
For every Backlog or Todo issue whose declared blockers are now all closed (shipped earlier in this run or already closed at run start) and which is automation-eligible (skip anything not automation-eligible — gateway → Automation eligibility), invoke narrow prep — the faff-prep skill via the Skill tool, autonomous on just that issue. (Prep itself also returns ineligible for a not-eligible issue, so this filter is the early-exit, not the guarantee.) Prep handles three cases through its existing autonomous returns (see step 3 of the full pipeline):
- Backlog, unspecced (was blocked from being specced): prep generates a fresh spec and, on high confidence, promotes to Todo (
promoted).
- Backlog, specced (was specced but never promoted, or was demoted): prep confirms the spec is still valid (or refreshes if stale — upstream work just shipped) and promotes (
refreshed or promoted).
- Todo, specced (was specced-and-blocked, now unblocked): prep confirms or refreshes the spec — staleness matters here since the upstream work just landed; the item stays in Todo (
refreshed).
In all three cases prep may instead return parked (low confidence) or errored — those items are logged and skipped for the rest of the run.
This is the mechanism that brings mid-run-unlocked chains into the build queue and keeps specs honest against upstream work that has just shipped. Do not re-run full tidy — tidy fires once at the top of the run.
-
Re-run step 4 (build queue assembly). The Todo set now includes any items prep just promoted. Re-assembly inherits step 4's at-the-gate discipline (gateway → Re-ground before gate): the labels + status feeding faff next / faff eligible and the claim-before-admit re-read are sourced live at this re-entry, never from the wave-1 read — so an issue a human cranked up between waves is correctly judged eligible on re-entry, not on its stale pre-crank labels.
-
Wave-boundary stop. How a wave's empty (or all-parked) build queue terminates the run depends on the run's level and whether --converge is set:
- Plain L3, no
--converge (the long-standing default, unchanged): queue empty → exit to reporting; else run steps 5–7 again (conflict analysis → build pass → wave drain) and return to step 8 — termination by queue emptiness, no iteration cap.
- A lights-out-minted (L4) run — with OR without
--converge: doneness terminates, never bare queue-emptiness. Consult faff run-done at run-end and let its verdict decide run-complete vs escalate vs continue. This is what makes run-done the L4 terminator: an L4 run that empties its queue while its budget escalated or its PRD is unsatisfied escalates to needs-human rather than silently reporting "complete". --converge is not required for this consult — it only adds the within-run convergence loop (step 8.0 folds this wave's discovered scope back in, and the non-convergence backstop below applies).
- Any run under
--converge (L3 opt-in or L4): same faff run-done consult, plus the discovered-scope fold at 8.0 and the non-convergence backstop.
The faff run-done consult (the terminating-condition predicate — composes the signals into run-complete | continue | escalate over a fixed safety floor + the methodology's run-termination-policy ladder; gateway → The methodology slot). Resolve the binary (gateway → Resolving the faff executable) and call faff run-done --json with RunSignals as flags — --queue-empty/--all-parked (8.4 queue), --ledger-clean (faff runcheck-clean), --budget '{breached,outcome}' (8.1's), --no-prd (unless a PRD is in scope), --non-convergence (only under --converge, per the backstop below), --policy '<ladder>' (the methodology's, else omit) — then branch on the JSON verdict: continue → re-enter (steps 5–7 → step 8), folding in chain-unlocks and (under --converge) the eligible discovered scope from 8.0; run-complete → both tributaries dry, exit with Stop reason: converged/both-dry; escalate → exit with the run-done reason (non-convergence / budget-escalated(<dims>) / product-incomplete) + the needs-human signal — reported, never a silent truncation. Non-convergence backstop (--converge only): keep a no_progress_waves ledger counter — reset to 0 when the wave made progress on either bottom-up tributary (filed_this_wave > 0 or it built ≥1 newly-admitted issue, e.g. a chain-unlock), increment only when both were dry; at K = convergence.max_waves (default 6) pass --non-convergence above. Resetting on either tributary is deliberate: a run still productively draining a deep chain (filing no new discovered items) is not stalled and must not false-escalate — the backstop catches only a loop re-entering with genuinely no progress on either axis. The cap never decides the normal exit (dryness + budget do), and firing it is always reported.
Log each wave's admissions, drain count, filed_this_wave, no_progress_waves, the run-done verdict (under --converge or at L4), the faff sentry check intervention (every run — advisory outside L4), and exit reason under .faff/runs/<run-id>/wave-N/. Exit reasons are drained (queue empty after build pass), all-parked (every remaining issue parked or errored), budget-hit (budget gate fired during or at the boundary of this wave), sentry-abort (an L4 sentry abort intervention fired), or — under --converge or at L4 — converged/both-dry and non-convergence (the run-done verdicts above).
Why this exists: chains unlocked mid-run (ISSUE-A ships → ISSUE-B's blocker clears) are picked up here. The chain's downstream items live in Backlog (per the promotion rule above), which is why prep is the entry point — the orchestrator does not move state directly. Chains visible at first assembly are still handled by step 5's conflict analysis as collision groups — wave re-entry catches the rest.
9. Wave-1 empty short-circuit
If wave 1's build queue is empty after assembly (step 4), skip steps 5–8 and proceed directly to reporting. No subsequent waves run — there's nothing for shipped work to chain on. Prep output still counts as a successful run.
10. File discovered-scope tickets
After the wave loop converges (and only when builds ran — the wave-1 short-circuit at step 9 skips this), collect the discovered scope /faff-graft recorded during the run and file the concrete items as Backlog tickets. This is bottom-up source (b) — execution-discovered work (gateway → Agent Lanes; design/planning-loop.md). It is the one step where beep-boop writes tickets directly rather than via tidy — legitimately, as the orchestrator lane (full tracker write). Same procedure, two timings: by default it runs once here at end-of-run (file-and-defer); under --converge the same sub-steps 1–7 are invoked per wave at step 8.0 so scope re-enters the same run, and this end-of-run pass files only the residual (final-wave scope, or scope a budget breach short-circuited). File-and-defer is the fallback whenever the mode is off or budget-capped — no discovered scope is ever dropped.
- Collect. Glob
.faff/runs/<run-id>/*/discovered-scope.json (each built issue's file; absent when it found nothing). Every built issue contributes regardless of its terminal outcome — a shipped, pr-open, or parked issue can all carry discovered scope.
- Containment check (FAFF-221 — runs before the appetite gate; this is chokepoint #1). This step is an autonomous-by-construction create path, so each
concrete item passes autonomous_file_check (see Containment at the filing chokepoint below) before the gate. The mandate is the <ISSUE> dir the item came from — the built issue beep-boop was dispatched from the human-admitted queue. Fetch the intended parent's ancestry fresh and call faff contain. outward (or an item graft already tagged containment: outward-new-root) → skip steps 3–4, jump to step 6 (surface-only). contained → fall through to the appetite gate. vague items skip the check (they are never filed regardless).
- Gate per item:
vague items → never filed. Aggregate for the run summary + the next /faff-wtf morning brief only.
concrete + contained items → appetite-gated (gateway → Appetite for destruction, Execution-discovered auto-create row): low surfaces only; medium files only with an opinionated methodology; high (default) files every concrete item; full always files. An outward-new-root item is never filed at any level including full (the hard floor) — it was already routed to step 6.
- Dedup before creating. Match each item's title/surface + relationship target against existing open tickets, including
faff-chain-gap-fill tickets tidy created this run — a build often "discovers" a downstream the spec already named and tidy already filled. Skip duplicates; count them.
- File each surviving item per the
faff-chain-gap-fill recipe (see /faff-tidy → Chain gaps — do not restate it): status Backlog, tag faff-chain-gap-fill via faff label add <issue> faff-chain-gap-fill and its descriptor's write (gateway → Control-label provisioning), the recorded relationship link to the originating issue, and a "discovered during build of SHF-XX" provenance line in the description. Stamp initiated: autonomous on the create — faff intake-record <new-issue> --via jot --initiated autonomous (FAFF-220; the marker is the autonomous-filing audit trail). In the default (file-and-defer) timing the next run's tidy + prep pass picks them up — depth grows one layer per run; under --converge (filed at step 8.0) the same run's wave re-entry picks up the eligible ones — depth grows many layers per run, bounded by faff run-done.
- Outward-new-root (surface-only, never filed). For each
outward item: keep/record its DiscoveredScopeEntry { containment: "outward-new-root" }, create nothing, surface it in the run summary's discovered-scope section so /faff-wtf §4 shows it, and post a comment on the mandate issue (the <ISSUE> it came from — an already-sanctioned surface, so the comment is in-lane, not scope expansion). Do not park the build. The human creating the root later via /faff-jot (→ initiated: interactive) is the approval.
- Record the filed count to the ledger's informational
discovered_scope_filed field (see Run ledger) and log per-item (id, source issue, relationship, containment verdict, gate decision) under .faff/runs/<run-id>/discovered-scope-filed.md.
Filed tickets are new work, not admitted issues — they sit outside runcheck's admitted − outcomes invariant and never affect run completeness. Once an eligible discovered ticket is admitted to a wave's build queue (under --converge — see Within-run convergence in Budget flags and the run-done stop at step 8.5), it is an admitted issue and gets a terminal outcome like any other, so runcheck's invariant covers it from that point.
Containment at the filing chokepoint (FAFF-221)
This is one of the two autonomous-by-construction tracker-create paths (the other is tidy chain-gap auto-fill); both call the same autonomous_file_check before any create, so an agent-discovered item can only become a ticket inside the subtree of the mandate it was discovered under. faff-graft only records discovered scope (gateway → Agent Lanes) — it is not a create path. The mode signal is structural, not a flag (gateway → scope-containment is by-construction, FAFF-217); interactive jot/plot create freely and stamp initiated: interactive.
PROCEDURE autonomous_file_check(mandate, candidate):
1. parent := candidate.intended_parent # sanctioned ancestor, or none → --root
2. ancestry := <fresh agent-side tracker read of parent's parentId chain, at filing time>
3. verdict := faff contain <mandate> (--parent <parent> | --root) --ancestry <json> # exit 0/3/2
4. contained (0): proceed to the existing appetite gate; on create stamp initiated: autonomous
(faff intake-record <new> --via jot --initiated autonomous)
5. outward (3): create NOTHING; record containment: "outward-new-root"; surface (run digest →
/faff-wtf) AND comment on the mandate issue; do NOT park
6. usage (2): malformed ancestry → log + surface, no create, no crash
# NEVER self-call `faff intake-record --via fast-track` to convert an outward verdict — fast-track is human-only.
- Ancestry is fetched fresh at filing time (never a cached chain from an earlier pass — a stale chain could make an outward parent read as contained).
--root, a cycle, or an unknown/absent parentId all return outward (fail-closed, delegated to faff contain); mandate == parent is the contained base case.
- Containment is a precondition, not a replacement for appetite — a
contained verdict still goes through the existing appetite gate, which decides whether to file at this level. outward-new-root is never filed at any appetite including full (the hard floor — gateway → Appetite for destruction).
10b. Per-run holdout phase (L4 lights-out signal only — the enforced holdout guardrail)
Under the L4 lights-out signal only (resolve fail-safe off; an ordinary L3 run runs no holdout phase), after step 10 and before step 11's runcheck, invoke the holdout step once per run over the integrated post-merge system. This is the orchestrator caller that makes the holdout guardrail enforced, not merely reachable — code-blindness is free here (the orchestrator lane never writes code, so the evaluator cannot see it). It composes the shipped CLI gates and re-implements none: each step's exit code is the gate. Heartbeat faff heartbeat "$run_dir" around the evaluate.
holdout_step(spec, prdr_id, key, run_dir) → meets-spec | gaps | fails | needs-human — call-site-agnostic, so the per-issue graft merge-gate reuses it unchanged with a per-issue association:
- Extract the architecture proposal from the spec — the fenced
faff-contract:architecture-proposal block prep landed there when the work warranted one. A malformed block is treated as absent and surfaced loudly — never repaired or invented, and never regenerated here (a proposal produced after the build is one the critic never critiqued). Invoke slots.env (faff config get slots.env) with that proposal + the infra profile → an env-handle; a spec with no proposal block engages the env occupant's absent-proposal posture (profile-derived services, explicit note, recommendation treated as build). No handle (recommendation ≠ build) → provision nothing, surface the proposal, return needs-human.
- Pipe the handle to
faff contract env-handle; non-zero (not ready / no endpoint / no health-checks / no teardown_ref) → needs-human, go to teardown (a non-ready env is never judged).
- Invoke
slots.evaluator (faff config get slots.evaluator) code-blind — hand it only {spec, env-handle}, never the diff / codebase / build-history / builder's tests (separate worktree/context; the lane invariant, not a preference).
- Pipe the verdict to
faff contract holdout-verdict; non-zero (not code-blind / prose-judged / evidence-missing / incoherent) → needs-human (never meets-spec), go to teardown.
- Confirm the evaluator persisted
.faff/holdout/<key>.json, then bridge: faff holdout verdicts --association {"<key>":"<prdr_id>"} --dir .faff/holdout → faff prdr coverage --dod-verdicts <map> → faff run-done --prd-coverage <coverage> (prd_satisfied === false ⇒ escalate / product-incomplete, the fixed floor no policy weakens).
- Teardown on every path (success, early return, exception):
faff env down --project <teardown_ref> — a teardown failure is logged loudly, never changes the verdict. Return the aggregate.
Wiring. Run-keyed: key = run id, prdr_id = the run's project PRDR, association {run-id: project-prdr-id} (matching the run-keyed verdicts→PRDR→coverage bridge). Detect-and-escalate, not prevent — it runs after each graft already self-merged, so a fails / gaps / needs-human aggregate makes the run escalate and refuse to claim PRD-done (the new product-incomplete run outcome, surfaced in reporting) and never blocks a merge; per-issue merge-gating is the sibling graft-gate call-site that reuses this step unchanged (/faff-graft Step 10 → Holdout gate, the fourth L4 merge-floor condition — FAFF-311). De-risk: faff is a Node CLI, not a service — if no in-repo runnable-system spec yields a provisionable env, narrow to the thinnest provisionable target and defer the real runnable-system proof to the greenfield cross-repo acceptance follow-up (a valid, honest outcome, not a gap to hide).
11. Run completeness check (mechanical)
Before writing the run summary, run the bundled runcheck script — the mechanical backstop for the "never silently defer the queue" guarantee. It reads the run ledger and fails if any admitted issue has no terminal outcome (resolve the faff executable per gateway → Resolver if it isn't on PATH):
faff runcheck # audits the latest .faff/runs/* ledger
- Exit 0 (clean) — every admitted issue reached a terminal outcome. Proceed to reporting.
- Exit 3 (undispatched) — one or more admitted issues never reached a terminal state. The run is not complete: return to step 8 and dispatch them, or genuinely park them under a valid category, then re-run runcheck. Do not write a "complete" summary while runcheck fails — that is the deferred-queue anti-pattern (gateway → Autonomous Mode Contract), now caught mechanically rather than left to prose compliance.
- Exit 2 (no/malformed ledger) — the ledger wasn't maintained; treat as a run-integrity error, reconstruct it from the per-issue dirs + verdict cache, then re-check.
Run ledger (mechanical completeness)
beep-boop maintains a machine-readable ledger at .faff/runs/<run-id>/run-ledger.json so the completeness guarantee is checkable by runcheck (step 11) and the Stop hook (below) rather than resting on prose alone:
Mint the run directory as run-YYYYMMDD-HHMMSS-beepboop-<mode> (UTC; beepboop one word; <mode> the run mode, e.g. full/list). This is the canonical run-id — the gateway .faff/ layout, wtf's enrichment, and the CLI's latestRunDir/STRAY_TRANSCRIPT all key off it. Legacy dirs coexist and are tolerated by mtime-ordered resolution; never rename them.
{ "run_id": "<run-id>", "admitted": ["SHF-1", "SHF-2"], "outcomes": { "SHF-1": "shipped" },
"discovered_scope_filed": 0,
"review_adversarial_skipped": [],
"budget": { "envelope": { "ceilings": { "max_attempts": 8 }, "at_ceiling": "stop", "price_per_mtok": 0 },
"tokens_at_start": 12000 },
"owner": { "status": "running", "pid": 12345, "session_id": "<run-id>",
"started_at": "2026-06-22T16:00:00Z", "last_heartbeat": "2026-06-22T16:07:00Z" } }
admitted — every issue the verdict gate admits to the build queue (fire-and-forget + likely-fire). Append at step 4 (build queue assembly) and at every wave re-entry re-assembly (step 8.4). Explicit-list mode appends each admitted issue the same way.
outcomes — written the moment an issue reaches a terminal bucket: shipped, pr-open, parked, errored, routed-out (routed out by the verdict gate at step 4), or unreached-budget (admitted but a budget flag fired before dispatch). These are exactly the run-summary buckets — the ledger is the structured twin of the summary.
The invariant runcheck enforces: admitted − outcomes.keys() == ∅. Any admitted issue with no recorded outcome is an undispatched queue, not a finished run.
discovered_scope_filed (informational) — the count of execution-discovered tickets filed at step 10 (default timing) and at step 8.0 (under --converge). These are new tickets, not admitted build-queue issues, so they are outside the invariant above and never affect runcheck — until one is admitted to a wave's build queue, at which point it appears in admitted and is covered by the invariant like any other issue.
review_adversarial_skipped (informational, additive — top-level [issue-id] array) — the ids of issues that shipped without adversarial review because the second-opinion chain was in a full-chain outage (every backend unreachable) at review time, in autonomous mode. The build agent's review slot returned signal: pass carrying adversarial_outcome: "chain-outage-skipped" (an infra outage produces no finding to gate on — a down provider is not a code defect); graft persists that on the per-issue review-verdict.json artifact, and the orchestrator reads it from there during its reconciliation (not from a changed token shape) and records the id here. These issues still land in outcomes as shipped (or whatever terminal bucket they reached) — this array is an orthogonal annotation, not a bucket, so it is outside the runcheck completeness invariant and needs no ledger migration. It drives the distinct run-summary subsection (## Shipped (adversarial review skipped — chain outage)) so a review-skipped merge is never presented as an undifferentiated auto-merge. Empty on a run with no outage.
budget (FAFF-36) — the resolved BudgetEnvelope plus the tokens_at_start baseline, written once at run start (alongside the owner stamp): { envelope: {ceilings, at_ceiling, price_per_mtok}, tokens_at_start }. faff budget check reads it each between-units checkpoint; tokens_at_start is the run-start transcript-sum so the token dimension counts this run's spend, not whole-session history. Absent ⇒ no budget set (every check returns breached: []). Informational to runcheck — outside the completeness invariant. (FAFF-408) When events are token-tagged, this block additionally carries tokens_at_start_by_class (the run-start four-class baseline that seeds the per-event checkpoint) and an advancing tokens_at_last_event (the last tagged event's cumulative); both are additive telemetry state the CLI maintains — budget check still gates on the scalar tokens_at_start total, unchanged.
owner (FAFF-205) — the liveness contract that lets the Stop hook tell an in-flight run apart from an abandoned one. Without it, a parallel session's hook can't distinguish "an orchestrator is actively draining this" from "this queue was deferred", and it false-blocks the unrelated session (see Stop hook below). Stamp it at run start, refresh last_heartbeat across the whole graft lifecycle, and close it at exit:
status — "running" while the orchestrator holds the run; set to "done" at orchestrator exit (clean drain, all-parked, or budget-hit — every exit path).
pid — the orchestrator's process id (same-host best-effort liveness corroborator; may be omitted).
session_id — the owning-session token (use the <run-id>); paired with the FAFF_RUN_DIR export below it lets a session recognise its own run.
started_at / last_heartbeat — ISO-8601. started_at is written once at run start; last_heartbeat is refreshed across the whole lifecycle (see Owner stamp & heartbeat).
Owner stamp & heartbeat (FAFF-205)
-
At run start (run-id minted, before step 4): write owner with status:"running", pid, session_id:<run-id>, started_at:now, last_heartbeat:now, and export the per-session pointer so this session's own Stop hook recognises its own run:
export FAFF_RUN_DIR="$PWD/.faff/runs/<run-id>"
export FAFF_SESSION_ID="<run-id>"
Resolve the budget envelope and baseline tokens here too (FAFF-36): resolve the BudgetEnvelope from the budget: config block + --until/--max flags, and capture the run-start transcript-sum as tokens_at_start, writing both into the ledger budget block before step 4. The cleanest way is one faff budget check call at run start (its spent.tokens against an empty baseline is the start-of-run sum); record envelope + tokens_at_start so every later check measures this run's delta. (FAFF-408) If you token-tag events, also seed budget.tokens_at_start_by_class = {input:0, output:0, cache_write:0, cache_read:0} here (the four-class baseline the first --tokens event's checkpoint advances from); a stale/absent value degrades safely (the CLI falls back to zeros).
-
Refresh last_heartbeat across the WHOLE graft lifecycle, not just at issue boundaries — via faff heartbeat, never a hand-rolled ledger edit (FAFF-234). Run faff heartbeat "$FAFF_RUN_DIR" (the single sanctioned write path: it field-merges owner.last_heartbeat = now atomically, re-reading the ledger first so it never clobbers a concurrent outcome write) at every wave/issue boundary. The slow phases now run inside the dispatched build subagent (FAFF-201), which ticks faff heartbeat from inside its own long sub-steps (faff-graft Step 7.5 / Step 9) — the orchestrator forwards run_dir in the BuildDispatch so the subagent resolves the owning ledger deterministically. A boundary-only refresh would let the heartbeat go stale mid-review and false-block again; the default staleness threshold is 900s (FAFF_RUN_HEARTBEAT_STALE_SECS) and a single review step can approach it. This is the load-bearing reason liveness is owner-emitted, never inferred from worktree mtimes (a quiet worktree mid-review is normal in-flight, not stalled).
-
At orchestrator exit (every path — clean drain, all-parked, budget-hit): set owner.status:"done". A run that exits with status:"done" but admitted-without-outcome work still trips runcheck (that's a real bug) — done only tells the hook "no live owner is holding this", it does not exempt the completeness invariant.
The hook reads this owner record but never writes it — only the run's own agents (the orchestrator and its dispatched build subagents) write it, and only via faff heartbeat (the owner.status:"done" close at exit is the orchestrator's one direct edit). Liveness is on-disk owner-emitted state, never a tracker read, network call, or foreign-pid probe (the pure-function CLI invariant).
Stop hook (harness-enforced backstop)
Step 11 is agent-run, so it shares the failure mode it guards against — a non-compliant run could skip it. The Stop hook closes that gap: it runs runcheck --hook outside the agent's control when the session ends and blocks Stop with a reason if the latest run left admitted issues undispatched. On first autonomous run, register faff's Stop-hook command set deterministically — run faff hooks-ensure (never hand-edit settings.json):
faff=$(command -v faff || echo "${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/skills/faff/bin/faff")
"$faff" hooks-ensure
hooks-ensure (FAFF-192) idempotently ensures both faff Stop-hook commands are in .claude/settings.json — runcheck --hook (this backstop) and prepcheck --hook (faff-prep's same-turn-attach guard, FAFF-178) — non-destructively (a byte-stable no-op when already present), and skips any command the resolved faff can't serve (a stale/copy install) rather than wiring a session-blocking hook. In --hook mode runcheck stays silent for any session without an open beep-boop ledger, so it never disrupts ordinary work — it only fires when a beep-boop run left admitted issues undispatched. Tell the user when the hook set is added and why.
Ownership + liveness gate (FAFF-205). The Stop hook is registered globally and fires on every session's turn-end, so a parallel beep-boop drain's legitimately in-flight ledger (admitted, no terminal outcome yet) used to false-block an unrelated interactive session. runcheck --hook now gates before auditing: it audits-and-may-block only when the resolved run is one this session owns (the FAFF_RUN_DIR / FAFF_SESSION_ID match against the owner stamp above — the backstop is preserved for the owning session) or is genuinely abandoned (owner absent, status≠"running", or last_heartbeat staler than FAFF_RUN_HEARTBEAT_STALE_SECS). A foreign run a live owner is still holding (running + fresh heartbeat) → the hook stays silent. This is why the owner stamp + lifecycle heartbeat above are load-bearing: they are the on-disk signal that lets the hook tell in-flight from abandoned. A legacy ledger with no owner is treated as unowned and audited exactly as before (zero regression).
Run-event log (observability substrate, FAFF-35)
Alongside the terminal run-ledger, the orchestrator appends an ordered timeline of meaningful pipeline transitions to .faff/runs/<run-id>/events.jsonl — the structured surface /faff-wtf, the morning report, and the L4 runner will consume (this slice only produces it; nothing human-facing is rendered). The ledger is the end state; the event log is how the run unfolded. Emit each via echo '<payload>' | faff events append --run <run-id> — the CLI owns the envelope (schema/run_id/seq/ts); you supply only {phase, type, issue?, data?}. seq (the line count) is the authoritative order — ts is annotation, never order by it. Append-only, single-writer (only the orchestrator emits; build subagents return their token and the orchestrator emits issue-outcome from it), a logging hard-floor (written even under logging: essential). Emit at these existing boundaries — meaningful transitions only, never per-file/test/CI-poll micro-actions (faff events validate line-checks a log; faff events read --run <run-id> [--type T] [--issue I] is the thin reader for consumers):
| Transition | phase / type | --tokens? |
|---|
| Run start (after run dir + ledger init) | run / run-start | no (delta ~0; seeds the baseline) |
| Tidy pass returns (step 1) | tidy / tidy-done | yes |
| Issue admitted to the build queue (step 4) | run / issue-admitted (issue) | no |
| Prep of an issue start/finish (step 3) | prep / prep-start · prep-done (issue) | done: yes; start: no |
| Graft subagent dispatched (step 6) | build / build-start (issue) | no |
| Graft subagent returns its token | build / issue-outcome (issue, data.outcome, + data.gate/data.rework_turns on a non-clean build) | yes |
| Discovered-scope ticket filed (step 10) | run / discovered-scope-filed | no |
| Budget checkpoint | run / budget-checkpoint (data = BudgetState) | yes |
| An issue is parked | prep|build / park (issue) | yes |
| Orchestrator exit (any path) | run / run-end | yes |
Token-tagging the phase-closers (FAFF-408). Pass --tokens on the phase-closing emissions above (the --tokens? column) — echo '<payload>' | faff events append --run <run-id> --tokens. The CLI then injects data.tokens (the four-class delta {input, output, cache_write, cache_read} consumed since the run's last checkpoint) and data.tokens_source (transcript when metered, estimate — with tokens: null — when no transcript is readable) into the event, and advances the ledger checkpoint. Counts-only (never prompt/response payload); schema stays 1 (additive under the free-form data). This is what lets a token-usage pivot attribute spend to a phase by event (prep spend = prep-done's delta; build+review = issue-outcome's; tidy = tidy-done's; residual orchestrator = budget-checkpoint/run-end) rather than guessing from a ts window. Do not tag the phase-opening events — their delta is ~0 and the transcript read is pure cost.
Quality-tagging the issue-outcome (FAFF-418). On the issue-outcome emission, also carry two optional data fields so faff quality can report park/rework/gate-catch: data.gate — the quality gate that caught a non-shipped build (structural | adversarial | holdout | ci, read from the build's return token / review-verdict.json / holdout verdict), omitted on a clean ship or a non-gate park; and data.rework_turns — the build's count of gate-driven fix-and-re-run loops (a clean first pass is 0). Both are single scalars (non-leak), additive under the free-form data; schema stays 1.
Explicit-list mode
/faff-beep-boop ISSUE-XX ISSUE-YY …
For each listed issue:
- Skip if cancelled or archived (log the skip with reason).
- Skip if the issue doesn't exist (log and continue).
- If spec missing → invoke the
faff-prep skill (autonomous). Apply return per the full mode prep queue logic.
- If spec present → re-scan the live comment thread before queueing (gateway → Automation-routing verdict (fixed) → Live-thread reconciliation; faff-prep → Scenario B Step 2a). A Resolution or Challenge posted after the spec supersedes its retained rating — route the issue through narrow prep (the
faff-prep skill, autonomous) to fold it in and re-rate first; otherwise queue for build. Source the issue's labels + status from this same at-queueing read (gateway → Re-ground before gate): the explicit-list entry is itself a fresh read, so eligibility/verdict here is computed on live labels — never on a snapshot the invoking human took before cranking the issue up.
After the list is processed:
- Compute the automation-routing verdict inline for every spec-gated issue (gateway → Automation-routing verdict (fixed)) — there is no tidy pass to read a cached verdict from, so the live-thread re-scan above is the only thing standing between a stale retained rating and the verdict; it is mandatory, not optional, in explicit-list mode. Admit only
fire-and-forget and likely-fire; route the other four verdicts out with a one-line reason in the run summary's "Routed out" section.
- Conflict analysis on the set that reached build-ready.
- Build pass per the shared flow.
- Report.
Conflict analysis
Before the build pass, partition the ready set into independents (safe to build in parallel) and collision groups (must be serialised within the group, though parallel with other groups).
Critical framing: conflict analysis is the mechanism that handles in-queue dependencies. Issue A depending on issue B, where B is in the same run's queue, is a collision group — not a park. "Serialise A behind B" is the answer. Parking A because "B isn't Done yet" when B is literally about to be built in this same run is the failure mode that breaks the pipeline: a chain of 5 issues all parking for "depends on earlier" means nothing ships. The chain is the whole point of overnight automation.
Heuristics — issues are considered likely to collide when any of these hold:
- Same file(s). Their specs name one or more of the same files — exact path overlap.
- Same directory at full-path granularity. Their specs touch the same directory taken as a full path from the repo root — e.g. both under
src/auth/. A shared top-level directory alone is not a collision: two issues both somewhere under src/ (one in src/auth/, one in src/billing/) are independent. Match on the deepest shared directory the specs actually name, not the first path segment — top-level matching spuriously serialises half the queue.
- Named shared module / util / symbol. One spec names a module, utility, component, service, or symbol (in a
**Chosen:** decision, the approach, or an **Assumes:**) that the other also edits or depends on — even when the file paths differ. A declared shared dependency collides: two issues that both modify src/lib/auth.ts's exported verifyToken, or both extend the same base component, will conflict regardless of which files name it. Use what the specs declare (no import-graph build is required); a shared named surface is the collision signal.
- Declared blocker. One issue declares another in-queue issue as a blocker — serialise the dependent behind the blocker. Both still build in this run.
- Shared scope tag / label that indicates a shared subsystem (per project conventions in
CLAUDE.md).
- Inferred producer→consumer dependency. One spec consumes a surface another spec produces, with no declared blocker and no file/symbol overlap already catching it — see Inferred build-order dependencies below. Serialise the pair into a collision group, producer-before-consumer. This is the asymmetric case rules 1–4 miss: B creates a module/endpoint/migration/symbol/config-key, A assumes it exists, and the specs share nothing the other rules can see, so both would otherwise read as independent and A could build before B.
When in doubt, serialise. Parallelism is a speedup, not a correctness requirement — a false-positive collision costs a little time; a false-negative costs merge conflicts and broken builds. The full-path rule (2) trims the false positives that collapse the queue to near-sequential; the named-shared-surface rule (3) catches the false negatives that bare path-matching misses. The parallel executor's rebase-before-merge step (see the concurrency slot) is the backstop for any collision that slips through both.
Output of conflict analysis:
{
"independents": ["ISSUE-A", "ISSUE-B", "ISSUE-C"],
"groups": [
["ISSUE-D", "ISSUE-E"],
["ISSUE-F", "ISSUE-G", "ISSUE-H"]
],
"inferred": [
{ "consumer": "ISSUE-E", "producer": "ISSUE-D", "match_kind": "paraphrase",
"confidence": "firm", "evidence": "E **Assumes:** a rate limiter exists; D produces module `rate_limiter`" }
]
}
Log the partition and the reasoning ("ISSUE-D and ISSUE-E both touch src/auth/"; inferred edges with their evidence) to .faff/runs/<run-id>/conflict-analysis.md.
Inferred build-order dependencies (heuristic 6)
A correctness input — serialise a real producer→consumer dependency — not an ordering opinion (value/risk/priority stays with the methodology slot; gateway → Ordering & judgement delegation). It is methodology-agnostic by construction: it runs here in conflict analysis, so it holds under the zero-config thematic default exactly as under an opinionated lens. It never writes a tracker blocker link and has no config knob — authoring a link between two existing human-owned tickets is human-only across the suite (jot/plot at creation; tidy surfaces, never auto-writes). Inference serialises the build for this run and surfaces each edge; that is its only effect.
Run this as the first pass within Conflict analysis (step 5), over the already-assembled build-ready set — after the verdict gate has admitted issues, before heuristics 1–5 apply to that same set. It only re-partitions an admitted set; it never changes which issues are admitted.
- Extract signals per issue.
- Produces-signal — the issue creates a nameable surface (module/file, exported symbol, endpoint, migration/table, config key, CLI command). Read from WHAT/HOW +
**Chosen:**.
- Consumes-signal — the issue depends on a surface it doesn't create:
**Assumes:** X exists, or "calls X", "builds on Y", "once Z exists".
- Match each consumes-signal C in issue A against the produces-signals of every other in-queue issue B (never A against itself), by ticket-ID reference or clear paraphrase. Then:
- Skip if A already declares B a blocker (heuristic 4 serialises it) or A and B already collide via file/dir/symbol (heuristics 1–3) — no duplicate edge.
- Classify the match:
firm (id_reference, or a paraphrase naming a concrete surface) vs ambiguous (vague, no nameable target, or multiple candidate producers).
- Fold into the partition.
firm → place the pair in a collision group, producer before consumer, merging transitively with existing groups. Adding a real producer→consumer edge can move an issue out of independents into a group — that is the correctness win, the whole point of the heuristic. What it must never do is reorder by value/risk/priority (the methodology's job): the order it imposes is the dependency direction alone.
ambiguous → do not serialise; record under "surfaced, not serialised" (mirrors chain-gap's ambiguity downgrade — a false-positive serialisation drives the queue sequential, so don't guess).
Edge cases:
- Cycle (A↔B consume each other, over the inferred edges): collapse into one serialised group, deterministic intra-group order (ticket-id), and flag the cycle in the run summary as a likely spec error. Cycle detection is over the firm inferred edges built in this pass only.
- Producer not in the build-ready set: not an in-queue inferred dep — an external open issue is
faff next --blocked's job (excluded); no ticket is chain-gap's (out of scope). Inference acts only when both endpoints are in-queue.
- Inferred dep contradicts a human "not blocked" signal (an explicit human non-dependency — a declared non-blocker relation, or an issue comment stating it is not blocked by the producer): the human wins — surface-only, note the conflict.
Record every firm and ambiguous inference (with quoted evidence) in .faff/runs/<run-id>/conflict-analysis.md, and add them to the conflict-analysis output's inferred list (firm and ambiguous), and surface them in the run summary's Inferred build-order deps section. The inferred list is audit-only: the concurrency slot reads {independents, groups} and ignores it.
Build-pass execution (the concurrency slot)
The build pass is executed by the configured concurrency slot, a mechanism slot that consumes the conflict-analysis partition and drives the faff-graft skill per issue. It defaults to faffter-noon-concurrency-sequential (one build at a time — no worktree contention, no merge races) and is overridable with faffter-dark-concurrency-parallel (runs independents concurrently, each in its own worktree, up to concurrency_max — default 4 — with rebase-before-merge so a moving main can't merge stale-green).
Every executor honours the same slot contract: build every issue in the partition, serialise within collision groups, record each terminal outcome to the run ledger (so runcheck can verify completeness), and never weaken the merge gate. A missing slot is never a park reason — it defaults to sequential. The contract is fixed in the gateway → Mechanism slot (concurrency) → The concurrency slot contract — see it for the full obligations.
Isolation floor (orchestrator invariant). Never run a build inline in the orchestrator; the concurrency slot dispatches each build as an isolated subagent that does its own worktree/build/review/merge and returns only a terminal token { issue, outcome, pr }, so the orchestrator holds only N tokens + the ledger, never N build contexts. This is the orchestrator's floor — it binds even a swapped-in third-party concurrency occupant, regardless of how that occupant implements it. The orchestrator reconciles each token against on-disk .faff/runs/<run-id>/ISSUE-XX/ artifacts + git ground truth (never the subagent transcript) before recording the ledger bucket. Turn-survival invariant. Every Agent-tool dispatch this orchestrator makes — a build, a prep producer, any producer, and whatever transport other prose here names — must hold a turn-safe posture: foreground (run_in_background: false) when its result is consumed inline, or covered by an executor's await-all gate; the orchestrator must never end a turn with a dispatched agent still in flight, because an idle unattended parent is reaped and takes the in-flight agent down with it (gateway → Producer dispatch for the background-by-default why). This binds even the prep-via-Agent-tool path below, without rewriting that transport prose. Prep-producer isolation is a deferred sibling (the inline prep producer is the same bloat class but its subagent dispatch was scoped out of FAFF-201); the floor covers builds only until that lands, at which point the prep producer is folded into the same wording.
Park protocol and tracker tagging
Beep-boop itself rarely parks — its sub-skills do. But when a sub-skill returns parked for an issue, beep-boop ensures:
- The tracker comment written by the sub-skill is present on the issue.
- The issue carries the
faff-parked tag (or tracker-equivalent label). If the sub-skill didn't apply it, beep-boop does — via faff label add <issue> faff-parked and its descriptor's write (gateway → Control-label provisioning).
- The per-issue log directory (
.faff/runs/<run-id>/ISSUE-XX/) has the parked reason written to a top-level park.md.
This is what /faff-wtf looks for to surface parked issues in the morning.
Reporting
All human-facing output beep-boop emits — its run summary, the run-summary tracker status post, park comments, and discovered-scope tickets it files — passes through the configured rendering_adaptor normalise pass before it is printed or written (gateway → Rendering, Universal-routing rule), so enumerable sets render as lists, never ·/comma run-on paragraphs (the prose-skimmability rule). The table-vs-definition-list rule is part of that pass.
On run completion, produce:
1. .faff/runs/<run-id>/summary.md
When a methodology skill is configured, the first line of the summary file is the literal string:
Methodology: [skill-name]
(followed by the existing summary layout below). When no methodology is configured, this line is omitted and the file starts with the # Beep-Boop Run … heading as normal.
# Beep-Boop Run — YYYY-MM-DD HH:MM:SS
Mode: [full | explicit-list]
Duration: Xh Ym
Waves: N
Stop reason: queue-drained | all-remaining-parked | budget-hit(<dims>) | budget-escalated(<dims>)
## Unit economics — call `faff economics --run-dir <run-dir> --json` (pure) after step-11 runcheck; the CLI computes, beep-boop only renders (FAFF-357)
Run spend <tokens_total> (<tokens_source>)[ · $<cost_total>] · per shipped <tokens_each>[· $x] (`— (0 shipped) ⚠ ZERO-SHIP` when zero_ship) · per attempt <tokens_each> (<attempt_count>) · per bucket shipped N·parked N (`no build outcomes this run` if empty) · per issue FAFF-49 6.1M (omit if per_issue empty) · one `⚠ <warning>` per warnings[]
## Methodology findings (rendered only when a methodology skill is configured)
(One-line summary of how the lens shaped this run — e.g. "Re-ordered 2 collision groups for value-aware sequencing; no methodology violations surfaced." Or list the diagnoses the methodology surfaced during the run, one per line, as it returned them.)
## Build queue verdicts at admission
- fire-and-forget: N
- likely-fire: N
(admitted: N total)
## Routed out (not built — needs human action)
needs-decision-first (N)
- ISSUE-AA [synthesis gloss] — Punt in spec: [decision asked]
- ISSUE-QQ [synthesis gloss] — confidence: medium (spec thin on [area]); attached for review, not built
gap-blocked (N)
- ISSUE-BB [synthesis gloss] — spec assumes [named gap]
circular-blocked (N)
- ISSUE-CC [synthesis gloss] — in cycle [CC → DD → EE → CC]
repeat-parked ⚠ (N)
- ISSUE-DD [synthesis gloss] — parked N runs same root cause: [class]
## Resolve-attempts (appetite high/full)
- Attempted: N
- Succeeded (proceeded with audit trail): N
- Failed (parked): N
### Proceeded on medium confidence: N
> These shipped autonomously on an inferred answer. The audit-trail comment is on each PR — review before merge if you disagree; faff re-parks on a dissenting comment. Full record in `.faff/calibration/appetite-decisions/`.
- ISSUE-PP [synthesis gloss] — inferred [answer] from [evidence]; PR #nnn (audit comment posted)
## Shipped (auto-merged): N
- ISSUE-XX: title (PR #nnn)
## Shipped (adversarial review skipped — chain outage): N
- ISSUE-XX: title (PR #nnn) — every adversarial backend was unreachable; merged on Phase-1 + AC + CI, WITHOUT a second-opinion pass (ledger: review_adversarial_skipped)
## PR open for human review: N
- ISSUE-YY: title (PR #nnn) — reason: CI failing on e2e; AC3 requires visual review
## Parked: N
- ISSUE-ZZ: title — reason: low-confidence fresh-spec (log: ISSUE-ZZ/prep.md)
## Errored: N
- ISSUE-WW: title — MCP timeout during build
## Unreached (budget hit): N
- ISSUE-VV: title — admitted to build queue, not dispatched (--until 06:00 fired before launch)
## Product-incomplete (holdout escalation): rendered only when the L4 holdout phase ran
- The per-run holdout verdict rolled up `fails`/`gaps`/`needs-human` → run escalated, PRD-done not claimed: [aggregate] (holdout: `.faff/holdout/<run-id>.json`)
## Inferred build-order deps: N (run-local — no tracker write)
- ISSUE-E ← ISSUE-D (consumer ← producer; firm, paraphrase): serialised D-before-E — E **Assumes:** a rate limiter; D produces module `rate_limiter`
- Surfaced, not serialised (ambiguous): N
- ISSUE-F: "integrate with the new infra somehow" — no nameable producer; left independent
- Cycle flagged (likely spec error): ISSUE-G ↔ ISSUE-H — collapsed to one serialised group, ticket-id order
(Appears only when heuristic 6 inferred ≥1 edge. Audit detail in `.faff/runs/<run-id>/conflict-analysis.md`.)
## Discovered scope (execution-reported): N filed
- ISSUE-XX → filed SHF-NN (downstream): "title" — discovered during build, Backlog/`faff-chain-gap-fill` (next run preps it)
- Deduped (already tracked): N
- Surfaced only (vague, or low appetite): N
- ISSUE-YY: "logging inconsistent across handlers" — vague, not filed
## Human follow-ups: N
- ISSUE-XX: delete local branch `feat/issue-xx` (cleanup skipped — shell was inside worktree)
- ISSUE-YY: remove worktree `~/.faff/worktrees/faff/issue-yy` (cleanup skipped — permission denied)
- ISSUE-ZZ: bump tracker status to Done (MCP returned 5xx during post-merge update)
## Prep queue summary (full mode only)
- Refreshed: N
- Promoted: N
- Promoted (needs review, medium confidence): N
- Parked: N
- Errored: N
## Tidy findings (full mode only) — see logs/YYYY-MM-DD/HHMMSS-tidy.md