Fab Architecture

September 11, 2026 · View on GitHub

Directory structure, conventions, configuration, distribution, and agent integration.


Directory Structure

Kit content (skills, templates, migrations) does not live inside user projects. It is distributed to the system cache at ~/.fab-kit/versions/<version>/kit/, managed by the fab binary (brew install fab-kit). A user project contains only its own configuration, changes, documentation, and deployed skill copies:

project/
├── .fab-status.yaml → fab/changes/{name}/.status.yaml  # Symlink to active change status (gitignored)
├── fab/
│   ├── project/
│   │   ├── config.yaml             # Project-specific configuration (sparse overrides + managed reference fence)
│   │   ├── constitution.md         # Project principles & constraints
│   │   ├── context.md              # Free-form project context (optional)
│   │   ├── code-quality.md         # Coding standards for apply/review (optional)
│   │   └── code-review.md          # Review policy (optional)
│   ├── backlog.md                  # Local backlog (consumed by fab batch new)
│   ├── .fab-version                # Project-pinned engine version (router resolves the fab-go binary)
│   ├── .kit-migration-version      # Version the project's file formats were written for
│   └── changes/
│       ├── 260115-a7k2-add-oauth/  # Active change
│       │   ├── .status.yaml        # Stage tracking
│       │   ├── .history.jsonl      # Append-only event log
│       │   ├── intake.md
│       │   └── plan.md             # Co-generated at apply entry — ## Requirements + ## Tasks + ## Acceptance
│       └── archive/                # Completed changes, date-bucketed
│           ├── index.md            # Archive index (most-recent-first)
│           └── 2026/01/250920-m3x1-add-2fa/
├── docs/
│   ├── memory/                     # Post-implementation source of truth (domain folders + generated indexes)
│   └── specs/                      # Pre-implementation design intent (human-curated)
├── .claude/                        # Always-on Claude Code skill deployment
│   └── skills/
│       └── fab-new/
│           └── SKILL.md            # Copy deployed by `fab sync` from the kit cache
└── .agents/                        # Always-on cross-client Agent Skills deployment
    └── skills/
        └── fab-new/
            └── SKILL.md            # Same canonical skill, deployed independently

In the fab-kit dev repo, src/kit/ is the canonical source for all kit content (skills in src/kit/skills/, templates in src/kit/templates/, migrations, scaffold), and src/go/ holds the Go binaries. Releases package src/kit/ into the per-version cache archives — projects never carry a .kit/ directory. docs/specs/ and docs/memory/ are dev-repo-only: deployed kit content under src/kit/ must not cite them (Constitution V — restate the rule in the deployed file instead).


Folder Naming Convention

Format: {YYMMDD}-{XXXX}-{slug}

ComponentGenerated byPurposeExample
YYMMDDfab change new (always today)Chronological sort, temporal context260115
XXXXfab change new (4 random lowercase alphanumeric)Uniqueness guaranteea7k2
slugCaller-provided via --slug (2-6 words from description)Human readabilityadd-oauth

Examples: 260115-a7k2-add-oauth, 260202-m3x1-fix-checkout-bug, 260205-k8ui-refactor-auth

Constraints: All components are lowercase only — avoids collisions on case-insensitive filesystems (macOS default, Windows). The {YYMMDD}-{XXXX} prefix is immutable; only the slug can be changed (fab change rename).

Why this format?

  • Unique by construction — date + random token means no collision scanning needed
  • Chronological ls — folders sort by creation date naturally
  • Stable across lifecycle — same name from creation through archive (no rename on archive)

For the full set of naming patterns (branches, worktrees, PRs, backlog entries), see _preamble.md § Naming Conventions and Naming.


Active Change Tracking (.fab-status.yaml)

.fab-status.yaml is a symlink at the repo root that points to the active change's .status.yaml file (e.g., fab/changes/260115-a7k2-add-oauth/.status.yaml). It removes the need to scan changes/ or remember folder names.

Lifecycle:

  • Created by /fab-new (auto-activation via fab change switch) or /fab-switch
  • Updated by /fab-switch — symlink replaced to point to the new change's .status.yaml
  • Read by every other skill — /fab-continue, /fab-clarify, /fab-status all resolve the active change via .fab-status.yaml rather than requiring a name argument (a transient [change-name] argument overrides it without modifying the symlink)
  • Removed by /fab-archive — symlink is deleted after archiving the active change (no active change)

Switching between changes: If multiple change folders exist and you want to switch context:

