CLI reference
This page is a command reference for operators and integrators. New adopters should start with Your first runs. Return to the task guide that linked here once you have the command details you need.
The faff plugin includes a dependency-free Node CLI. Skills and hooks locate
and invoke it automatically, so normal plugin use needs no separate CLI setup.
It owns the repeatable checks and transitions that should not be reinterpreted
by a model.
Most subcommands accept --selftest for their in-memory checks and --json for
structured output. Pure commands make no tracker or network calls; the caller
maps live state into their flags.
Config & install health
| Subcommand | What it does |
|---|---|
config <path|get|spec-docs-path|prd-docs-path|prdr-docs-path|adr-docs-path|spike-docs-path|dump|resolved> … | Resolve / read the two-file merged config — the only sanctioned config-read path. Resolution is .faffrc.local.yaml (a gitignored, machine-local overlay) deep-merged over .faffrc.yaml (the committable base) over the DEFAULTS registry: maps deep-merge per leaf, sequences are replaced wholesale by the overlay, scalars take the overlay value. An overlay parse failure is loud (exit 2, never a silent partial-apply). With no overlay present, behaviour is byte-for-byte the single-file behaviour. path prints each resolved file on its own line (base first; exit 3 only when neither exists); get <dotted.key> reads a scalar; get --json <dotted.key> returns structured values (objects, and native arrays from YAML block sequences); resolved echoes both file paths (config: + config local:), non-default slots, per-lane models (models.<lane>), and per-lane efforts (effort.build|methodology|intake) for a run banner. A per-lane models.<lane> / effort.<lane> value outside its closed Agent-token / effort vocabulary fails loud at get (exit 2, names value + legal set) — never a silent inherit at the dispatch site. |
config init --set k=v [--set …] [--force] [--dry-run] | Write/merge a tracking: block into .faffrc.yaml — the only sanctioned config writer (no skill or agent hand-writes an rc file). Writes the base file only. |
config check [--json] [--root DIR] | Deterministic, read-only posture + integrity checker for the two-file model (no tracker access, no network, no writes). Checks: base/overlay parse; posture (is the base git-tracked and not ignored? — an unmigrated/uncommitted base is unrecoverable if corrupted, so the finding carries the 3 migration steps); overlay hygiene (the machine-local overlay must NOT be tracked); a secret scan over every scalar value (known credential prefixes + a key-name-gated high-entropy shape; *_env keys exempt) whose finding is redacted to key-path + value length + first-4-chars only — never the value; and legacy filename presence. Exit 0 clean (incl. no config, and not-a-git-repo which skips the posture checks and says so — parse + secret still run) / 1 ≥1 finding / 2 unreadable or a parse failure. --selftest runs the secret-pattern + merge + posture tables. |
adversarial-backends [--json] [--root DIR] | The mechanical assembler for the adversarial-review fallback chain — replaces agent hand-JSON.parse + merge + temp-file with one deterministic emitter, so a config value (e.g. a nvidia/-prefixed model id) is never retyped by the model. Reads faffter_dark.adversarial via the existing loadConfig/dig path and prints a primary-first JSON array of {provider, model, host, api_key_env?, reasoning_off?, timeout?} (snake_case — the exact shape review-call.mjs's --backends-json mapper already consumes; review-call.mjs itself is unchanged). A named refs: block sequence resolves against the shared backends: namespace first; else a native backends: array is emitted as-is, each element standalone; else the legacy primary + fallbacks-string form is synthesized to [primary, …fallbacks], with primary-key inheritance for any omitted optional key on a fallback (an explicit fallback value is never overwritten). Exit 0 on a ≥1-backend chain; exit 3 when the adversarial block is absent or its host is unset (never a localhost-defaulted chain, so the calling skill's needs-human path is preserved); exit 2 on malformed fallbacks JSON or an unresolvable refs: name (fail-loud, never a silent [primary]-only chain). --json is accepted-and-ignored — the default output is already the JSON array --backends-json wants. |
backends <resolve|realizable> --refs a,b,c [--requires local] [--harness NAME] [--json] [--root DIR] | The shared named model/provider/auth backend config namespace — a top-level backends: map (name -> {provider, model, host, auth, api_key_env?, egress, reasoning_off?, timeout?}) generalizing the top-level engines: map (folds in at load; a name declared in both is a hard error, never last-wins), referenced by an ordered refs: list (index 0 first-served, no "primary"). auth (subscription-seat|api-key|none) and egress (local|external) are explicit-preferred, else derived: api_key_env present → api-key; keyless anthropic → subscription-seat (binds to the ambient interactive session — no seat_ref handle field); loopback/RFC1918/tailscale (*.ts.net) host → local egress, else external. resolve prints the resolved chain as JSON (fail-loud exit 2 on an unknown name or a namespace merge error). realizable runs the run-start fail-closed check: any requires: local ref whose chain resolves an egress: external backend refuses with a residency-violation naming it (checked before host/matrix realizability, per-entry, absolute); a whole chain with zero (harness, provider, auth)-admitted and host-set refs refuses chain-unrealizable — a served fallback admits, not any single backend being down. v1 pins harness to the single value the CLI passes today (claude-code — the ambient session subscription-seat binds to); the harness-varying half of the matrix is future work once a real per-run harness axis exists. engine:<name> lanes (faff engine call, config get models.<lane>) resolve against this same merged namespace — provider: anthropic is accepted here but still refused at that consumer boundary. Exit 0 realizable / 1 refused (realizable); 0 ok / 2 usage-or-resolve-fault (resolve). --selftest runs the derive/merge/resolve/realizable/config-check-guard table. |
hooks-ensure [--dry-run] | Idempotently register faff's Stop-hook set (runcheck + prepcheck + sentrycheck --hook) and the PreToolUse fences (merge-fence --hook + background-fence --hook) in .claude/settings.json. |
merge-fence --hook [--selftest] | The outermost mechanical wall against a raw gh pr merge Bash call (registered by hooks-ensure as a PreToolUse hook). --hook reads a PreToolUse JSON event on stdin; a match on the pure matcher (a gh token — path/env-prefix tolerant — followed by pr then merge) denies with a single stderr line naming faff merge-gate as the sanctioned remedy and exits 2; a non-Bash tool, an absent/non-string command, or malformed/empty stdin all exit 0 silently, never blocking. faff merge-gate's own merge runs as a spawnSync child process rather than a Bash tool call, so it is invisible to the fence by construction. |
background-fence --hook [--selftest] | The mechanical wall against a build subagent self-backgrounding its own gate/test command (registered by hooks-ensure as a PreToolUse hook, probe-gated on the PreToolUse event exposing run_in_background — confirmed present). --hook reads a PreToolUse JSON event on stdin; a match on the pure matcher (tool_name==="Bash" and run_in_background===true (strict) and the command matches a narrow gate-family regex — node --test / faff gates run / npm test|run test* / npx vitest|jest|mocha|ava|tap / make test / pytest / cargo test / go test) denies with a single stderr line naming the foreground remedy and exits 2; a non-Bash tool, a non-gate command, an absent/false/non-boolean run_in_background, or malformed/empty stdin all exit 0 silently, never blocking. A regex token-sequence matcher, not a shell parser — a command that breaks a required token via quoting/splicing/substitution is a documented, honestly-pinned LIMITATION, same stance as merge-fence. |
gitignore-ensure [--json] | Idempotently, non-destructively add faff's local artifacts (.faffrc forms + .faff/) to .gitignore. |
doctor [--target DIR] [--root DIR] | Install health — by default scans the installer's global target directories, de-duplicated by resolved path: the install.skill_targets config key when set to a usable list, else the hardcoded pair ~/.claude/skills and ~/.agents/skills — flagging each faff skill as a live symlink, a stale copy-install, or missing from one scanned directory but present in another (a half-install — one harness can see it, another can't). --target DIR pins the scan to exactly one directory and does not read the config key. Plus the merge-fence PreToolUse registration under <root>/.claude/settings.json (missing → non-zero exit naming faff hooks-ensure). |
sync [--dry-run] | Repair a stale copy-install — re-link the faff skills + the CLI via scripts/link-skills.sh --global --replace (inherits install.skill_targets, config-driven, for free). |
install.skill_targets is a hand-authored .faffrc.yaml block sequence — the same shape as worktree_root, but a list rather than a scalar:
install:
skill_targets:
- ~/.claude/skills
- ~/.agents/skills
- ~/.config/some-other-harness/skills # any harness's auto-load directory
Each entry is absolute or ~/~/…-relative (a bare unquoted ~ parses as YAML null — quote it: "~"); a non-absolute, non-~ entry is skipped with a stderr notice. Unset, or every entry unusable, falls back to the hardcoded pair above — in both link-skills.sh --global and faff doctor's default scan set — byte-for-byte identical to before this key existed. Governs --global installs and doctor's default scan set only: never repo-local (non---global) mode, --target, or $CLAUDE_PLUGIN_ROOT.
| validate-adapters [--skills-dir DIR] | Lint the shipped slot-skills for conformance drift (CI / pre-commit). |
| validate-adapters --configured [--root DIR] | Pre-flight your configured (swapped-in) slot occupants before an unattended run. |
Eligibility & routing (pure — no tracker/network)
| Subcommand | What it does |
|---|---|
eligible --label L [--label L …] [--default opt-in|opt-out] | Is a ticket automation-eligible? (true/false; hold > automate > default). |
claim-verdict --claimed-at <ISO8601> --now <ISO8601> --ttl-hours N [--selftest] | Is a faff claim (an issue at In Progress) live or stale? Pure age + TTL → verdict — reads no tracker and no system clock; the agent supplies --claimed-at from the connector's latest transition into the claim state (Linear: latest In Progress stateHistory.startedAt), resolves --ttl-hours from faff config get claim_ttl_hours, and separately checks the faff-claimed provenance label. stale iff strictly older than the TTL (at/under the TTL, and any non-positive age from clock skew, stays live — the fail-safe direction). Emits {verdict, age_secs, ttl_secs}; --selftest drives the boundary table (just-under / at / just-over TTL) with no tracker/system-clock dependency. Exit 0 / 2 usage or invalid input. |
pr-body sanitize|check --target <TARGET-ID> [--selftest] (body on stdin) | PR-body citation hygiene — prevents a tracker's GitHub integration from auto-transitioning a sibling issue merely cited (not targeted) in a PR body. sanitize rewrites the complete body so only <TARGET-ID> keeps a Linear-recognisable ASCII identifier, as the sole trailing Closes <TARGET-ID> line: every other recognised [A-Z][A-Z0-9]{1,15}-[1-9][0-9]* token — target mentions elsewhere included — renders with U+2011 NON-BREAKING HYPHEN outside an http(s):// URL span, or %2D inside one (so a cited link destination still resolves). Idempotent — re-sanitizing an already-sanitized body is a byte-for-byte no-op. check re-verifies that same invariant with no transformation, exiting 0 when valid or 1 on a violation (diagnostic names only the broken rule + offending identifier/count, never body content). Pure: stdin/stdout only, no repository/tracker/network/environment access. Exit 0 sanitize / 0 check-valid / 1 check-violation / 2 usage or malformed --target. |
admissible --spec <path|-> [--lights-out] [--json] | Lights-out quality-IN gate — pure structural check over a spec's machine-verifiable DoD (## Scenarios + ### N. DONE). Gating R1 (≥1 born-verifiable scenario) / R2a (non-empty DONE) / R2b (no banned-vague DONE item); advisory R3 (runnable-check command → warnings, never gates) / advisory R4 (a SCENARIOS/DONE heading-level mismatch → warnings, never gates — the structural smell behind the section over-capture trap). The ## Scenarios section is bounded by name at the DONE heading (any level), the trailing confidence: line, or a faff-contract: fence — the same boundary dod classify uses, so the two parsers cannot disagree on a document's section extent. No LLM, never re-invokes the producer. Fail-safe: ambiguous/unparseable/absent DoD → inadmissible; --lights-out absent → admissible no-op (L1–L3 unchanged). Emits AdmissibilityVerdict {admissible, reasons, checks, warnings}; exit 0 admissible / 1 inadmissible / 2 usage. |
dod classify --spec <path|-> [--json] | Classify a spec's DoD criteria — the ## Scenarios blocks/assertions + the ### N. DONE checklist — by born-verifiability class (scenario | assertion | prose), reusing the same classifyCriterion rule admissible applies (no forked rule). Each criterion also carries a holdout boolean (marker-carried; only source:"scenarios" can be true — a holdout:-prefixed DONE item stays literal, holdout:false, with a stderr advisory naming the DONE-never-withheld rule), and the output adds a top-level holdout_counts: {holdout, visible} (additive only — counts is unchanged and still sums to criteria.length). The evaluator slot consumes this to decide which criteria it may machine-judge (scenario/assertion) versus force to needs-human (prose — the machine never judges a non-born-verifiable criterion), evaluating the full set (holdout + visible) regardless. Pure (file/stdin in, JSON out, no tracker/network/LLM); emits {criteria: [{text, class, source, holdout}], counts, holdout_counts}; exit 0 on a successful parse (an empty set is valid data) / 2 usage or unreadable. |
dod split --spec <path|-> --view builder|full | Print the requested visibility view of a spec's ## Scenarios section. --view full is the identity (byte-for-byte). --view builder removes each holdout-marked scenario unit (a ```holdout / ~~~holdout fence, or a holdout:-prefixed bullet — recognised only inside ## Scenarios, the same section extent dod classify shares) and inserts one blockquote note under the heading (> N holdout scenario(s) withheld from this view…) only when N ≥ 1; a marker-free spec's builder view is a guaranteed byte-identical no-op. Coherence invariant: dod classify on the builder-view output equals the full classification minus the holdout:true criteria. Over-withholding (the builder view retains zero born-verifiable source:"scenarios" criteria while the full spec had ≥ 1) is an advisory on stderr, never a gate. Pure beyond the file/stdin read (no tracker/network/LLM); exit 0 on success / 2 on a missing/unknown --view or an unreadable spec. |
holdout verdict --issue <id> [--dir .faff/holdout] [--require-spawner-attested] [--json] | The per-issue merge-floor gate lookup for faff-graft Step 10's fourth (L4) merge-floor condition — persist-once/consume-twice. Reads the single per-issue verdict .faff/holdout/<id>.json, re-validates it through the same computeHoldoutVerdict gate (via holdoutGateResult — never a forked rule) the run-bridge (holdout verdicts) uses, and reduces it to one pass/block decision keyed to the issue being built — before the PRDR fold. Fail-closed: a missing / unreadable / malformed / non-code_blind / incoherent verdict, or any aggregate ≠ meets-spec (fails/gaps/needs-human), is block (exit 1) — never a silent pass; a conformant code_blind: true meets-spec is pass (exit 0); a missing --issue is exit 2. --require-spawner-attested arms the spawner-attestation ratchet (the caller sets it iff the run's lane-boundary intent promised the evaluator cage): a code_blind that was self-attested by the judged party — not stamped by the evaluate-call.mjs spawner — then blocks. Emits {issue, gate, reason, aggregate?, code_blind?, violations?} on stdout (a block is also summarised to stderr). Pure beyond the single-file read (no tracker/network/LLM). The same file still feeds faff holdout verdicts --association unchanged — one artifact, two consumers. |
holdout verdicts --association <json|@file|-> [--dir .faff/holdout] [--require-spawner-attested] [--json] | The pure, trust-gated bridge from the evaluator slot's persisted holdout verdicts (.faff/holdout/<issue|run>.json) to the already-shipped faff prdr coverage --dod-verdicts flag — the missing pipe that lets prd-satisfied read the evaluator's judgements instead of conservatively defaulting every DoD to unverified. Reads each verdict in --dir, re-validates it through the same computeHoldoutVerdict gate (never a forked rule — --require-spawner-attested additionally arms the spawner-attestation ratchet, so a self-attested code_blind is treated as untrusted), and yields the literal "met" only for a block that is conformant and code_blind: true and aggregate: meets-spec; every other trusted aggregate (gaps/fails/needs-human) passes through as its own ≠met string, and an untrusted (contract-rejected) file is omitted entirely. Keys the map by the orchestrator-supplied issue→PRDR --association (the evaluator stays PRDR-blind); a key with no association entry is skipped (a PRDR is never guessed). Conservative duplicate-PRDR fold: a PRDR is met only when every holdout file mapping to it is met — a single failing re-run is never masked. Emits {verdicts: {<prdr>: <value>}, skipped: [{key, reason}]} as JSON on stdout (the skipped audit trail is also summarised to stderr). Pure (filesystem read of --dir only — no tracker/network/LLM, parity with dod classify / prdr coverage); does not modify prdr coverage / computePrdCoverageVerdict. An absent --dir → empty map, exit 0 (the valid "nothing trusted yet" answer coverage already handles); a missing/non-object --association, malformed --association JSON, or an unreadable --dir → exit 2; a successful run (including an empty map) → exit 0 — there is no exit 1 (report-only). |
spec-review-lenses --tags T,T,… --level L1|L2|L3|L4 --appetite low|medium|high|full [--selftest] | Cost-gate that runs in front of the spec_review producer — maps a spec's classified change-surface tags + the runtime level + appetite to a LensSelection {lenses, mode, rationale}. Pure: the heuristic signal→tag classification is the prose layer's job (faff-prep, reusing the already-shipped-scan surface extraction); this only maps a classified surface to a selection. v1 is additive-only / safe-direction: only the architectural lens (or methodology where no tag adds it) is ever dropped, and only on a confidently-classified surface; infosec + QA are sticky (always fire at L1–L3); an unclassified or unrecognised tag fires all four (fail-safe); L4 is pinned to the full adversarial set and never narrowed by appetite; low/medium appetite widens to all four. No new lens/severity/verdict — the spec-review-verdict contract is unchanged. Exit 0 (pure). |
next --status S --spec none|low|medium|high […] | Legal next step for an issue (JSON {next, reason}). |
project-next --current planned|started|completed|cancelled [--kind project|issue] --total N --active N --done N [--has-dod] [--dod-met] [--json] | Container (project / parent-issue) state-coherence + DoD-gated-Done transition predicate — emits a ContainerTransition {kind, current, desired, action, reason} from a child-issue rollup. The /faff-tidy orchestrator-lane sweep maps live child statuses to {total, active, done} and applies any advance via the tracker. Forward-only + monotonic — advance only when rank(desired) > rank(current); never moves a container backward. v1.1: project → started (first child starts) · parent-issue → started (any child in progress) · project → completed (all children done and no DoD) · project → completed (all children done and has DoD and --dod-met — the release gate is satisfied; unmet/unverified holds the project open with reason "release gate not passed — held open (DoD authoritative)"; the gate only ever tightens Done, never loosens it — gate-met with open children never completes a container). --dod-met without --has-dod is a malformed rollup (exit 2). Parent-issue → Done out of scope. Exit 0 (pure) / 2 malformed rollup. |
state <issue> [--json] [--root DIR] | Local read-model: resolve an issue's spec/parked/branch/worktree/ledger state as JSON; no MCP. |
tracker probe [--json] [--root DIR] | Classify the tracker-availability pin: reads .faffrc tracking.tracker and prints pinned (a connector is asserted to exist — a skill must NOT downgrade to git-only on tool-invisibility, it discovers or fails loud) or unpinned (no assertion — attempt discovery before concluding absence). Pure (no MCP/network): it reports only the deterministic pin half; live reachability is the model's call after a harness discovery attempt (a CLI cannot probe MCP). --json → {pin, resolution}. Exit 0 on a successful classification / 2 on an unknown or missing verb, malformed config, or usage error. |
queue-state new-key | Print one freshly-minted stable item-key ("gitkey", gk-<YYYYMMDD>-<6×base36>) to stdout. Minted once, persisted, never recomputed from mutable content — rewording a roadmap line or renaming a spec never changes it. Pure (no FS/tracker/network); the shared mint authority /faff-plot and /faff-jot shell out to when stamping a git-only work item at creation time. |
queue-state derive [--run-dir DIR] [--root DIR] [--json] | The git-only queue_empty/all_parked differ — the automatic source faff run-done's --queue-empty/--all-parked flags need when there is no tracker queue to read. Collects the item-key union (every <!-- gitkey:K --> marker under <root>/.faff/intake/*.md, plus every filename stem under <root>/.faff/specs/*.md, de-duped by exact string), reads outcomes{} from the resolved run-ledger (--run-dir → $FAFF_RUN_DIR → latest under .faff/runs), and classifies each key terminal/pending against the active governance profile's terminal_states (activeProfile(), $FAFF_GOVERNANCE_PROFILE-overridable). Emits {queue_empty, all_parked, items_total, items_terminal, items_pending, reason} — fail-safe toward not-empty at every turn: a zero-key set → not empty, reason:"no-item-keys"; any pending key → not empty, reason:"work-remaining"; every key terminal and all parked → not empty, all_parked:true, reason:"all-parked"; every key terminal with ≥1 non-parked → empty, reason:"drained". Pure: no tracker/network/writes. A missing ledger is a valid non-drained state (exit 0, queue_empty:false) — a run that hasn't recorded outcomes yet simply isn't drained; a present-but-unparseable ledger is loud (exit 2), never silently coerced to a verdict; an unknown subverb/flag is exit 2. --selftest runs the classifier + mint-format/uniqueness + missing-vs-malformed-ledger + reworded-line/renamed-spec round-trip table. |
findings-reconcile [--stdin] | The resolved-elsewhere correlation — the deterministic half of tidy's finding-ticket diagnostic. Reads { finding_tickets, fix_corpus } JSON on stdin (the agent supplies fetched data; the CLI never touches the tracker) and emits { candidates }: open finding-tickets whose fix appears to have merged under a different ticket number. Two deterministic signals only — anchor co-reference (a shared findings-log path, e.g. .faff/logs/…-findings.md, tightened on the F<n> finding-id when both sides carry one; the path is an opaque identity key, never read as a file) and tracker/citation relation (a fix record's cited_ticket_ids naming the finding). Symptom similarity is not computed here — that recall layer is tidy's LLM judgement, weak-only. Self-reference guard: a fix whose source_ticket/ref is the finding is excluded (its own normal build path, not "resolved elsewhere"). strength: strong ⇔ ≥1 deterministic evidence item with merged: true; an unmerged citation surfaces but stays weak; a terminal-status finding (Done/Cancelled/Duplicate, name-based) never surfaces; a finding with no supplied anchor field has one derived from symptom_text by the same grammar (anchor: null opts out). Pure + surface-only — it never dispositions; the human closes a confirmed candidate per the settled disposition convention (Done with a comment trail naming the resolver, never Duplicate/cancelled-category). Exit 0 report-only (the verdict is the payload) / 2 on malformed input — or on an interactive TTY with nothing piped (refused with usage, never a silent hang) — loud, never a silent empty result. --selftest runs the anchor-grammar + correlation + self-reference-guard + terminal-filter table. |
contain <mandate> (--parent ID | --root) --ancestry JSON [--record RUN-ID] [--phase run|tidy|prep|build] | Subtree-of-mandate containment — is parent ∈ subtree(mandate)? Fail-closed. Trust boundary: --ancestry is agent-sourced, so the walk binds structure, not truthfulness. --record RUN-ID appends a containment-check event binding this verdict to the exact ancestry walked, so faff audit can recompute-and-compare post-hoc — a detective control, never preventive; a missing/non-dir run dir under --record exits 2 (usage-class, no verdict printed, nothing appended) rather than degrading to an unrecorded verdict. --phase (default run) tags the event's phase and requires --record. Without --record, behaviour is byte-identical to before. |
self-intake <mandate> --target JSON [--record RUN-ID] [--phase run|tidy|prep|build|plot] | The mechanical same-repo/team gate on the self-hosting outward → outward-self-intake reclassification — the filing chokepoint honours the reclassification only when this primitive confirms the discovered item's home is the mandate's own tracking team/repo. The self side is never caller-suppliable (no --self flag — rejected by name, exit 2): the CLI re-derives it from the merged committed config via its own loader, reading exactly containment.self_hosting_intake (the lane dial), tracking.team_key, and tracking.repo (empty/absent → null). The target side ({team, repo}) is caller-supplied on the same trust boundary as contain's ancestry (agent-fetched, bound by --record). Fail-closed first-matching-rung ladder: lane-off → unresolved-target → unresolved-self → team-match → repo-match → mismatch; strict === comparisons (case-mismatch → not-self); null never matches null. Exit 0 self (reclassification honoured) / 3 not-self (the mechanical outward-new-root verdict stands, hard-floored) / 2 usage, malformed --target, config parse failure (loud — never a silent not-self with a wrong reason), or --record run dir missing/unsafe run-id. --record RUN-ID appends a self-intake-check event binding the verdict to the exact target_raw string plus the config snapshot used (a missing run dir exits 2 before any verdict), so faff audit recomputes-and-compares post-hoc — a detective control, never preventive: the target is agent-fetched; the self side and the comparison are what stop being forgeable. |
container-check [--json] | Is this run inside a host-isolated container (the blast-radius boundary)? Reads only standard runtime signals (KUBERNETES_SERVICE_HOST, /.dockerenv, /run/.containerenv, systemd container= on PID 1, truthy env container) — invents no marker, never parses /proc/1/cgroup. Prints {result, basis}; exit 0 contained / 1 not_confirmed. The autonomous-entry preflight warns (never blocks) on not_confirmed by default. |
evaluator-preflight [--repo-path PATH] [--json] [--selftest] | The assert-in half of the rung-2 evaluator code-blindness seam — the evaluator lane runs this at entry and refuses (exit 1) unless it is inside a container (in-container leg, reuses container-check verbatim) and the codebase directory is physically unreadable (repo-absent leg, fsq.isDirectory(repoPath) === false), so a code_blind: true holdout verdict is backed by a physical fact, not the judged party's self-attestation. Sibling to container-check (which warns) — the evaluator must refuse. Pure over (env, fsq, repoPath); never throws (an fsq error fail-closes to the blind/passing state). The refuse decision rests on the physical probe, never on a declared intent artifact's claim. --repo-path defaults to the resolved repo working tree; --json prints {holds, repo_path, refusals[{leg, detail}]}, the human form lists the refusals; exit 0 holds / 1 refuse. Now wired: the evaluate-call.mjs spawner runs this in-cage before judging (a non-zero exit is the spawner's exit-10, needs-human); the host-socket-absent refuse-leg remains deferred. |
corrective-integrity [--consumer corrective|detection] [--json] | The corrective-integrity fail-safe gate — distrust by default over the forge surface a same-uid build lane shares with the orchestrator (corrective artifacts + run-ledger.json). Under a shared uid faff cannot cryptographically stop that lane from forging a corrective artifact or rewriting the ledger (it is a guardrail, not a cryptographic control — a local agent can always write a marker), so the pure probe proves no integrity boundary (asserted:false for all inputs; the signal parameter is an inert forward-compatible seam for a future un-forgeable channel — a signal from any build-lane-writable source is never honoured) and the gate degrades: corrective → Channel D (human relay), detection → reconcile-only (ledger content cross-checked vs git), an unknown consumer → Channel D (fail-safe, never trusted). No crypto, no secret, no signature — a shared-uid secret is itself forgeable. Prints {asserted, basis, trusted, disposition}; always exit 0 (report/degrade, never a hard refuse). Folds a corrective_authority: available | channel-D-only capability record into the lights-out ledger. |
integrity-boundary [--root DIR] [--run-dir DIR [--issue ID] [--events]] [--json] [--selftest] | The emitter half of the corrective-integrity boundary — faff originates the (version, dir-set) content of the FAFF_INTEGRITY_BOUNDARY declaration the cage (or today's hand-operator) exports, so a future dir-set change is a faff-only change (no consumer hard-codes faff-internal dir names — the drift class that added events.jsonl once already, killed). Default (launch grain) prints exactly v1:<abs-root>/.faff/runs — the stable ancestor the coverage math accepts, mintable before any run dir exists (per-run paths can't be named at cage launch). --run-dir DIR [--issue ID] [--events] prints the per-run grain: v1: + the exact correctiveIntegrityDirs(DIR, issue, {events}) set, for a cage that mounts fine-grain at build-lane-spawn time. Origin only — it never reads or validates pid-1 environ (that is the reader's separate authority; the assert-don't-implement trust boundary stands, faff still never provisions the mount). Strict root resolution, never a guess: --root must name an existing directory, else walk up from cwd for the first .git/.faff/.faffrc.yaml ancestor and exit 2 on no marker — an emitter whose output becomes a trust declaration must fail rather than print a findRoot-style guessed path. The version token is provenance, not a gate (the reader stays ungated). --json prints {version, mode, dirs, declaration}; --issue/--events without --run-dir, an unknown flag, or a comma in any emitted path (it would re-parse into a silently-wrong dir set) all exit 2. Invoke against the same path the run will use (coverage is textual prefix math, not realpath — a symlinked checkout that resolves differently fails closed at merge as dir-mismatch). Exit 0 printed / 2 bad input; --selftest runs the round-trip table. |
integrity-digest <snapshot|verify|hash> --run-dir DIR [--issue ID] [--events] [--manifest json|file|-] [--json] [--selftest] | Custody-based tamper detection over the evidence set (correctiveIntegrityDirs) — the sanctioned interim integrity rung while the real read-only mount is deferred. A trusted dispatcher holds the emitted manifest in its own conversation context across an untrusted subagent dispatch (custody the subagent structurally cannot read or alter — it replaces signatures, which need an on-disk key a same-uid lane can read); verify then recomputes-and-compares to detect tampering before the evidence is consumed (the same recompute-and-compare detective pattern faff contain uses for agent-supplied ancestry, applied to the forge surface). snapshot walks correctiveIntegrityDirs(runDir, issue, {events}) and emits a per-member digest manifest to stdout: run-ledger.json + the per-issue files hashed byte-exact, corrective/ as a per-sub-file map (which-file granularity), events.jsonl as {length, prefix_sha256} — prefix-preserving, so a legitimate append is not tampering but a truncate/prefix-rewrite is; an absent member is recorded, so an appear/disappear is caught too. verify --manifest <json|file|-> recomputes and prints digest-verified (exit 0) or tampered naming the mismatched path(s) down to the sub-file (exit 1). hash reads stdin to EOF and prints the 64-hex digest of those bytes (exit 0) — the orchestrator's intended-content check pipes its in-context bytes here so it shares this one resolver rather than carrying its own tool policy. Hashing resolves its tool from a fixed list of absolute paths (/usr/bin/sha256sum, /bin/sha256sum, /usr/bin/shasum -a 256), probed in order — first present wins, never PATH (the same-uid tool-poisoning mitigation, now portable across Linux and macOS), and the spawn runs under a sanitized minimal env (PATH only, no inherited PERL5LIB/PERL5OPT) so the Perl-script shasum candidate adds no injection channel; no candidate present is fail-loud exit 2, never a silent verified. It emits its own trust class digest-verified and never references FAFF_INTEGRITY_BOUNDARY / integrityGate (the mount-asserted basis is a separate authority; conflating them is the lying-attestation the assert-the-boundary decision forbids — folding this verdict into the gate is the consumer's job, not this CLI's). Exit 0 verified/printed / 1 tampered / 2 bad input or hash-unavailable; --selftest runs the round-trip + tamper + events-prefix + trust-boundary table. |
corrective <author|check> --run-dir DIR --issue ID [--json] | Sentry-2 Channel A — stop-and-redispatch with a machine-authored, subtractive-only corrective input. The op vocabulary is a closed enum (park-with-cause, forbid-surface, tighten-threshold, descope-to-subset) — additive intent is inexpressible by construction; anything else fails validation. author --op OP <op flags> --cites-signal S [--cites-seq N] [--cites-evidence S] validates the record (closed enum, op-shaped payload, a required citation — no un-cited input ever validates — and a tighten-threshold value strictly tighter than the effective sentry.* config value) and writes it onto the corrective-integrity forge surface (correctiveIntegrityDirs(runDir)'s corrective dir) + appends a corrective-authored event; invalid → exit 1, nothing written. author --json additionally carries sha256 — the sha256 of the file it just wrote at path — so an orchestrator holding an integrity-digest custody baseline can check this write's touched member without a second read. check gates every read through integrityGate(correctiveIntegrityProbe(...), "corrective"): unasserted → {disposition:"channel-D", consumed:false, inputs} — surfaced for human relay, never acted on as authentic; asserted → validates + folds the cumulative constraint set (order-independent intersection semantics across multiple inputs on one issue — the audit trail that makes sequential-narrowing steering reviewable) into {disposition:"trusted", mandate:"narrowed"|"empty"|"indeterminate", constraints, applied, rejected} + appends corrective-consumed. An invalid/foreign/additive artifact is rejected (never applied) and reported in rejected for park/needs-human routing; an empty/doubtful indeterminate mandate is the orchestrator's park-before-dispatch signal, never a spin. Reads are scoped to the named --issue only. Pure cores + thin I/O, never a crash on an unreadable/unparseable artifact; exit 0 report (check) / 2 usage / 3 no run dir. |
merge-gate --pr N --issue ID --run-dir DIR --level L1|L2|L3|L4 [--merge-args "flags"] [--execute|--check-only] [--interactive] [--human-override] [--allow-no-ci] [--json] | The sole sanctioned gh pr merge path — a pure decideFloor core (registered as the integrity-floor contract, --selftest-covered) wrapped in a thin impure gh/git shell. The shell observes CI itself on the resolved PR head sha (it never accepts a caller-supplied CI verdict — the model's self-report is exactly what the gate distrusts), re-reads the persisted floor artifacts from the run-dir (<run-dir>/<ISSUE>/ac-checklist.json + review-verdict.json, the latter re-validated through the same computeReviewVerdict rule; under --level L4 the .faff/holdout/<ISSUE>.json verdict via holdoutGateResult), decides via the pure contract, and runs gh pr merge only on merge-ok. The merge is pinned to the observed head sha (gh pr merge --match-head-commit <observed-sha>, gate-composed — callers cannot supply it), so a push landing on the PR branch between CI observation and merge no longer slips through: the forge itself rejects the pinned merge and the gate refuses (no merge of a never-observed commit). CI observation is fail-closed on loss of the primary signal: the check-runs API is the primary source and the legacy commit-status API only a supplement — if check-runs is unreadable the reading is indeterminate even when the legacy status is green (a legacy success alone is never trusted; a readable check-runs with the legacy API down still classifies normally). Fail-closed everywhere: a missing/unreadable artifact, an indeterminate CI reading, no-ci-coverage (default; --allow-no-ci opts a change-class in), a head-sha mismatch (stale green — CI attributed to an earlier commit), a head that drifted after observation (forge-rejected pin), review != pass, or an L4 holdout != meets-spec all refuse. With --merge-args "--auto" the pin guards only auto-merge enablement (the forge refuses to enable auto-merge against a drifted head); it does not cover the eventual forge-side merge, which fires when required checks pass against whatever head then exists — that later merge is delegated to the forge's own observation, unchanged by this gate. --check-only computes the verdict without merging; --interactive --human-override records an override to the run-dir and replaces the autonomous refusal (the merge path stays singular even for a human override); an already-merged PR is an idempotent no-op. --merge-args is validated against the closed gh pr merge flag allowlist (--squash/--merge/--rebase/--delete-branch/--auto); any other token → exit 2, so no untrusted free-text reaches the merge shell. --admin is deliberately not on the allowlist — it instructs the forge to bypass branch protection, the very backstop the merge floor leans on (and which branch-protection-check asserts), so faff never issues one under any lane; --merge-args "--admin" → exit 2. The two human-only flags --human-override and --allow-no-ci are fenced on a genuine interactivity signal — they are honoured only when process.stdin.isTTY === true and --interactive is also present; a non-TTY invocation (the autonomous lane, or any agent-driven Bash call) passing either one → exit 2 fail-loud before any gh call, naming the run-it-yourself-in-a-real-terminal remedy. A sibling argv flag is self-assertable, so it can never be the fence; the TTY signal is one the autonomous lane structurally cannot mint. On a merge this invocation actually confirms landed (the clean-success tail, or the classifyPostMerge "merged with a failed post-merge step" path) it also observes — appends {kind:"merge", target:"pr:<n>"} (+ {kind:"branch-delete", target:<head-branch-name>} iff --delete-branch was requested and the merge itself succeeded cleanly) to the run-dir's declared-effects.jsonl via the shared appendEffectEntries helper (effects.js), and warns on stderr, once per effect — never refuses — if no faff effects declare at --step merge covers it (declaring is graft's job, before this command is ever invoked; this CLI is the mechanical observer only). --check-only, a refuse verdict, and the already-MERGED idempotent no-op write nothing to the ledger. Exit 0 merge-ok / 1 refuse / 2 fail-loud (bad inputs / cannot establish PR identity). |
branch-protection-check [--repo owner/repo] [--branch name] [--json] | Assert-don't-enforce branch-protection probe (mirrors container-check) — is the target branch protected by required status checks? Probes the effective-rules API gh api repos/{repo}/rules/branches/{branch} — the documented union of classic protection and GitHub rulesets, so ruleset-only protection is seen (the classic protection endpoint 404s on it) — and classifies the result purely: protected when a required_status_checks rule applies (carrying its contexts), unprotected when the rules array carries no such rule (incl. []), indeterminate on a 404 / unparseable body / gh failure. Prints {status ∈ protected|unprotected|indeterminate, required_checks, basis}; exit 0 protected / 1 not-confirmed (unprotected/indeterminate). The autonomous-entry preflight (beep-boop + lights-out) warns by default on not-confirmed, blocks only under the opt-in autonomous.require_branch_protection knob, and never fires in interactive mode — the compensating control that makes the merge floor binding at the forge. faff never mutates the consumer's GitHub settings. |
github-auth-check [--json] [--selftest] | Run-start GitHub-auth preflight — is the GitHub push credential live? A read-only gh api user probe (user-scoped, so it needs no repo slug and runs before any repo resolution), classified purely: authed on exit 0 with a parseable .login; auth-failed when the API rejects the credential (HTTP 401/403 / bad-credentials / not-logged-in — the loud, actionable "re-auth" outcome); indeterminate when gh can't run (binary missing, network error/timeout) or the failure is not attributably auth. Deliberately fail-open: only a rejected credential is auth-failed; a missing gh or a network blip is never a re-auth claim, and the token value never appears in basis (gh's auth stderr reports the HTTP status, not the token). Prints {status ∈ authed|auth-failed|indeterminate, basis, login} under --json, else <status> (basis: <basis>) (— <login> appended when authed); exit 0 authed / 1 otherwise. The beep-boop autonomous-entry preflight runs it before the build queue is minted and surfaces auth-failed loudly at kickoff, warns by default, blocks only under the opt-in autonomous.require_github_auth knob, and indeterminate never blocks even under block. --selftest runs the pure classifier (authed / auth-failed 401 / indeterminate) offline. |
post-merge-check --issue ID --pr N --run-dir DIR [--sha SHA] [--json] | Post-merge verification (verification-only — no auto-revert) — invoked at faff-graft Step 10's shipped arm (autonomous mode only, after faff merge-gate returns merge-ok). Re-runs the project's own declared UNIT rung — resolved via the same discoverRungs/runRung (gates.js) faff-graft Step 7.5/Step 8 already trust, never a second, divergent resolver — against an ephemeral, detached git worktree at the sha faff merge-gate already pinned into <run-dir>/<issue>/merge-record.json (head_sha; --sha overrides for a human-invoked re-check only, never a second automatic sha resolution). The worktree is named under the system tmpdir (outside any configured worktree_root) and is always removed — success, a failing rung, or a worktree/spawn error (FINALLY semantics) — so a crash mid-run never leaves a stray git worktree entry. Classifies purely: the rung pass → verified-ok; fail → verified-fail; no UNIT rung discovered, or any worktree/fetch/spawn error → the distinct, non-blocking unverified (never a fail-closed park — a repo with no declared test command must not park every merge). Persists <run-dir>/<issue>/post-merge-verification.json ({issue, pr, merge_sha, verdict, basis, command, discovered_scope_ref, checked_at}); the pure classify+shape half is computePostMergeVerification, registered as CONTRACTS["post-merge-verification"] (see the contract row). This CLI only observes and annotates — it never merges, reverts, reopens the issue, or opens a PR; on a verified-fail exit, faff-graft's Step 10 prose (not this CLI) posts the tracker comment and appends the source: "post-merge" discovered-scope entry. post_merge.check (.faffrc, default on) is consulted by the caller (graft) before invoking this CLI at all. Exit 0 verified-ok / 1 verified-fail / 2 fail-loud (bad inputs / cannot resolve a merge sha) / 3 unverified; --selftest runs the verified-ok/verified-fail/unverified/worktree-cleanup table against a real tmp git repo. |
ci-triage --pr N --issue ID --run-dir DIR [--repo owner/repo] [--transience transient|persistent|unknown] [--fault-domain infra|code|unknown --fault-domain-source llm] [--json] | CI failure triage — invoked at faff-graft Step 10's ci-red branch (both interactive and autonomous). Classifies a red CI result across three axes — transience (transient/persistent/unknown), fault_domain (infra/code/unknown), origin (mine/main-was-red/unknown) — and derives a per-class action via the pure core deriveTriageAction (registered as the ci-triage contract, --selftest-covered, no network). The thin impure shell observes only: the PR head's check-runs, main's head check-runs (git rev-parse origin/main then the same check-runs API), and the committed flaky register (operations/ci/flaky-register.json, behind a single read/append accessor) — it never re-triggers a run, parks, files a ticket, or merges. origin is per-check (a main failure on a different check is still mine) and fails closed to unknown on an unreadable main head. fault_domain is mechanical-first from check-run metadata (startup_failure/timed_out → infra; anything else → unknown, never guessed as code) — --fault-domain/--fault-domain-source llm is the only way to narrow an unknown metadata read (a malformed/out-of-enum value coerces to unknown). --transience is fed in by the caller (graft, after it performs its own clean same-sha re-run) — this CLI never re-triggers one itself, so an omitted/malformed value defaults to unknown (the pre-rerun first call). action is always the pure function of the three axes, never a caller-trusted field: origin: main-was-red (or unresolved unknown) wins outright; a transient result proceeds to proceed-to-merge-gate regardless of fault domain (the real procedure skips the fault-domain question entirely once the re-run goes green); an unresolved transience fails closed; a genuinely persistent failure routes infra → park-errored / code → fix-attempt / still-unknown → the same fail-closed park. On --transience transient, appends one flaky-register event per this pass's failing check signatures and quarantines (marks the register entry — never an autonomous test-file edit) the first time a signature's event count reaches 3 with no quarantine_ticket already set (an already-quarantined signature is never re-filed). Persists the full verdict to <run-dir>/<issue>/ci-triage.json on every invocation. The verdict is never an input to faff merge-gate — a cleared-transient failure proceeds to the merge gate only because the clean re-run made the head-sha checks actually green, and merge-gate independently re-observes them. Exit 0 verdict produced · 2 fail-loud (missing --pr/--issue/--run-dir, or the PR/repo identity can't be resolved); --selftest drives the pure cores (the full deriveTriageAction axis table incl. all-unknown, the metadata fault-domain reader, the per-check origin reader, and the flaky-register fold) with no network. |
governance-check --run-dir DIR [--run-dir DIR …] [--issue ID] [--level L1|L2|L3|L4] [--json] [--summary-md FILE] | The harness-independent enforcement binding — the composition verb behind .github/actions/governance-check, a required status check that binds faff's governance floor to any PR emitter (not just the Claude Code harness). Composes already-shipped cores, adding no new invariant: runcheck's completeness audit (auditLedger); the ledger's last recorded budget-checkpoint event in events.jsonl (breached != [] at outcome escalate ⇒ fail — never a live token recompute; an absent events.jsonl falls back to the ledger's own stop_reason for a budget-escalated(...) record); merge-gate's floor-artifact re-validation for each target issue (readAcComplete + readReviewVerdict — the same functions merge-gate reads, plus readHoldout at --level L4); audit's buildReconstruction events↔ledger coherence join (report-only, never gating, mirroring faff audit's own posture); and the run-ledger owner block's shape (well-formed ⇒ gating; status + heartbeat age are report-only — a committed snapshot cannot prove the emitting run is still heartbeat-live at CI time). Issue derivation for the merge-floor leg: explicit --issue > branch-name match (case-insensitive [A-Za-z]+-[0-9]+, upcased, via a local git rev-parse --abbrev-ref HEAD — no network) > every admitted issue with a terminal outcome in that run dir. Walks each --run-dir independently and aggregates: pass = AND over every run's gating legs; reasons names run-id + leg + cause per failure. Pure evaluator: no network call anywhere, reads only the given run dirs + local repo files. A malformed/unreadable run-ledger.json is fail-loud (exit 2, never folded into a leg failure) — mirrors runcheck/disposition's own malformed-ledger posture. --summary-md FILE renders the same job-summary table the default text output prints (byte-identical local vs CI, so $GITHUB_STEP_SUMMARY and a terminal run never drift). Exit 0 pass · 1 fail · 2 usage (no --run-dir, unknown --level, non-directory --run-dir) or a malformed ledger; --selftest runs the leg-table + multi-run-dir AND-aggregation + malformed-ledger-exit-2 table. |
harness <seams|check> [--json] [--root DIR] | The harness-abstraction seam register: the in-code declaration of the seven seams where faff's behaviour depends on which harness it runs under, each carrying its binding kind (code | prose | unbound), the disposition doc row it traces to (docs/reference/architecture/harness-coupling.md → ## The seams), and the concrete artifact the Claude Code driver uses today. seams prints the register (--json emits it structured, tracker-access shown as carrying no driver-side check). check is the falsifiable half — it asserts every declared binding resolves: each code seam's exported symbols are present on its module, each prose seam's section text appears verbatim in its file, every seam's doc_row label is a bold row of the disposition inventory's ## The seams slice (a label colliding with the disposition-vocabulary table never satisfies it — only the seams slice is parsed), the headless-session-entry floor contract is a key of the COMMANDS map, and a reverse credential sweep over plugin/skills/faff/bin/lib/*.js + eval/*.mjs reports any forwarding spawn-family call site (spawn resolved by binding — including a parameter defaulting to spawnSync — with an env value not provably a fixed literal) that no seam's credential_scope registers. Both verbs print, in text and --json, that the check covers the declared bindings only and is not a general prose-reference checker. Performs no network access and spawns no child process. Exit 0 clean · 1 one or more bindings failed to resolve (or an unregistered forwarding spawn) · 2 usage fault; --selftest exercises every finding kind with injected readers/resolver/COMMANDS/spawn (zero real I/O). |
Run orchestration & safety (beep-boop)
| Subcommand | What it does |
|---|---|
runcheck [--hook] [--recover] [--json] [RUN_DIR] | Audit a /faff-beep-boop run ledger; --hook gates session-end on dangling admitted work (owning session / --recover hard-blocks; foreign runs warn at most). |
heartbeat [RUN_DIR] [--run-dir DIR] [--unit ISSUE] [--json] | The single sanctioned write path for run liveness — refresh the dedicated .faff/runs/<run-id>/heartbeat file to now (resolves RUN_DIR → $FAFF_RUN_DIR → latest run) so a live-but-quiet build's long sub-steps keep the run held. The tick's only write is that file (atomic tmp + rename) — it never touches the run ledger, so concurrent tickers can never clobber an orchestrator outcome write. Every reader overlays the file over the legacy owner.last_heartbeat ledger field (the effective heartbeat is the max of both), so a pre-upgrade run keeps working with zero migration. Strict flag parsing: the flag set is closed — --run-dir DIR is an exact equivalent of positional RUN_DIR (giving both exits 2), and any unknown --flag (e.g. an invented --run <id>, formerly a silent exit-0 no-op) exits 2 with a usage message naming the legal flags; flag values never leak into the positional slot. An explicit target (positional or --run-dir) that is missing or has no run-ledger.json exits 3 with the path named on stderr; the soft no-op (exit 0, written:false) is scoped to ambient resolution (no explicit target — an empty-string positional counts as ambient, so an unset shell var stays safe to pass) and to a done/unowned run (no file written either); exit 2 on a malformed ledger or a value-flag missing its value. --unit <issue> (the fleet member tick): writes BOTH the run file above AND a dedicated heartbeat.<issue> file — same atomic single-value shape, single-writer by construction (only that member ever ticks its own file). faff sentry check reads it to resolve member-scoped liveness under the parallel executor. An invalid --unit value (empty/traversal-shaped) exits 2 before any write; --json output gains "unit": <issue>|null. |
prepcheck [--hook] [--recover] [--json] | Audit faff-prep attach-state markers (.faff/prep/*.json); --hook gates session-end on a produced-but-not-attached spec (owning session / --recover hard-blocks; a foreign marker a live run still holds, or one written recently, warns at most). |
budget check [--until HH:MM] [--max N] [--json] | Emit a run cost/compute BudgetState (spent, breached, outcome, warnings) from the ledger + config budget: + local transcripts. budget.cost prices per-model × per-class from a built-in price map, unconditionally (pricing:"map" in the resolved envelope) — a model absent from the map prices at the map's costliest known per-class rate (fail-safe overcount, named in a warning) rather than ever silently undercounting. The legacy flat budget.price_per_mtok scalar is removed — a .faffrc.yaml that still sets it is ignored (cost still prices from the map) and named in a warnings entry (naming budget.price_per_mtok_by_model as the remedy for a per-model override); it is never a hard exit here (that would fail-open the whole budget signal, see below), but faff lights-out's mint-time preflight refuses outright on the same condition (no fail-open risk there). Only a legacy ledger minted before this removal can still carry a recorded pricing:"flat" — its own baked-in figure is honoured byte-for-byte via envelopeFromLedger so an in-flight run's dollar ceiling never silently changes mid-run. A pre-change ledger with no per-model baseline pro-rates the per-model deltas from the scalar this-run fraction and warns; tokens_source:"estimate" under map pricing reports cost:null with a loud warning (no per-model data to price — never a silent flat-scalar guess). Cost warnings surface only when budget.cost is actually configured — an unconfigured cost dimension stays silent. A malformed budget.until/--until (not valid HH:MM) is never a vacuous ceiling that silently never breaches — it degrades to a warnings entry (present only when non-empty, mirrored to stderr) naming the raw value; breached never contains until for it; exit stays 0 (a hard failure would fail-open the whole budget signal for sentryReadBudget/run-done --budget, masking a real tokens/cost breach). This is distinct from the run's own ledger being unreadable (explicitly-named and absent, or present-but-corrupt): that is an own-fault, not a config problem — it emits {outcome:"indeterminate", indeterminate:true, reason} (zeroed spent, tokens_source:null, breached:[]) and exits 3, never silently coerced into an all-clear reading. A legitimately empty surface (no run requested, none found) is unaffected — outcome:"none", exit 0, byte-for-byte as before. |
economics [--run-dir DIR] [--by class|model|mcp|day|effort] [--json] | Per-run unit economics — emit a UnitEconomics JSON (tokens_total, tokens_source, price_per_mtok, pricing, cost_total, buckets, shipped_count, attempt_count, cost_per_shipped, cost_per_attempt, per_issue, zero_ship, warnings) deriving cost-per-shipped-issue + cost-per-attempt from the run ledger's terminal outcomes + budget check's transcript-summed this-run tokens. Pure, rendering-only (no tracker/network/LLM — parity with budget check/runcheck): reuses budget's exact token path (baselined at run start; estimate fallback labelled) and touches no producer (ledger/budget check/events byte-unchanged). The top-line prices via the SAME map + rate source budget.cost uses — pricing:"map", unconditionally, on a fresh resolve — so cost_total stays consistent with tokens_total (both this-run, baselined at run start) and with budget check's spent.cost, closing the "two cost figures" gap (which was a RATE difference — flat scalar vs the map — not a population one). The top-line prices THIS RUN's per-model delta (honouring a per-model baseline when the ledger carries one, else pro-rating); at a run-start baseline of 0 (the common single-session case) it equals the sum of --by model's priced rows. An unpriced model's tokens are excluded from the top-line (cost:null for that model's row — the existing REPORTING convention, distinct from the governor's fail-safe overcount) and named in a warnings entry, while priced models still sum. The legacy flat budget.price_per_mtok scalar is removed — a .faffrc.yaml that still sets it is ignored (the top-line still prices from the map) and named in a warnings entry; only a legacy ledger minted before this removal can still carry pricing:"flat", the legacy flat-scalar top-line, byte-for-byte. cost_* is null with no dollar column when nothing could be priced (a legacy pricing:"flat" ledger with price_per_mtok=0, or tokens_source:"estimate" with no records to price); zero_ship (0 shipped, >0 tokens) is flagged loud; attempt_count excludes routed-out/unreached-budget; per_issue is best-effort agent-*.meta.json attribution (session-owned transcripts, [A-Z]+-\d+ in the description matched to a ledger outcome), and run-level figures never depend on it. beep-boop's ## Reporting step renders the block. --by class|model|mcp|day pivots the same transcript walk into per-token-class / per-model / per-day / per-MCP-tool buckets, priced per-model × per-class from a built-in price map (living alongside budget check's own pricing logic, so both consult one source), optionally overridden by budget.price_per_mtok_by_model; an unknown model prices to cost: null in this REPORTING surface, distinct from $0 (the budget.cost GOVERNOR instead fail-safe-overcounts an unknown model — the two surfaces deliberately differ: a reporting null is honest, a governor null would be vacuously unbreachable). The class/model/day axes reconcile to the top-line tokens_total; --by mcp reports per-tool call_count/request_bytes/response_bytes plus a measured per-tool cache-read attribution (each billed cache_read split pro-rata across the resident context, so an MCP tool_result's share is measured, not a single global amplification factor). --by effort is the one axis that reads the run's events.jsonl (not the transcript) — reasoning-effort is a request-time setting the transcript never records, so it is captured at dispatch time on the token-tag event. It buckets dispatches by their data.effort label (severity order, plus a (none) bucket for token-bearing dispatches that carried no effort), sums the four-class token deltas per bucket, prices each at the run's dominant model (an ESTIMATE — events carry no model), and reports coverage_pct (events token total ÷ top-line) rather than forcing a reconciliation, since events only span tagged windows (source: "events"; degrades to estimate with no events log). --by X --json rides a breakdown object on the JSON; --by X without --json prints a skimmable table; the token/bucket counts stay byte-for-byte with no --by; the top-line cost_* figures reflect map pricing by default, as described above. An unrecognised axis exits non-zero naming {class, model, mcp, day, effort}. Non-leak: sizes/counts/names/model-ids/effort-labels/costs only — never transcript payload content. |
disposition [--run-dir DIR] [--json] [--root DIR] [--selftest] | The run-end disposition verdict a headless (CI / cron / container) wrapper runs as its final, exit-propagating step — classifies one run dir's on-disk end state into clean vs needs-attention, so a lights-out run whose issues parked / errored / escalated exits non-zero instead of reporting green-by-silence (the write/exit side of unattended visibility, paired with audit/events on the read side). Run-dir resolution: --run-dir → $FAFF_RUN_DIR → latest under .faff/runs (an explicit --run-dir is honoured as-is, never redirected to latest when its ledger is missing — a wrapper naming a dir with no ledger sees exit 3, not a verdict about a different run). Pure: filesystem reads under the run dir only — no tracker / network / LLM, and it writes nothing (parity with runcheck / economics / audit); reuses auditLedger + TERMINAL_STATES (runcheck) and extractParksBlock (park-history), forks none. Adds no second copy of the durable signal — per-issue surfacing (the faff-parked label + reason comment) already ships in the park protocol and the run summary.md already ships as a hard-floor artifact; this verb adds only the process-exit contract over them. Classification (the exit code depends only on run-ledger.json): outcomes parked / errored / unreached-budget / pr-open each → an issue-outcome attention item, while shipped / routed-out are clean (a PR left open is a "human must act" outcome; routed-out was never attempted — counting it would make near-every run red); an escalate-class stop_reason (budget-escalated(...) prefix, non-convergence, product-incomplete, sentry-abort) → a run-escalation item even when every issue shipped; a ledger abort entry or owner.status == "aborted-resumable" → an aborted item; auditLedger.clean == false (undispatched admitted issues, or invalid outcome tokens) → an incomplete-ledger item naming them (an abandoned/killed run reads as needs-attention independent of stop_reason adoption — fail toward attention). Cause joining (best-effort, degrade-don't-crash): each attention issue's cause is filled from the summary.md faff-parks block's root_cause_class → else the latest issue-outcome event's data → else null; a missing/malformed summary.md or events.jsonl degrades cause to null and never changes the exit code. --json emits a DispositionReport {run_id, disposition, attention[{kind, issue, outcome, cause}], counts} (the counts histogram is informational); default output is skimmable one-line-per-attention-item text ending with the disposition. Exit 0 clean · 1 needs-attention · 2 usage / malformed ledger · 3 no run dir / no ledger; --selftest runs the classifier fixture table. |
quality [--run-dir DIR] [--json] | Per-run quality/outcome telemetry — the quality mirror of economics. Emit a QualityReport JSON (run_id, outcomes, shipped_count, attempt_count, parked_count, park_rate, rework {total_turns, reworked_attempts, tagged_attempts, rework_rate, mean_turns}, gate_catch, per_issue, source, warnings) from the run ledger's terminal outcomes (authoritative) + the events.jsonl issue-outcome events' data.gate / data.rework_turns tags. Pure, read-only (no tracker/network/LLM — parity with economics/budget check/runcheck). Pairs with economics by sharing run_id and the exact shipped/attempt denominators (ECONOMICS_BUCKET_ORDER + the routed-out/unreached-budget exclusion), so a $/quality read is economics.cost_per_shipped beside quality.park_rate on one run — no cost is duplicated into quality. park_rate/rework_rate are over attempt_count (dispatched builds) and are null on a zero-attempt run (never 0/0); mean_turns averages over tagged_attempts (the attempts that actually carried a rework_turns tag), and a warning flags rework_rate as a lower bound when tags don't cover every attempt; gate_catch is the distribution of which quality gate {structural, adversarial, holdout, ci} caught a non-shipped build; source is ledger+events, or ledger (with gate_catch: [] and a nulled rework) when the run has no events.jsonl. --json emits the report; no flag prints a skimmable table; a missing run dir exits 2. Non-leak: outcome-names / gate-names / counts / rates only — never any finding, spec, or transcript payload. |
reconcile --run-dir DIR --level L1|L2|L3|L4 [--json] [--selftest] | The blocking run-end ground-truth gate — confronts each ledger shipped outcome (+ each spec-referenced, non-admitted sibling's terminal-state) with live git/forge/tracker evidence the orchestrator already gathered, never re-observed here: the verb stays pure, mirroring merge-gate's decideFloor split. runcheck audits ledger completeness; this audits ledger-vs-world ground-truth — deliberately a separate verb so runcheck's no-I/O Stop-hook purity is never touched. Reads a ReconcileInput {level, shipped[], siblings[], sibling_baseline} on stdin, emits a faff-contract:run-reconcile block {divergences, consistent, disposition}. Divergence classes: phantom-merge (the shipped issue's recorded merge-record.json head_sha != the observed merged head sha), claimed-shipped-unmerged (no merge record, or the observed PR isn't merged — fail-closed: unprovable ⇒ divergence), unowned-sibling-mutation (a non-admitted, spec-referenced sibling flipped into a terminal state during the run), sibling-check-unproven (sibling_baseline is absent, or captured isn't strictly true: the sibling check itself did not provably run, distinct from running and finding nothing; a run-level divergence, issue: null). sibling_baseline: {captured, entry_count} defaults degraded — silence about it reads exactly as captured: false, so a caller that forgets the attestation lands in the loud branch rather than a vacuous pass; entry_count catches an internally incoherent claim (fewer entries than siblings.length) but proves omission was avoided, not that the claim is genuine. disposition is level-gated: consistent → pass; any divergence → needs-human at L4 (hard-block/escalate — the only level nothing is watching), warn at ≤L3 (surfaced, non-blocking). Exit 0 consistent / 1 divergence(s) / 2 malformed ReconcileInput or missing --run-dir/--level (fail-loud, never a silent pass); --selftest runs the divergence-class + level-gating + validate table. |
run-done [--queue-empty] [--all-parked] [--ledger-clean] [--budget JSON] [--prd-coverage JSON | --no-prd] [--inflection reached|none] [--non-convergence] [--policy JSON] [--json] | The terminating-condition predicate — compose faff's shipped run signals into the one decision that ends an unattended run (run-complete / continue / escalate). Pure: no tracker/network/disk beyond args, and it never calls the methodology (the orchestrator resolves the policy and passes --policy). Reimplements no signal — reads queue state + runcheck's .clean (--ledger-clean) + budget check's {breached,outcome} (--budget) + prdr coverage's prd-satisfied (--prd-coverage → .satisfied; --no-prd when no PRD is in scope). A fixed safety floor no --policy may weaken (budget-escalate → escalate; unclean ledger at drain → continue/undispatched-ledger; prd_satisfied==false → escalate/product-incomplete) precedes the policy-weighted rungs (the structural-default ladder — non-convergence→escalate, budget-narrow→continue, budget-stop→complete, value-inflection→complete, work-remaining→continue, drained|all-parked→complete — overridable by the methodology's run-termination-policy via --policy). Emits a RunDoneVerdict {verdict, reason, signals, policy_source, conformant, violations}, schemaChecked against run-termination belt-and-braces; malformed JSON → exit 2; report-only exit 0 (the verdict is in the payload). |
run-ledger <init-interactive --issue <ISSUE-ID> [--root DIR] [--id RUN-ID] | record-outcome --issue <ISSUE-ID> --outcome <TERMINAL> [--run-dir DIR]> [--json] [--selftest] | The standalone-interactive L2 mint — the L2 sibling of lights-out's L4 mintLightsOut. A directly-invoked /faff-graft ISSUE-XX (no beep-boop orchestrator above it) has no run dir of its own, so it mints neither the events.jsonl nor the run-ledger.json that faff events anchor (graft Step 9b) byte-copies into the committed anchor faff merge-gate reads — the anchor cannot be built and the merge floor refuses. init-interactive creates a fresh .faff/runs/run-YYYYMMDD-HHMMSS-graft-<issue>/ carrying a minimal honest run-ledger.json (level:"L2", admitted:[<issue>], outcomes:{}, a running owner, a minimal budget.envelope) + a genesis events.jsonl run-start chain, so the EXISTING anchor + gate path just works — satisfying the fail-closed floor with honest inputs, never weakening it. level is the CONSTANT "L2", written unconditionally — there is NO flag that sets/raises/lowers it (exactly as mintLightsOut hardcodes "L4"); the anchored level is the merge-gate ceiling, never operator-settable. Every chain hash (the genesis prev = SHA-256(run_id), each link, the anchor's chain-head.json witness) is CLI-computed — the caller never supplies a hash; because the run dir's basename IS the run_id, the genesis chain still verifies after events anchor relocates it under .faff/anchors/<run-id>/<issue>/. CLI-level trust guard (defense-in-depth): if FAFF_RUN_DIR (or --root's newest run dir) already resolves a live run at a level ABOVE L2 (L3/L4, owner.status:"running"), the verb refuses (exit 3) and emits a refusal observe onto that run's timeline, minting no L2 dir — an interactive L2 mint must never silently downgrade a live higher-level run, and the boundary is enforced in the deterministic CLI, not only in swappable graft prose. Bare stdout is JUST the absolute run dir path (so a caller can export FAFF_RUN_DIR="$(…)"); --json emits {proceed, level, run_id, run_dir, ledger_sha256_before:null, ledger_sha256_after}. Exit 0 minted · 2 bad --issue/usage (mints no partial dir) · 3 the live-higher-level guard. record-outcome is the honest terminal write (§4 step 6) on the interactive-minted live ledger, run at the graft terminal AFTER Step 9b already committed the immutable anchor: it sets outcomes[<issue>] = the terminal-state string (a faff events ledger-outcome vocabulary member) + owner.status:"done" under the ledger lock (which also folds a ledger-write event — belt-and-braces chain honesty), and on the shipped path appends an issue-outcome close event; because the write lands only on the live dir, the already-committed anchor stays a byte-stable pre-merge outcomes:{} snapshot. Resolves the run dir from --run-dir > FAFF_RUN_DIR > newest under --root; exit 0 recorded · 2 bad --issue/--outcome · 3 no live run dir resolved. --selftest validates the minted ledger shape + the guard predicate + the terminal-outcome edit + the genesis chain in-memory (mirrors lights-out/events). |
run-outward --target JSON [--self JSON] [--json] | The signals.outward producer feeding run-start's outward-only floor — is this run's resolved target aimed outward (a non-self adopter/greenfield container) or is it self-directed (faff's own container)? Pure: no tracker/network/disk beyond args, parity with contain/run-start — the caller resolves the live TargetRef {container, repo, source} (explicit > inherited > methodology-default) and the live SelfRef {container, repo, is_self} (the repo-slug oracle: self.repo := tracking.repo, is_self := target.repo == tracking.repo) and passes both in; this CLI only decides. A fixed fail-safe ladder (first-matching-check-wins, biased toward self-directed): self.is_self===true → outward:false/self-marked; target container and repo both null → outward:false/unresolved-target; target.container non-null and equal to self.container → outward:false/self-container; target.repo and self.repo both non-null and equal → outward:false/self-referential; otherwise → outward:true/outward-adopter. Null-safety is structural — two absent containers never read as a match, and an all-null SelfRef with a resolved non-self target correctly falls through to outward:true. Emits an OutwardSignal {target, outward, reason}; re-implements no run-trigger reason (the refusal itself stays single-homed in run-start — report-only exit 0, the boolean lives in the payload, so a caller feeds .outward straight to run-start --outward|--no-outward rather than branching locally). Malformed --target/--self JSON → exit 2 (usage). |
run-start [--signals JSON] [--target-resolved|--no-target-resolved] [--outward|--no-outward] [--prd-present|--no-prd-present] [--prd-ambiguous|--no-prd-ambiguous] [--prd-admissible|--no-prd-admissible] [--coverage-measurable|--no-coverage-measurable] [--coverage-covered|--no-coverage-covered] | The run-start trigger predicate — the mirror of run-done (run-done ends a run failing toward escalate; run-start opens one failing toward refuse). Composes faff's shipped run-start signals — target resolution + the outward-only read (signals.outward, the container mechanism supplied elsewhere) + prd-readiness admissibility + prdr coverage's .covered — into the one decision that opens an unattended run (plan / drain / refuse). Pure: no tracker/network/disk beyond args, reimplements no signal (each arrives as a passed-in boolean via --signals JSON and/or per-signal flags, a flag overriding the bundle). A fixed refusal-biased ladder (first-failing-check-wins): target empty→refuse/no-target; inward (outward==false)→refuse/self-directed (before PRD checks); multiple Active/Frozen PRDs→refuse/prd-ambiguous; no PRD present→drain/no-prd-nothing-to-plan; prd-readiness not admissible→refuse/prd-inadmissible; coverage unmeasurable→refuse/coverage-unmeasurable; covered==false→plan/coverage-thin; covered==true→drain/prd-covered. The ordering is load-bearing — the outward floor precedes the PRD checks so inward+no-PRD is self-directed while outward+no-PRD is a benign drain. Fail-safe: any non-true signal coerces to false, so a malformed/empty bundle derives refuse/no-target and the privileged plan is unreachable unless every affirmative signal is explicitly true. Emits a RunTriggerVerdict {verdict, reason, signals, conformant, violations}, schemaChecked against run-trigger belt-and-braces; malformed --signals → exit 2; report-only exit 0 (the verdict is in the payload). Validated consumer-side by contract run-trigger (Pattern-B — it re-derives {verdict, reason} from signals and rejects a hand-altered verdict). |
audit <run-id> [--root DIR] [--issue ISSUE] [--json] | Read-only run-reconstruction forensics view — joins the three substrates a finished run leaves on disk (events.jsonl timeline + run-ledger.json final state + .faff/provenance/<ISSUE>.json intake proofs) into one who/what/why reconstruction: per-run lifecycle (started/ended/duration/complete/phases/owner), one rollup per admitted-or-evented issue (provenance via/initiated + its events in seq order + ledger outcome + park cause), a budget summary (ledger envelope + tokens_at_start + budget-checkpoint events — never live-recomputed), a supervision summary (sentry-checkpoint events in seq order + last_intervention — proof a faff sentry check consult ran at every between-units checkpoint; 0 checkpoint(s) / last intervention: — on a legacy run with none), and a coherence block (reuses auditLedger for undispatched/invalid + adds events↔ledger mismatches + missing substrates + a containment recompute — see below). Pure (no tracker/network/LLM); degrade-don't-crash (reconstructs from whatever exists, reports gaps); coherence is reported, never gated. Exit 0 readable / 2 missing run-id / 3 no run dir, both core files missing, or --issue not in the run; --json emits the Reconstruction object (with a supervision field alongside budget). Containment coherence members: containment_mismatches — every recorded containment-check event (from faff contain --record) is recomputed with the same parseAncestry/subtreeContains pipeline that produced it; a disagreement (or an unparseable recorded ancestry_raw → recomputed:"unreproducible") is listed as {seq, issue, recorded, recomputed} — this catches a tampered/foreign event line, never a fabricated-but-internally-consistent ancestry (detective, not preventive; see contain's trust-boundary note above). unrecorded_creates — true when the ledger's discovered_scope_filed is non-zero but the timeline holds zero containment-check events (the beep-boop step-10 filing chokepoint ran unrecorded or was skipped); both flip coherence.clean to false when non-empty/true. |
park-history --now ISO [--issue ID] | Deterministic repeat-park counts over .faff/runs summaries (≥3 same root-cause class in 21d). |
worktree-prune [--own PATH] [--branch B] [--issue ID] [--dry-run] | Scoped, fail-safe prune of only this run's own dangling worktrees — never the repo-wide prune that clobbers a live peer. |
worktree-root [--assert PATH] [--root DIR] [--json] [--selftest] | The single canonical worktree-root resolver + containment assert. Prints the resolved root (precedence FAFF_WORKTREE_ROOT → .faffrc worktree_root → ~/.faff/worktrees/<basename(repoRoot)>; env/config verbatim, only the default appends <repo>). --assert PATH exits 0 iff PATH is strictly under the resolved root, else exit 1. The one source the setup-worktree hook, the lights-out preflight, and the graft worktree assert all call so they never drift. |
stage-guard --worktree <dir> --mode assert|filter [--json] | Build-safety staging guard — a filename-class secret check over the current git index, not a content scanner (content scanning is out of faff's concern — that is a repo's own pre-commit / CI). The primary defence is allowlist selective staging (git add -u + explicit named paths, never git add -A); this is the cheap backstop. assert exits 1 if any staged path is secret-class (.env/.env.*, *.pem/*.key/*.p12/*.pfx/*.keystore/*.jks, id_rsa*/id_dsa*/id_ecdsa*/id_ed25519* but not *.pub, .netrc/.pgpass/*.pgpass, credentials/credentials.json, .npmrc, .pypirc; *.example/*.sample exempt), 0 when clean — for the precise build commit. filter always exits 0, git restore --stageds each secret-class path and reports it — for WIP-preservation commits (sentry-abort, member-park) that must stay resumable. Pure git-plumbing (no tracker/network); the shared lib bin/lib/stage.js is the single home both sentry.js and the graft/concurrency prose route through. --selftest runs the classification table. |
sentry <check|abort> [--run-dir DIR] [--forbidden-side-effect] [--authority available|channel-D-only] [--issue ID] [--signal NAME] [--worktree PATH] [--json] | Live-run derailment detection + the hard kill-switch (the supervisory lane). check READS a live run's append-only surface — events.jsonl + run-ledger.json + the effective heartbeat (the dedicated heartbeat file overlaid over the legacy owner.last_heartbeat field) + a consumed faff budget check child call — without mutating it, and emits a DerailmentVerdict {signal, severity: warn|trip, evidence} per trigger plus the chosen intervention (continue / surface / pause / correct / abort; surface is the softest trip-response, a run-scoped log+/faff-wtf write that never parks an issue and never escalates). Sentry-2 Channel A: correct sits between pause and abort and is reachable only when authority is available — derived from a child call to corrective-integrity --consumer corrective (never a direct require, so this stays a pure governance span), fail-safe to channel-D-only on any non-OK child; only fix-review-thrash's mapped pause ever upgrades, every abort route is unchanged. --authority is a hermetic test-only override seam (explicit-flag-only, mirrors --budget-json/--now-ms) — production never passes it. v1 signals: budget-breach (consumes faff budget check's {breached,outcome}, no own counter), wall-clock-runaway (heartbeat staleness beyond the window OR run-elapsed beyond the ceiling; evidence names `heartbeat_source: "heartbeat-file" |
sentry-poller <start|stop|status|run> [--run-dir DIR] [--interval-secs N] [--json] [--root DIR] | The primary detached watchdog locus. Every sentry check consult elsewhere in the CLI is cooperative — it only runs when the supervised orchestrator volunteers control at a between-units checkpoint. This is the locus that does not depend on that cooperation: a mint-scoped, detached, unref'd child process that loops the unmodified sentry check/sentry abort CLI on a fixed interval (default 90s, ~1/10 of the 900s staleness window), so a wall-clock-runaway orchestrator that stops reaching checkpoints is still caught from outside its own control flow. It carries no detection math of its own — every verdict originates from a sentry check child call — and its only ledger-writing action is the existing sentry abort child (ledger-mark cancellation only: no code path here ever sends a real signal to any PID). start is idempotent on a live pid (process.kill(pid, 0) probe), clears a stale stop sentinel, spawns detached+unref'd, and writes the handle sentry-poller.json {pid, started_at, interval_secs} atomically (tmp+rename); exit 3 no run dir/ledger resolves, 2 on a bad --interval-secs (integer ≥ 1). stop writes the sentry-poller.stop sentinel — race-free, no kill signal ever sent — and exits 0 once a run dir resolves (including when no handle exists); the poller exits within one interval. status reports {running, pid, started_at, interval_secs} from the handle plus a liveness probe ({running:false} when no handle). run is the internal foreground loop start spawns (documented for completeness; prose never calls it directly) — each tick: a stop sentinel or a gone run dir exits immediately; owner.status leaving "running" self-stops (the backstop that bounds orphan-process risk if the sentinel never arrives); a mint-scoped action — the ledger's level === "L4" — runs sentry abort on a tripped:true/intervention:"abort" verdict and appends one sentry-checkpoint event (never per-tick); a non-L4 run (or a pause/correct intervention, at either level — an anti-pattern the poller deliberately never acts on) only logs advisory-trip and keeps polling, mirroring the existing "the consult always runs; acting is mint-scoped" rule; a sentry check own-fault (exit 3 / unparseable output) logs indeterminate and keeps polling — never coerced into an abort — capped at 20 consecutive faults (fault-cap-exit) so a permanently broken surface doesn't poll forever. All tick telemetry lands in the append-only sentry-poller.log (best-effort — a log-write fault never blocks or terminates the loop). This is the codebase's first detached spawn (every other child call is a blocking spawnSync). --selftest drives the pure tick-decision core over the full dispatch table (sentinel / dir-gone / ledger-fault / owner-status / L4-abort / non-L4-advisory / pause / indeterminate / fault-cap) plus --interval-secs validation, with no filesystem access and no real child process. |
sentrycheck --hook [--root DIR] [--selftest] | A cheap assist watchdog locus — the third member of the Stop-hook family alongside runcheck/prepcheck, a staleness-triggered sentry check consult with zero new detection math. Every faff Stop hook fires on every session's turn-end and looks at the newest run ledger; this reuses runcheck's ownership/liveness gate verbatim (the same env/session-id ownership match, heartbeat-only liveness with the dedicated heartbeat file overlaid first — the recorded owner pid is never consulted) and consults only on the exact foreign + owner.status:"running" + not-held case — the abandoned-looking run runcheck --hook already warns on. Owned, held (a live foreign owner), not-running (done / aborted-resumable / a legacy no-owner ledger), no run dir, and an unreadable ledger all fast-path to a silent exit 0 with no child spawned — a per-turn-end child on every session's Stop event would be unacceptable hot-path cost, and an owned run is already covered elsewhere. On a genuine consult it spawns exactly one faff sentry check --json --run-dir <dir> child, bounded at a 10-second timeout (the same child-spawn pattern the budget consult uses) — tripped:false stays silent (sentry's verdict is authoritative); tripped:true writes one non-blocking stderr [warn] line naming the run id, the tripped signal(s), the intervention, and both remedy commands (faff sentry check / faff sentry abort); a consult failure (timeout, spawn error, unparseable stdout, or a clean exit-3 indeterminate reply) writes a distinct non-blocking notice instead of silently swallowing the fault. Advisory-only at every level, including L4 — acting (sentry abort) is mint-scoped and belongs to sentry-poller (this hook has no --worktree to commit WIP against, and a non-owner never writes a foreign run's ledger); the module contains no call to sentry abort and performs no ledger write. --hook mode always exits 0 and never emits a stdout decision payload — a foreign run's state must never make an unrelated session un-exitable, the exact silence discipline runcheck --hook/prepcheck --hook already honour. --selftest runs the pure gate table (owned via both ownership signals / held / stale-running / done / aborted-resumable / legacy no-owner / unparseable heartbeat / a shrunk staleness-window override) plus the consult-outcome classifier table (tripped / ok / indeterminate / spawn-error / timeout / unparseable-stdout / unexpected-exit-code). |
lights-out [--check] [--until HH:MM] [--max N] [--slot-unreachable review|spec_review …] [--id RUN-ID] [--json] | The L4 lights-out entry point — "leave the building" as one enforced action instead of a hand-assembly of flags. Runs a fail-closed basic preflight over the launch preconditions, then on pass mints a strict-defaults L4 run-ledger and persists a banner; on any miss it refuses (mints nothing, emits no work). The preflight: container-check must be contained (warn→block on this path only — the cage is the container's job, faff never self-grants --dangerously-skip-permissions); the review + spec_review slots must be reachable (configured-but-unreachable counts as absent — the second-opinion gate must never silently pass+skip); a spend/time budget ceiling must be set — budget.cost (dollars) is the recommended default L4 governor: it is priced per-model × per-class from a built-in price map, unconditionally (the map always has SOME price, including a costliest-known-rate fallback for an unpriced model), or budget.tokens / budget.until / --until — a count-cap (max_attempts) alone is refused here because a tally is not an L4 governor (it may ride along only as an extra backstop); a .faffrc.yaml that still sets the removed legacy budget.price_per_mtok scalar refuses outright on its own gate budget-price-per-mtok-removed naming the raw value (no fail-open risk at mint time, unlike budget check's warn-and-ignore posture), and the minted run-ledger envelope defaults at_ceiling: escalate when config leaves it unset (an explicit value is honoured verbatim); a malformed budget.until/--until (not valid HH:MM) refuses on its own gate budget-until-invalid naming the raw value — this fires regardless of any other ceiling set (a run must never mint a ledger carrying malformed until), and when the malformed until is the run's only ceiling, budget-ceiling fires alongside it too (each names its own remedy); a token-dependent ceiling (budget.tokens, or an armed budget.cost) additionally requires the budget meter to be measurable (transcripts readable), not merely configured — estimate-only metering (transcripts unreadable) refuses on gate budget-metering by default, or under the explicit opt-in budget.on_estimate_only: warn proceeds with a loud DEGRADED banner line + a degrades[] JSON entry and records budget.metering: {source_at_mint, degraded} in the minted ledger (an until-only/count-only governor is a clock and never gates on this); all 8 shipped guardrail contracts — admissible --lights-out, the spec-review-verdict contract, run-done, budget check, events, sentry, holdout/prdr coverage, container-check — must pass a genuine reachability probe (not a config read), each marked live/degraded/absent in the ledger's armed map (the banner is derivable 1:1 from it); and the floor assertions hold. The floor is a checked/static split the banner labels per entry: worktree_isolation is a genuine runtime check (the resolved worktree root must be strictly outside the repo working tree and creatable/usable — a writable nearest-existing ancestor — probed side-effect-free with no mkdir), rendered checked; no_execute and autonomous_contract are static invariants of the shipped code (nothing external can runtime-verify "the runner derives no command from free text"), rendered static — the loop still fails closed on any non-true value, but the surface no longer presents a static invariant as a live check. Beyond those single-dial checks it runs a dial-coherence pass over the assembled dial tuple (slot occupants + gates.fallback), refusing combinations that pass every individual check but are jointly reckless for an unattended run — a non-adversarial review/spec_review occupant (dial-coherence:adversarial-review / -adversarial-spec-review) or a gates.fallback that is not fail-closed (dial-coherence:gates-fallback); a vetted level-recipe short-circuits it as coherent-by-construction, and it is fail-closed on the unknown (appetite is out of scope — L4 forces appetite: full). The banner additionally reports each guardrail's enforcement state — reachable (its contract answers the probe) vs enforced (an orchestrator step actually invokes it this run), recorded in the ledger's enforced map — so a guardrail that is wired-but-never-called reads reachable-only, never a bare live; the status line states ARMED — N/8 enforced (8/8 now the per-run holdout phase invokes the env→evaluate chain). Enforcement is reported, never gated on — proceed turns only on reachability. It composes the contracts and re-implements none. --check runs the preflight without minting. Exit 0 armed/minted · 1 refused. |
Provenance & control labels
| Subcommand | What it does |
|---|---|
intakecheck <issue> [--labels csv] [--interactive] | Intake-provenance guard — did the ticket enter through a sanctioned front door? (reads the CLI-written .faff/provenance/<issue>.json marker). |
intake-record <issue> --via jot|backfill|fast-track | Write/update the provenance marker + emit the descriptor (migration / orchestrator tool, not the human steady-state remedy). |
labels [--names] | Print the canonical faff control-label manifest (JSON; --names for bare names). |
label add|remove <issue-id> <label> | Emit a faff-contract:label-op descriptor for a control-label mutation the agent performs via MCP — pure, no tracker I/O; refuses the tracker-owned eligibility labels. |
Contracts, schemas & quality
| Subcommand | What it does |
|---|---|
contract <name> [--in FILE] | [<name>] --describe [--json] | Per-slot contract script: extraction JSON in → canonical contract data out. --describe is a read-only rendering branch — no stdin, no validation — that prints the contract's prose semantics (purpose, enum values + meanings, coercion/fail-direction, envelope, producer notes) generated from the SAME describe data bound by reference to the validation enums below, so the described and validated values can never drift apart; --describe --json emits the same data as JSON; an unnamed --describe prints the one-line-per-contract index. --json without --describe, or --describe combined with --in, is a usage error (exit 2) (spec-readiness, review-verdict, quality-gates, delivery-outcome, prd-readiness, prdr-admission, adr-admission, prdr-yagni, prd-coverage, run-termination, spec-review-verdict, architecture-proposal, env-handle, holdout-verdict, post-merge-verification, ci-triage); exit 0 conformant / 1 non-conformant / 2 fail-loud. The adr-admission contract validates faff adr admit's computed verdict {disposition, authority{actor,supersedes_provenance,by_level}, challenge{ran,outcome}, ratchet{lineage_supersessions,breached}, reasons[], violations[]} — the PRDR two-gate admission pattern ported onto the ADR axis, with the PRD-specific upper/lower value gates replaced by one folded challenge (drift-review) gate: an admit disposition MUST satisfy challenge.outcome==survived ∧ by_level==ok ∧ ¬ratchet.breached ∧ ¬(actor==loop ∧ supersedes_provenance==human), else it is a violation (exit 1); an out-of-enum disposition/actor/supersedes_provenance/by_level/challenge.outcome is fail-loud (exit 2), never coerced toward admit. The architecture-proposal contract validates the architecture slot's proposal envelope {chosen_architecture, rationale, adr_candidates[], assumptions[], recommendation}: a bad recommendation (∉ build/buy/hybrid) or empty chosen_architecture/rationale is a violation (exit 1); a non-object is fail-loud (exit 2). The env-handle contract validates the env slot's provisioned-environment handle {status, endpoint, endpoints?, health_checks[], readiness?, teardown_ref, teardown_cmd?, credentials?, provisioned_at, provisioner, violations}: a pure function over the handle object (no I/O) — exit 0 only when the handle is conformant and status: ready; a non-ready status (provisioning/failed/terminated), a bad status enum, a missing required field, or a ready handle with no endpoint or no health_checks are violations (exit 1); a non-object is fail-loud (exit 2). The holdout-verdict contract validates the evaluator slot's code-blind verdict {aggregate, code_blind, criteria[{class, verdict, evidence_present}], violations}: exit 0 only when code_blind: true, every prose criterion is needs-human (the machine never judges a non-born-verifiable criterion), every met/unmet carries evidence, and the aggregate matches the derivation from the criteria; an out-of-enum aggregate coerces to needs-human (never meets-spec) at exit 1; a non-object is fail-loud (exit 2). The post-merge-verification contract validates faff post-merge-check's computed verdict {verdict, command, basis, violations}: an out-of-enum verdict coerces to unverified (never verified-ok — never guess green); a verified-ok/verified-fail verdict carrying no command, or any verdict carrying an empty basis, is a violation (exit 1); a non-object is fail-loud (exit 2). The ci-triage contract validates faff ci-triage's computed TriageVerdict {pr, head_sha, transience, fault_domain, origin, action, evidence, violations}: action is always recomputed via deriveTriageAction(transience, fault_domain, origin) — a caller-supplied action that disagrees with the derived one is a violation, never trusted as-is; an out-of-enum transience/fault_domain/origin each coerce to unknown (never guessed toward a merge-eligible value); a non-object is fail-loud (exit 2). |
gates <discover|run> [--json] | Cost-ordered engineering-quality gate ladder — discover/run the repo's own declared cheap checks (pre-commit / package.json / Makefile / .github/workflows/*.yml — the CI source is a run:-line scan against a curated recognised-runner allow-list, so a CI-only repo resolves discovery: confident; local sources win dedup) cheapest-first, fail-fast, emit a faff-contract:quality-gates block. |
engine call --lane methodology|intake --system FILE --user FILE [--root DIR] [--run-dir DIR] | One-shot local-engine dispatch for an engine-valued pure-data-in lane. Resolves models.<lane> = engine:<name> against the top-level engines: map (provider / model / host / api_key_env indirection — the env var name, never the key; provider families: ollama, the openai-compatible aliases, or codex; anthropic refused — that's what the Agent-token vocabulary is for). The two HTTP families run a family-conditional preflight (ollama /api/tags, openai /v1/models — engine-unreachable and model-not-served are distinct named exits), then one non-streaming completion (ollama /api/chat stream:false; openai /v1/chat/completions). The codex family is a spawn transport instead of an HTTP POST: it takes bin_path (no host) and auth: subscription-seat | api-key, probes the seat with codex login status, then spawns codex exec --json --ephemeral --skip-git-repo-check --sandbox read-only -m <model> - with the prompt on stdin and a throwaway temp cwd removed afterwards, parsing the JSONL event stream after exit and taking the last agent message as the producer block. Either way stdout is the completion text (the producer output). --run-dir binds the dispatch's measured spend to a run (written to <run-dir>/engine-spend.jsonl); a sink fault warns and never changes the exit code. Fail-loud and terminal: no retry, no fallback chain — the caller surfaces/parks and never re-dispatches on the session model. The lane allowlist (methodology | intake) is enforced at dispatch and at config read; a non-inherit effort.<lane> on an engine-valued lane is refused. exit 0 ok · 2 usage/config fault · 4 model-not-served · 5 engine-unreachable · 6 auth-failed · 7 malformed-response. The codex family reuses that taxonomy: a missing binary, an unusable temp cwd, a child timeout or a non-auth child failure all exit 5; not being logged in, or an auth-shaped child failure, exits 6; an unparseable event line or a run that produced no agent message exits 7. Treat exit 7 on a run you have manually confirmed as good as the signal that codex's event serialization has drifted — the flag and event shapes are pinned from codex-rs source rather than observed output, and want re-pinning against a live binary. |
models build-for [<confidence>] [--tier <tier>] [--confidence <conf>] [--root DIR] | Per-issue build-model routing — print the build-model Agent-token for an issue, via up to two optional sibling matchers that extend the per-run models.build scalar: models.build_by_tier (a default plus mechanical|standard|complex-keyed leaves) and models.build_by_confidence (a default plus confidence-keyed leaves). Pure (tier/confidence passed in, no tracker call). Resolution order, first hit wins: (1) models.build_by_tier configured and a --tier given → build_by_tier.<tier> → .default → fall through to (2) — a tier matcher configured with no --tier given is skipped entirely, never guessed; (2) models.build_by_confidence.<conf> → .default (the confidence chain, unchanged); (3) models.build → inherit. The tier matcher — the better-informed key, since it already folds confidence in as a prior — outranks the confidence matcher whenever both are configured and a tier is given. The resolved token is validated against the closed build set at every configured leaf, up front (exit 2 fail-loud, naming the legal set, never a silent inherit). Both matchers absent ⇒ the scalar path, byte-for-byte; the bare positional <confidence> form (no flags) is unchanged. inherit ⇒ the caller omits the Agent-tool model param. |
tier <spec-file> [--gate-history N] [--json] | Deterministic prep-time build-tier classifier — print mechanical|standard|complex for the given spec file, a pure weighted-linear score over mechanically-extracted spec features (spec_lines, done_items, scenario_count, the retained confidence line as an additive prior, and an optional --gate-history N prior-park-count term) against baked, corpus-calibrated cut points. Same input always yields the same output — no LLM judgement, no randomness. --json prints {tier, score, features} for inspectability; bare mode prints just the token. An absent/unparseable confidence line uses the default prior, never high. Exit 0; exit 2 on a missing spec file or a non-numeric --gate-history. Read-only. |
effort build-for [--tier <tier>] [--root DIR] | Per-issue build-effort routing — print the build reasoning-effort level for an issue, via the optional sibling matcher effort.build_by_tier (a default plus mechanical|standard|complex-keyed leaves) that extends the per-run effort.build scalar. Pure (tier passed in, no tracker call). Fallback, first hit wins: build_by_tier.<tier> → .default → fall through when the matcher is unconfigured or --tier is absent (never guessed) → effort.build → inherit. No confidence-keyed effort matcher exists or is ever created — spec confidence carries little routing signal, so this matcher is tier-keyed only. The resolved level is validated against the closed effort set at every configured leaf, up front (exit 2 fail-loud, naming the legal set, never a silent inherit). Matcher absent ⇒ the scalar path, byte-for-byte. inherit ⇒ the caller omits the reasoning-effort arg. |
review-iteration-cap --appetite <low|medium|high|full> | Single-owner the review fail→fix→review loop bound. Prints the integer cap (low→1, medium→3, high→5, full→10) on stdout, exit 0 — the single authoritative source both faff-graft's Step 9 review loop and the review slot's Appetite integration table (faffter-noon-review/SKILL.md) derive from, so the two can never silently disagree. Pure (appetite passed in — already resolved via faff config get appetite — no tracker/network call). An unrecognised/absent --appetite exits 2, names the legal set, and prints nothing to stdout. |
spec-review-churn --prev FILE --curr FILE | Detect a non-converging prep↔review Spec-review-gate loop — the churn signal faff-prep/SKILL.md's Loop cap paragraph checks on the second revise/design-lens reject-approach round, before spending the remaining iteration. Reads two round-record JSON files (each the {verdict, objections} extraction JSON faff-prep already parses for the faff contract spec-review-verdict pipe, persisted verbatim to .faff/runs/<run-id>/ISSUE-XX/spec-review/round-<n>.json) and prints a {churn, prev_lenses, curr_lenses, new_lenses, reason} result on stdout, exit 0. churn: true iff --curr's deduped objecting lens-set contains a lens that was not objecting in --prev (severity and same-lens objection content are not tracked — a same-lens objection being swapped for a different one at the same lens is accepted as an undetected narrow gap, not engineered around). A missing/unreadable --prev (round 1 has no prior record) degrades to churn: false with reason: "no prior round on disk", never a crash; a malformed (present-but-corrupt-JSON) --prev or --curr is fail-loud (exit 2) — plumbing breakage, not a legitimate degrade case. Pure (no tracker/network writes). |
adr <next-number|new|list|live-decisions|validate|supersede|admit|renumber> | Deterministic mechanics over the configured ADR log. validate names every colliding file on a duplicate number; it never requires the optional Provenance: field (absent reads human) but flags a present out-of-enum value. new --provenance human|loop stamps the tier (default human); list --json / live-decisions both surface it. admit --actor loop|human --supersedes-provenance human|loop|none [--self] [--challenge survived|overturned] [--lineage-supersessions N] is the two-gate admission gate that ports the PRDR admission pattern onto the ADR axis — a pure function (no tracker/network call) emitting one admit/propose-only/reject verdict a caller pipes to faff contract adr-admission; admit requires the drift challenge to have survived (absent/overturned never passes), by_level: ok, and an unbreached adr.thrash_max/adr.thrash_window ratchet — a loop-over-human move gates at best to propose-only. renumber <filename|number> --to <NNNN|next> [--ref-scope f,f…] is the merge-time collision-repair primitive: move one ADR to a free number, fix its heading + in-scope canonical back-refs, and re-validate (git-agnostic fs move — the caller stages it); it refuses (exit 1) an occupied target, an ambiguous bare number (pass the filename), or a still-red re-validate, leaving no half-renamed tree. The faff-graft Step-10 merge guard calls it to renumber a concurrent-collision ADR before ship. |
andon <pump|send> [--json] | Push alerting for run-critical events — the one command in this binary that opens a real network connection (node:http/node:https built-ins only, zero new dependencies). pump --run-dir DIR is the orchestrator-facing verb: a cursor-based reader over <run-dir>/events.jsonl that classifies each new event against the closed run-critical set (park / sentry-trip / budget-checkpoint with data.breached≠[] → budget-breach / run-end) plus three opt-in informational classes — lifecycle progress, not a run-critical condition, absent from the default events: set: issue-admitted → one run-level admitted summary listing every ticket the run admitted (no issue field), and prep-start/build-start → one issue-scoped notification each naming the run, the ticket, and the stage. Dedupes a persisting condition to one notification via a per-run key (andon-state.json's notified), and POSTs one skimmable {run_id, class, issue?, title, body, ts, seq} record — reshaped per andon.format (generic|ntfy|slack|discord) — to andon.url. Fail-open by construction: a send failure (timeout/refused/non-2xx) is recorded in andon-state.json.failures and the cursor holds (retried next pump) but the command still exits 0 — notification outcome never gates, parks, or halts a run. At most 10 individual sends per pump; further pending critical/informational events in the same pump collapse into one rollup notification (both still marked notified + the cursor still advances past them). andon.url unset ⇒ complete no-op — zero network calls, zero state writes (the andon is dark by default), and with no andon.events override the resolved set stays exactly the three run-critical classes — existing configs are unaffected. send --class C --title T --body B [--issue ID] is the direct one-shot escape hatch bypassing the event log entirely (e.g. a fatal error outside any run dir); same config/format/fail-open posture. --json reports {disabled, sent, failed, skipped, cursor, malformed} (pump) or {disabled, sent, failed} (send). Malformed events.jsonl lines are skipped and counted, never abort the pump. See .faffrc.example.yaml's andon: block for the full config surface (url/format/token/events) — url/token are secret-bearing and belong in the gitignored .faffrc.local.yaml overlay, never the committed base. |
decisions <match --punt "<topic>"|list|validate> [--json] [--root DIR] | The decisions register (ADR-lite) — deterministic mechanics over a single committed docs/decisions.md, the "pino vs winston" park-eliminator the gateway's needs-decision-first resolve-attempt consults before its bounded inference. match --punt "<topic>" normalizes (lowercase, collapse whitespace, strip surrounding punctuation/quotes) the punt topic and every entry's Matches: keys and returns the single normalized-full-equality match {id, chosen, rationale, scope} — never substring/containment, so a generic key contained in a longer unrelated punt never fires; two or more equal-matching entries is ambiguous and returns {match: null}, same as no match at all. Scope: is descriptive-only, never consulted by the match. list [--json] enumerates every entry (id/topic/chosen/date); validate structure-lints required fields (Chosen/Rationale/Scope/Matches/Date, Matches non-empty) and unique citation ids, printing FAIL docs/decisions.md ✗ <reason> per violation (exit 1). Pure, no tracker/network access, no writes (region factory, mirrors the faff next/faff eligible caller-passes-state contract): an absent docs/decisions.md is a clean {match: null}/empty list, never an error (exit 2 is reserved for a genuinely unreadable file). The CLI is deliberately read-only — register writes are ordinary committed-file edits landed in a human-ratified PR (faff-prep records a ## Decisions-register intent capture comment, faff-graft materialises the entry on the feature branch), never a CLI-driven runtime write. |
prd <path|new|link|list|validate [--strict]> | Deterministic mechanics over the configured PRD log (default docs/prd/) — the product-axis counterpart to adr: scaffold/list/validate a per-container PRD (slug-keyed) and emit the container-link line; the caller commits + applies it. validate is lenient (metadata + non-empty body, never section shape); validate --strict additionally form-checks ## Acceptance criteria as born-verifiable — every criterion must be a Given/When/Then scenario or a MUST/comparator assertion (loose prose, placeholder-only, or a missing section FAILs). A Frozen-status PRD always runs that born-verifiable check even without --strict (the freeze precondition); ## Requirements and other sections stay lenient. The check gates FORM only — semantic verifiability is the evaluator's/human's job. |
prd-checklist <path> | The git-only PRD gate's parse-and-emit half: parse a checklist-style PRD's GFM task-list stop-conditions (- [ ]/- [x]/- [X], any nesting depth, fenced-code-block lines ignored) and emit the existing prd-coverage contract shape — no new schema. Every parsed task-list item maps onto the completion face, never the coverage face: covered is always true / uncovered_goals always [] (a listed checkbox is trivially tracked), while checkbox state drives completion.all_met / unmet_or_unverified / the satisfied roll-up run-done reads via --prd-coverage. Pure: filesystem read of <path> only, no tracker/network/writes. Governing safety constraint — never a false covered/satisfied: an empty/whitespace-only file, a file with no GFM task-list items (prose/headers/plain bullets only), or a checkbox with an empty label all degrade loudly (exit 2, stderr diagnostic, no stdout block) rather than a vacuous satisfied:true. Output is raw single-line JSON on stdout, exit 0, belt-and-braces schemaChecked against prd-coverage, and accepted unchanged by faff run-done --prd-coverage. |
prdr <path|new|list|supersede|validate|admit|yagni|coverage> | Deterministic mechanics over the configured PRDR product-requirements decision-record log — the supersedable record between the immutable PRD and the per-slice spec (PRD content, ADR mechanics: globally-numbered, immutable, supersedable). new scaffolds a record citing its container + PRD-goal; supersede is a pure mechanical linker; validate is presence-only (metadata + the four body sections + symmetric supersession, never section content). admit <prdr> --actor loop|human --supersedes-provenance human|loop|none [--self] [--new-capability] [--drops-last-goal] [--lineage-supersessions N] [--thrash-max N] [--upper JSON] [--lower JSON] is the two-gate admission gate: a pure verdict (no tracker/network call) that computes authority (provenance check), the recursive by-level invariant (a loop cannot supersede the PRDR governing its own increment), and the count thrash-ratchet itself, then folds in the upper (YAGNI) and lower (coverage) gate verdicts — or their fail-safe defaults when absent (a new-capability PRDR is not admitted without the YAGNI judge; a supersession dropping a goal's last cover escalates) — and prints a prdr-admission verdict (admit / propose-only when a loop moves a human-provenance PRDR needing human ratification / reject). yagni [<prdr>] --prd-goal <g> --prd-goals JSON [--proposal admit|reject] [--proposal-reason S] [--serves-goal] [--within-scope] [--challenge survived|overturned] [--challenge-reason S] [--grounding-present] is the upper (YAGNI) gate: a pure two-phase arbitration (no tracker/network) — a deterministic trace-to-goal precondition (prd_goal ∈ PRD goals, else reject at the door), the methodology slot's Phase-1 proposal, and the adversarial-review slot's Phase-2 challenge → conservative-reject-on-doubt; prints 255's upper {admit, reason} verdict as a prdr-yagni block, fed to prdr admit --upper. coverage --prd-goals JSON [--live-prdrs JSON] [--dod-verdicts JSON] [--container <slug>] is the lower (coverage) gate + prd-satisfied roll-up (two faces of one goal↔PRDR↔DoD relation): a pure verdict (no tracker/network) computing both the static coverage — every PRD goal stays covered by a live PRDR, emitting 255's lower {covered, uncovered_goals} (fed to prdr admit --lower; a supersession dropping a goal's last live PRDR → uncovered → lower violation, no silent abandonment) — and the dynamic roll-up prd-satisfied ⟺ coverage ∧ (∀ live PRDR DoD met), reading the evaluator's per-PRDR DoD verdicts (--dod-verdicts) with a conservative default (a DoD with no met verdict is unverified ⇒ not satisfied, so while the evaluator is unbuilt prd-satisfied is conservatively false — the run cannot claim product-done without the judge). --live-prdrs overrides the configured live-set read; prints a prd-coverage block — the product-done predicate the run-terminating-condition composition and the per-project release-gate Done transition consume. Reads tracking.prdr_docs_path (default docs/prdr/), prdr.thrash_max, prdr.thrash_window. |
profile <validate|show|mine> [--file F] [--json] | Infra-profile schema + CLI + repo-mining acquirer. validate a profile JSON; show the effective profile (.faff/infra-profile.json ⊕ .faffrc.yaml infra:, override wins per field); mine is the default profile-slot acquirer — a deterministic, read-only repo-miner that scans --root for committed infra artifacts (CI workflows, Dockerfile/compose, Terraform, netlify/vercel/Procfile, language manifests) and emits one faff-contract:infra-profile block (--json for raw JSON). Pure file inspection: no network/install/subprocess; writes no files (the orchestrator validates the block then stores it). |
profiles <list|validate> [--file F] [--json] | Governance profiles (unrelated to profile above — this is the runcheck/events/sentry vocabulary table, not the infra profile). The three governance engines are dialect-independent machinery; the closed word-lists they validate against (terminal states, event types + per-type field rules, sentry thresholds/predicate bindings) are single-sourced into one DELIVERY_PROFILE constant each engine reads via a threaded, defaulted profile parameter — byte-identical to today's hardcoded vocabulary when it's the active profile (the default). list prints the active profile ($FAFF_GOVERNANCE_PROFILE names an override file); validate [--file F] shape-validates a profile (F, or the active one) against the closed-vocab guard (every leaf a string / finite number / array-of-strings / flat object — a conditional/nested-policy shape is refused, naming the offending leaf): exit 0 valid / 1 invalid+reasons / 2 unreadable. A missing/malformed/shape-invalid override is loud — every governance subcommand exits 2 with the reason on stderr, never a silent fallback to delivery. --selftest drives all three engines' pure cores under both DELIVERY_PROFILE and a synthetic disjoint-vocabulary SECOND_PROFILE fixture: a delivery fixture is rejected under SECOND_PROFILE (and vice versa), and sentry's thrash predicate trips at SECOND_PROFILE's own thrash_n — the dialect-independence proof. |
fixtures <validate|show|realise> [--file F] [--out DIR] | Fixtures dataset-manifest schema + CLI — validate a manifest JSON, show the effective manifest (.faff/fixtures/manifest.json ⊕ .faffrc.yaml fixtures:, override wins per field), or realise a deterministic dataset from (manifest, seed) into dataset_path/--out. The generation strategy is a deferred slot; this ships the contract plus a trivial reference generator. |
env <compose-gen|up|seed|down> [--profile F] [--plan F] [--out PATH] [--project NAME] [--manifest F] [--sla-secs N] [--poll-secs N] | Live compose provisioning for the env slot. compose-gen is the deterministic, docker-free core — maps an infra profile (--profile, else .faff/infra-profile.json) through the built-in DATASTORE_TABLE to a docker-compose file (--out, default .faff/env/docker-compose.yml) and prints a ProvisionPlan (services, endpoint, health_checks[], seed_targets[], unprovisionable[]); byte-identical across runs (no wall-clock/random). An unknown datastore kind is reported in unprovisionable[] (the producer fails loud), never silently skipped. When the repo ships its own committed compose (docker-compose.yml / compose.yaml / …) that declares a single buildable app service, compose-gen reconciles the synthesized app against it — its port, environment (incl. DATABASE_URL, with datastore hosts realigned to the generated service names), build context/dockerfile, and healthcheck win over the profile-shaped defaults (the healthcheck is taken verbatim, and a synthesized fallback never assumes curl); with no repo compose the output is byte-identical to profile-only synthesis. The reconcile also covers the datastore tier: each generated datastore service inherits the repo datastore service's auth env (POSTGRES_USER/POSTGRES_DB/POSTGRES_PASSWORD, etc.) merged over the table default (so the role/database the app's connection string binds to are actually created), and the app service gains a depends_on: { <store>: { condition: service_healthy } } gate so a connect-at-boot app doesn't race the datastore. The live verbs touch docker: up runs compose up -d (with --project-directory <repo-root>, so a relative build context: . resolves to the repo root rather than the generated-compose dir) + health-waits to --sla-secs (default 60, --poll-secs 2); seed realises the dataset (faff fixtures realise --manifest) and loads per seed_targets (sql-load = postgres/mysql/sqlite; mount = the provisioned-but-unseeded fallback for redis/mongo until an engine-native loader lands); down runs compose down -v (idempotent) — resolving the same compose context up used when the default generated compose file exists (--project-directory + -f + --remove-orphans, which covers a stale or other-project file at the default path), else the label-based -p-only form. Every verb inherits the ambient engine context: the docker CLI resolves DOCKER_HOST itself; faff never sniffs sockets, never sets DOCKER_HOST, and never falls back to the default socket when DOCKER_HOST is set but dead — an unreachable engine is a loud terminal error naming the effective context (DOCKER_HOST=…, or default socket when unset). That ambient model is what makes the verbs engine-agnostic across host daemons and rootless nested engines; rootless caveats (storage driver, cgroup delegation, port-publishing loopback) are cage-image preconditions, not faff's — the joint acceptance runbook at docs/reference/cage-engine-acceptance.md covers them. The default faffter-noon-env-compose producer orchestrates these and emits the faff-contract:env-handle. |
eval affected [--surfaces a,b,…] [--diff <ref>] [--json] [--root DIR] [--selftest] | Advisory (non-contract) deriver of the affected eval --kind subset a change needs: composes each touched skill surface's judgement_seam: frontmatter with eval/seam-registry.json (the KIND→surface SSOT) so the operator-owned eval-sweep-gate can re-baseline a scoped subset instead of a full frontier sweep, or skip on none. Touched surfaces come from --surfaces (comma list) and/or --diff <ref> (git diff --name-only <ref>...HEAD, via an argv-array, never a shell string); at least one is required (else exit 2). Fail-safe to full, never under-recommend: none is emitted ONLY when every touched surface is confidently non-grading (judgement_seam: none or owns no graded kind); any doubt — an unresolvable diff ref, a touched file outside any plugin/skills/<surface>/, an undeclared (null-frontmatter) surface, a seam whose registry row names a different surface, or an unavailable registry — resolves to full with a reason. Plain stdout is one line (subset → the comma-joined sorted-unique kinds, usable directly as run-evals.mjs --kind; none — …; full — <reason>); --json emits {verdict ∈ none|subset|full, kinds (sorted+unique), surfaces, reason}. Reuses validate-adapters.js's readJudgementSeam/loadSeamRegistryForLint (no re-parse); --selftest runs the in-module case table. exit 0 for any classification (none/subset/full all valid) / 2 usage. |
events <append|validate|read> --run <run-id> [--ts ISO] [--tokens] [--file F] [--type T] [--issue I] [--json] | Structured run-event log — the timeline substrate for run observability. append adds one RunEvent (the CLI fills the schema/run_id/seq/ts envelope; seq = current line count is the authoritative monotonic order, ts best-effort) to the append-only .faff/runs/<run-id>/events.jsonl; validate checks a JSONL stream line-by-line; read filters it. --tokens (opt-in on append) injects a four-class token delta into the event's data: data.tokens = {input, output, cache_write, cache_read} (spend since the run's last checkpoint, measured via the same transcript-sum + child-attribution path budget check uses) and data.tokens_source (transcript when metered, or estimate with tokens: null when no transcript is readable); it also advances the ledger's budget.tokens_at_last_event checkpoint and stamps data.ledger_sha256_before/data.ledger_sha256_after — the ledger digest this locked write saw and left, so an orchestrator holding an integrity-digest custody baseline can close its mid-bracket re-baseline sequence without a second read. Counts-only (never prompt/response payload), and schema stays 1 (additive under the free-form data). Absent --tokens, the record is byte-identical to before (no measurement, no ledger read). Emitters tag phase-closing events only. An optional data.effort label (one of low|medium|high|xhigh|max) may be supplied in the append payload to record the request-time reasoning-effort a dispatch used — validated against that closed vocabulary, additive, non-leak (a single label); economics --by effort pivots it. An issue-outcome event may also carry an optional data.gate (which quality gate — structural|adversarial|holdout|ci — caught a non-shipped build) and data.rework_turns (a non-negative integer fix-loop count) — both validated, additive, non-leak (single scalars); faff quality pivots them. The in-flight view and morning report are later producers that read this log. append's run dir is worktree-aware: without an explicit --root, it resolves .faff/runs/<run-id> against cwd first and, if absent there, against the MAIN checkout (via git rev-parse --git-common-dir) — so an append from inside a linked build worktree lands in the main checkout's events.jsonl, not a missing worktree-local one; an explicit --root is a strict escape hatch with no such fallback. |
review-progress <read|write> <run-dir> <issue> [--phase1-pass --diff-hash H | --phase2 STATUS [--findings PATH] [--attempts N] | --outage-retry] | Per-issue review checkpoint at <run-dir>/<issue>/review-progress.json so a re-dispatched build subagent resumes the graft review step (skips a completed Phase-1/Phase-2) instead of repeating the slow adversarial second-opinion. read → ReviewProgress JSON (exit 0) / exit 3 when absent (no checkpoint yet — not an error). write --phase1-pass --diff-hash H records the passing Phase-1 verdict + the diff hash it was computed against (the diff-identity guard — a resume skips Phase-1 only when the current diff still matches). write --phase2 <pending|in_flight|complete|skipped_deadline|skipped_unreachable> records Phase-2 progress. write --outage-retry increments the top-level outage_retries counter (absent → 1; requires an existing record — exit 2 if none) for graft's retry-later hold on a mandatory-review unavailable verdict. Pure JSON (no tracker/network) — a hint, never authoritative over git/PR truth; exit 2 usage / bad status. |
build-progress <read|write> <run-dir> <issue> [--build-complete --diff-hash H --branch B] | Per-issue build-complete checkpoint at <run-dir>/<issue>/build-progress.json — written at graft build-complete (Step 8b) after the branch is pushed to origin, so a re-dispatched graft resumes at review (recreating the worktree from origin/<branch>, skipping the build) instead of rebuilding. read → BuildProgress JSON (exit 0) / exit 3 when absent (no checkpoint yet — not an error). write --build-complete --diff-hash H --branch B records the remote-three-dot diff hash it was gated at + the pushed branch (the diff-identity guard — a resume skips the build only when the current remote diff still matches). Pure JSON (no tracker/network/git — the hash is computed in graft prose); a hint, never authoritative over git truth; exit 2 usage / missing flag. |
effects <declare|observe|check> --run <run-id> [--issue ID] [--step NAME] [--ts ISO] [--json] | Escaped-side-effect detection via a declared-effects ledger — the producer of the escaped-side-effect signal Sentry (the kill-switch) consumes. declare / observe read an EffectDescriptor ({kind ∈ a closed effect vocabulary, target, reversible?}, object or array) on stdin and append a LedgerEntry (the CLI fills the schema/run_id/seq/ts envelope; kind_of_entry/issue/step required) to a parallel .faff/runs/<run-id>/declared-effects.jsonl — never an events.jsonl schema bump; exit 0 ok / 1 bad descriptor (unknown kind / missing target) / 2 malformed JSON or a missing --run/--issue/--step / 3 run-dir missing. check computes observed-minus-declared per (issue, step) with exact-target + * matching and emits {escapes: [EscapeSignal{signal:"escaped-side-effect", issue, step, escaped[], event_seq}], any_escape} (--json); a missing ledger file is a clean state ({escapes:[], any_escape:false}, exit 0 — not exit 3). Detection only — it never aborts (that is the kill-switch's / recovery's job); the orchestrator bridges any_escape into Sentry via faff sentry check's --forbidden-side-effect CLI flag (the signals.forbidden_side_effect seam's CLI surface), run immediately after this check at every between-units checkpoint. declare/observe share one append core, appendEffectEntries (validates every descriptor before writing any, all-or-nothing; merge-gate calls the same helper for its own mechanical observe — see that row). merge-gate --execute is the first mechanical producer: graft Step 10 declares a PR's merge (+ branch-delete iff requested) before handing off to it, and merge-gate observes what it actually did after a confirmed merge — real declared-vs-observed pairs, and a real uncovered-observe warning, exist end to end at that one chokepoint. Pure (no tracker/network; writes only under the run dir). The run dir is worktree-aware: without an explicit --root, declare/observe/check resolve .faff/runs/<run-id> against cwd first and, if absent there, against the MAIN checkout (via git rev-parse --git-common-dir) — so a call from inside a linked build worktree reads/writes the main checkout's ledger instead of exit-3ing or false-reporting clean against an empty worktree root; an explicit --root is a strict escape hatch with no such fallback. |
lint-refs [--root DIR] | Ban external-artifact refs (ticket tags, ADR citations, numbered ADR-file pointers) in enforced prose — scans docs/guide/**; names each file:line ✗ match and exits 1 on a hit, else 0. |
lint-cli-doc [--root DIR] [--json] | Assert docs/guide/cli.md documents every subcommand the CLI dispatches (the COMMANDS registry), bidirectionally — names each ✗ missing/✗ orphaned and exits 1 on drift, else 0. |
lint-cli-coverage [--root DIR] [--json] | Assert every subcommand in the COMMANDS registry is tested by something — a non-null REGION_SELFTEST_ARGV selftest, or a declared test/*.mjs file for a null-selftest command. Bidirectional and fail-closed: names each ✗ uncovered/✗ orphaned/stale-declaration and exits 1 on a gap, else 0. |
cli-surface --json | --selftest | The declared, machine-readable CLI grammar — a per-verb {kind, subcommands, flags, required_flags} descriptor assembled from each dispatch module's own exported SURFACE + accepted-flag CommandSpec (never a re-listing), in bijection with COMMANDS. --json prints the full map (kind ∈ subcommand_dispatch|positional|flat; flags: null = no declared spec, an unknown accepted set, never an empty one). --selftest asserts the bijection, a pinned classification for each dispatch verb (a real member of its live vocabulary) plus the three pinned positional verbs, and that every declared required_flags name is itself in the verb's own accepted-flag set; exit 0 clean / 1 violation(s) / 2 usage. The scaffolder CLI-surface drift-guard imports this module's SURFACES directly (never spawns faff <verb>) for both verb/subcommand existence and the flag-layer assertions (unknown flag, missing required flag) on RUNBOOK command-block lines. |
regions <list|check|selftest> [--json] [--region governance|factory|all] | The logical governance/factory boundary inside the CLI, enforced structurally over its real require() graph. list prints the single in-code region map (command → region; --json for the object). check builds the file→region map from each entrypoint/bin/lib module's own region banner, then walks each governance/shared-infra module's require("./…") edges against it — a governance file requiring a factory file, or a shared-infra file requiring any local module, is a violation naming both files, their regions, and the require line (comment/string-embedded require-shaped text is ignored); exit 0 clean / 1 violation(s) / 2 malformed (a file with no region banner, mixed-region banners, an unknown region, a non-literal require argument in a bound file, a bound file's require resolving outside the source set, or map/registry drift); there is deliberately no suppression mechanism. selftest spawns each member command's own --selftest per an explicit full allowlist (default --region all) and prints a per-command pass/fail table — a governance member without a selftest is fatal; a factory one is reported no-selftest, non-fatal; exit 2 before any spawn on allowlist drift (a command missing from the allowlist, or a no-selftest entry whose handler now carries a selftest branch). |
Most are invoked by the skills and hooks for themselves. A few are handy by hand — e.g. faff config get <dotted.key> to read a value from your config, or faff validate-adapters --configured to pre-flight your swapped-in slots before an unattended run.
Running it by hand
The binary lives at plugin/skills/faff/bin/faff inside the installed plugin. Locate it and (optionally) symlink it onto your PATH once:
faffbin=$(find ~/.claude -path '*/skills/faff/bin/faff' -type f 2>/dev/null | head -1)
ln -s "$faffbin" ~/.local/bin/faff # then add ~/.local/bin to PATH if it isn't already
export PATH="$HOME/.local/bin:$PATH"
Or just call it by that full path. (Inside skills and hooks it's resolved automatically — command -v faff first, then the install-relative path — so you never have to set this up for normal use.)