Kit Architecture

September 11, 2026 · View on GitHub

Domain: distribution

Overview

src/kit/ is the portable engine directory that contains all workflow logic: skill definitions, artifact templates, reference contracts, and version tracking. It is content-only — no binaries. The system provides three binaries: fab (router), fab-kit (workspace lifecycle), and fab-go (workflow engine), all installed via brew install fab-kit. The fab router dispatches to either fab-kit or the version-resolved fab-go. src/kit/ provides content (skills, templates, configuration). This doc covers the .kit/ directory structure, the three-binary architecture, agent integration, distribution, updating, and monorepo guidance.

CLI Command Reference: For calling conventions and full command signatures, see $(fab kit-path)/skills/_cli-fab.md (the canonical CLI reference, core families — fab pane/fab dispatch live in _cli-fab-pane.md, fab operator/fab agent in _cli-fab-operator.md; loaded selectively via a skill's helpers: frontmatter; the most-used command families are inlined into _preamble.md § Common fab Commands).

Requirements

Directory Structure

The .kit/ directory SHALL contain:

src/kit/
├── VERSION                 # Semver string (e.g., "0.1.0")
├── skills/                 # 27 user-facing skills + the 11 underscore partials below
│   ├── _preamble.md         # Shared context loading convention (always-loaded)
│   ├── _cli-fab.md          # Fab CLI command reference — core families (selective via `helpers:`)
│   ├── _cli-fab-pane.md     # Fab CLI reference — `fab pane` + `fab dispatch` (selective via `helpers:`)
│   ├── _cli-fab-operator.md # Fab CLI reference — `fab operator` + `fab agent` (selective via `helpers:`)
│   ├── _cli-external.md     # Fab-owned external-tool content (operator spawning choreography, escalation rk-notify usage + role self-mark pointer, tmux/fab pane); wt/idea/rk/hop usage delegated to `<tool> skill` (selective via `helpers:`)
│   ├── _cli-agents.md       # Agent-orchestration reference (selective via `helpers:`)
│   ├── _generation.md       # Spec/tasks generation procedures (selective via `helpers:`)
│   ├── _review.md           # Review procedures (selective via `helpers:`)
│   ├── _srad.md             # SRAD autonomy framework (selective via `helpers:`)
│   ├── _pipeline.md         # Pipeline-stage procedures (selective via `helpers:`)
│   ├── _intake.md           # Intake-authoring procedures (selective via `helpers:`)
│   └── …                    # plus the 27 `docs-*`/`fab-*`/`git-*`/`internal-*`
│                             # skill files — `ls src/kit/skills/` is the roster
├── migrations/             # Version migration instructions (markdown)
├── templates/              # Artifact templates
│   ├── intake.md
│   ├── plan.md             # Unified ## Requirements + ## Tasks + ## Acceptance — apply-stage artifact (j6cs)
│   ├── memory.md           # Canonical FKF memory-file template (type: memory + description: + Overview/Requirements/Design Decisions skeleton, no ## Changelog) — read on demand by the doc skills (2fm8)
│   └── status.yaml         # .status.yaml template (6-stage progress, plan: block, stage_metrics: {}, issues: [], prs: [])
├── reference/              # Reference-to-read contracts shipped to the cache, read via $(fab kit-path)/reference/... (frlo)
│   └── fkf.md              # Byte-copy of docs/site/fkf.md (canonical FKF standard, published at shll.ai/fab-kit/fkf), synced by scripts/sync-fkf.sh + drift-guard test fkf_sync_test.go; deployed skills cite $(fab kit-path)/reference/fkf.md
└── scaffold/               # Overlay tree — paths mirror repo root destinations
    ├── fragment-.envrc     # .envrc required entries (line-ensuring merge)
    ├── fragment-.gitignore # .gitignore entries (line-ensuring merge)
    ├── .claude/
    │   └── fragment-settings.local.json  # Baseline permissions (JSON merge)
    ├── docs/
    │   ├── memory/index.md # Initial docs/memory/index.md (copy-if-absent)
    │   └── specs/index.md  # Initial docs/specs/index.md (copy-if-absent)
    └── fab/
        ├── changes/archive/.gitkeep  # Archive directory marker
        ├── project/
        │   # config.yaml is NOT scaffolded (j0qm) — `fab init` generates it
        │   # from the registry via `fab config init --project` (hard error on an old fab-go)
        │   ├── constitution.md # Constitution skeleton (copy-if-absent, /fab-setup detects)
        │   ├── context.md      # Project context template (copy-if-absent)
        │   ├── code-quality.md # Code quality defaults (copy-if-absent)
        │   └── code-review.md  # Review policy defaults (copy-if-absent)
        └── sync/README.md     # README template for fab/sync/ (copy-if-absent)

The repo source tree carries no bin/ — it is content-only. Packaging adds one: just dist-kit creates dist/kit/bin/ and scripts/just/package-kit.sh drops the cross-compiled fab-go into it, so every shipped kit-{os}-{arch}.tar.gz contains .kit/bin/fab-go alongside the copied content. See distribution.md § Release Archive Contents.

Shell Scripts

Session-Command Resolution (internal/spawn/)

Provider resolution lives in internal/agent: ResolveProvider(name) returns independent interactive_command, native, and headless_command capabilities plus fills. Session consumers compose interactive_command with a role profile through spawn.WithProfile; dispatch consumers pass the capabilities to the shared internal/dispatch.SelectMode preference-descent selector.

release.sh (dev-only, at scripts/release.sh)

Bumps VERSION (accepts [patch|minor|major] argument), validates the migration chain (warns if no migration targets the new version, warns on overlapping migration ranges), commits the version change, tags it, and pushes to the remote. CI takes over from the tag push to cross-compile, package, and create the GitHub Release. Requires clean working tree. This script is not shipped inside src/kit/ — it is a dev-only tool for maintainers of the fab-kit repo.

Batch Commands (fab batch)

The fab batch subcommand group in fab-go. Source: src/go/fab/cmd/fab/batch.go (parent command), batch_new.go, batch_switch.go, batch_archive.go. The new and switch subcommands share common patterns: tmux tab creation, session-command resolution via internal/spawn/ + internal/agent, --list/--all flags. archive is mechanical — it creates no tmux tab and resolves no session command (it archives in-process via a Go loop), and uses its own --yes/-y + --dry-run confirmation/preview model rather than a --list/--all flag shape (753q) (see below).

  • fab batch new — Per backlog ID: creates a worktree via wt create --non-interactive, opens a tmux tab, starts a Claude Code session running /fab-new <description>. Parses fab/backlog.md with continuation line handling. Supports --list, --all, and positional ID arguments. Upfront wt guard (nnda): after the $TMUX check and before any per-item work, runBatchNew checks exec.LookPath("wt") once and returns exactly wt is required for 'fab batch new' — install it via: brew install sahil87/tap/wt when wt is absent — one actionable upfront error instead of N cryptic per-item wt create: exec: "wt": executable file not found failures (wt is a standalone sibling formula, not a fab-kit depends_on, so it may be absent; batch_new.go gained the os/exec import). Follows the internal/prereqs.go LookPath + install-hint shape. The tmux command is composed via a shared defaultRoleSpawnCommand (batch.go) (tykw) — the default-role provider interactive_command (a Tier-1 role, so agent.session picks the provider) with that role's profile substituted through spawn.WithProfile — so workers spawn with a profile (there is no spawn.StripPlaceholders: workers never spawn from an empty-profile raw command, so no placeholder-leak path exists). A templated provider command has its {model}/{effort} filled; a non-templated Claude command gets --model/ --effort appended.
  • fab batch switch — Per change name/ID: creates a worktree whose branch name IS the change folder name (no prefix — the one branch-naming convention /git-branch, /fab-new, and docs/specs/naming.md share, so the worktree attaches to the change's real branch), opens a tmux tab, runs /fab-switch <change>. It carries the same upfront wt guard as batch new (nnda)runBatchSwitch checks exec.LookPath("wt") once after the $TMUX check and before any per-change work, returning wt is required for 'fab batch switch' — install it via: brew install sahil87/tap/wt when absent (batch_switch.go already imported os/exec). Change resolution uses resolve.ToFolder in-process (ye8r) — a fab change resolve subprocess would be a self-exec PATH dependency whose shim round-trip could trigger a cache download and whose .Output() discarded the resolver's specific stderr; the warn-and-skip warning names the specific error, e.g. Multiple changes match…. The whole batch family resolves in-process. Its tmux command is composed by the same defaultRoleSpawnCommand (default-role provider interactive_command + profile) as batch new (tykw). Supports --list, --all, positional arguments, and --quiet/-q (o5f9). --quiet/-q (o5f9): suppresses the Opening N tabs for all changes... preamble (--all path) and the per-change {name} resolved-name line via two inline if !quiet guards in runBatchSwitch; ALL stderr (Warning: could not resolve …, Error: failed to create worktree …) and the --list output are unaffected, and no summary footer is added — a quiet successful run is stdout-silent (standard Unix quiet semantics), tmux window creation remaining the observable effect (toolkit principle №9). Branch-existence probe-and-route (260717-otol): switch attaches worktrees to existing changes, whose branches usually already exist (created by /fab-new in the original checkout), so it mirrors wt's own dispatch under the 2af2 contract rather than relying on the retired positional dual-semantics. It takes branchName = match (the resolved change folder name), then probes existence via the branchExists helper — local first (git show-ref --verify --quiet refs/heads/<branch>, no network), and only on a local miss the origin remote (git ls-remote --heads origin <branch>, matching non-empty output) — and routes: exists → --checkout <branch> (the explicit existing-branch opt-in), missing → the positional <branch> (new-branch creation). --reuse --worktree-name <match> is retained on both arms (wt's reuse name-collision short-circuit ignores branch selectors). A failed/offline ls-remote degrades to not-remote → positional → wt itself re-checks and errors visibly (loud, not a silent skip). The invocation now goes through pane.RunCmd("wt", …) (stdout/stderr captured separately) instead of .Output() (which discarded stderr), so a wt create failure surfaces the child's stderr via pane.StderrError in the warn-and-skip line (Error: failed to create worktree for '<match>' (<err>: <wt-stderr>), skipping) — wt's typed exit-2 error and fix hint reach the operator. Same pane.RunCmd/StderrError pattern batch new already uses. External coupling: the --checkout path requires the wt release carrying upstream change 260717-2af2 (installed wt v0.0.23 predates it — the change is merged upstream but unreleased). There is no wt version detection — the hard break was decided upstream (both tools share an author and release channel); an older wt fails the --checkout path loudly (unknown flag → warn-and-skip with the surfaced stderr, recoverable by upgrading wt). Source: batch_switch.go (branchExists free function + routed runBatchSwitch); batch_new.go is unaffected (no positional — exploratory create, unchanged by 2af2).
  • fab batch archive — Finds changes with hydrate: done|skipped in .status.yaml and archives each one mechanically in-process via a Go loop (archiveLoopinternal/archive.ArchiveWithBacklog) — folder move, index update, backlog mark-done, and pointer clearing, with no spawned Claude session and no tmux tab. Change resolution uses resolve.ToFolder (not a fab change resolve subprocess). Per-change failures are isolated (a failure on one change is reported and the loop continues); already-archived changes are a soft skip (ErrAlreadyArchived), counted as skipped rather than failed. The loop prints per-change lines plus an Archived N, skipped M, failed K. footer, and the command exits non-zero only when failed > 0. The loop logic lives in the testable archiveLoop helper (returns counts, no os.Exit); runBatchArchive returns errors through RunE (ERROR: {K} change(s) failed to archive / ERROR: No valid changes to archive. — no in-handler os.Exit) (ye8r). Flag/confirmation model (753q): archive is the one bulk-mutating member whose moves are effectively irreversible within the loop, so instead of staying list-by-default behind --all it uses a list-then-confirm model with a --yes escape hatch (apt/npm/gh-style). A bare fab batch archive on an interactive stdin lists the archivable set then prompts Archive these N? [y/N] (default No — Enter or any non-y/yes answer aborts, exit 0); --yes/-y archives all with no prompt (the non-interactive escape hatch, resolved behavior of the former --all); --dry-run lists only with no prompt/action (the former --list); a non-TTY stdin without --yes refuses with guidance and a non-zero exit rather than hanging (the tmux/operator runtime passes --yes); explicit positional args archive the named changes with no prompt and no TTY guard; --dry-run --yes is mutually exclusive (non-zero exit). The empty archivable set remains a benign no-op (No archivable changes found. + zero footer, exit 0), checked before any prompt/guard so finding F49 is preserved. TTY detection uses the stdlib os.ModeCharDevice pattern via an injectable isStdinTTY seam (no golang.org/x/term dependency, mirroring src/go/fab-kit/internal/upgrade.go). It imports none of internal/spawn, os/exec, or syscall. --quiet/-q (o5f9): archive also carries a --quiet/-q bool flag (BoolVarP, beside --yes/-y) that suppresses the Archiving N changes... preamble and every archiveLoop per-change line via a progress writer (io.Discard) threaded runBatchArchive → archiveResolvedNames → archiveLoop, while the Archived N, skipped N, failed N. footer, all stderr, the empty-set no-op, the --dry-run listing, and the full consent flow are retained; --quiet is orthogonal to consent (never implies --yes). See pipeline/change-lifecycle.md for the full quiet contract.

Operator Command (fab operator)

fab operator is a fab-go parent command with subcommands. Source: src/go/fab/cmd/fab/operator.go. Default behavior (no subcommand): creates a singleton tmux window named "operator" running the resolved operator-role session command (composed from the operator provider's interactive_command + that role's profile via internal/agent + internal/spawn) (tykw) with '/fab-operator'. If the window already exists, switches to it. Requires an active tmux session ($TMUX check).

Subcommands:

  • fab operator tick-start — start-of-tick atomic state update: increments tick_count, writes last_tick_at (RFC3339 UTC), outputs tick: N\nnow: HH:MM. Writes to a server-keyed XDG state file<stateDir>/fab/operator/<server-slug>.yaml, NOT the old repo-rooted .fab-operator.yaml (see "Operator State File" below). Source: src/go/fab/cmd/fab/operator_tick_start.go. With --diff it probes the tracked items (pane snapshot join, due shell probes, done_when/depends_on/staleness evaluation) and appends deltas/candidates/needs_check/items blocks, and also writes last_full_at (RFC3339 UTC) on every tick that emits the full document; --diff --quiet replaces items: with a five-count fleet_summary: (tracked ≥ waiting + idle + active + unknown) on a no-delta, no-needs_check tick whose last full document (last_full_at) is less than 10 minutes old — a built-in constant, not a flag or config knob (full document on deltas, needs_check, and any tick whose last_full_at is at least 10 minutes old; a missing or unparseable stamp counts as due).

Operator State File (server-keyed XDG)

The operator's coordination state lives at a server-scoped path resolved by helpers in cmd/fab/operator.go, not at a repo-rooted path. Server-scoping makes one tmux server (one operator) own one state file regardless of which repo any pane sits in — repo-rooting loses cross-repo state, and a fixed global path would force a machine-wide singleton.

  • stateDir() (string, error) — resolves the XDG state base dir uniformly on Linux and macOS: returns $XDG_STATE_HOME only when it is set AND absolute, else $HOME/.local/state. Deliberately NOT ~/Library/... on macOS (terminal users expect ~/.local/state; the Go stdlib has no UserStateDir()).
  • serverSlug(server string) string — queries the tmux socket path via tmux <…> display-message -p '#{socket_path}' (built through pane.WithServer) and slugifies it; falls back to the literal "default" when tmux cannot be queried, so the operator still functions if the query fails.
  • slugify(string) string — the deterministic, collision-free, filesystem-safe rule: escape literal - by doubling it (---) FIRST, then strip the leading path separator and replace remaining separators with a single - (so /tmp/tmux-1000/defaulttmp-tmux--1000-default). Escaping before substitution makes the mapping injective, so a socket path with a literal - cannot collide with one whose separator falls at the same spot (/tmp/tmux/1000/defaulttmp-tmux-1000-default, distinct). Empty input slugifies to "default".
  • StatePath(server string) (string, error) — returns <stateDir>/fab/operator/<server-slug>.yaml, creating the parent dir with MkdirAll (0o755). tick-start calls StatePath("") (server "" → the operator's own current tmux server). The test seam is operatorStatePathOverride (a full file path, not a directory).
  • Mutation-verb family (full mediation) — every state-file mutation goes through a fab operator subcommand; the agent never hand-writes the YAML. The verbs share one read-modify-write helper in src/go/fab/cmd/fab/operator_state.go (typed structs for the owned sections, atomicfile.WriteFile, StatePath("") + the override seam): state [--all] [--json] (prints the file; persists the empty skeleton — tracked: [], branch_map: {} — when missing; human output opens with an OPEN NOTES header from kind: note items), the track verbs (add — creates an item with the kind's binary-filled defaults, the pane-kind flag sugar writing scope (recording pane_pid whenever the pane is set) plus the branch_map { branch, repo } pair in the same mutation; update — mutates passed fields, --scope merged per key, --pause/--resume toggling paused; observe — records an agent-probe result into last, appending --seen ids with the 200-entry oldest-first cap enforced in the binary, --error incrementing failures; rm — deletes the item, retains branch_map; list [--kind] [--json]; clock (--every|--idle-every) <dur> --for <dur> | --off — the bounded clock_override), and branch-map rm <id>|--all (the explicit user-initiated clear). IO posture: tolerant-read / typed-write — unknown top-level keys survive any read-modify-write (the tick-start precedent), while the owned sections (tracked, branch_map, clock_override, plus the tick_count/last_tick_at/last_full_at scalars) are re-marshaled from typed structs on mutation, so an invented in-section field can neither be introduced nor survive. All timestamps are binary-computed (RFC3339 UTC); no verb accepts a timestamp flag. Stage-valued flags validate against the six stage names; every validation failure exits non-zero with a one-line error and no state written. Every mutation verb also carries the operator-clock side effect: the tracked-predicate mute/unmute flip plus the schedule reconcile (the derived cadence applied via one rk cron edit only on change). Legacy conversion: a legacy-shaped file (any of monitored/watches/autopilot/notes present, tracked absent) converts on the first read-modify-write by any verb — including tick-start and the read verbs — in the same atomic write, refusing with a one-line error while autopilot.state == running (see migrations.md); a second, idempotent pass fires on a tracked-present file holding an item under the retired change-keyed kind, rewriting it to kind: pane with scope.change seeded from the item's id and scope.pane_pid null. Sources: operator_state.go, operator_track.go, operator_track_types.go, operator_migrate.go, operator_clock.go, operator_tick_start.go.

Keying on the socket path (not server PID) is deliberate: the socket path survives a tmux-server restart (same -L label → same path), so a restarted operator resumes the same state file; a PID would change and orphan the file. There is no migration of old repo-rooted .fab-operator.yaml files — they are abandoned in place. runOperator's launch CWD uses gitRepoRoot() as the new-window working directory; only the state path is decoupled from the repo.

  • fab operator time — pure clock query: outputs now: HH:MM; with --interval <duration> also outputs next: HH:MM. No file I/O. Source: src/go/fab/cmd/fab/operator_time.go.

Agent Skill Deployment

fab-kit sync deploys skills to three targets. The portable .agents/skills/ target carries agentConfig.AlwaysOn and bypasses availability checks. Claude Code at .claude/skills/ is gated on claude; OpenCode at .opencode/commands/ is gated on opencode. Unavailable targets print Skipping Claude Code: claude not found in PATH or the equivalent OpenCode message and preserve existing content. Sync emits no aggregate no-agent warning because .agents/skills/ always deploys. The FAB_AGENTS environment variable (a space-separated list of CLI command names) overrides PATH lookup for both gated targets in tests and CI; it cannot suppress .agents/skills/.

Sync computes Claude availability once and shares that result with skill deployment, the scaffold walk, and legacy Claude agent cleanup. When the gate is closed, the walk skips every destination under .claude/ before creating directories or merging permissions, including .claude/settings.local.json. A fresh checkout receives no .claude/ tree; an existing tree is preserved untouched. Fab-internal helper and dispatch read instructions use .agents/skills/{name}/SKILL.md.

Because deployment is a verbatim copy, portability covers content, not only placement: a deployed skill's citations must resolve in the host repo, which is what § Portability's citation rule and its Go guard enforce (d5tk).

Fab deploys no per-brand targets such as .codex/skills/, .agy/skills/, or .kimi/skills/; those clients share the generic .agents/skills/ target. Clients that scan multiple supported locations can discover byte-identical copies of the same skill; their same-name shadowing behavior resolves those copies.

All *.md files in $(fab kit-path)/skills/ are deployed, including underscore partials (_preamble.md, _generation.md, _review.md, _cli-fab.md, _cli-fab-operator.md, _cli-fab-pane.md, _cli-external.md, _cli-agents.md, …) which have 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:

Claude Code (claude) — detection-gated directory-based copies:

.claude/skills/fab-new/
└── SKILL.md    (copy of $(fab kit-path)/skills/fab-new.md)

OpenCode (opencode) — detection-gated flat-file copies:

.opencode/commands/
└── fab-new.md    (copy of $(fab kit-path)/skills/fab-new.md)

Agents dir — always-on directory-based copies in the generic workspace location:

.agents/skills/fab-new/
└── SKILL.md    (copy of $(fab kit-path)/skills/fab-new.md)

Codex, agy, and kimi discover skills there natively — agy reads <workspace>/.agents/skills/<skill>/SKILL.md, and kimi merges that generic group with its own brand group by priority — so none of them gets a per-brand target of its own.

Generated manifest and scoped pruning (jjg0). After a target's deploy succeeds, sync writes {target}/.gitignore as a whole-file-owned artifact (overwrite, byte-stable across syncs, the same "fab owns this file" model as the config.yaml reference fence): two header comment lines, the self-entry /.gitignore, then one anchored entry per skill actually deployed in that run — /{name}/ for a directory-format target, /{name}.md for the flat one — in kit-list order. This generated file is also fab's ownership manifest: cleanStaleSkills reads it back before deploying and prunes an entry only when the previous manifest recorded it AND the current kit no longer ships it. Entries fab never recorded — user-added skills or commands — are neither ignored nor pruned, which is what lets a fab-managed project commit its own content under .claude/, .agents/, and .opencode/. A skipped target gets no manifest; a target with no manifest yet (first sync after the upgrade) prunes nothing and prints a one-line note when the directory held a non-kit entry — the 2.22.0-to-2.23.0 migration owns the one-time cleanup of pre-manifest stale entries. The manifest is written only on a fully successful deploy (a partial deploy must not claim ownership of skills that never landed), and a failed manifest write fails the sync (jznd fail-loud contract). The root .gitignore carries no agent-directory ignore — the scaffold fragment ships only non-directory lines (see setup.md § .gitignore dedup).

Distribution & Bootstrapping

.kit/ is a content-only directory — no binaries. The system binaries (fab, fab-kit, installed via brew install fab-kit) provide version-aware execution and workspace lifecycle management. .kit/ provides content (skills, templates, configuration).

Packaging invariant — the whole src/kit/ tree is copied verbatim

New kit content ships automatically with no Go/binary, packaging-list, or release.yml change: both distribution paths copy the entire src/kit/ tree verbatim. just install runs rsync -a --delete src/kit/ {{local_cache}}/kit/; just dist-kit runs cp -a src/kit/. dist/kit/ and then archives the whole dist/kit/ tree. Neither enumerates individual files, so adding a new file or directory under src/kit/ (e.g. src/kit/reference/fkf.md) (frlo) ships to the cache on the next just install / release with zero packaging edits. This is why the shipped FKF contract is a pure content change.

Bootstrap Sequence

Primary method (recommended):

brew tap sahil87/tap && brew install fab-kit
cd <repo>
fab init

fab init populates src/kit/ from the version cache, stamps fab/.fab-version (j0qm), generates config.yaml from the registry (shell-out to the pinned fab-go fab config init --project with a mechanically-detected identity seed; a pinned fab-go that predates the subcommand is a hard error naming the fab-go upgrade — see § fab-kit Init config generation below), and calls Sync() directly (the same logic as fab-kit sync).

Legacy method (curl one-liner, for environments without Homebrew):

os=$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64) arch=arm64;; esac
mkdir -p fab; curl -sL "https://github.com/{repo}/releases/latest/download/kit-${os}-${arch}.tar.gz" | tar xz -C fab/

Manual copy (from a local clone):

cp -r /path/to/fab-kit/fab/.kit fab/.kit

Then in either case:

  1. User runs /fab-setup → generates config.yaml, constitution.md
  2. User optionally runs /fab-hydrate → ingests external sources
  3. User runs /fab-new → first change created

Why Two Phases

/fab-setup is itself a skill defined inside .kit/. It cannot run until .kit/ exists. fab init solves this by populating .kit/ from the cache before any skill invocation.

Version Tracking (Dual-Version Model)

Three version locations track the relationship between the installed engine and the project's file format:

  • $(fab kit-path)/VERSION (engine version) — ships inside .kit/, replaced on each fab upgrade-repo run. Enables version display, update comparison, and migration targeting.

  • fab/.fab-version (project version) (j0qm) — a one-line plain-text file (bare semver + \n), committed, sibling to .kit-migration-version. Set by fab upgrade-repo and fab init. Used by preflight to detect sync staleness (compared against $(fab kit-path)/VERSION) and by the router for pinned-version resolution. Written by stampFabVersion(repoRoot, version) (src/go/fab-kit/internal/init.go), shaped exactly like stampMigrationVersion and called from both Init and Upgrade. No fab-kit code path writes config.yaml — this is the second half of the single-writer invariant: internal/configupgrade is fab-go's only writing engine for existing config files (see the fab-go § internal/configupgrade below and configuration.md § fab config upgrade). fab/.fab-version is the sole version source for both reader stacks (fab-kit readFabVersion, fab-go internal/config) — config.yaml's fab_version: key is never consulted (fab-go tags Config.FabVersion yaml:"-"). See distribution.md § Update Preserves Project Files and migrations.md.

    Committability — the negation is load-bearing (8ken). For "committed" to be true, fab/.fab-version must not be gitignored, but the scaffold's .fab-* ignore line (added for the root runtime files) is unanchored and swallows it at any depth — the field failure: fresh worktrees/clones with no version source, fab sync/wt init failing loud. Two mechanisms guarantee committability. (1) The scaffold fragment src/kit/scaffold/fragment-.gitignore carries !fab/.fab-version immediately after .fab-* — its load-bearing line — so every fab sync's lineEnsureMerge self-heals a project's .gitignore (see setup.md § .gitignore dedup and migrations.md § 2.15.1-to-2.15.2). (2) stampFabVersion gained a sibling warnIfFabVersionIgnored(repoRoot) (internal/init.go) wired into both callers — Init (init.go:48) and Upgrade (upgrade.go:148) — right after the successful stamp: it shells out to git check-ignore -q fab/.fab-version and prints a fail-open fab: warning: … is gitignored — commit it … to stderr only on the exit-0 (ignored) case; exit 1 (not ignored) and any git error (git absent, not a repo, exit >1) are silent (the rk fail-silent discipline). It is never an error and never changes the exit code, so a written-but-ignored version file surfaces loudly at stamp time but never bricks init/upgrade.

  • fab/.kit-migration-version (local project version) — lives outside .kit/, NOT replaced on upgrades. Tracks the version the project's config.yaml, .status.yaml, and conventions were written for. Created by fab-kit sync.

VERSION and .kit-migration-version contain a bare semver string (MAJOR.MINOR.PATCH). See migrations.md for the full migration system.

Updating .kit/

Run fab upgrade-repo to update to the latest release. The command requires FAB_KIT_PATH to be unset, then downloads the new version to the cache if absent (verified + atomic — see distribution.md's Auto-Download Hardening), calls Sync() first with the target kit version, and stamps fab/.fab-version only after sync succeeds. A sync failure exits non-zero with repair guidance and leaves the stamp unwritten, so a re-run retries. Released kit content is served from the cache and is not copied into the repo. After the upgrade, if fab/.kit-migration-version is behind the engine version, the output includes a migration reminder. See distribution.md for full upgrade details.

Skill deployments are refreshed by fab-kit sync after the update — .agents/skills/ is always refreshed; .claude/skills/ and .opencode/commands/ are refreshed when their respective claude and opencode CLI gates fire.

Preserved (lives outside .kit/): config.yaml, constitution.md, docs/memory/, docs/specs/, changes/, .fab-status.yaml, .kit-migration-version Replaced (lives inside .kit/): templates/, reference/ (shipped read-only contracts, e.g. reference/fkf.md) (frlo), skills/, scaffold/, migrations/, VERSION

Portability

The .kit/ directory MUST work in any project via cp -r, given the system binaries are installed (brew install fab-kit installs fab and fab-kit; wt and idea are separate sibling formulas installed standalone via brew install sahil87/tap/wt / sahil87/tap/idea, and the workflow degrades gracefully when they are absent). The system binaries provide version-aware routing and workspace lifecycle management; src/kit/ provides content (skills, templates, configuration). It SHALL have no assumptions about the host project's structure, language, or toolchain beyond the presence of a fab/ directory. Project-specific configuration belongs in fab/project/config.yaml and fab/project/constitution.md, not in .kit/.

Portability binds content, not only placement (d5tk): deployed kit content (skills, templates, migrations, scaffold, reference) MUST NOT cite files that exist only in the fab-kit repository (Constitution V). A deployed file MAY cite another kit skill or helper, a fab command, a fab/project/ file, a $(fab kit-path)/… deployed asset, or a documented host-project convention path (docs/memory/index.md, docs/specs/index.md, docs/memory/_shared/removed-domains.md, docs/memory/_shared/utilities.md). It MUST NOT cite fab-kit's own docs/specs/*, docs/memory/*, docs/site/*, or src/go/* as an authority or rule owner, and an external repository's doc path is named in prose or by URL, never as a bare relative path. Rule ownership that lives in fab-kit's docs is carried into the deployed file by restating the operative rule; the fab-kit doc then points at the skill.

The rule is guarded mechanically by src/go/fab-kit/cmd/fab/kit_portability_test.go — a plain test in the fab-kit module (fab-kit owns fab sync, the deploy boundary this rule is about), reusing that package's findRepoFile walk-up helper; no CLI surface changed. It walks every regular file under src/kit/ except VERSION and reference/fkf.md (the latter a drift-guarded byte-copy of the published FKF standard), matches each line against docs/(specs|memory|site)/….md and src/go/…, and fails per hit with file:line — cites a repo-local doc path <p>; restate the rule, or name the external repo/standard in prose. Exemptions: the named allowlist constant hostConventionPaths (the four convention paths above), any docs/memory/**/index.md or docs/specs/**/index.md tier index (generated in the host at every depth), and placeholder-shaped paths (a segment containing {…}, or a bare x.md leaf). There is no exemption marker and no attribution-token recognition — see § Design Decisions → "The Guard Has No Escape Hatch". A t.TempDir() fixture test (one violating, one allowlisted, one tier-index, one placeholder file) exercises the matcher itself, not only the live tree.

Monorepo Guidance

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 — one change folder, one spec
  • Memory is domain-based, not package-based — docs/memory/auth/ describes auth regardless of which package implements it
  • One developer, one change at a time — .fab-status.yaml points to a single active change
  • Simplicity — multiple fab/ directories means multiple constitutions, memory trees, and symlink conflicts

For mixed tech stacks, use labeled sections in config.yaml's context field so skills can load relevant context per package.

Three-Binary Architecture

The system provides three distinct binaries, each independently executable with its own --help:

fab (Router)

The fab binary (installed via brew install fab-kit) is the user-facing entry point. It uses negative-match routing: a static allowlist of fab-kit commands (init, upgrade-repo, sync, update, doctor, migrations-status) is dispatched to fab-kit via syscall.Exec; the inline arguments --version/-v and --help/-h/help are handled by the router itself (printing version or composed help); all other commands are dispatched to the version-resolved fab-go via syscall.Exec. The allowlist is derived from the shared internal.LifecycleCommands table (src/go/fab-kit/internal/lifecycle.go, since 260612-ye8r) — the single source of truth for the workspace command set (names + cobra Shorts), also feeding the router's help section, cmd/fab-kit's fabKitCommands, and a registration cross-check test. Doc↔code drift is test-guarded in both directions: a fab-kit-module contract test pins _cli-fab.md's router line to the table, and a fab-module collision test asserts no top-level fab-go command name (from the in-process help-dump tree) appears in that documented allowlist (a colliding name would be shadowed by the system-wide shim forever).

The router applies an always-route policy for non-fab-kit commands: every such command is dispatched to fab-go regardless of whether fab/project/config.yaml is present. There is no router-side config gate, no per-command allowlist, and no "Not in a fab-managed repo" exit from the router. Per-command guards inside fab-go (typically a call to resolve.FabRoot()) are the authoritative answer to "does this need config?" — they fail-closed with ERROR: fab/ directory not found for commands that require project state (preflight, score, resolve, status, change, log, batch, fab-help), while config-free commands (kit-path, pane, operator's switch path, hook session-start|stop|user-prompt, completion, shell-init, help, and any <subcommand> --help) run cleanly from any directory.

Version selection for the fab-go exec is inline in execFabGo (and mirrored in printHelp): walk up from CWD to find fab/project/config.yaml; if cfg != nil use cfg.FabVersion (project-pinned), else use the router's build-time version constant (router-bundled). The resolved fab-go binary lives at ~/.fab-kit/versions/{version}/fab-go; missing binaries are auto-fetched from GitHub releases and cached. On the execFabGo dispatch path, if config.yaml exists but cannot be parsed, the router hard-errors with the parse error from internal.ResolveConfig — only the missing-config case becomes a soft fall-through. The router-inline help and version paths take the opposite stance (see next paragraph) because they must remain available even with a broken config.

fab help composes help from both sub-binaries: workspace commands are rendered in-process from the shared LifecycleCommands table (names + Shorts — never by exec'ing fab-kit --help, so the section renders even when the fab-kit binary is absent and its Shorts cannot drift from the cobra registrations); workflow commands (from fab-go) are also always shown — inside a fab-managed repo using the project-pinned fab_version, and outside using the router's build-time version (bundled fab-go), so all workflow commands remain discoverable from scratch tabs. Errors during version resolution or subprocess execution for the workflow-commands block are silently swallowed — help is best-effort. fab --version and fab -v always print the system-installed binary version (fab {version}); when run inside a fab-managed repo, a second line shows the project-pinned version (project: {version}) read from fab/.fab-version — the sole version source (readFabVersion in src/go/fab-kit/internal/config.go; config.yaml carries no fab_version: key) (j0qm). Config resolution errors are silently ignored — the command always exits 0.

fab-kit (Workspace Lifecycle)

The fab-kit binary (installed via brew install fab-kit) owns workspace lifecycle operations:

  • fab-kit init — initialize fab in a repo. A non-empty FAB_KIT_PATH is refused before the git-repository check, downloads, config writes, or version stamps. With no override, init resolves and caches the latest release, stamps version state, generates config.yaml, and runs sync; sync failures propagate.
  • fab-kit upgrade-repo [version] — upgrade to a release version. A non-empty FAB_KIT_PATH is refused before repository discovery, network/cache work, sync, config reconciliation, or version stamping. With no override, upgrade downloads to cache, runs sync first, stamps fab/.fab-version only after success, and auto-runs the pinned fab-go fab config upgrade fail-open.
  • fab-kit sync — reconcile the workspace with the resolved kit directory. With no override, the 6-step pipeline is prerequisites, version guard, ensure cache, scaffolding, direnv, and project scripts. With FAB_KIT_PATH, sync validates and absolutizes the directory, prints kit: <absolute-dir> (FAB_KIT_PATH override), skips the version guard and cache step, and runs the remaining pipeline against override content. Supports --shim and --project; invalid overrides fail loudly in every mode. It exits 3 (ExitNotManaged) (52i9) outside a fab-managed repo and 1 on genuine failures.
  • fab-kit doctor [--porcelain] — validate seven prerequisites (git, fab, bash, yq v4+, jq, gh, direnv+hook). Normal output includes kit: <absolute-dir> (FAB_KIT_PATH override) only when the variable is non-empty; the line is informational and does not alter the seven-check denominator, failure count, or exit code. --porcelain remains errors-only and omits provenance.

fab-kit sync selects kit content through ResolveKitDir(fabVersion). A non-empty FAB_KIT_PATH wins, is converted to an absolute path, and must name an existing directory; resolution errors name the variable and never fall back. Without the override, CachedKitDir preserves local-cache-before-remote-cache selection. fab migrations-status uses the same resolver for the engine VERSION and migration directory, so both reader surfaces select one content source.

Sync(systemVersion, kitVersion string, shimOnly, projectOnly bool) resolves the repository and kit directory before its flag-specific work. Under the override it prints provenance and bypasses both versionGuard and EnsureCached; prerequisites, resolved-kit scaffolding, agent skill deployment, direnv allow, project scripts, flags, and failure propagation keep their normal contracts. Without the override, the 6-step pipeline remains (1) prerequisites, (2) version guard, (3) ensure cache, (4) workspace scaffolding, (5) direnv allow, and (6) project scripts. Deployment and scaffold writes fail loudly. The sync implementation remains split across sync.go, semver.go, prereqs.go, scaffold.go, and skills.go, and its integration suite pins idempotency.

fab-kit Init config generation

Because there is no scaffold config.yaml (j0qm), fab-kit's Init generates the initial fab/project/config.yaml instead of relying on the scaffold walk's copy-if-absent. generateProjectConfig(fabGoBin, repoRoot, configPath) runs BEFORE the scaffold walk, preserving the scaffoldDirectories config.yaml-presence classification (a fresh fab init is still "new project"):

  • DetectiondetectProjectSeed(repoRoot) mechanically derives the identity seed non-interactively: project name from the repo-folder name, source_paths from an existing src/, and test_paths from on-disk marker files (detectTestPaths, the same marker→ecosystem table /fab-setup and the 2.7.1-to-2.8.0 migration use). Any field with no confident detection is left empty. The description is NOT detected (only /fab-setup config adds it).
  • Shell-out — passes the detected seed as --name/--source-path/--test-path flags to the pinned fab-go fab config init --project (EnsureCached(version) returns the binary path; same one-brew-package skew + fail-open discipline as the auto-run below).
  • An old fab-go is a hard error, never a stub — if the pinned fab-go predates fab config init --project (non-zero/unknown-command) and no config.yaml landed, fab init exits non-zero naming the cause and instructing the user to upgrade fab-go. Nothing is written in that state, and no config content is embedded in any binary — the config embed census is exactly: fab-go's module-root defaults.yaml (built-in values, embedded by the root fab package, parsed by internal/agent), the internal/configref registry (schema + prose), internal/configscope (scope taxonomy), and src/kit/scaffold/ (non-config files only). The skew window a stub would cover closed when fab config init --project shipped in 2.15.x.

The registry guard is configref.InitSeedKeys() + TestConfigInitSeedKeysSubsetOfRegistry (init-seeded keys ⊆ registry keys); the multi-language test_paths examples and providers narrative live in registry Description/Segment text. See setup.md § Config Create-Mode.

Source layout: Both fab (router) and fab-kit share a single Go module at src/go/fab-kit/ with two cmd/ entries: cmd/fab/main.go and cmd/fab-kit/main.go. Both import shared internal/ packages for cache, download, and config resolution. This avoids Go workspace complexity and keeps infrastructure code importable by both without duplication.

Both fab (router) and fab-kit are Go binaries; there is no shell dispatcher in bin/. FAB_KIT_PATH is the environment-only override for kit content. It does not alter router selection or fab-go binary resolution, which remain version-pinned.

Distinguishable Exit Codes — ExitNotManaged

The fab-kit binary's main() returns exit 1 for any RunE error (cmd/fab-kit/main.go), so "not a fab-managed repo" was historically indistinguishable — at the exit-code level — from a genuine sync failure (corrupt config, failed scaffold write, version-guard trip). External callers that probe arbitrary directories (wt's default init, hop, operator scripts) could not tell "skip me, this isn't a fab repo" from "a real failure happened here" without duplicating fab's fab/project/config.yaml walk-up client-side. The unmanaged-repo outcome therefore has its own distinct, documented exit code (52i9):

  • internal.ExitNotManaged = 3 — a single exported named constant in internal/config.go (with a doc comment; no bare 3 at any call site — R3), deliberately distinct from the generic exit 1. Chosen as 3 to sit alongside the fab binary's own in-handler os.Exit(N) tiering convention (pane_window_name.go uses 2/3 for the pane family — see pane-commands.md; memory_index.go uses 2 for destructive-loss). It collides only theoretically with fab-kit doctor's dynamic os.Exit(failureCount) (0–7), an unambiguous diagnostic count on a different command.
  • internal.RequireManagedRepo() (*ConfigResult, error) — the shared guard consolidating the ResolveConfig() + if cfg == nil { return fmt.Errorf("not in a fab-managed repo…") } check across its call sites (R4). It returns a genuine ResolveConfig error unchanged (corrupt config / missing fab_version still collapse to exit 1 in main() — R2), and on the (nil, nil) "walked to filesystem root, no config" case it prints the actionable not in a fab-managed repo. Run 'fab init' to set one up to stderr and calls os.Exit(ExitNotManaged) in-handler (a returned error would collapse to exit 1). This mirrors the fab binary's in-handler-os.Exit pattern precisely because the fab-kit main() funnel exits 1 uniformly.
  • Two call sites: internal.Sync() (its kitVersion == "" branch — the plain fab sync path) and cmd/fab-kit's runMigrationsStatus. Both call RequireManagedRepo(); the not in a fab-managed repo literal appears in neither sync.go nor migrations_status.go.
  • Git-independence: RequireManagedRepo() gates before gitRepoRoot() in Sync(). The managed-repo check is a config.yaml walk-up that does not depend on git, so a directory that is neither git-tracked nor fab-managed exits 3, not 1 — keeping fab sync symmetric with fab-kit migrations-status (which has no git precondition and already exited 3 in the same directory). A managed repo that lacks git context still fails at gitRepoRoot() with a genuine error → exit 1 (R2 unchanged). Init/Upgrade pass kitVersion explicitly (config.yaml is not yet stamped), so they skip the check.
  • Deliberate exclusion — internal/upgrade.go is untouched: Upgrade's two not in a fab-managed repo returns still exit 1. Its guard is a different semantic — it tolerates a config.yaml that is present but missing its fab_version field (a partially-managed repo, not an unmanaged one), so folding it into RequireManagedRepo() would conflate the two. Documented here so a reader does not over-generalize the exit-3 contract to every "unmanaged repo" case; re-tiering upgrade.go's exit code is a recorded future follow-up.

The _cli-fab.md CLI reference carries the same contract for the skill-facing surface (updated during apply). The intended downstream beneficiary: wt can retire its interim client-side ResolveConfig-mirroring marker probe and branch on exit 3 directly, keeping fab authoritative over the "is this a fab repo?" question (the change adds no new CLI flag surface — no --if-managed — the distinct exit code fully satisfies the need).

Binary-Wide Exit-Code Convention — 0/1/2 usage-error classification

The fab/fab-go binary follows the toolkit convention (principle №4, fail-fast): 0 success / 1 operational failure / 2 usage error. A usage error is a malformed invocation caught at parse/validation time — an unknown/malformed flag (fab score --nope), an arg-count violation (fab score with no args), an unknown subcommand (fab nonsense), or a mutually-exclusive flags-group conflict (fab resolve --status --folder). An operational failure is a syntactically valid invocation that fails on a runtime/data condition (a missing change, a failed preflight, a below-gate --check-gate, a tmux/gh/filesystem error). The four usage classes exit 2 so scripts and agents can branch on failure class (swon).

Classification rides on execution phase, never message strings. main() delegates to a testable run(args []string, errW io.Writer) int helper (os.Exit(run(os.Args[1:], os.Stderr))), mirroring pane_exitcode_test.go's classifier-test shape. A markRunReached tree-walk wraps every command's RunE in the assembled newRootCmd() tree so a shared reached flag is set at the last moment before the real handler runs. On an Execute() error run prints the existing ERROR: %s\n line once (wording unchanged — the sentinel must not double-print) and returns 2 when reached is still unset (a usage error surfaced during cobra's execute() — flag parse, ValidateArgs, unknown-subcommand Find/legacyArgs, or ValidateFlagGroups — all of which run before any RunE) else 1 (an operational error from inside a RunE). The single execution-phase seam captures all four usage classes cleanly — including the mutually-exclusive flags-group conflict, which has no public cobra hook (ValidateFlagGroups returns a plain error mid-execute()) so it cannot be wrapped per-class. Setting the flag in a PersistentPreRunE was rejected: it runs before ValidateFlagGroups, so a flags-group conflict would be misclassified as operational. No code path inspects stderr or the error message to classify — the same error-value discipline as paneValidationExitCode. Pinned by main_exitcode_test.go (six cases: four usage → 2, one operational resolve <nonexistent>1, one success → 0), alongside the unmodified pane_exitcode_test.go. Source: src/go/fab/cmd/fab/main.go.

Coexistence with the in-handler domain schemes (no renumbering). The pane family (2 = pane missing, 3 = other tmux failure; see pane-commands.md) and fab docs-index docs/memory --check (tiered 0/1/2, 2 = destructive loss; memory_index.go) set their non-1 codes via os.Exit inside the handler, which bypasses run()'s usage/operational mapping entirely — so their codes are preserved with zero handler changes (the coexistence rule is structurally true). For those subcommands exit 2 is therefore intentionally ambiguous between "usage error" (at parse time) and the domain meaning (in-handler); this is documented per subcommand — principle №4 sanctions per-subcommand exit-code tables — rather than renumbered, because renumbering breaks the pinned pane_exitcode_test.go contract and downstream consumers (operator/run-kit branching on the pane codes, the hydrate refuse-before-regen guard branching on docs-index tier 2). A usage error is a static caller bug fixable at authoring time, not a runtime condition scripts branch on, and stderr wording disambiguates. This is distinct from the fab-kit binary's ExitNotManaged = 3 (see § Distinguishable Exit Codes above) — a different binary whose main() funnel exits 1 uniformly and therefore sets its distinct code in-handler; the fab-go convention here classifies at the run() funnel by execution phase, and the two are documented side by side so a reader does not conflate them. Documented in _cli-fab.md § Exit-Code Convention + _preamble.md.

Go Binary (fab-go)

The workflow engine backend for all fab CLI operations. Source: src/go/fab/.

internal/kitpath.KitDir() is the single fab-go content-resolution seam. Its test-only SetOverride has highest precedence. Otherwise a non-empty FAB_KIT_PATH is absolutized and must identify an existing directory; an invalid value returns an error naming the variable without trying the executable-sibling kit. When the variable is empty, executable and symlink resolution select the sibling kit/ directory as usual. This contract covers fab kit-path, templates, reference files, preflight's engine-version read, and other fab-go kit consumers.

Module: github.com/sahil87/fab-kit/src/go/fab (Go 1.26+, dependencies: cobra v1.10.x, gopkg.in/yaml.v3, no CGo) (F41) (tb6f)

Binary location: ~/.fab-kit/versions/{version}/fab-go — cached per-version by the system shim. Included in per-platform release archives (kit-{os}-{arch}.tar.gz). Not stored in bin/ — the repo holds content only. Cache installs are atomic (dn2c): the archive is digest-verified and extracted into versions/{version}.tmp-<pid>, then moved into place (one rename) under a version-keyed flock — a version dir that exists with fab-go is complete, so ResolveBinary's exec-bit probe cannot observe a partially written binary. See distribution.md's Auto-Download Hardening.

Subcommands:

  • fab resolve [--id|--folder|--dir|--status|--pane] [--or-none] [--server <name>] [<change>] — the five output-mode flags are mutually exclusive (MarkFlagsMutuallyExclusive; conflicting flags fail loudly with the usage-error exit 2, and --id is a real explicit-default flag wired into the selection — both since 260612-ye8r). --or-none (dow0) is not in that group — it composes with every output mode and with --server, and maps state-sentinel resolution failures to the exact token (none) on stdout + exit 0 (ErrNotFound always, bare and with an explicit <change> override; ErrAmbiguous only bare — changeArg == ""). A named-but-multi-matching override stays a non-zero error, and infrastructure errors (missing fab/ root, I/O) stay non-zero flag or no flag — the mapping applies to the change-resolution step only, so a --pane lookup failure after successful resolution is not a sentinel and stays non-zero. On the none path (none) replaces the mode-specific output for every mode; the token is exactly (none) — not none (a legal 4-char change ID, which would collide with --id output) and not empty (illegible in transcripts, and hazardous in command substitution: cd $(fab resolve --dir …) with empty output cds to $HOME). Without the flag, absence stays an error (the hard stop $(…) consumers rely on); absence-as-data is strictly opt-in and the flagless path is byte-identical. internal/resolve is untouched — the sentinels (ErrNotFound/ErrAmbiguous via classifiedError with Unwrap, matched by errors.Is) already existed; --or-none only exposes them at the CLI surface. fab change resolve is a thin cobra wrapper over the same shared runResolve implementation with --folder mode fixed and deliberately flag-free (no --or-none — the query flags live on top-level fab resolve only, so the wrapper passes orNone as false); the two spellings cannot drift, and callers needing the probe form use fab resolve --folder --or-none. Source: cmd/fab/resolve.go (noneToken const + the sentinel branch in runResolve); the flag-path + flagless-regression cases are in resolve_test.go

  • fab config explain [<key>] [--json] — canonical pure registry query; bare form renders the full commented schema or JSON table, keyed form renders the owning segment or its row(s). Unknown keys fail naming the key. reference remains an invisible Cobra alias for historical pointers.

  • fab config show [<key>] [--origin] — pure effective-config query. A known key selects a raw scalar/list or YAML subtree including defaults; bare --origin is winner-only, one line per leaf with origin ∈ {$FAB_… variable, system path, project path, default}, while keyed --origin lists the key's full tier stack (winner marked (effective), the rest (shadowed)). Provenance comes from config.LoadLayers (a LoadPath sibling that runs the same four-tier cascade and carries per-key environment origins) plus the materialized defaults tier configref.DefaultsMapFor, so show cannot drift from what consumers see, and a typo'd file override surfaces as origin default.

  • fab config set <key> <value> [--system] / unset <key> [--system] — exact-argument surgical writers through internal/configupgrade; set accepts only known scalar leaves and one-line comment-free YAML string/bool/int/float values, materializing every missing registry-rendered ancestor, while collection keys/values point to manual editing and an empty value is refused in favor of unset. A write a higher tier shadows warns and still exits 0; an absent-key unset names the tier where the key is live. Unset remains the shape-agnostic repair path, system scope is enforced, and no whole-document YAML marshal occurs.

  • fab config init [--system] — bare init generates the project file (--project remains compatible); --system writes the registry-filtered all-commented user scaffold. Explicit modes are mutually exclusive and both retain overwrite refusal.

  • fab config upgrade [--project|--system|--all] [--check] — target-parameterized reconciliation through internal/configupgrade: bare/--project handles the repo file, --system handles ~/.fab-kit/config.yaml without a repo, and --all handles both with labelled results. Live content/comments are preserved, one managed # >>> fab reference (kit …) >>> fence per target is regenerated, unknowns are parked, and writes are atomic/idempotent; --check is the zero-write form. The same engine owns surgical set/unset writes.

  • fab config's loader cascade — the visibility commands sit atop the four-tier cascade in internal/config.LoadPath: environment > system ~/.fab-kit/config.yaml > project fab/project/config.yaml > built-in point-of-use defaults, resolved at the single seam every consumer reaches. Environment names derive by forward-walking configscope.DottedKeys() (FAB_ + uppercase dotted key with dots→underscores); only both/system rows are honored, and values are YAML-parsed into a highest-precedence overlay. File and env maps merge per leaf through config.MergeLayers (lists/scalars replace; an empty leaf — null/""/[]/{} — falls through to the tier below). System-file failures and per-variable env parse/type failures warn and skip their layer or variable; project-scoped system/env overrides warn and are ignored. internal/configscope remains the dependency-free leaf for both scope taxonomy and ordered dotted keys, with configref parity tests preventing drift across the import-cycle boundary. Full semantics: configuration.md § Override Cascade & Scope Enforcement

  • fab setup [--defaults] [--project] [check] — the setup family. Bare fab setup runs the interactive setup wizard (cmd/fab/setup_wizard.go, package main — deliberately NOT a new internal package: it reuses setup.go's unexported set/origin seams configMutationPath, effectiveTierFor, warnIfShadowed, and the stdin-TTY helper): an existing-system-scaffold refresh through configupgrade.Upgrade(SystemTarget(...)), then one setupcheck.Run probe and a scope banner (system tier default, --project retargets to the project config and errors outside a fab repo), provider/mode questions filtered to detected/viable options with current-effective-value defaults, an opt-in advanced section with the at-default-never-overridden skip rule, a diff-before-write summary plus confirmation, and surgical configupgrade.SetSystem/Set writes with warnIfShadowed per key. --defaults is the non-interactive accept-all (zero preference write; an existing stale system scaffold may self-heal first); non-TTY stdin without it errors naming the flag. fab setup check is the read-only setup-state doctor: all probing lives in internal/setupcheckProbeProviders / ProbeEnvironment / ProbeVersions / ProbeDispatchMode / ProbeOverrideMasking as pure-ish functions with injected seams (lookPath, $TMUX, kit dir, config layers — the dispatch.SelectMode purity precedent), aggregating into a structured Report via Run, which the wizard consumes to filter its interview options without shelling out — and cmd/fab/setup.go owns only input wiring, rendering, and exit mapping (any failure-severity finding returns an operational error → exit 1; warnings-only exits 0; usage errors exit 2 at the cobra layer — no new exit tier). The override-masking probe introspects the binary's own embedded defaults through the agent.BuiltinProvider(name) (config.ProviderConfig, bool) export — the unmerged built-in table entry exactly as parsed from the go:embed'd defaults.yaml (ResolveProvider cannot serve this: its output already has user overrides folded in, which would make a load-bearing override invisible). Always-routed — setup is not in LifecycleCommands, and the collision test covers it. Behavior contract: setup.md § Interactive Setup Wizard and § Setup-State Doctor.

  • fab log command|confidence|review|transition ...

  • fab status start|advance|finish|reset|skip|fail|all-stages|progress-map|... (stage-machine operations plus status/diagnostic utilities)

  • fab preflight [<change>]

  • fab change new|rename|switch|list|resolve ...

  • fab score [--check-gate] [--stage <stage>] <change>

  • fab change archive|restore|archive-list ...

  • Retired command surface — the CLI ships no fab hook family (session-start/stop/user-prompt/artifact-write/sync) and no fab runtime set-idle|clear-idle|is-idle (y022) (ioku): agent active/idle state production is divested to run-kit's @rk_pane_agent_state tmux pane-option convention — fab is a pure reader (see runtime-agents.md) — and artifact bookkeeping is pull-based via fab status refresh [<change>] (internal/refresh.Refresh; the change argument is optional — omitted, it resolves the active change via the .fab-status.yaml symlink), self-healed at the transition seams (fab status advance/finish, fab preflight). fab registers no Claude Code hooks, so there is nothing to sync and fab sync never touches .claude/settings.local.json. An un-migrated settings file still invoking fab hook <x> gets a cobra unknown-command error until the 2.13.6-to-2.14.0 migration (the checkout it runs in) and the 2.15.7-to-2.15.8 migration (every worktree, main checkout included) (weoh) remove the entries; see migrations.md § 2.15.7-to-2.15.8.

  • fab pane — parent command grouping eight pane-related subcommands (map, capture, process, window-name, open, ready, deliver, kill). Available from any directory, including outside a fab repo — config-independent by virtue of the router's always-route policy plus the absence of any per-command resolve.FabRoot() guard (pane subcommands resolve state from pane IDs, not the invoker's CWD). Detailed subcommand behavior and the --server/-L flag live in pane-commands.md.

  • fab dispatch start|restart|status|wait|logs|kill|reap|clean — process manager for pane and headless stage workers. Forced flags keep precedence and hard errors. Otherwise the shared selector starts at dispatch.mode and descends pane → native → headless; start/restart probe tmux, re-descend when it is unreachable, and return native-dispatch guidance before state writes when native wins. Successful automatic launches report the preferred/descended rung and reasons.

    • headless consumes headless_command, launches a detached process group, and exposes five derived states.
    • pane consumes interactive_command, opens a split pane or new window, and exposes the running/done/orphaned subset. Automatic missing pane prerequisites become descent reasons; forced pane remains a hard error.

    The two provider command fields never fall back to each other in either direction — a missing field errors naming the stage, role, provider, and config key. Both modes share .fab-dispatch/{4-char-id}/ at the repo root, the loader, and the refuse-if-running check. The testable core (state read/write, WrapperArgv composition, DeriveState/DerivePaneState, process signaling, tmux pane primitives) lives in internal/dispatch; only the launch/signal syscalls are platform-split (dispatch_posix.go !windows / dispatch_windows.go windows) so POSIX-only v1 is a compile-time reality for the headless path (Windows returns the clear POSIX-only error) while the tmux primitives compile everywhere. Like fab config (6nke), dispatch is always-routed and its name must not collide with the fab-kit allowlist (TestNoTopLevelCommandCollidesWithRouterAllowlist stays green). Full runtime behavior — both launch models, the .fab-dispatch/{id}/ layout and derived-mode record, per-mode state derivation, refuse-if-running/last-attempt-only, timeout-in-wrapper, pane kill/logs semantics, and the two cleanup paths — lives in runtime/dispatch.md.

  • fab resolve <change> --pane — output the tmux pane ID (e.g., %5) for the pane running the resolved change; composable with tmux send-keys -t "$(fab resolve <change> --pane)" "<text>" Enter

  • fab resolve-agent <stage|role> [--alias] [--provider <name>] [--model <id>] [--effort <level>] — deprecated compatibility projection of a pipeline stage/role profile; its frozen ordered line protocol remains available, while skill dispatch consumes fab agent <stage|role> -o yaml. Semantics: runtime/providers-and-profiles.md

  • fab impact <base> <head> — the canonical true-impact math as YAML on stdout: git diff --shortstat <base>...<head>, plus an excluding pass when true_impact_exclude is non-empty and a tests pass when test_paths is non-empty. Schema: pipeline/schemas.md

  • fab pr-meta <change> — render a fab-generated PR's complete ## Meta block (table, Pipeline, optional Issues, optional Impact) as final markdown, reading .status.yaml, plan.md, config.yaml, the impact math, and git/gh context itself; /git-pr passes only the change reference, the resolved --type, and optional overrides. Schema: pipeline/schemas.md

  • fab kit-path — print the absolute resolved kit content directory that skills interpolate as $(fab kit-path)/.... A valid FAB_KIT_PATH wins over the executable-sibling kit; invalid values fail loudly without fallback. Config-free — runs from any directory

  • fab fab-help — dynamic skill discovery and help overview (scans .kit/skills/ frontmatter, groups by category)

  • fab docs-index [<root-path>] [--check [--json]] [--rebuild] — deterministically (re)generate index files for every configured docs_index.roots root (or one positional configured root; an unconfigured path errors naming the key — there is no --root) so agents never hand-edit them; zero config yields the implicit {docs/memory, index.md, log: true, max_depth: 3} root with byte-identical output. The generator is root-agnostic: it recurses to arbitrary depth (a root's max_depth is an advisory soft-warn bound, never a traversal limit), writes index_file landings as whole generated files and an existing also_accept landing (e.g. README.md) as a marker-delimited generated block with outside prose byte-preserved, renders superseded:-glob subtrees as a pointer+count row at the parent plus per-version rows at their own index (no per-file rows or description reads inside; file-level matches fold into the folder count), seed-imports pre-existing navigation rows into the always-emitted hand-managed manual block on first run (no adopt flag), skips exclude:-glob files and folders entirely (no row, no count, no landing — exclude beats superseded on a double match), and bounds advisory reporting (≤5 details per kind across all roots, … and N more (M total) on stderr, additive warnings_total in JSON). The full generalized contract lives in memory-docs/docs-index.md and _cli-fab.md § fab docs-index; fab memory-index survives as a deprecated memory-only alias (one stderr notice, ≥1 minor version). For the memory root it regenerates the root docs/memory/index.md (domains-only| Domain | Description |, no inlined per-file column) and every docs/memory/{domain}/index.md (file rows — | File | Description |) from folder contents + each file's description: frontmatter — content-only, with no dates (no Last Updated column (ugde) — a git log projection is HEAD/branch-relative and so not idempotent; the batched git log pass serves log.md only). Sub-domain tiers (sx7a): a {domain}/{sub-domain}/ directory holding ≥1 non-index .md gets its own generated {domain}/{sub-domain}/index.md (same file-row contract), and the parent domain index gains a ## Sub-Domains table (| Sub-Domain | Description | linking to {sub-domain}/index.md) emitted only when sub-domains exist — so sub-domain-free domain indexes render byte-identically (no ## Sub-Domains table). Byte-stable / idempotent (second run = no diff), so the indexes stop drifting and stop generating per-row merge conflicts. Emits non-fatal stderr warnings across the recursive tree — all advisory, never affecting the byte-stable output: shape warnings when a folder (domain or sub-domain) exceeds the soft width bound (~12 topic files) or its root's max_depth (reserved domains _shared//_unsorted/ are width-exempt; the width exemption is domain-tier only — an over-wide sub-domain still warns), the 501–1000-rune description: trim nag, a missing-description advisory per sparse-generic-root file (row renders H1 + ), and the FKF present-truth debt meters — per-topic-file narration-marker density ≥5, file size >400 lines or >15360 bytes, _unsorted/ non-empty, and broken bundle-relative ](/...) links (mxgu; the narration-density and broken-link diagnostics are log: true-scoped and do not run on generic roots). A blocking content class (xu0k) (mxgu) is distinct from these advisories: four description:/frontmatter signatures — an unclosed frontmatter fence, a quote-strip-failing description:, a registry-gated change-id in description: (log: true roots), or a gross over-cap description: (>1000 runes; log: true roots) — floor the --check exit at 1 independent of drift ( findings enumerated to stderr with a fix-the-file remediation), while never being a destructive-loss tier-2 category. --check is tiered (glwc): it writes nothing and classifies the rendered-vs-existing drift by severity encoded in the exit code — 0 clean, 1 benign drift (regen changes content but destroys nothing — e.g. an improved description:; the former "out of date" condition, so existing "non-zero = stale" CI/preflight consumers keep working), 2 destructive loss (regen would wipe hand-managed/historical content). Tier 2 has three categories (the mechanical form of /docs-reorg-memory's prose signals): (1) a hand-managed description that would regenerate to because the file lacks description: frontmatter; (2) a tombstone row whose root-relative link target is absent on disk (external/absolute links excluded — no false positives); (3) a custom structural grouping heading in the root index.md beyond the domains-only table. On tier 2 (non---json) it enumerates each loss to stderr by category and ends with the pointer → run /docs-reorg-memory to remediate (it relocates removal-history rows to _shared/removed-domains.md and backfills description: frontmatter via /docs-hydrate-memory) before regenerating. (/docs-reorg-memory is the orchestrator for all three categories — it relocates tombstone rows itself and dispatches /docs-hydrate-memory backfill mode for descriptions; backfill alone does not relocate tombstones). Loss is a strict subset of drift (one render pass serves both); a born-compatible fab-kit tree is provably never tier 2 (frontmatter present, no off-disk rows, domains-only root). Multiple roots aggregate worst exit wins. The optional --json flag (with --check) emits the report as a single snake_case JSON object on stdout — {"tier": 0|1|2, "drift": bool, "losses": [{"category": "description"|"tombstone"|"grouping", "path": "<repo-rel index>", "detail": "..."}], "malformed": [{"kind": "malformed-fence"|"malformed-description"|"description-change-id"|"description-over-cap", "path": "<repo-rel file>", "detail": "..."}], "warnings": [{"kind": "description-length"|"missing-description"|"narration-density"|"file-size"|"unsorted-nonempty"|"broken-link", "path": "<repo-rel file/folder>", "count": N, "bytes": N, "detail": "..."}], "warnings_total": N} (mirrors the fab pane/migrations-status --json convention) — suppressing the human text; the exit code is unchanged. The malformed array (the four blocking kinds) (xu0k) (mxgu) and the warnings array (six advisory kinds — the four debt meters (mxgu), the 501–1000-rune description-length trim nag carrying its rune length in count (which the /docs-distill-memory survey consumes as its canonical signal source), and missing-description for sparse generic roots) are both additive and always present (empty arrays, never null, like losses); warnings is sampled to ≤5 details per kind across all selected roots with warnings_total counting all JSON-eligible advisories before sampling (width/depth warnings stay stderr-only, excluded from the total) — consumers treat counts derived from a sampled list as lower bounds; the malformed JSON key is retained for consumer compatibility even though the internal predicate generalized to IsBlocking(); tier/drift/losses are unchanged. Callers pick a threshold: CI/pre-commit fails on exit ≥1 (now including the two escalated description checks); the hydrate/reorg refuse-before-regen guards fail only on exit ==2. The classifier + existing-index-row parser are pure functions in internal/memoryindex (unit-tested like RenderRoot/Gather); the cmd reuses the existing rendered-vs-existing byte-compare. Source: cmd/fab/memory_index.go + internal/memoryindex/. Consumed by the hydrate skills (/docs-hydrate-memory, /fab-continue hydrate — both with a refuse-before-regen guard keyed on exit 2), /docs-reorg-memory (compatibility detection via --check --json), and /docs-distill-memory (survey signal source via --check --json)

  • fab operator — parent command: default behavior launches singleton tmux tab for the operator skill (resolves the operator role in-process → that role's provider interactive_command + profile) (tykw). Subcommands: tick-start (start-of-tick state update: increments tick_count, writes last_tick_at RFC3339 UTC to the server-keyed XDG state file — see "Operator State File" above — outputs tick: N\nnow: HH:MM; --diff probes the tracked items and emits the tick document), time (pure clock query: outputs now: HH:MM; with --interval <duration> also outputs next: HH:MM), and the state mutation-verb family — state [--all] [--json], track add/update/observe/rm/list/clock, branch-map rm (full mediation of the state file — see "Operator State File" → "Mutation-verb family" above)

  • fab agent [role|stage] [--provider <name>] [--model <id>] [--effort <level>] [--headless] [-t|--template] [-o yaml] [--workers <provider>] [-p|--print] [--repo <path>] [-- <agent-args>...] (tykw) — compose a session command and exec it in the current shell (via /bin/sh -c). Selector-addressed (the positional): resolve a role profile (default when the arg is omitted; the six role names and the six stage names accepted — a stage maps through the fixed stageRoles table to its role, and the review/hydrate collisions are fixed points, so either name resolves identically) and compose providers.<profile.provider>.interactive_command with that role's {model}/{effort} substituted (or Claude-style flags appended) via spawn.WithProfile. Selector + --provider: re-resolve the selector's role from the named provider's own fills (agent.ResolveRoleWith with the provider pinned). Provider-addressed (bare --provider <name>, no selector): bypass role resolution and look up providers.<name> directly via agent.ResolveProvider, composing its interactive_command with the --model/--effort values through the same WithProfile (omitted ⇒ empty ⇒ the token-drop rule, so a bare provider invocation results and the CLI's own default model applies). Flag.Changed guards, not value emptiness; --model/--effort are general overrides on every addressing form, applied verbatim post-refill across both structured and deprecated projections. The print-family sinks — one per invocation — are -p/--print (the resolved command), -t/--template (the provider's raw template, placeholders intact; rejects --model/--effort), -o yaml (the full structured resolution, owned by _cli-fab-operator.md § fab agent; mutually exclusive with --print and -t), and --headless (selects headless_command instead of interactive_command; sink-only — exec is a usage error; a missing capability hard-errors naming providers.<name>.headless_command). An unknown provider name is a lookup failure listing agent.ProviderNames(cfg) (built-in ∪ project keys, sorted, over the nil-safe config.ProviderNames() accessor); an unknown selector errors naming the valid role and stage names. --print prints the fully-resolved command instead of executing — with a semantic upgrade over any raw print: the output is profile-resolved (model/effort substituted), so callers that spawn from the printed command get the profile. --repo <path> reads <path>/fab/project/config.yaml directly (no upward search — the operator's fetch-a-target-repo's-command use case) and composes with any addressing form; without --repo, resolves the current repo's config via upward resolve.FabRoot(). Falls back to spawn.DefaultSpawnCommand (the {model}/{effort} template claude --permission-mode bypassPermissions -n "$(basename "$(pwd)")" --model {model} --effort {effort}, 260703-gvxd; profile-substituted) when the config is missing/empty/unreadable. Exec does NOT TTY-guard (exec-and-let-the-CLI-fail). Source: src/go/fab/cmd/fab/agent.go (agentCmd(), wired into the root command in main.go). There is no fab spawn-command (tykw) — no deprecation alias (its only CLI consumer, the operator skill, ships in the same kit). Semantics: runtime/providers-and-profiles.md § fab agent.

  • fab batch new|switch|archive — multi-target batch operations via tmux tabs with Claude Code sessions

  • fab shell-init <shell> — emit the shell-completion script for bash, zsh, or fish. Equivalent to (and delegated to) Cobra's auto-generated fab completion <shell>; provided as the tu-style verb users expect. Source: src/go/fab/cmd/fab/shell_init.go. Recommended install: add eval "$(fab shell-init zsh)" to ~/.zshrc (or the bash/fish equivalent). Config-independent — works outside a fab repo

  • fab skill [topics]visible toolkit-standard bundle command (fskl) (shll skill standard @ v0.0.23; always-routed, skill ∉ LifecycleCommands allowlist so no router change, guarded by lifecycle_collision_test.go). Prints the canonical agent-usage bundle docs/site/skill.md (124 lines, static-only, ≤150-line budget) as raw markdown to stdout, byte-identical, stderr empty, exit 0, no pager/framing. The single accepted positional is the standard's reserved topic topics (psgm): fab skill topics enumerates content-topic names one per line raw to stdout, stderr empty, exit 0 — fab ships zero topic pages, so it prints nothing (zero bytes), the standard's scriptable "zero topics" answer. Implemented as a custom Args validator (zero args, or exactly topics; anything else falls through to cobra.NoArgs) with an early-return RunE branch — deliberately not a cobra child command, so topics appears in neither fab skill --help nor the frozen help-dump JSON tree; the validator extends to a topic-name set if topic pages ever ship. Any other positional is a usage error → exit 2 via the binary-wide run()/markRunReached classifier (no new exit code); pinned by TestSkill_TopicsEmptyContract + TestSkill_RejectsArgs. Testable seam runSkill(stdout io.Writer) error writes the embedded bytes verbatim (mirrors runStandards/runList). This is fab-go's first go:embed usage, done via the sync + drift-guard pattern the shll standards mechanism established, adapted to a single file: the fab-go module root is src/go/fab/ and docs/site/ sits above it, so //go:embed cannot reach the canonical file directly — a committed copy src/go/fab/cmd/fab/skill.md (beside skill.go, embedded via //go:embed skill.md) lets a clean go build ./... compile without running any script; scripts/sync-skill.sh (set -euo pipefail, cp -f docs/site/skill.md → package dir), referenced by a //go:generate ../../../../../scripts/sync-skill.sh directive (five levels cmd/fab→repo root), refreshes the copy; and a drift-guard test TestSkillEmbedMatchesCanonical compares the embedded bytes to canonical docs/site/skill.md byte-for-byte, failing the build on divergence (contract tests also pin byte-identity via the seam, empty stderr, exit 0, and the ≤150-line budget). The near-duplicate walk-up helper was avoided by hoisting the shared findRepoFile in lifecycle_collision_test.go (used by both the collision and drift-guard tests). Source: src/go/fab/cmd/fab/skill.go (skillCmd() + runSkill), registered in newRootCmd(); skill_test.go. Docs: _cli-fab.md § fab skill; docs/specs/architecture.md (config-free roster). Renders at shll.ai/tools/fab-kit/skill for free (pulled docs/site/** surface). Note the vocabulary split the bundle itself disambiguates: fab skill (this bundle) ≠ fab's kit-skills (the /fab-* markdown deployed to .agents/skills/ unconditionally and .claude/skills/ when claude is available by fab sync). See distribution.md § Toolkit Standards Conformance

  • fab help-dumphidden, machine-consumer command (Hidden: true, cobra.NoArgs) invoked by shll.ai's puller on its own schedule (the pull model — fab-kit never pushes; the release workflow carries no help-dump step (mtf9)). Walks the live cobra command tree of the assembled root command programmatically (via cmd.Commands(), not regex-parsing -h) and writes the frozen shll.ai "command reference" contract JSON to stdout: the envelope is exactly {tool:"fab", version (from main.version ldflags), schema_version:1, root:Node} where Node={name=cmd.Name(), path=cmd.CommandPath(), short, usage=cmd.UseLine(), text=cmd.UsageString(), commands[]}. No captured_at — the capture timestamp is owned by shll.ai's puller, which stamps it after capture (a tool cannot know its own capture time); the help-dump standard (shll v0.0.23) forbids the tool emitting it, and schema_version stays 1 since removing a consumer-owned field is not a breaking change (see distribution.md § Toolkit Standards Conformance) (ptwh). At every level the walk drops completion, help, and any Hidden command (self-excluding help-dump), then sorts surviving children by Name() for byte-stable output; leaves emit commands:[] (never null). The encoder uses 2-space indent and SetEscapeHTML(false) to preserve raw -h bytes. Because it is Hidden, it is absent from fab --help and from its own dumped tree. Source: src/go/fab/cmd/fab/help_dump.go (helpDumpCmd(), dumpDoc, recursive buildNode); the command reference is one of the two pull surfaces shll.ai consumes from fab-kit (see distribution.md § shll.ai Public Docs Site)

Architecture: provider/role resolution lives in internal/agent (tykw) (ResolveProvider(name) → a provider's interactive_command/headless_command + per-role profiles fills, per-field merged over the built-in table; ResolveRole/ResolveRoleWith/Resolve → the six roles resolved through the single fill precedence — --provider flag, then agent.profiles.<role>, then the role's depth knob, then built-in claude for the provider; flag, then agent.profiles.<role>, then the provider's profiles.<role>, then its profiles.default, then empty for model/effort — with the fixed stageRoles and roleDepth maps and IsSessionRole as the exported read of the partition; the built-ins parsed from the embedded defaults.yaml), and internal/spawn provides only the command-line composition. Consumers — operator, agent, and the batch new/batch switch subcommands (batch archive does not spawn) — compose a resolved provider interactive_command + a role profile. agent.DefaultInteractiveCommand (re-exported as spawn.DefaultSpawnCommand) is the built-in claude fallback; --repo <path> (on fab agent) reads a target repo's config directly via internal/config.LoadPath. spawn.WithProfile(cmd, model, effort) is the one command-line-composition seam (6tmi): it appends --model/ --effort only when cmd carries no {model}/{effort} placeholder (Claude-shaped back-compat), and otherwise substitutes the resolved profile into the placeholders (all-or-nothing template mode, with an empty-value token-drop rule that also strips a preceding --flag; the all-non-empty path is a raw strings.ReplaceAll that preserves author whitespace). (There is no spawn.StripPlaceholders (tykw) — no raw empty-profile print path exists: fab agent --print prints profile-resolved output and fab batch composes with a profile, so no consumer interpolates an unresolved templated command into a shell. See _shared/configuration.md § providers, runtime/providers-and-profiles.md, and runtime/operator.md.) internal/frontmatter provides YAML frontmatter parsing (used by fab-help and docs-index to read the description: field). internal/configscope (lpb5) is the dependency-free leaf package holding the config-field scope enum (Scope/ScopeProject/ScopeSystem/ScopeBoth/Valid) and the top-level keyScopes table (ScopeFor); it exists to let internal/config (which prunes project-scoped keys from the system layer) and internal/configref (which aliases the enum and derives each Field.Scope from it) share the taxonomy cycle-free — a direct configref import would close the configref → agent → config cycle. (keyScopes carries no "fab_version" entry — the field is not a config.yaml key (j0qm).) internal/configupgrade (j0qm) is the config-rewriter leaf, the memoryindex analog for config.yaml: its Target descriptor carries path, header, field filter, and fence preamble; ProjectTarget and SystemTarget feed the shared Upgrade(target, kitVersion) / Check(target, kitVersion) compute path. It owns both managed-fence renderings, legacy unfenced system-scaffold adoption, RenderInitProject / RenderSystemScaffold, the surgical Set/Unset/SetSystem/UnsetSystem path splicer, parking, rename carry, and CommentOutSegment. It reads configref.Fields() as the sole schema source and writes via internal/atomicfile, byte-stable and idempotent. See configuration.md § fab config upgrade. internal/memoryindex (name kept — a rename adds no behavior) powers fab docs-index: it follows the internal/prmeta Render/Gather split — pure RenderRoot(RootData) string (domains-only table) + RenderDomain(DomainData) string (file rows) renderers — generalized root-aware in docsindex.go (GatherRoot(repo, fabRoot, config.DocsIndexRoot, rebuild) walks any configured root to arbitrary depth, reading each topic file's H1 + description: frontmatter via internal/frontmatter.Field, computing per-folder counts/depth, honoring index_file/also_accept landings and the max_depth soft-warn, and collecting shape Warnings width-exempting _shared/_unsorted), with superseded.go (glob status matching plus pointer/count/per-version rendering) and adoption.go (first-run seed-import of existing navigation rows into the manual block, grouping, and tombstones) alongside it. Root config resolution lives in internal/config (docs_index.go — the DocsIndexRoot type, per-root defaults, the implicit zero-config memory root, and overlap rejection), registered for fab config explain via internal/configref. The index render is content-only (ugde) — it consumes no git dates: there is no index-side date plumbing (FileEntry.LastUpdated, gitDates.byPath, (*gitDates).lookup, the gitLastUpdated per-file fallback), while loadGitDatesForRoot / gitDates.commitsByPath are retained because log.md generation (gatherLogEntries, log: true roots only) still consumes the batched git log pass. Sub-domain recursion (sx7a): DomainData carries a SubDomains []DomainData field; RenderDomain appends a ## Sub-Domains table only when len(SubDomains) > 0 (sub-domain-free output unchanged), and the same RenderDomain renders each sub-domain index.md (no bespoke RenderSubDomain — the file-row contract is tier-agnostic). (gatherFiles reaches depth-3 sub-domain topics — the PR #377 Copilot finding; the generalized walk recurses past it, max_depth warning only.) The pre-generalization Gather/GatherLogs/LogTarget/loadGitDates/domainTitle wrappers survive only as legacy-test compatibility surfaces — production is GatherRoot-based. Repo root is resolved as filepath.Dir(resolve.FabRoot()) (the prmeta repoDir idiom). The curated domain description is round-tripped through the generated domain index.md's own description: frontmatter so the root row survives regen. The pure renderers are byte-for-byte unit-testable without git fixtures. internal/intake derives the mechanical archive-index description for a change: Title(changeDir) reads the # Intake: {title} heading from intake.md (de-prefixed, internal whitespace collapsed; "" on any read failure), and DescriptionFor(fabRoot, folder) prefers that title, falling back to a humanized slug (folder name minus the YYMMDD-XXXX- prefix, hyphens → spaces). internal/archive depends on internal/intake, not the reverse. internal/backlog holds the shared backlog parser (Item, ParsePending, ExtractContent) so the batch-new and archive paths share one copy of the [a-z0-9]{4} regex (ParsePending returns ([]Item, error) (hv7t) — open/read failures surface instead of a silent nil — and ExtractContent distinguishes read errors from a genuinely missing ID, not found in backlog); it also adds Path(fabRoot) and MarkDone(backlogPath, id), which flips a backlog line - [ ] [<id>]- [x] [<id>] in place (never moving it to a ## Done section) and returns marked / already (no write) / not_found (no match, or backlog.md missing — silent nil-error no-op). internal/archive keeps Archive() pure (folder move / index / pointer; it auto-derives an empty --description from the intake title via internal/intake before the move) and adds an ArchiveWithBacklog() orchestrator that runs Archive(), extracts the 4-char change ID via resolve.ExtractID (the change ID is the originating backlog ID), and calls backlog.MarkDone — recording the result on ArchiveResult.Backlog, which FormatArchiveYAML emits as a backlog: field. Re-archiving an already-present change returns the ErrAlreadyArchived sentinel, which both fab change archive (exit-0 soft skip) and fab batch archive (counted skipped) treat as an idempotent no-op via errors.Is. internal/lines (hv7t) is the shared read-lines helper — ReadFileLines(path) ([]string, error) and Split(content) []string, splitting on "\n" with a per-line trailing-"\r" TrimSuffix to preserve bufio.ScanLines' CRLF behavior. It replaces every unchecked production bufio.Scanner site (score's countGrades via Split — it takes already-read content (F02) (mz4q), archive's removeFromIndex, backlog's ParsePending/ExtractContent, artifact's section parsers via Split, prmeta's checkbox counters, frontmatter's Field/HasFrontmatter, memoryindex's readH1): reads are all-or-nothing, so bufio's 64KB MaxScanTokenSize truncation class is gone. (No production bufio.NewScanner remains — internal/proc, the grandparent PID walker that streamed /proc, does not exist (ioku).) internal/atomicfile (hv7t) is the temp+rename write helper serving the archive-index writers — WriteFile(path, data, perm): temp in the destination dir, write, fsync, chmod to perm, rename, temp removed on any failure. It mirrors the statusfile.Save pattern; statusfile.Save keeps its own inline implementation because F03/F04 (mz4q) gave it a fsync posture (.status.yaml fsyncs as the pipeline's source of truth) that the always-fsync helper matches (the once-distinct ephemeral runtime.SaveFile no-fsync posture is moot — no runtime file exists (ioku)). internal/statusfile is the shared foundation — a StatusFile struct parsed once via Load(), passed by pointer across all operations, and written atomically via Save() (inline temp+fsync+rename, under the cross-process lock from internal/lockfile). All other packages (resolve, log, status, preflight, change, score, archive) import statusfile for YAML access — and that single ownership holds everywhere (hv7t): there is no hydrateStatusRe regex outlier in batch_archive.goisArchivable goes through statusfile.Load + GetProgress("hydrate"). Worktree discovery (git worktree list --porcelain) and the fab state resolution layered on it live in internal/pane — there is no internal/worktree package; the full worktree-management library belongs to the standalone wt repo (github.com/sahil87/wt), not to this tree. There is no internal/runtime or internal/proc package (agent-state divestment) (ioku): fab reads the @rk_pane_agent_state tmux pane option instead of any runtime file (see runtime-agents.md). internal/lockfile serializes .status.yaml for status/preflight/score. The internal/artifact package (ffny) provides only the shared parsing primitives (change type inference, task/checklist section counting) in artifact.go — it has no hook-sync half (ioku). Its artifact-parsing consumer is internal/refresh (the pull-based fab status refresh), not a PostToolUse hook (y022) — internal/refresh.Refresh calls artifact.InferChangeType/HasSectionHeading/CountSectionItemsBounded/CountCompletedSectionItemsBounded directly. The internal/pane package provides shared pane resolution logic extracted from pane_map.go: ValidatePane(paneID) (checks pane exists via tmux list-panes), ResolvePaneContext(paneID) (resolves worktree, change, stage, agent state into a PaneContext struct), GetPanePID(paneID) (shell PID via tmux display-message), and FindMainWorktreeRoot(cwds) (main worktree root discovery). Every fab pane subcommand goes through it — map, capture, send, process, and window-name (whose ReadWindowName/RunCmd/WithServer calls keep the one tmux argv builder). The pane parent command in cmd/fab/pane.go groups its subcommands: map, capture, send, process, window-name. The map subcommand in cmd/fab/pane_map.go combines tmux pane discovery, worktree resolution, change state, and runtime state into a single observation command — delegating pane validation and context resolution to internal/pane. The internal/dispatch package (6sgj) is the stage-dispatch analog of internal/pane: it owns the .fab-dispatch/{id}/ state read/write (via internal/atomicfile), the sh -c wrapper composition (WrapperArgv, optional timeout N), the two pure state derivations (five-state DeriveState for headless, three-state DerivePaneState for --pane — separate functions so each is independently table-testable), the pure shared mode-selection ladder SelectMode(paneFlag, headlessFlag, timeoutSet, serverSet bool, preference string, native, interactiveCommand, headlessCommand bool, tmux TmuxAvailability) (Mode, AutoReason, error) — explicit flags first, then the configured dispatch.mode preference descending pane → native → headless against provider capability, returning mode: <rung> (preferred) / mode: <rung> (descended: <reasons>) AutoReasons (ReasonExplicit stays empty) and an error when no rung is possible — the same table-testable-without-I/O shape as the derivations, with the caller supplying os.Getenv("TMUX"), the derived-mode accessors (IsPane/Mode + the ModeHeadless/ModePane constants) and WindowName, and the platform-split launch/signal syscalls (dispatch_posix.go !windows uses SysProcAttr{Setsid:true} + the syscall.Kill(pid, 0) liveness probe + syscall.Kill(-pgid, SIGTERM) group kill; dispatch_windows.go windows returns the POSIX-only error). Its tmux side (pane_mode.goServerReachable / OpenWindow / PaneAlive / KillPane) sits in the platform-independent core because these are plain tmux subprocess calls with no syscall dependency, and each delegates to internal/pane's RunCmd / WithServer / StderrError so the binary keeps exactly one tmux argv builder and one stderr-enrichment convention. It consumes internal/agent + internal/spawn for the resolved spawn command; internal/archive.Archive() imports it for the archive-time .fab-dispatch/{id}/ deletion. Supports --json (JSON array output), --session <name> (target specific session), and --all-sessions (enumerate all sessions). discoverPanes(mode, sessionName) accepts a session targeting mode and extends the tmux format string with #{session_name}, #{window_index}, and #{window_id} (the last a stable per-window identifier surfaced in map --json as window_id; 260713-ueuy). Shared pane-matching functions (discoverPanes, matchPanesByFolder, resolvePaneChange) also live in pane_map.go and are reused by resolve --pane.

Shared agent-resolution projection: internal/agent.Resolution is the complete result shared by fab agent and deprecated fab resolve-agent, with YAML and frozen ordered-line projections, per-field source provenance, fill mode, and optional labelled dispatch. cmd/fab/resolution.go is their single profile-to-resolution composer; it consumes the traced internal/agent precedence, internal/spawn fill discriminator/composition, and internal/dispatch mode selector. The command files normalize addressing and render the result.

Parity: All subcommands produce stdout/stderr output matching the bash versions (modulo timestamps).

Testing: Unit tests in src/go/fab/ cover all internal packages via go test ./.... Run with just test (or just test-v for verbose). Tested packages: cmd/fab (pane_map, pane_capture, pane_process, tmuxsocket, operator, operator tick-start, operator time, the operator track-verb family + legacy conversion (operator_track, operator_migrate) and the derived-schedule reconcile (operator_clock), batch_new, batch_switch, batch_archive, fab_help, memory_index, setup (the wizard driven through injected stdin — all-Enter zero-write idempotence with a clean or missing system scaffold, changed-answer diff + surgical write, --defaults non-interactive, the non-TTY error, provider-option and dispatch.mode filtering, the advanced skip rule both ways — plus check on a fixture repo: exit 0 warnings-only vs exit 1 on a failure finding, usage error → 2, read-only assertion), config (config_show_init_test.go — bare and keyed show cover effective values, compact list origins, map drill-down, and unknown keys; bare init generates the project file, while explicit project/system modes retain mutual exclusion and overwrite refusal; config_test.go covers explain/reference and --json), and — resolve (output-flag mutual exclusion, --id wiring, change resolveresolve --folder parity, --server registration) (ye8r), log (fab log command always-exit-0 + stderr-warning failure paths), pane exit-code mapping (paneValidationExitCode 2-vs-3 classification via errors.As), and the lifecycle collision test (lifecycle_collision_test.go — no top-level command of the in-process help-dump tree appears in the _cli-fab.md router allowlist)), internal/config (incl. the cascade suite (lpb5) — per-field deep merge table [maps per-key incl. nested role-profile fields, lists replace, scalars replace], absent-system-file byte-identical, malformed-system-file warn+skip fail-open with project result intact, malformed-project-file still errors, scope pruning of a project-scoped system key with the exact warning text, t.Setenv("HOME", …) for the system path), internal/configscope (ScopeFor returns the decision-6 taxonomy for every top-level key; Valid accepts the three scopes and rejects others) (lpb5), internal/hooks, internal/artifact (parsing primitives only, no hook-sync half) (ioku) (ffny), internal/refresh (pull-based artifact-derived .status.yaml recompute via fab status refresh) (y022), internal/setupcheck (the fab setup check probe layer — nested-sh -c token unwrapping, configured-vs-unconfigured missing-binary severities, PATH/env seams, fixture kit dir, override-masking fixture layers), internal/pane (shared pane validation, context resolution, PID resolution, and the pure parseAgentState/@rk_pane_agent_state reader (ioku); there is no internal/runtime or internal/proc), internal/lines (CRLF trim, >64KB lines, missing-file error, trailing-newline semantics), internal/atomicfile (content/perm, overwrite, failure leaves original + no temp residue), internal/status (tb6f) (the exhaustive 216-cell lookupTransition matrix in transitions_test.go — stage × event × from-state, hand-written expectations pinned to the tables now enumerated in pipeline/schemas.md, incl. the failed→active start override and AllowedStates rejections; Skip forward-cascade tests; direct tests in mutators_test.go for the formerly-0% SetChangeType/AddIssue/ProgressMap/ProgressLine/AllStages plus Advance's remaining branches; and a 3-cycle stage_metrics.review.iterations rework regression test pinned to the shipped behavior (k4ge) — package 67.8% → 88.5%), internal/statusfile (incl. golden_test.go (tb6f) — the .status.yaml load→Save round-trip pinned byte-for-byte over a fully-populated document), internal/resolve, internal/log, internal/preflight, internal/score, internal/archive (incl. the golden archive-index full-content test (tb6f)), internal/intake, internal/backlog, internal/change, internal/spawn, internal/frontmatter, internal/memoryindex (byte-for-byte RenderRoot/RenderDomain golden output, idempotency, missing-description/-date degradation, shape-warning thresholds, reserved-domain exemption, loom's stale-roster self-heal regression fixture, and the sub-domain recursion (sx7a): nested-tree RenderDomain with a ## Sub-Domains table, sub-domain-free byte-identical output, depth-3 sub-domain discovery + deterministic ordering, idempotency, depth-4 over-depth warning, empty-sub-dir non-recursion, and over-wide-sub-domain warning; also golden_test.go (tb6f), pinning the complete generated root/domain index documents byte-for-byte). cmd/fab also has cobra-execution tests (tb6f) (change_exec_test.go, extending the memory_index_test.go setupFabRepo + SetArgs pattern) over the formerly low-coverage RunE bodies — change archive, change archive --list, change switch, change restore, change list, change rename, log review, and the backlog pending-item listing — asserting the exact stdout shapes skills parse: the archive structured YAML, the already archived: soft-skip line (exit 0), and the index: failed print-then-error contract (hv7t) (YAML still on stdout, non-zero exit). fab-kit tests: cmd/fab-kit (doctor; the registration cross-check (ye8r) — registered cobra commands ↔ LifecycleCommands table, names asserted in both directions plus Short equality — replacing a tautological string re-declaration), cmd/fab (router fabKitArgs derived-from-table assertion; clifab_doc_test.go, the _cli-fab.md router-line contract test, walk-up doc location in the changetypes_doc_test.go style — both (ye8r)), and internal (tb6f) (sync_integration_test.go's twice-run Sync harness — temp git repo + fake cached kit + PATH shims for checkPrerequisites, asserting a correct workspace tree then a content-identical no-op second run, directly encoding constitution III idempotency — plus the shimOnly/projectOnly branch split, cleanLegacyAgents deletion scoping at 100% (legacy targets deleted, project files outside the documented scope survive), Upgrade's stamp-after-success branches, and the post-split suites semver_test.go/prereqs_test.go (numeric yq-major regression — v10 passes the v4+ check)/scaffold_test.go/skills_test.go; module 67.1% → 80.2%). Golden byte-stability suite (tb6f): internal/statusfile/golden_test.go, internal/memoryindex/golden_test.go, and internal/archive/golden_test.go pin the .status.yaml emit format and the generated memory/archive-index output byte-for-byte — they are the standing parity arbiter for any future YAML-library change (see the yaml.v3 Design Decision below): a candidate library is admissible only if they pass unmodified. CI (ci.yml) runs both module suites with -race and cross-compiles darwin/arm64 (build + vet) on both matrix legs on every PR; releases gate on just test before any tag mint or build (release.yml — see distribution.md). Test patterns: t.TempDir() for filesystem isolation, table-driven tests with t.Run() subtests, standard testing package only (no external test frameworks). There are no parity tests (src/go/fab/test/parity/) — the bash scripts they validated against do not exist.

The internal/score package additionally carries a code↔doc consistency test (changetypes_doc_test.go, TestDocTablesMatchScoringMaps): the expectedMin / gateThresholds maps in score.go are the canonical source for per-change-type scoring data, and the test parses the "Expected Minimum Decisions" and "Gate Thresholds" tables in docs/specs/change-types.md to assert they mirror the resolved getExpectedMin / getGateThreshold values for all 7 change types, plus a bidirectional check that the doc covers exactly the canonical type set. The parser uses a test-local bufio.Scanner/pipe-split loop (no markdown library) — this scanner is test-only (hv7t): the production sites (including countGrades) are swept to internal/lines, and the test's scanner is a recorded deletion candidate per the change plan (hv7t). findDocFile walks up from the test CWD to the repo root to locate the doc. Drift between the maps and the doc tables now fails just test (and CI). See configuration.md for the scoring-data direction-of-truth history.

Skill Invocation Convention (_cli-fab.md)

The _cli-fab.md partial defines the calling convention for all kit operations. Skills invoke operations via fab <command> <subcommand> [args...] — this calls the system shim, which resolves the version and dispatches to the cached fab-go. _cli-fab is loaded selectively via a skill's helpers: [_cli-fab] frontmatter rather than universally via _preamble (or0o). The 6 most-used command families (preflight, score, log command, change, resolve, status) are inlined into _preamble.md § Common fab Commands so most skills never need _cli-fab. The partial includes the full command mapping table, argument formats, stage transition sequences, and error patterns in ≤300 lines.

Underscore File Ecosystem

The _ (underscore) prefix denotes internal partial files that are loaded by skills but not user-invocable. These files have user-invocable: false frontmatter and are deployed alongside regular skills via fab-kit sync. The ecosystem consists of:

FileLoad strategyPurpose
_preamble.mdAlways-load (every skill)Context loading, SRAD, confidence scoring, Next Steps, Skill Helper Declaration, inlined Naming Conventions, inlined HexoKit (rk) Reference, Common fab Commands
_cli-fab.mdSelective (via helpers: [_cli-fab])Fab CLI command reference (core families) — commands and flags beyond the Common fab Commands headline in _preamble. No skill declares it currently; consumers reach it by pointer
_cli-fab-pane.mdSelective (via helpers: [_cli-fab-pane])Fab CLI reference — fab pane and fab dispatch families. Used only by fab-operator
_cli-fab-operator.mdSelective (via helpers: [_cli-fab-operator])Fab CLI reference — fab operator and fab agent families. Used only by fab-operator
_generation.mdSelective (via helpers: [_generation])Spec/tasks/intake generation procedures. Used by fab-new, fab-draft, fab-ff, fab-fff, fab-adopt
_review.mdSelective (via helpers: [_review])Review procedures. Used by fab-ff, fab-fff, fab-adopt
_srad.mdSelective (via helpers: [_srad])SRAD autonomy framework — decision scoring, confidence grades, artifact markers, the Assumptions Summary block. The most widely declared helper: fab-new, fab-draft, fab-continue, fab-clarify, fab-ff, fab-fff, fab-adopt, plus the report-only analysis skills code-reorg and code-dedupe (report-item confidence grading)
_pipeline.mdSelective (via helpers: [_pipeline])Shared ff/fff pipeline bracket — intake gate, apply → review → hydrate, the auto-rework loop and its exhaustion stop. Used by fab-ff, fab-fff (full bracket) and fab-adopt (partial consumer — the rework loop + hydrate dispatch only)
_intake.mdSelective (via helpers: [_intake])Shared pre-boundary Create-Intake Procedure, parameterized by a {questioning-mode} knob. Used by fab-new, fab-draft
_cli-agents.mdSelective (via helpers: [_cli-agents])Agent-CLI interaction reference — generic spawn / pre-send-validation / peek / await procedures for driving another agent CLI in a tmux pane, plus a four-provider operational dictionary (claude, codex, agy, kimi). Used only by fab-operator
_cli-external.mdSelective (via helpers: [_cli-external])Fab-owned external-tool content only (clix): the operator spawning choreography, the escalation rk notify usage and the pointer to the operator's startup role self-mark, the agent-messaging and pane peek/kill/process usage pointers (rk mux send/await usage owned by _cli-agents.md § Pre-Send Validation / § Await and fab-operator.md §3/§5; rk mux capture/kill/process usage owned by _cli-agents.md § Peek; the verbs' contracts are tool-owned), the absent-binary discipline, and tmux (reduced — captures ride rk mux capture, sends ride rk mux send, fab pane capture staying dispatch-internal; new-window remains). Each owned binary's usage knowledgewt/idea (bare) and rk/hop (command -v-gated fail-silent) — is delegated at use-time to <tool> skill (with a silent https://shll.ai/<tool>/skill version-skew fallback), its command tree to <tool> help-dump. Used only by fab-operator

Only _preamble.md is always-loaded. All other helpers are opt-in via the helpers: frontmatter field on each skill. _naming.md and _cli-rk.md do not exist as separate files — their content is inlined into _preamble.md (## Naming Conventions, ## HexoKit (rk) Reference).

Skill → helper mapping (the declaration lives in each skill's frontmatter; grep -l '^helpers:' src/kit/skills/*.md is the roster):

  • fab-new, fab-draft[_generation, _srad, _intake]
  • fab-ff, fab-fff[_generation, _review, _srad, _pipeline]
  • fab-adopt[_srad, _generation, _review, _pipeline]
  • fab-continue, fab-clarify, code-reorg, code-dedupe[_srad]
  • fab-operator[_cli-fab-operator, _cli-fab-pane, _cli-agents, _cli-external]
  • The other 19 of the 29 user-facing skills declare no helpers: (they load only _preamble)

_preamble.md § Skill Helper Declaration is where the field is specified: it enumerates the ten allowed values (_generation, _review, _cli-fab, _cli-fab-operator, _cli-fab-pane, _cli-external, _cli-agents, _srad, _pipeline, _intake) and shows a fenced fab-ff frontmatter example. That example's helpers: line is illustrative — the preamble itself declares no helpers and loads nothing extra by default.

fab resolve --pane Flag

Outputs the tmux pane ID for a change's worktree. Signature: fab resolve <change> --pane [--server <name>]. The --pane flag is a Bool flag, mutually exclusive with the other four output-mode flags (MarkFlagsMutuallyExclusive — conflicting flags fail loudly) (ye8r).

Pane resolution: Reuses discoverPanes() and matchPanesByFolder() from pane_map.go with resolvePaneChange as the resolver function. No new tmux discovery logic. Without --server: same session-scoped discovery as fab pane map (current session, $TMUX required). With --server <name>/-L <name> (matching the pane family's persistent flag) (ye8r): every tmux invocation runs with -L <name> and discovery is server-wide across all sessions — "current session" is undefined on a foreign socket, and cross-socket callers (daemons) are not inside that server.

Tmux guard: Without --server, checks that $TMUX is set; if not, exits non-zero with ERROR: not inside a tmux session (returned through RunE) (ye8r). With --server, the guard is skipped — tmux's own connection failure surfaces if the socket is unreachable.

No matching pane: If no pane matches the change, exits non-zero with: ERROR: no tmux pane found for change "<folder>".

Multiple panes: When multiple panes match the same change, outputs the first match and prints a warning to stderr: Warning: multiple panes found for {change}, using {pane}.

Composable usage: Intended to be composed with raw tmux send-keys for sending text to agent panes: tmux send-keys -t "$(fab resolve <change> --pane)" "<text>" Enter. There is no fab send-keys subcommand (kvng) — the decomposed approach is more composable and avoids duplicating tmux functionality in the CLI. For validated sending with idle checks, prefer rk mux send when rk is installed (command -v rk-gated, fail-open to raw tmux send-keys) (4i0n).

Design Decisions

All Logic in Markdown and Shell (with Three-Binary Go Architecture)

Decision: Workflow logic lives in markdown skill files and shell scripts. Three system binaries (fab router, fab-kit workspace lifecycle, fab-go workflow engine) are installed via brew install fab-kit. The fab router dispatches to fab-kit (for workspace commands) or the version-resolved fab-go (for workflow commands). No runtime dependencies for end users; the Go toolchain is only needed for building from source. Why: Constitution I (Pure Prompt Play) and Constitution V (Portability). Any AI agent that can read markdown and execute shell commands can drive the workflow. All Go binaries are pre-compiled static binaries (no runtime dependencies via CGO_ENABLED=0). fab-go is cached per-version at ~/.fab-kit/versions/. The three-binary split enables independent testability (fab-kit -h, fab-go -h, fab -h each work independently) and clean separation of concerns (workspace lifecycle vs workflow engine). Rejected: CLI tool, npm package, or Python script — all introduce system dependencies. Also rejected: binary in repo (redundant when the router manages versions). Also rejected: FAB_BACKEND override mechanism (Go is the only backend). Also rejected: two-binary shim model (shim was untestable in isolation, blurred workspace and workflow concerns). Introduced by: doc/fab-spec/README.md, fab/project/constitution.md, 260401-46hw-brew-install-system-shim, 260402-3ac3-three-binary-architecture

The CLI Reference Is Split by Command Family

Decision: _cli-fab.md splits into three partials by command family — _cli-fab.md (core: change/status/score/preflight/log/resolve/config/…), _cli-fab-pane.md (fab pane, fab dispatch), and _cli-fab-operator.md (fab operator, fab agent, with fab agent riding the operator file). No separate _cli-fab-agent.md. Why: the only skill that loads a family file is fab-operator, which uses both fab operator and fab agent; every pipeline-side consumer of fab agent reaches it by pointer and loads nothing, so a third file would split 58 lines for no loader that benefits. The split cuts the operator's per-reload CLI-reference load from one 1,475-line file to the ≈585 lines it actually uses — the operator is long-lived and re-pays its helpers on every compaction, /clear, and restart. Rejected: three files (correct only if pipeline skills ever declare a family helper — revisit then); bundling fab agent with pane/dispatch (it is a resolution verb, not a pane primitive); section-addressable helpers inside one big _cli-fab.md (the helper model is file-based). Introduced by: 260910-si4k-operator-rk-mandatory-helper-split

Agent Skill Deployment Strategy

Decision: Agent skills are deployed as copies to three target locations. The target table uses an explicit AlwaysOn field for the portable .agents/skills/ row; the brand-specific .claude/skills/ and .opencode/commands/ rows are gated on claude and opencode, respectively. The Claude availability result also gates .claude/ scaffold destinations and legacy agent cleanup. FAB_AGENTS overrides availability only for gated rows. syncAgentSkills retains a symlink mode, but no shipped target selects it. Why: The unconditional portable directory gives every checkout a baseline skill tree without a CLI candidate roster. Brand-specific surfaces are gated to avoid creating unused directories on machines without those clients. The gated .claude/skills/ copy supports Claude Code's native skill discovery; internal reads use the guaranteed .agents/skills/ copy. Copies work without symlink support, and the single generic .agents/skills/ target avoids multiplying copies across per-brand directories. Byte-identical duplicates for clients that scan multiple standard locations are an accepted tradeoff. Rejected: An unconditional .claude/skills/ copy, because it creates unused brand-specific content; detection-gating .agents/skills/, because internal read paths must resolve on every machine; deploying only .agents/skills/, because Claude Code would lose native skill discovery; symlinks, because supported clients do not reliably follow them and cache moves break their targets; per-brand directories for clients that already read .agents/skills/, because they add redundant discovery locations. Introduced by: 260303-l6nk-gemini-cli-agent-aware-sync, 260219-d2y2-copy-template-skills-drop-agents, 260402-3ac3-three-binary-architecture; Updated by: 260808-rpsr-remove-gemini-add-agy-kimi, 260908-t513-unconditional-agent-skill-deploy-targets, 260908-yd9s-repoint-agents-skills-gate-claude

The Generated .gitignore Is the Manifest

Decision: fab sync writes one generated .gitignore per fired deploy target listing exactly the entries it deployed there, and cleanStaleSkills reads that same file as the record of what fab owns — the prune scope is (previous manifest − current kit list), and nothing else. Why: One file, one truth — the ignore list and the prune list cannot drift from each other, and the file self-ignores via its own /.gitignore entry with no root-.gitignore change needed. Pruning by "not in the kit list" (the pre-manifest rule) treated user skills in a fab target as stale and deleted them; the manifest makes ownership exact, so fab-managed projects can commit their own .claude/ and .agents/ content. Rejected: A separate .fab-manifest file (two files can disagree); prefix-based namespacing of fab skills (git-*/docs-*/code-* collide with user-authored skills and bake a naming rule into an ignore rule); treating any entry matching a historical fab skill name as owned on a manifest-less first run (the binary has no name history — the first run prunes nothing and the migration owns one-time cleanup). Introduced by: 260828-jjg0-sync-owned-skill-gitignores

No-Manifest First Run Prunes Nothing

Decision: When a deploy target has no generated .gitignore manifest yet, sync prunes nothing and prints a one-line note (only when the directory held a non-kit entry); the 2.22.0-to-2.23.0 migration owns the one-time cleanup of pre-manifest stale entries, deleting only on user confirmation. Why: Guessing ownership from "not in the current kit list" is the data-loss bug the manifest fixes; the cost is that pre-existing stale fab skills linger until the migration or a manual delete. Rejected: Treating any entry matching a historical fab skill name as owned (the binary has no name history); pruning everything on a manifest-less first run (destroys user skills). Introduced by: 260828-jjg0-sync-owned-skill-gitignores

Scaffold Overlay Tree with Fragment Prefix

Decision: scaffold/ is structured as a repo-root overlay tree where file paths mirror their destinations. Files requiring merge strategies use a fragment- filename prefix. Template files (config.yaml, constitution.md) are detected at runtime by /fab-setup via placeholder string checks rather than being excluded from the tree-walk via a skip-list. Why: Implicit mapping — a file's path IS its destination, no lookup table needed. Adding a new scaffold file only requires dropping it in the right location. The fragment- prefix is self-describing (only 3 of 11 files need it), avoids a coordination manifest file, and enables generic strategy dispatch. Template detection in fab-setup (rather than a skip-list in the tree-walk) keeps the tree-walk fully generic with zero special cases. Rejected: Flat scaffold directory with bespoke sync sections — required a new code block per file, hardcoded path mappings. Also rejected: .merge-rules manifest file — adds a coordination file when the prefix convention is simpler. Also rejected: skip-list in tree-walk for fab-setup files — couples sync to fab-setup ownership, and would incorrectly skip scaffold/fab/sync/README.md if using subtree exclusion. Introduced by: 260218-09fa-scaffold-overlay-tree

Single Entry Point for Workspace Sync

Decision: fab-kit sync (Go binary) is the single entry point for workspace sync — there is no shell orchestrator. It reads from the validated FAB_KIT_PATH content source when set and otherwise from the version cache. Project-level fab/sync/*.sh scripts execute after kit-level sync. No kit-level sync scripts remain. Why: One Go implementation keeps workspace reconciliation testable and consistent while one resolver keeps every sync mode on the same declared content source. Rejected: Keeping a fab-sync.sh alongside fab-kit sync (duplication, testing burden). Introduced by: 260402-3ac3-three-binary-architecture, 260402-ktbg-sync-from-cache

Environment-Only Kit Content Resolution

Decision: FAB_KIT_PATH is resolved at fab-go's KitDir() and fab-kit's reader resolver. Reader commands follow it per process; fab init and fab upgrade-repo refuse it before any release-version stamping work. Binary selection remains version-pinned. Why: A single environment-selected source lets kit-development worktrees exercise their own content without persisting a teammate-specific path or associating arbitrary content with a release stamp. Rejected: A sync-only source flag, repository autodetection, and a registry/config field because each would split reader behavior, make source selection implicit, or persist stale machine-specific state. Introduced by: 260808-j9rb-kit-path-override

FKF Standard Published Canonically; Kit Copy Is a Byte-Copy

Decision: The authoritative FKF standard lives at docs/site/fkf.md (published at https://shll.ai/fab-kit/fkf via the daily docs/site/** pull), and src/kit/reference/fkf.md is a byte-copy shipped into the kit cache. scripts/sync-fkf.sh (cp -f docs/site/fkf.md → src/kit/reference/fkf.md) is the one-line refresh; the drift-guard test src/go/fab/cmd/fab/fkf_sync_test.go fails CI on any divergence, naming scripts/sync-fkf.sh as the fix. docs/specs/fkf.md is the non-normative rationale/history companion (no normative rule text) linked from the standard's header. Why: The standard needs a stable public URL for the repos that consume the format (loom, run-kit), and docs/site/** publishes with zero infrastructure. Making the published page canonical and the shipped copy a byte-copy turns the former hand-synced two-prose-variant duty ("update BOTH files") into a mechanically-enforced one-line copy. The repo already proves the pattern with docs/site/skill.md + scripts/sync-skill.sh + its drift-guard, so no new machinery is introduced. reference/fkf.md keeps its path and section anchors, so every deployed-skill citation of $(fab kit-path)/reference/fkf.md §N stays valid unchanged. Rejected: A curated re-worded extract independent of the published page (the prior arrangement — two prose variants drift silently with no enforcement). A release/build step writing into src/ (heavier than a copy script; the drift-guard test enforces equality without one). Publishing from docs/specs/ (that tree is never pulled by shll.ai). Introduced by: 260720-g538-publish-fkf-standard-site

Three-Binary Split for Testability

Decision: The system fab shim is split into fab (router) and fab-kit (workspace lifecycle) as separate binaries. Together with fab-go (workflow engine), there are three independently-invocable binaries. Why: The two-binary shim model was untestable in isolation — fab init --help could trigger dispatch to fab-go. Three binaries means fab-kit -h, fab-go -h, and fab -h each work independently. Clean separation: workspace lifecycle (init, upgrade, sync) is a different concern from workflow execution (status, resolve, preflight). Rejected: Keeping two binaries (shim + fab-go) — untestable, blurred concerns. Also rejected: prefix-based routing (e.g., fab kit sync) — changes user-facing CLI surface. Introduced by: 260402-3ac3-three-binary-architecture

Negative-Match Router Dispatch

Decision: The fab router maintains a static allowlist of fab-kit commands and dispatches everything else to fab-go. The fab-kit command set is small and stable; fab-go commands change with every release. Why: Negative match means the router doesn't need updating when fab-go adds subcommands. Same pattern as the previous nonRepoCommands map. Rejected: Positive match (router would need fab-go's command list, requiring updates on every new subcommand). Also rejected: prefix-based routing (changes CLI surface). Introduced by: 260402-3ac3-three-binary-architecture

Single Go Module for fab + fab-kit

Decision: Both fab (router) and fab-kit binaries share a single Go module at src/go/fab-kit/ with two cmd/ entries (cmd/fab/, cmd/fab-kit/) sharing internal/ packages. Why: Both binaries need EnsureCached(), CachedKitDir(), Download(), and ResolveConfig(). A shared internal/ package is the standard Go pattern. No Go workspace complexity or published shared modules needed. Rejected: Separate Go modules (requires Go workspaces or a published shared module). Code duplication (maintenance burden). Introduced by: 260402-3ac3-three-binary-architecture

Clean Cut for Sync Migration

Decision: Shell scripts (fab-sync.sh, 1-prerequisites.sh, 2-sync-workspace.sh, 3-direnv.sh) are removed immediately when fab-kit sync ships — no deprecation period. Why: Both implementations would need to coexist and be tested if phased, adding complexity for no benefit since this is a version-gated change. User explicitly decided clean cut. Rejected: Phased migration (fab-sync.sh delegates to fab-kit sync as intermediate step) — unnecessary complexity. Introduced by: 260402-3ac3-three-binary-architecture

Single fab/ Per Repository

Decision: Even in monorepos, use one fab/ at the repo root. Why: Changes span packages, memory is domain-based, and .fab-status.yaml assumes a single active change. Per-package fab/ directories would fragment the system. Rejected: Per-package fab/ directories — conflicting constitutions, fragmented memory trees, symlink conflicts. Introduced by: doc/fab-spec/ARCHITECTURE.md

LIFO Rollback Stack

Decision: Multi-step wt commands (wt create, wt delete) use a LIFO rollback stack — a Rollback type in the wt repo's own internal/worktree/ (github.com/sahil87/wt) with Register(cmd), Execute() (LIFO order), and Disarm() methods. Commands register undo operations after each successful step. On success, Disarm() clears the stack. Execute() continues executing remaining commands even if individual commands fail. Signal handlers (SIGINT, SIGTERM) trigger rollback. Originally implemented as bash arrays with EXIT traps; now ported to Go. Why: Git worktree creation involves multiple coupled steps (worktree add, branch create). A partial failure must undo completed steps. LIFO ordering ensures dependent resources are cleaned up before their prerequisites. Rejected: Manual cleanup in error handlers at each callsite — fragile, easy to miss paths. Temp directory approach — doesn't apply to git branch/worktree state. Introduced by: 260218-qcqx-harden-wt-resilience, 260310-qbiq-go-wt-binary

No cd-in-Current-Shell for wt open

Decision: wt open does not and cannot offer a "cd here" option that changes the calling shell's working directory. Why: Unix process model constraint — child processes cannot modify the parent shell's environment. The wt binary runs as a child process of the calling shell. When the child exits, the parent's working directory is unchanged. Only shell builtins and shell functions (which run in the caller's process) can cd. A shell function wrapper (e.g., wt-cd() { cd "$(wt list --path "\$1")"; }) is the standard workaround but would require users to source a file from their rc, which is a different distribution model than the current PATH-based .envrc approach. Users who want this can define a 4-line function in their own .bashrc/.zshrc. Rejected: Adding a cd app type to wt open that prints the path for eval — adds complexity to wt open for something wt list --path already provides. Shipping a shell function in .envrc — mixes PATH setup with function definitions, different sourcing semantics. Introduced by: 260223-ufk6-wt-open-cd-current-shell (abandoned — documented as design constraint)

Non-Interactive Porcelain Output Contract

Decision: wt create --non-interactive mode redirects all human-readable messages to stderr and writes only the worktree path to stdout. Batch callers capture the path via $(wt create --non-interactive ...) instead of | tail -1. Why: Two batch consumers (batch-fab-new-backlog.sh, batch-fab-switch-change.sh) relied on | tail -1 to extract the path — fragile against any output format change. The --reuse codepath already followed this pattern (messages to stderr). Making --non-interactive imply porcelain output unifies the contract without adding a separate flag. Rejected: Separate --porcelain/--quiet flag — --non-interactive already existed with the same audience. Fd-based output (fd 3) — non-standard, breaks simple $(command) capture. Env var — subshells can't export to parent. Introduced by: 260222-s101-wt-create-stderr-wt-list-flags

wt delete Interactive Menu Includes "All" Option

Decision: When wt delete is invoked without arguments from outside a worktree, the interactive selection menu shows "All (N worktrees)" as the first option (item 1), followed by individual worktrees. Selecting "All" deletes all worktrees sequentially. The --delete-all CLI flag is preserved for non-interactive usage. Why: Deleting all worktrees is the most common interactive use case. Putting it in the menu eliminates the need to remember the --delete-all flag. Introduced by: 260305-38q7-wt-delete-show-all-in-menu

Hash-Based Stash over Index-Based

Decision: wt delete uses git stash create + git stash store (hash-based) instead of git stash push/git stash pop (index-based). Stash hashes are registered with the rollback stack. Implemented in the wt repo's internal/worktree/ (github.com/sahil87/wt) as StashCreate(msg) and StashApply(hash). Why: Index-based stash (stash@{0}) is a global counter vulnerable to race conditions in concurrent worktree operations. Hash-based stash returns a stable SHA that uniquely identifies the stash regardless of concurrent git stash activity. git stash store writes the hash to the reflog for discoverability via git stash list. Rejected: Index-based git stash push/git stash pop — unsafe with concurrent worktree deletions; another worktree's stash could shift indices. Introduced by: 260218-qcqx-harden-wt-resilience

Generated Docs Indexes via Deterministic Go (fab docs-index)

Decision: Docs indexes are regenerated by a deterministic fab docs-index Go subcommand (internal/memoryindex, modeled on internal/prmeta's Render/Gather split), not hand-edited by the hydrate/reorg skills — root-agnostic over the configured docs_index.roots, with the docs/memory root as the zero-config default. A per-file description: frontmatter field feeds the index; the memory root index is domains-only. The command also walks the tree to emit non-fatal shape-bound warnings (the "detect" half of the memory-tree-shape work). Why: Hand-maintained per-row index cells were the dominant docs/memory/ merge-conflict and drift source (measured: 65/100 fab-kit commits touched a memory file, ~57 of those were pure in-place row rewrites; loom's hand-maintained root index was stale on 4/7 sampled folders). A generated, byte-stable index is correct by construction and removes the hand-edit conflict class. Reuses the established deterministic-render Go pattern (prmeta/impact/score), admitted by the constitution and fully unit-testable. The generalization extends the same guarantee to any doc root (notably docs/specs) without forking the hardened safety machinery. Rejected: Hand-edited rows (the churn this kills). Shelling the regeneration from the skill (non-deterministic, untestable). Splitting wide domains first to relocate the hot row (Approach A — only relocates the conflict and manufactures a one-time link-rewrite bomb). A second specs-specific generator (duplicated machinery, divergence). The file-moving rebalancer — splitting an over-wide domain into sub-domains, with relative-link rewriting — is an enhancement to the existing docs-reorg-memory skill (sx7a): an agent-driven propose-then-apply path, per Pure Prompt Play — not a Go file-mover; the Internal-vs-External addressing decision resolved to External. The apply path (move → both-direction link rewrite → frontmatter → fab docs-index docs/memory → no-dangling-link guard) is cheap and conflict-free because the generated index exists; the generator's sub-domain-tier recursion (the PR #377 Copilot finding) now runs to arbitrary depth, giving the External addressing tier generated indexes. Introduced by: 260607-tciy-memory-tree-shape-rebalance; Updated by: 260607-sx7a-reorg-memory-shape-rebalance, 260908-flt3-root-agnostic-docs-index (root-agnostic generalization)

Post-State Version Guard with Threaded Binary Version

Decision: versionGuard does not trust Update()'s return value. When fab_version > systemVersion it attempts Update(), then re-checks the actually installed binary version via installedBinaryVersion() (runs fab-kit --version from PATH — after brew upgrade, the PATH symlink already points at the new Cellar binary — and parses the trailing vX.Y.Z; a package-var test seam like isBrewInstalled). When tripped, the guard ALWAYS fails the current sync run: installed-now-new-enough → fab-kit was updated to vX — re-run 'fab sync' (never continue in-process on the old binary); otherwise distinct actionable errors for update-failed, unverifiable post-state, and Homebrew tap release-lag (Update returned nil having upgraded nothing). Update() returns the ErrNotBrewInstalled sentinel on the not-brew path, and the guard keys on that sentinel to block sync (the update command separately maps it to exit 0 — see distribution.md § fab update Exit Semantics — but the guard reads the sentinel, not the command's exit code). The guard's input is honest too: Init(systemVersion)/Upgrade(systemVersion, target) thread the embedded binary version from cmd/fab-kit/main.go into Sync(systemVersion, kitVersion, …) — passing the kit version as systemVersion would make the guard compare a version against itself. dev bypass unchanged (and it does not shelter local builds — the justfile injects real semver via -X main.version). Why: one post-state check covers all three silent-defeat shapes (non-brew installs, tap release lag, genuine update failure) — the guard's documented contract ("ensures fab_version <= system version") is now enforced rather than aspirational. Scope note: an in-flight sync that trips the guard still completes nothing on the old binary — the benefit is failing loudly so the next run is correct. Rejected: trusting Update()'s nil (the prior model — guard silently defeated for every non-brew install); branching only on ErrNotBrewInstalled (misses the release-lag no-op); re-exec'ing the upgraded binary inside the guard (fail-current-run with re-run guidance chosen instead). Introduced by: 260612-dn2c-fab-kit-download-lifecycle-hardening (findings F19/F22; companion contracts in distribution.md — stamp-after-success upgrade, SHA256SUMS baseline, atomic cache install)

yaml.v3 Stays Pinned Despite Archive Status (goccy/go-yaml Rejected)

Decision: Both Go modules stay on gopkg.in/yaml.v3 v3.0.1 even though the go-yaml project was archived in April 2025 and receives no fixes. The candidate replacement github.com/goccy/go-yaml (evaluated at v1.19.2, F41) (tb6f) was rejected on proven non-parity: a scratch round-trip probe showed it cannot reproduce yaml.v3's byte output (map key order is not preserved without yaml.v3's yaml.Node document API, default indentation is 2-space vs yaml.v3's 4-space, block sequences are unindented, and flow-style mappings are expanded to block style) — and it provides no drop-in equivalent of the yaml.Node AST that internal/statusfile's field-preserving serialization layer (sparse-key insertion, F07) (mz4q) is built on, so a swap is a serialization-layer rewrite, not a dependency substitution. Why: Byte-stable .status.yaml output is a documented contract (every saved file in the wild carries yaml.v3's emit style; skills diff and parse it). Golden byte-stability tests (internal/statusfile/golden_test.go, internal/memoryindex/golden_test.go, internal/archive/golden_test.go) now pin that format and are the standing parity arbiter: any future yaml-library candidate is admissible only if those tests pass byte-for-byte unmodified. yaml.v3's archived status is an accepted, monitored risk — the library parses only first-party files (.status.yaml, config.yaml), not untrusted input, which bounds the security exposure. Migration plan (if ever forced): (1) port internal/statusfile's raw-node layer to the replacement's AST, (2) run the golden suite — byte-identical output is the gate, (3) if parity is impossible, ship a one-time format migration for .status.yaml (+ regenerate all indexes) as a src/kit/migrations/ file per the migrations model, never a silent format change. Rejected: goccy/go-yaml swap (non-parity, no Node API); vendoring/forking yaml.v3 (no fixes exist to pull; vendoring adds maintenance without benefit while upstream stays frozen). Introduced by: 260612-tb6f-tests-ci-toolchain (finding F41; report docs/specs/findings/binary-review-2026-06-12.md §B6)

Distinguishable Exit Code via In-Handler os.Exit + Shared Guard

Decision: When a fab-kit outcome needs to be branchable by an external caller distinct from generic failure, encode it as a named exit-code constant (internal.ExitNotManaged = 3) emitted via an in-handler os.Exit(N) inside a shared guard helper (RequireManagedRepo()), rather than a returned error (which main() collapses to exit 1) or a new CLI flag. The guard consolidates a formerly copy-pasted check across its call sites and applies the distinct-exit behavior in exactly one place. Genuine failures keep returning a normal error → exit 1, unchanged. Why: The fab-kit main() funnel exits 1 for any RunE error, so a distinct exit code MUST be set in-handler — the same constraint the fab binary already solved for its pane/docs-index tiers (pane_window_name.go, memory_index.go). Reusing that proven precedent keeps the fix idiomatic and adds no new API surface. A shared guard (vs. a per-call-site literal) avoids the magic-number anti-pattern (R3) and the duplication anti-pattern (R4), and lets external consumers (wt, hop, operator scripts) branch on "not applicable" vs. "real failure" without replicating fab's config.yaml walk-up. Reusable pattern for any future fab-kit command needing a branchable non-1 outcome. Rejected: a returned sentinel error mapped once per binary's Execute() funnel via errors.Is (would make the exit branch unit-testable and is in fact single-site per binary — a genuine alternative, but kept the in-handler os.Exit to match the existing untested-thin-wrapper precedent and avoid widening scope after two rework cycles; flagged as a candidate future follow-up). Also rejected: a new --if-managed no-op flag (new API to learn/maintain, no existing pattern). The nil → os.Exit(3) path is deliberately not unit-tested (an os.Exit inside a test kills the process); the constant value + the non-nil / real-error paths are, mirroring the memory_index.go / doctor.go precedent. Introduced by: 260705-52i9-sync-distinguishable-unmanaged-exit

The Guard Has No Escape Hatch

Decision: The portability test recognizes only a named allowlist, tier-index paths, and placeholder shapes. No portability-ignore marker, no attribution-token recognition. Why: Every exemption mechanism is a second place to be wrong. Naming a repo or standard in prose costs nothing and reads correctly from any repo. Rejected: <!-- portability-ignore: <repo> --> same-line marker (adds prose noise to skills and a second exemption surface to audit). Introduced by: 260910-d5tk-deployed-skills-no-fabkit-paths

Performance Benchmark: Script Runtime Comparison

Benchmark conducted 2026-03-05 comparing 4 implementations of statusman.sh operations (progress-map, set-change-type, finish) on aarch64 Linux.

Results Summary

Contenderprogress-mapset-change-typefinishStartup
bash+yq (baseline)19.5 ms6.8 ms39.4 ms2.5 ms
optimized bash4.1 ms (4.8x)3.5 ms (1.9x)7.4 ms (5.3x)1.4 ms
node (js-yaml)14.2 ms (1.4x)14.8 ms (0.5x)15.4 ms (2.6x)12.6 ms
go (yaml.v3)0.69 ms (28x)0.80 ms (8.4x)0.80 ms (49x)0.54 ms

Key Findings

  • Go is 8-49x faster than baseline. Trivial cross-compilation (GOOS/GOARCH) is a major practical advantage
  • Optimized bash (batched yq reads + awk writes) achieves 2-5x improvement with no new dependencies
  • Node is slower than bash+yq baseline for simple operations due to V8 startup overhead (~13ms floor)
  • The finish operation (39ms baseline) exposes the real cost of repeated yq subprocess spawns — each of the ~10 yq invocations adds ~4ms

Constitution Alignment

Constitution Principle I requires "single-binary utilities" with no runtime dependencies. Go fits this constraint. Node violates it (requires node runtime + node_modules). Optimized bash stays within the current architecture but has a performance ceiling. Go's cross-compilation story (GOOS=linux GOARCH=arm64 go build) is straightforward.

Benchmark Code

The benchmark implementations, harness, and fixtures are deleted (F47) (tb6f); the historical decision record (README.md + RESULTS.md) lives at docs/findings/statusman-benchmark/ (ffny) — this section is the surviving summary.