/fab-switch add-oauth
→ ".fab-status.yaml → 260115-a7k2-add-oauth"

/fab-switch accepts partial matches — the slug portion is enough to identify the change unambiguously.

Quick check from terminal: For instant identification when switching between editor windows, run /fab-status — it outputs a formatted status block with version, change name, branch, stage progress, plan counts (tasks + acceptance), and suggested next command.

Why a symlink?

  • Direct access — reading .fab-status.yaml yields the active change's status directly, no intermediate lookup step
  • Atomic pointer — the symlink target encodes both the change name and the path to its status file
  • Git-friendly.fab-status.yaml is gitignored since it's local working state

Abandoning a Change

To discard a change that won't be completed:

  1. Delete the change folder: rm -rf fab/changes/{name}/
  2. Remove the symlink (if it's the active change): rm .fab-status.yaml
  3. Optionally delete the associated git branch: git branch -d {branch}

There is no /fab-abandon skill — this is a manual operation. If you want to preserve context about why the change was dropped, note the reason in the intake and archive the folder instead of deleting it (/fab-archive requires hydrate done; for a truly abandoned change, move the folder under fab/changes/archive/{yyyy}/{mm}/ by hand).


Status Tracking (.status.yaml)

Every change folder contains a .status.yaml manifest (see Templates for the full schema and field notes):

Example — intake stage (plan not yet generated):

id: a7k2
name: 260115-a7k2-add-oauth
created: 2026-01-15T14:30:00Z
created_by: Jane Smith
change_type: feat
issues: []
progress:
  intake: active
  apply: pending
  review: pending
  hydrate: pending
  ship: pending
  review-pr: pending
plan:
  generated: false
  task_count: 0
  acceptance_count: 0
  acceptance_completed: 0
confidence:
  certain: 0
  confident: 0
  tentative: 0
  unresolved: 0
  score: 0.0
stage_metrics: {}
prs: []
last_updated: 2026-01-16T09:15:00Z

Example — review stage (acceptance partially verified):

id: a7k2
name: 260115-a7k2-add-oauth
created: 2026-01-15T14:30:00Z
created_by: Jane Smith
change_type: feat
issues: [DEV-907]
progress:
  intake: done
  apply: done
  review: active
  hydrate: pending
  ship: pending
  review-pr: pending
plan:
  generated: true
  task_count: 8
  acceptance_count: 12
  acceptance_completed: 10
confidence:
  certain: 9
  confident: 2
  tentative: 0
  unresolved: 0
  score: 4.4
stage_metrics:
  intake: {started_at: "2026-01-15T14:30:00Z", driver: fab-new, iterations: 1, completed_at: "2026-01-15T15:02:11Z"}
  apply: {started_at: "2026-01-15T15:02:11Z", driver: fab-fff, iterations: 1, completed_at: "2026-01-17T18:40:09Z"}
  review: {started_at: "2026-01-17T18:40:09Z", driver: fab-fff, iterations: 1}
prs: []
last_updated: 2026-01-18T11:00:00Z

All mutations go through the fab CLI (fab status <event>, including the pull-based fab status refresh self-healed at the transition seams) — skills never hand-edit the file. The current stage is derived from the progress map (the entry marked active); there is no separate stage: field.


Configuration (config.yaml)

Canonical full reference: run fab config explain to print a fully-commented config.yaml documenting every available option (both binary-consumed and skill-consumed keys), generated from the binary's own constants so the shown defaults cannot drift. The excerpt below illustrates the key relationships; the command is the authoritative, always-current reference.

The keys actually consumed by the binaries and skills:

Note: the project-pinned engine version is NOT a config.yaml key — it lives in the plain-text sibling fab/.fab-version (one line, bare semver), stamped by fab init / fab upgrade-repo (relocated out of config.yaml in 2.15.0). config.yaml is a sparse-overrides file whose reference fence is regenerated by fab config upgrade.

project:
  name: "My App"
  description: "App description"          # read by skills for context
  linear_workspace: myteam                # optional — enables Linear issue links in PR bodies

# Directories containing implementation code (relative to repo root).
# Read by skills for scoping; test_paths/true_impact_exclude feed `fab impact` / `fab pr-meta`.
source_paths:
  - src/
test_paths:
  - '**/*_test.go'
true_impact_exclude:
  - fab/
  - docs/

# Project-specific categories appended to default plan-acceptance categories.
# (Config key remains `checklist.extra_categories` for backward compatibility;
# semantically these are categories under plan.md ## Acceptance.)
checklist:
  extra_categories:
    - performance
    - ux

# Provider capability grammar (top-level). Each provider maps an opaque, user-chosen
# name to independent interactive_command, headless_command, and native capabilities.
# interactive_command opens an interactive agent
# SESSION (fab operator / fab batch / fab agent — and `fab dispatch open`,
# the interactive-pane stage adapter); headless_command runs ONE
# headless STAGE task via `fab dispatch` (which pipes the stage prompt to the
# command's STDIN). native:true records an Agent-tool seam. Capability presence
# says HOW a rung runs, never WHICH rung to prefer; dispatch.mode below owns policy,
# descends pane → native → headless, and never substitutes one command for another. fab-kit
# ships FOUR built-in providers — claude (the default), codex, agy and kimi — each
# with its command grammar, and (except kimi) a per-ROLE fill map,
# profiles.<role>.{model, effort}, supplying the {model}/{effort} placeholders when
# that provider plays that role (precedence: invocation flag > agent.profiles.<role>
# field > profiles.<role> > profiles.default > empty; the `default` entry is the
# provider's cross-role fallback, so a sparse map such as agy's is well-defined for
# the roles it omits; claude's and codex's maps are dense).
# So naming any built-in needs no providers: block at all. All four blocks below
# render LIVE and uniformly (one `#` deep in a fence); hoisting one PINS its fills
# against kit-release refreshes — prefer a single-field override.
# Non-claude fills are refreshed at kit-release cadence and pass through unvalidated —
# pin a newer model with providers.<name>.profiles.<role>.model. Claude ships
# session, native, and headless capabilities; codex, agy, and kimi ship both command
# fields without native capability. Under the default mode, claude resolves native
# while the non-claude built-ins descend to headless.
# (Automated PR reviewer toggles moved to code-review.md § Review Tools — absent = enabled.)
# Per-provider notes (kept out of the blocks below so uncommenting a whole block
# stays valid YAML): claude -p and codex exec both read the prompt from stdin.
# Codex carries --dangerously-bypass-approvals-and-sandbox; agy carries
# --dangerously-skip-permissions. Both flags are deliberate because unattended stage
# workers cannot answer approval prompts; override a provider command to restore
# approvals. kimi's dispatch form carries no approval flag at all: kimi -p already
# auto-approves tools and errors when combined with --yolo/--auto, so its full-auto
# flag rides its interactive_command instead.
# All four built-ins ship an interactive_command and are PANE-mode eligible. agy's is
# `agy --dangerously-skip-permissions --model {model}`. Its fresh-workspace trust
# prompt is an ordinary readiness-gate judgment round whose exact workspace path may
# be pre-seeded in `trustedWorkspaces` in ~/.gemini/antigravity-cli/settings.json;
# fab never writes the trust store. Backlog [agik] retains the live open → ready →
# deliver verification after quota reset. kimi's probe is done (2026-08-10): its wall is one
# readiness-gate judgment round and its side-bordered input box verifies under
# delivery's box-drawing-tolerant echo check, so it ships one and is pane-capable.
# Codex's -m takes a concrete model SLUG, so its shipped fills are pinned IDs.
# agy carries no {effort} — its model IDs embed the reasoning level as a suffix, so
# its fills carry none either. agy and kimi both take the prompt as the -p ARGUMENT
# and ignore stdin, so their headless_commands nest a shell (sh -c '… -p "$(cat)"'):
# POSIX expands $(cat) before fab dispatch's stdin redirect applies. kimi ships NO
# fills — its -m takes a user-config model alias, so the empty model drops the flag
# and its own default_model applies. This whole block is advertise:false — documented in
# `fab config explain`, not scaffolded into every project's managed fence.
providers:
  claude:
    native: true
    interactive_command: claude --permission-mode bypassPermissions -n "$(basename "$(pwd)")" --model {model} --effort {effort}
    headless_command: claude -p --permission-mode bypassPermissions --model {model} --effort {effort}
    profiles:                              # the six per-role fills — run `fab config explain` for the live values
      doing: { model: <model-id>, effort: <effort> }   # example: shape only
  codex:
    interactive_command: codex --dangerously-bypass-approvals-and-sandbox -m {model} -c model_reasoning_effort={effort}
    headless_command: codex exec --dangerously-bypass-approvals-and-sandbox -m {model} -c model_reasoning_effort={effort}
    profiles:                            # dense — all six roles; run `fab config explain` for the live values
      default: { model: <model-id>, effort: <effort> }   # example: shape only
  agy:
    interactive_command: agy --dangerously-skip-permissions --model {model}   # no {effort}: reasoning rides the model suffix
    headless_command: sh -c 'agy … --model {model} -p "$(cat)"'   # no {effort} flag; nested shell so $(cat) reads the piped prompt
    profiles:                            # model-only: the reasoning level rides the ID suffix
      default: { model: <model-id> }     # example: shape only
  kimi:
    interactive_command: kimi --auto -m {model}             # --auto: the full-auto flag its headless form rejects
    headless_command: sh -c 'kimi -m {model} -p "$(cat)"'   # no --yolo: kimi -p rejects it and already auto-approves
                                         # no profiles: fab ships no kimi fill (its -m takes a user-config alias)

# agent.session / agent.workers are the TWO ADVERTISED KNOBS, selecting a provider
# by agent DEPTH: session = the Tier-1 roles you talk to (default, operator —
# fab agent / fab operator / fab batch), workers = the Tier-2 roles pipeline stages
# dispatch to (doing, review, hydrate, fast). Both default to claude, and both are
# scope `both`, so "claude for what I talk to, codex for the workers" is settable
# once per machine. fab owns a FIXED, non-overridable stage→role mapping
# (default: intake advisory / doing: apply, review-pr / review: review /
# hydrate: hydrate / fast: ship + /fab-proceed prefix steps; operator: the fab
# operator coordinator session) AND the role→depth partition above.
#
# agent.profiles (optional, advertise:false) is the SPARSE per-role escape hatch
# beneath the knobs: {provider, model, effort} per role, every field optional, a set
# field beating the knob (provider) or the provider's own fill (model/effort). There
# is NO cross-role inheritance — agent.profiles.default is the `default` ROLE's own
# override. Resolved per stage/role by `fab agent <stage|role> -o yaml` at sub-agent
# dispatch time; see docs/specs/stage-models.md. (`agent.tiers` is the pre-2.17.0
# spelling — still read, rewritten by the 2.16.19-to-2.17.0 migration.)
# Run `fab config explain` for the current built-in profiles (rendered live, so it
# cannot go stale). Shape:
agent:
  session: claude
  workers: claude
  profiles:
    review: { provider: codex }   # example: shape only

# dispatch.mode (optional, default native) — the preference ceiling: pane, native,
# or headless. Resolution starts there and descends only through pane → native →
# headless. Pane requires tmux plus interactive_command; native requires native:true;
# headless requires headless_command. Missing prerequisites skip rungs, never
# ascend. `fab agent <stage|role> -o yaml` omits `dispatch:` iff native resolves and
# includes the selected pane/headless rung and command otherwise. Scope `both`, so it is settable once
# machine-wide in ~/.fab-kit/config.yaml, where it outranks the project file.
#
# dispatch.column_width (optional, default 45) — width, in percent of the window, of
# the pane-worker column. The first worker CARVES the column out of the dispatching
# agent's pane (`split-window -h -l <n>%`), so the agent you are watching keeps the
# rest; later workers stack inside that column with unsized `-v` splits and the
# left/right separator is never touched again. Out-of-range values (and an absent
# key, indistinguishable from 0) resolve to the default. Scope `both`.
#
# dispatch.min_cols / dispatch.min_rows (optional, defaults 50/20) — the GEOMETRY
# FLOOR for the split shape: the planned worker pane is priced from the window
# geometry before splitting, and a pane below either bound opens as a manually-sized
# detached window instead (with a reason-naming warning). A dimension exactly at the
# floor passes; absent/zero/negative resolves to the default. Scope `both`.
#
# dispatch.reap_done (optional, default true) — whether `fab dispatch reap`
# reclaims a DONE pane worker's tmux pane. A pane worker never exits on completion
# (it writes its result file and sits at its prompt), so without reaping every
# finished stage keeps its slice of the worker column for the rest of the run. Reap
# is NOT kill: it fires only on `done`, is a no-op for headless dispatches, and
# removes no .fab-dispatch/ state — so a reaped dispatch still reads `done`. Set
# false to keep a done worker's pane and its scrollback. Scope `both`.
dispatch:
  mode: native
  column_width: 45
  min_cols: 50
  min_rows: 20
  reap_done: true

# Optional pre/post shell commands honored by `fab status` (pre gates `start`,
# post runs after `finish` saves — see _cli-fab.md § stage_hooks). Not seeded
# by the scaffold — add by hand.
stage_hooks:
  apply:
    pre: ./scripts/check-clean-tree.sh
    post: make test

Project Constitution (fab/project/constitution.md)

The constitution is the architectural DNA of a Fab project. It defines immutable principles that govern how specifications become code. Inspired by SpecKit's constitutional system, adapted for Fab's lightweight workflow.

Purpose:

  1. Enforce discipline — prevent over-engineering and architectural drift
  2. Ensure consistency — all code follows the same patterns
  3. Guide AI agents — principles constrain agent behavior during planning and implementation

Structure:

# {Project Name} Constitution

## Core Principles

### I. {Principle Name}
{Description using MUST/SHALL/SHOULD keywords. Include rationale.}

### II. {Principle Name}
{Description}

## Additional Constraints
<!-- Project-specific: security, performance, testing, etc. -->

## Governance

**Version**: {MAJOR.MINOR.PATCH} | **Ratified**: {DATE} | **Last Amended**: {DATE}

How skills use it:

  • /fab-setup generates it from project context (README, existing docs, conversation with user)
  • /fab-continue and /fab-ff load it as context when co-generating the plan (## Requirements + ## Tasks + ## Acceptance) at apply entry
  • /fab-continue (review) checks implementation against constitutional principles (not just the plan's requirements)
  • Constitution violations found during review are flagged as high-severity issues

Relationship to config.yaml:

  • config.yaml holds factual project context (identity, source/test paths, independent provider capabilities — interactive_command, headless_command, and native — plus per-role fills (providers:), the two agent depth knobs plus any agent.profiles overrides, and the dispatch.mode preference ceiling whose automatic selection descends pane → native → headless)
  • constitution.md holds principles and constraints (what MUST/SHOULD/MUST NOT happen)
  • Think: config says what you use, constitution says how you use it

Versioning: Semantic versioning — MAJOR for principle removals, MINOR for additions, PATCH for clarifications. Changes to the constitution should be intentional and documented, not done as a side effect of a change.


Git Integration (Optional)

Fab works without git. Change folders are the unit of identity, not branches — the same change can be worked on across multiple branches, worktrees, or even repos. When git is available, Fab offers a lightweight convenience link. For state bookkeeping that link stays informational — no branch information is stored in .status.yaml, and no stage transition is derived from which branch is checked out. The ship path is the deliberate exception: /git-pr and /git-pr-review enforce branch↔change correspondence with a pre-mutation STOP (the branch-matches-change guard), so an autonomous run can never push one change's branch while recording another change's status.

Why Decoupled (During Development)

A change folder captures what is being built (intake, plan). Where that work happens in git is a separate concern while the work is in flight:

  • A developer might work on the same change across multiple worktrees
  • A change might span multiple branches during development (feature branch + hotfix backport) — at ship time, though, the checked-out branch must match the change being shipped; /git-branch aligns it before /git-pr
  • A change might start on one branch and move to another after a rebase

Fab stays out of this during development. No branch information is stored in .status.yaml/fab-status uses git branch --show-current for live display. The decoupling governs storage and stage state; the ship-time guard governs mutation (see How It Works).

How It Works

/git-branch is the standalone command for branch management. It creates or checks out a branch matching the active (or specified) change. /fab-switch only creates the .fab-status.yaml symlink — it does not touch git.

OptionWhen to useWhat happens
Create branchOn main/master (auto) or any non-target branch (prompted)/git-branch creates branch named after the change folder (e.g., 260115-a7k2-add-oauth)
Adopt current branchAlready on a feature branchNo git operation — acknowledge the current branch. A foreign-named branch (one that does not contain the change folder name) fails the ship-time guard: align via /git-branch before /git-pr — the guard STOPs on mismatch rather than checking anything out
SkipNon-git repo, or user prefers manual controlNo branch operation

/fab-switch suggests /git-branch after every switch. /fab-new creates or checks out the matching branch inline (its Step 11). /fab-status always displays the current branch via git branch --show-current. /git-pr and /git-pr-review enforce a branch-matches-change guard: when a change is resolved, the current branch must equal its folder name (or contain it as a substring) — a mismatch STOPs before any status mutation, commit, or push, with /git-branch///fab-switch guidance and no autonomous checkout. Fab never merges or deletes branches — that remains the user's responsibility (committing and pushing is /git-pr's for shipping).

Branch Naming

When Fab creates a branch, it uses the change folder name directly: 260115-a7k2-add-oauth. This gives you a 1:1 mapping between fab/changes/ and git branch.

When adopting an existing branch (e.g., feature/dev-907-oauth from Linear, or an exploratory worktree branch), Fab keeps whatever name it finds — no rename, nothing stored. Shipping from such a branch requires aligning first: the /git-pr//git-pr-review guard accepts only a branch that equals the change folder name or contains it as a substring (feature/260115-a7k2-add-oauth passes; feature/dev-907-oauth STOPs) — run /git-branch to create the matching branch.

PR Types

/git-pr categorizes every PR using a 7-type taxonomy derived from Conventional Commits, consolidated for practical use:

TypeDescriptionFab Pipeline?PR Template
featNew feature or capabilityYesTier 1 — intake/plan links
fixBug fixYesTier 1 — intake/plan links
refactorRestructure without behavior changeYesTier 1 — intake/plan links
docsDocumentation-only changesNoTier 2 — lightweight
testAdding/fixing tests onlyNoTier 2 — lightweight
ciCI/CD and build system changesNoTier 2 — lightweight
choreMaintenance, cleanup, housekeepingNoTier 2 — lightweight

For the full taxonomy — confidence thresholds, expected decision counts, keyword heuristics, and lifecycle — see Change Types.

Consolidation from Conventional Commits: style merged into refactor (formatting is restructuring), perf merged into feat or refactor (performance changes are capability or internal restructuring), build merged into ci (build config and CI config are the same concern).

Two tiers: Tier 1 (fab-linked) PRs include Summary, Changes, and Context sections with working links to intake.md and plan.md. Tier 2 (lightweight) PRs include an auto-generated summary and explicitly note "No design artifacts — housekeeping change." This signals to reviewers what level of scrutiny to apply.

Type resolution: /git-pr resolves the type via a four-step chain: (1) explicit argument (/git-pr chore), (2) change_type from .status.yaml, (3) infer from intake content if a fab change exists, (4) infer from diff file paths. The type appears as a conventional-commits prefix in the PR title: feat: Smart change resolution.

.gitignore Guidance

fab sync maintains the required entries (via the scaffold's .gitignore fragment):

.fab-status.yaml     # Local working state — each developer has their own active change

The fragment ships no agent-directory ignores. Instead, each deploy target (.agents/skills/ always, .claude/skills/ when claude is available, .opencode/commands/ when opencode is available) carries a generated .gitignore written by fab sync listing exactly the skills fab deployed there — so fab's copies are ignored while everything else in those directories (your own skills, agents, settings) is committable. The fragment also carries /.claude/settings.local.json (Claude Code's per-developer local settings file).

The .fab-* gitignore pattern also covers transient dirs like .fab-dispatch/. The former .fab-runtime.yaml (ephemeral agent-state file written by the removed hooks) no longer exists — fab stopped producing agent active/idle state (ioku) and now reads the @rk_pane_agent_state tmux pane-option convention instead.

What to commit (shared with team):

  • fab/project/ — project configuration, constitution, context, quality/review policy
  • fab/changes/ — change artifacts (intakes, plans, status)
  • fab/backlog.md — local backlog
  • docs/memory/, docs/specs/ — documentation

What to ignore (local state):

  • .fab-status.yaml (and transient .fab-dispatch/) — per-developer working state
  • Fab's deployed skill copies in agent deployment folders (.agents/skills/ always, .claude/skills/ when claude is available, etc.) — regenerated from the kit cache, ignored via each folder's generated .gitignore

Agent Integration

fab sync deploys skills from the kit cache (~/.fab-kit/versions/<version>/kit/skills/) to the always-on portable directory and two CLI-gated brand targets.

Agent targetDeploymentFormActivation
Claude Code.claude/skills/{name}/SKILL.mdDirectory-based copiesclaude available
OpenCode (opencode).opencode/commands/{name}.mdFlat-file copiesWhen opencode is available
Agents dir.agents/skills/{name}/SKILL.mdDirectory-based copiesEvery sync

.agents/skills/ is the generic workspace directory: codex, agy and kimi all discover skills there natively, so none of them gets a per-brand directory. That one-target-per-skill-set rule is deliberate — deploying the same skills to both a generic and a per-brand directory is what makes a CLI that reads both report every skill twice. FAB_AGENTS can override availability for the gated Claude Code and OpenCode rows in tests and CI, but it cannot suppress .agents/skills/. The Claude gate is computed once per sync and also controls .claude/ scaffold writes (including settings permissions) and legacy agent cleanup; when closed, sync preserves existing .claude/ content and creates none.

All *.md skill files are deployed, including underscore partials (_preamble.md, _generation.md, _review.md, _srad.md, _pipeline.md, _intake.md, _cli-fab.md, _cli-fab-operator.md, _cli-fab-pane.md, _cli-external.md, _cli-agents.md), which carry user-invocable: false frontmatter to prevent direct invocation. The skill prompt files are agent-agnostic markdown; only the deployment locations and formats differ per agent.

Because Claude Code deployments are copies (not symlinks), they go stale when the kit updates — re-run fab sync after an upgrade (preflight warns when $(fab kit-path)/VERSION and the project's pinned version in fab/.fab-version diverge). In the fab-kit dev repo, never edit .claude/skills/ directly — src/kit/skills/ is canonical and fab sync overwrites the copies.


Distribution & Binaries

Fab ships as Homebrew-installed binaries plus cached kit content — no kit directory inside the repo, no per-project install step beyond fab init.

Three Binaries

BinaryRoleDistribution
fab (router)Thin dispatcher — routes each invocation to fab-kit or a version-resolved fab-goHomebrew formula sahil87/tap/fab-kit
fab-kitWorkspace lifecycle — init, sync, upgrade-repo, update, doctor, migrations-statusHomebrew formula sahil87/tap/fab-kit
fab-goWorkflow engine — everything else (status, score, change, preflight, pane, batch, hooks, …)Per-version cache, auto-fetched from GitHub releases

The standalone wt (worktree management) and idea (backlog) companion CLIs are not formula dependencies — each installs from its own formula (brew install sahil87/tap/wt, brew install sahil87/tap/idea), and fab degrades gracefully when they are absent (command -v-gated skill delegations; upfront stop-with-install-hint on the wt-requiring entry points) — see Companions.

Cache Layout

Versioned artifacts live at ~/.fab-kit/versions/{version}/:

  • fab-go — the workflow binary for the pinned version
  • kit/ — full kit content (skills, templates, migrations, scaffold, VERSION)

Multiple versions coexist; each repo pins its own via fab/.fab-version. Auto-download on cache miss is hardened: bounded HTTP timeouts, a version-keyed download lock (N racing processes perform one fetch), SHA-256 checksum verification against the release's SHA256SUMS, and atomic install (temp dir + rename).

Bootstrap Sequence

1. brew tap sahil87/tap && brew install fab-kit
2. cd <repo> && fab init     →  stamps fab/.fab-version, generates config.yaml from the registry, caches the release, runs sync
3. /fab-setup                →  populates config.yaml identity fields, generates constitution.md (interactive)
4. /docs-hydrate-memory      →  optionally ingests external docs into docs/memory/
5. /fab-new <description>    →  first change

fab init requires a git repository and fails before any download or write otherwise. fab sync is re-runnable: it deploys skills to the always-on .agents/skills/ directory and any available CLI-gated brand targets, scaffolds workspace files, and stamps version markers from the cache. When claude is available, its scaffold merges permissions into .claude/settings.local.json. Sync registers no hooks; migrations clean legacy hook entries across worktrees.

Updating

fab upgrade-repo [version] resolves the target release, ensures it is cached, runs sync first, then stamps fab/.fab-version and auto-runs fab config upgrade to reconcile config.yaml's reference fence (fail-open) — all only after sync succeeds (a sync failure exits non-zero and leaves the pin unchanged, so a re-run retries). fab update upgrades the system binaries themselves via Homebrew. Project content (fab/project/, fab/changes/, docs/memory/, docs/specs/) is never touched by updates. When a release requires restructuring project data, it ships a migration in the kit's migrations/ — applied via /fab-setup migrations, discovered by fab migrations-status.

Version Tracking

Three version locations:

  • $(fab kit-path)/VERSION — the cached engine version (per release)
  • fab/.fab-version — the project's pin (plain-text, one line, sibling to .kit-migration-version) and the sole version source; the router resolves the fab-go binary from it, and preflight warns when it diverges from the deployed engine ("skills may be out of sync — run fab sync"). A stale legacy fab_version: key left in config.yaml is no longer read (the compat-window fallback closed in 260719-kq7v; the 2.14.0-to-2.15.0 migration moves the value for pre-2.15 repos)
  • fab/.kit-migration-version — the version the project's file formats were written for; drives migration discovery

Batch Operations

Multi-change operations are fab batch subcommands (fab batch <new|switch|archive> [flags] [targets...]new takes [--list] [--all]; switch takes [--list] [--all] [--quiet|-q]; archive takes [--yes|-y] [--dry-run] [--quiet|-q], having diverged to a list-then-confirm-with---yes-escape-hatch model for the one irreversible-within-loop bulk mutation; --quiet/-q on switch/archive suppresses per-change progress on stdout while retaining stderr, data, and archive's summary footer — new has no --quiet):

SubcommandPurposeCreates per target
fab batch newCreate changes from pending backlog itemsWorktree + tmux tab running rendered fab-new with <description>
fab batch switchSwitch to existing changesWorktree + tmux tab running rendered fab-switch with <change>
fab batch archiveArchive completed changes (hydrate: done|skipped)Mechanical in-process archive (move, .fab-dispatch/{id}/ deletion, index, backlog, pointer) — no worktree, no tmux tab, no spawned agent

Initial skill prompts are rendered by internal/agent.SkillPrompt for the receiving provider — $ for Codex, / for every other or unknown provider (internal/agent.SkillPrefix owns the rule; fab agent -o yaml exposes it as skill_prefix); launch commands quote the complete prompt as one shell argument. The fallback operator launcher shares this renderer.


Router Dispatch (fabfab-go)

The fab binary (installed via brew install fab-kit) is a thin router. It dispatches workspace commands (init, upgrade-repo, sync, update, doctor, migrations-status) to fab-kit, and every other command to a version-resolved fab-go binary cached at ~/.fab-kit/versions/<version>/fab-go. --version/-v/--help/-h/help are handled inline by the router itself.

Always-Route Policy

The router always routes non-workspace commands to fab-go — it does not short-circuit on the presence or absence of fab/project/config.yaml. Per-command guards inside fab-go (typically a call to resolve.FabRoot()) are the authoritative answer to "does this command need project state?". Commands like kit-path, pane, completion, help, shell-init, and skill run anywhere; setup check also runs outside a repo, degrading to the system+env config tiers instead of erroring. Workflow commands like preflight, score, status, change exit non-zero with ERROR: fab/ directory not found when invoked outside a fab repo.

Version Selection

The router picks which cached fab-go to exec using a single rule, applied inline in execFabGo (and the symmetric printHelp helper):

  • Config present (fab/project/config.yaml parses successfully): use cfg.FabVersion — the project-pinned version.
  • Config absent (no fab/project/config.yaml): use the router's build-time version constant — the bundled version shipped with this release of fab-kit.
  • Config corrupted (parse error): exit non-zero with the error from internal.ResolveConfig. The user must fix the file.

The bundled-version fallback is reachable in practice only for config-free commands (completion, help, kit-path, pane, operator's switch path, hooks); commands that touch project state self-reject before any version-sensitive logic runs. Version skew between the router-bundled fab-go and a project-pinned fab-go is therefore bounded to surface-level commands.


Monorepos

A monorepo is one Fab project. Place a single fab/ at the repository root — do not create per-package fab/ directories.

Why One fab/

  • Changes naturally span packages. "Add user avatars" touches the API, the frontend, and shared types. One change folder, one plan — that's exactly how Fab works.
  • Memory is domain-based, not package-based. docs/memory/auth/ describes authentication regardless of which package implements it. This is already the right abstraction for cross-cutting concerns.
  • One developer, one change at a time. .fab-status.yaml points to a single active change. In practice, AI-assisted development is sequential — you finish one change before starting the next.
  • Simplicity. Multiple fab/ directories means multiple constitutions, multiple memory trees, conflicting skill deployments, and no natural home for cross-package changes.

Structured Context for Mixed Tech Stacks

The main friction point in a monorepo is project context. A flat blob of all tech stacks is vague. Use labeled sections in fab/project/context.md so skills can load relevant context for the packages a change touches:

## packages/frontend
React, TypeScript, Next.js, Tailwind CSS
Server components preferred. Client components only for interactivity.

## packages/backend
Python, FastAPI, SQLAlchemy, PostgreSQL
Async handlers everywhere. Pydantic models for validation.

## packages/shared
TypeScript types and Zod schemas
Shared between frontend and backend via npm workspace.

Skills loading context will naturally scope to the relevant section based on what the change description and plan reference. No formal scoping mechanism is needed — the structure in the text is enough for the agent to focus.

What Works Without Changes

ConcernStatus
.fab-status.yaml (single pointer)Fine — one change at a time
fab/changes/ (flat)Fine — changes reference affected packages in their plan
docs/memory/ (domain-based)Already monorepo-friendly
fab/project/constitution.mdShared principles apply repo-wide; use sections for package-specific conventions if needed
.claude/skills/ (when claude is available), .agents/skills/ (always)One skill set per repo — correct for a single fab/
Git branchesRepo-wide by nature — matches single fab/ model