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 dispatchlive in_cli-fab-pane.md,fab operator/fab agentin_cli-fab-operator.md; loaded selectively via a skill'shelpers: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 viawt create --non-interactive, opens a tmux tab, starts a Claude Code session running/fab-new <description>. Parsesfab/backlog.mdwith continuation line handling. Supports--list,--all, and positional ID arguments. Upfrontwtguard (nnda): after the$TMUXcheck and before any per-item work,runBatchNewchecksexec.LookPath("wt")once and returns exactlywt is required for 'fab batch new' — install it via: brew install sahil87/tap/wtwhenwtis absent — one actionable upfront error instead of N cryptic per-itemwt create: exec: "wt": executable file not foundfailures (wtis a standalone sibling formula, not afab-kitdepends_on, so it may be absent;batch_new.gogained theos/execimport). Follows theinternal/prereqs.goLookPath+ install-hint shape. The tmux command is composed via a shareddefaultRoleSpawnCommand(batch.go) (tykw) — thedefault-role providerinteractive_command(a Tier-1 role, soagent.sessionpicks the provider) with that role's profile substituted throughspawn.WithProfile— so workers spawn with a profile (there is nospawn.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/--effortappended.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, anddocs/specs/naming.mdshare, so the worktree attaches to the change's real branch), opens a tmux tab, runs/fab-switch <change>. It carries the same upfrontwtguard asbatch new(nnda) —runBatchSwitchchecksexec.LookPath("wt")once after the$TMUXcheck and before any per-change work, returningwt is required for 'fab batch switch' — install it via: brew install sahil87/tap/wtwhen absent (batch_switch.goalready importedos/exec). Change resolution usesresolve.ToFolderin-process (ye8r) — afab change resolvesubprocess 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 samedefaultRoleSpawnCommand(default-role providerinteractive_command+ profile) asbatch new(tykw). Supports--list,--all, positional arguments, and--quiet/-q(o5f9).--quiet/-q(o5f9): suppresses theOpening N tabs for all changes...preamble (--allpath) and the per-change{name}resolved-name line via two inlineif !quietguards inrunBatchSwitch; ALL stderr (Warning: could not resolve …,Error: failed to create worktree …) and the--listoutput 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):switchattaches worktrees to existing changes, whose branches usually already exist (created by/fab-newin the original checkout), so it mirrorswt's own dispatch under the 2af2 contract rather than relying on the retired positional dual-semantics. It takesbranchName = match(the resolved change folder name), then probes existence via thebranchExistshelper — 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/offlinels-remotedegrades to not-remote → positional → wt itself re-checks and errors visibly (loud, not a silent skip). The invocation now goes throughpane.RunCmd("wt", …)(stdout/stderr captured separately) instead of.Output()(which discarded stderr), so awt createfailure surfaces the child's stderr viapane.StderrErrorin 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. Samepane.RunCmd/StderrErrorpatternbatch newalready uses. External coupling: the--checkoutpath requires the wt release carrying upstream change 260717-2af2 (installed wtv0.0.23predates 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--checkoutpath loudly (unknown flag → warn-and-skip with the surfaced stderr, recoverable by upgrading wt). Source:batch_switch.go(branchExistsfree function + routedrunBatchSwitch);batch_new.gois unaffected (no positional — exploratory create, unchanged by 2af2).fab batch archive— Finds changes withhydrate: done|skippedin.status.yamland archives each one mechanically in-process via a Go loop (archiveLoop→internal/archive.ArchiveWithBacklog) — folder move, index update, backlog mark-done, and pointer clearing, with no spawned Claude session and no tmux tab. Change resolution usesresolve.ToFolder(not afab change resolvesubprocess). 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 asskippedrather thanfailed. The loop prints per-change lines plus anArchived N, skipped M, failed K.footer, and the command exits non-zero only whenfailed > 0. The loop logic lives in the testablearchiveLoophelper (returns counts, noos.Exit);runBatchArchivereturns errors through RunE (ERROR: {K} change(s) failed to archive/ERROR: No valid changes to archive.— no in-handleros.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--allit uses a list-then-confirm model with a--yesescape hatch (apt/npm/gh-style). A barefab batch archiveon an interactive stdin lists the archivable set then promptsArchive these N? [y/N](default No — Enter or any non-y/yesanswer aborts, exit 0);--yes/-yarchives all with no prompt (the non-interactive escape hatch, resolved behavior of the former--all);--dry-runlists only with no prompt/action (the former--list); a non-TTY stdin without--yesrefuses 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 --yesis 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 stdlibos.ModeCharDevicepattern via an injectableisStdinTTYseam (nogolang.org/x/termdependency, mirroringsrc/go/fab-kit/internal/upgrade.go). It imports none ofinternal/spawn,os/exec, orsyscall.--quiet/-q(o5f9): archive also carries a--quiet/-qbool flag (BoolVarP, beside--yes/-y) that suppresses theArchiving N changes...preamble and everyarchiveLoopper-change line via a progress writer (io.Discard) threadedrunBatchArchive → archiveResolvedNames → archiveLoop, while theArchived N, skipped N, failed N.footer, all stderr, the empty-set no-op, the--dry-runlisting, and the full consent flow are retained;--quietis 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: incrementstick_count, writeslast_tick_at(RFC3339 UTC), outputstick: 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--diffit 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 writeslast_full_at(RFC3339 UTC) on every tick that emits the full document;--diff --quietreplacesitems:with a five-countfleet_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 whoselast_full_atis 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_HOMEonly when it is set AND absolute, else$HOME/.local/state. Deliberately NOT~/Library/...on macOS (terminal users expect~/.local/state; the Go stdlib has noUserStateDir()).serverSlug(server string) string— queries the tmux socket path viatmux <…> display-message -p '#{socket_path}'(built throughpane.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/default→tmp-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/default→tmp-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 withMkdirAll(0o755).tick-startcallsStatePath("")(server""→ the operator's own current tmux server). The test seam isoperatorStatePathOverride(a full file path, not a directory).- Mutation-verb family (full mediation) — every state-file mutation goes through a
fab operatorsubcommand; the agent never hand-writes the YAML. The verbs share one read-modify-write helper insrc/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 fromkind: noteitems), thetrackverbs (add— creates an item with the kind's binary-filled defaults, the pane-kind flag sugar writingscope(recordingpane_pidwhenever the pane is set) plus thebranch_map{ branch, repo }pair in the same mutation;update— mutates passed fields,--scopemerged per key,--pause/--resumetogglingpaused;observe— records an agent-probe result intolast, appending--seenids with the 200-entry oldest-first cap enforced in the binary,--errorincrementingfailures;rm— deletes the item, retainsbranch_map;list [--kind] [--json];clock (--every|--idle-every) <dur> --for <dur> | --off— the boundedclock_override), andbranch-map rm <id>|--all(the explicit user-initiated clear). IO posture: tolerant-read / typed-write — unknown top-level keys survive any read-modify-write (thetick-startprecedent), while the owned sections (tracked,branch_map,clock_override, plus thetick_count/last_tick_at/last_full_atscalars) 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 onerk cron editonly on change). Legacy conversion: a legacy-shaped file (any ofmonitored/watches/autopilot/notespresent,trackedabsent) converts on the first read-modify-write by any verb — includingtick-startand the read verbs — in the same atomic write, refusing with a one-line error whileautopilot.state == running(see migrations.md); a second, idempotent pass fires on atracked-present file holding an item under the retired change-keyed kind, rewriting it tokind: panewithscope.changeseeded from the item's id andscope.pane_pidnull. 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: outputsnow: HH:MM; with--interval <duration>also outputsnext: 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:
- User runs
/fab-setup→ generatesconfig.yaml,constitution.md - User optionally runs
/fab-hydrate→ ingests external sources - 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 eachfab upgrade-reporun. 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 byfab upgrade-repoandfab init. Used by preflight to detect sync staleness (compared against$(fab kit-path)/VERSION) and by the router for pinned-version resolution. Written bystampFabVersion(repoRoot, version)(src/go/fab-kit/internal/init.go), shaped exactly likestampMigrationVersionand called from bothInitandUpgrade. No fab-kit code path writesconfig.yaml— this is the second half of the single-writer invariant:internal/configupgradeis fab-go's only writing engine for existing config files (see the fab-go §internal/configupgradebelow and configuration.md §fab config upgrade).fab/.fab-versionis the sole version source for both reader stacks (fab-kitreadFabVersion, fab-gointernal/config) — config.yaml'sfab_version:key is never consulted (fab-go tagsConfig.FabVersionyaml:"-"). See distribution.md § Update Preserves Project Files and migrations.md.Committability — the negation is load-bearing (8ken). For "committed" to be true,
fab/.fab-versionmust 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 initfailing loud. Two mechanisms guarantee committability. (1) The scaffold fragmentsrc/kit/scaffold/fragment-.gitignorecarries!fab/.fab-versionimmediately after.fab-*— its load-bearing line — so everyfab sync'slineEnsureMergeself-heals a project's.gitignore(see setup.md §.gitignorededup and migrations.md §2.15.1-to-2.15.2). (2)stampFabVersiongained a siblingwarnIfFabVersionIgnored(repoRoot)(internal/init.go) wired into both callers —Init(init.go:48) andUpgrade(upgrade.go:148) — right after the successful stamp: it shells out togit check-ignore -q fab/.fab-versionand prints a fail-openfab: 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'sconfig.yaml,.status.yaml, and conventions were written for. Created byfab-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.yamlpoints 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-emptyFAB_KIT_PATHis 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, generatesconfig.yaml, and runs sync; sync failures propagate.fab-kit upgrade-repo [version]— upgrade to a release version. A non-emptyFAB_KIT_PATHis refused before repository discovery, network/cache work, sync, config reconciliation, or version stamping. With no override, upgrade downloads to cache, runs sync first, stampsfab/.fab-versiononly after success, and auto-runs the pinned fab-gofab config upgradefail-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. WithFAB_KIT_PATH, sync validates and absolutizes the directory, printskit: <absolute-dir> (FAB_KIT_PATH override), skips the version guard and cache step, and runs the remaining pipeline against override content. Supports--shimand--project; invalid overrides fail loudly in every mode. It exits3(ExitNotManaged) (52i9) outside a fab-managed repo and1on genuine failures.fab-kit doctor [--porcelain]— validate seven prerequisites (git, fab, bash, yq v4+, jq, gh, direnv+hook). Normal output includeskit: <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.--porcelainremains 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"):
- Detection —
detectProjectSeed(repoRoot)mechanically derives the identity seed non-interactively: project name from the repo-folder name,source_pathsfrom an existingsrc/, andtest_pathsfrom on-disk marker files (detectTestPaths, the same marker→ecosystem table/fab-setupand the2.7.1-to-2.8.0migration use). Any field with no confident detection is left empty. The description is NOT detected (only/fab-setup configadds it). - Shell-out — passes the detected seed as
--name/--source-path/--test-pathflags to the pinned fab-gofab 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 initexits 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-rootdefaults.yaml(built-in values, embedded by the root fab package, parsed byinternal/agent), theinternal/configrefregistry (schema + prose),internal/configscope(scope taxonomy), andsrc/kit/scaffold/(non-config files only). The skew window a stub would cover closed whenfab config init --projectshipped 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 ininternal/config.go(with a doc comment; no bare3at any call site — R3), deliberately distinct from the generic exit1. Chosen as3to sit alongside thefabbinary's own in-handleros.Exit(N)tiering convention (pane_window_name.gouses 2/3 for the pane family — see pane-commands.md;memory_index.gouses 2 for destructive-loss). It collides only theoretically withfab-kit doctor's dynamicos.Exit(failureCount)(0–7), an unambiguous diagnostic count on a different command.internal.RequireManagedRepo() (*ConfigResult, error)— the shared guard consolidating theResolveConfig()+if cfg == nil { return fmt.Errorf("not in a fab-managed repo…") }check across its call sites (R4). It returns a genuineResolveConfigerror unchanged (corrupt config / missingfab_versionstill collapse to exit 1 inmain()— R2), and on the(nil, nil)"walked to filesystem root, no config" case it prints the actionablenot in a fab-managed repo. Run 'fab init' to set one upto stderr and callsos.Exit(ExitNotManaged)in-handler (a returned error would collapse to exit 1). This mirrors thefabbinary's in-handler-os.Exitpattern precisely because the fab-kitmain()funnel exits 1 uniformly.- Two call sites:
internal.Sync()(itskitVersion == ""branch — the plainfab syncpath) andcmd/fab-kit'srunMigrationsStatus. Both callRequireManagedRepo(); thenot in a fab-managed repoliteral appears in neithersync.gonormigrations_status.go. - Git-independence:
RequireManagedRepo()gates beforegitRepoRoot()inSync(). The managed-repo check is aconfig.yamlwalk-up that does not depend on git, so a directory that is neither git-tracked nor fab-managed exits3, not1— keepingfab syncsymmetric withfab-kit migrations-status(which has no git precondition and already exited3in the same directory). A managed repo that lacks git context still fails atgitRepoRoot()with a genuine error → exit 1 (R2 unchanged).Init/UpgradepasskitVersionexplicitly (config.yaml is not yet stamped), so they skip the check. - Deliberate exclusion —
internal/upgrade.gois untouched:Upgrade's twonot in a fab-managed reporeturns still exit1. Its guard is a different semantic — it tolerates aconfig.yamlthat is present but missing itsfab_versionfield (a partially-managed repo, not an unmanaged one), so folding it intoRequireManagedRepo()would conflate the two. Documented here so a reader does not over-generalize the exit-3 contract to every "unmanaged repo" case; re-tieringupgrade.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 exit2, and--idis 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 + exit0(ErrNotFoundalways, bare and with an explicit<change>override;ErrAmbiguousonly bare —changeArg == ""). A named-but-multi-matching override stays a non-zero error, and infrastructure errors (missingfab/root, I/O) stay non-zero flag or no flag — the mapping applies to the change-resolution step only, so a--panelookup 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)— notnone(a legal 4-char change ID, which would collide with--idoutput) 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/resolveis untouched — the sentinels (ErrNotFound/ErrAmbiguousviaclassifiedErrorwithUnwrap, matched byerrors.Is) already existed;--or-noneonly exposes them at the CLI surface.fab change resolveis a thin cobra wrapper over the same sharedrunResolveimplementation with--foldermode fixed and deliberately flag-free (no--or-none— the query flags live on top-levelfab resolveonly, so the wrapper passesorNoneasfalse); the two spellings cannot drift, and callers needing the probe form usefab resolve --folder --or-none. Source:cmd/fab/resolve.go(noneTokenconst + the sentinel branch inrunResolve); the flag-path + flagless-regression cases are inresolve_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.referenceremains 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--originis winner-only, one line per leaf with origin ∈ {$FAB_…variable, system path, project path,default}, while keyed--originlists the key's full tier stack (winner marked(effective), the rest(shadowed)). Provenance comes fromconfig.LoadLayers(aLoadPathsibling that runs the same four-tier cascade and carries per-key environment origins) plus the materialized defaults tierconfigref.DefaultsMapFor, soshowcannot drift from what consumers see, and a typo'd file override surfaces as origindefault. -
fab config set <key> <value> [--system]/unset <key> [--system]— exact-argument surgical writers throughinternal/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 ofunset. A write a higher tier shadows warns and still exits 0; an absent-keyunsetnames 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 (--projectremains compatible);--systemwrites 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 throughinternal/configupgrade: bare/--projecthandles the repo file,--systemhandles~/.fab-kit/config.yamlwithout a repo, and--allhandles 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;--checkis 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 ininternal/config.LoadPath: environment > system~/.fab-kit/config.yaml> projectfab/project/config.yaml> built-in point-of-use defaults, resolved at the single seam every consumer reaches. Environment names derive by forward-walkingconfigscope.DottedKeys()(FAB_+ uppercase dotted key with dots→underscores); onlyboth/systemrows are honored, and values are YAML-parsed into a highest-precedence overlay. File and env maps merge per leaf throughconfig.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/configscoperemains 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. Barefab setupruns the interactive setup wizard (cmd/fab/setup_wizard.go, package main — deliberately NOT a new internal package: it reusessetup.go's unexported set/origin seamsconfigMutationPath,effectiveTierFor,warnIfShadowed, and the stdin-TTY helper): an existing-system-scaffold refresh throughconfigupgrade.Upgrade(SystemTarget(...)), then onesetupcheck.Runprobe and a scope banner (system tier default,--projectretargets 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 surgicalconfigupgrade.SetSystem/Setwrites withwarnIfShadowedper key.--defaultsis 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 checkis the read-only setup-state doctor: all probing lives ininternal/setupcheck—ProbeProviders/ProbeEnvironment/ProbeVersions/ProbeDispatchMode/ProbeOverrideMaskingas pure-ish functions with injected seams (lookPath,$TMUX, kit dir, config layers — thedispatch.SelectModepurity precedent), aggregating into a structuredReportviaRun, which the wizard consumes to filter its interview options without shelling out — andcmd/fab/setup.goowns only input wiring, rendering, and exit mapping (any failure-severity finding returns an operational error → exit1; warnings-only exits0; usage errors exit2at the cobra layer — no new exit tier). The override-masking probe introspects the binary's own embedded defaults through theagent.BuiltinProvider(name) (config.ProviderConfig, bool)export — the unmerged built-in table entry exactly as parsed from thego:embed'ddefaults.yaml(ResolveProvidercannot serve this: its output already has user overrides folded in, which would make a load-bearing override invisible). Always-routed —setupis not inLifecycleCommands, 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 hookfamily (session-start/stop/user-prompt/artifact-write/sync) and nofab runtime set-idle|clear-idle|is-idle(y022) (ioku): agent active/idle state production is divested to run-kit's@rk_pane_agent_statetmux pane-option convention — fab is a pure reader (see runtime-agents.md) — and artifact bookkeeping is pull-based viafab status refresh [<change>](internal/refresh.Refresh; the change argument is optional — omitted, it resolves the active change via the.fab-status.yamlsymlink), self-healed at the transition seams (fab status advance/finish,fab preflight). fab registers no Claude Code hooks, so there is nothing to sync andfab syncnever touches.claude/settings.local.json. An un-migrated settings file still invokingfab hook <x>gets a cobra unknown-command error until the2.13.6-to-2.14.0migration (the checkout it runs in) and the2.15.7-to-2.15.8migration (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-commandresolve.FabRoot()guard (pane subcommands resolve state from pane IDs, not the invoker's CWD). Detailed subcommand behavior and the--server/-Lflag 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 atdispatch.modeand 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,WrapperArgvcomposition,DeriveState/DerivePaneState, process signaling, tmux pane primitives) lives ininternal/dispatch; only the launch/signal syscalls are platform-split (dispatch_posix.go!windows/dispatch_windows.gowindows) 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. Likefab config(6nke),dispatchis always-routed and its name must not collide with the fab-kit allowlist (TestNoTopLevelCommandCollidesWithRouterAllowliststays 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. - headless consumes
-
fab resolve <change> --pane— output the tmux pane ID (e.g.,%5) for the pane running the resolved change; composable withtmux 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 consumesfab 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 anexcludingpass whentrue_impact_excludeis non-empty and atestspass whentest_pathsis non-empty. Schema: pipeline/schemas.md -
fab pr-meta <change>— render a fab-generated PR's complete## Metablock (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-prpasses 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 validFAB_KIT_PATHwins 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 configureddocs_index.rootsroot (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'smax_depthis an advisory soft-warn bound, never a traversal limit), writesindex_filelandings as whole generated files and an existingalso_acceptlanding (e.g.README.md) as a marker-delimited generated block with outside prose byte-preserved, renderssuperseded:-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-managedmanualblock on first run (no adopt flag), skipsexclude:-glob files and folders entirely (no row, no count, no landing —excludebeatssupersededon a double match), and bounds advisory reporting (≤5 details per kind across all roots,… and N more (M total)on stderr, additivewarnings_totalin JSON). The full generalized contract lives in memory-docs/docs-index.md and_cli-fab.md§ fab docs-index;fab memory-indexsurvives as a deprecated memory-only alias (one stderr notice, ≥1 minor version). For the memory root it regenerates the rootdocs/memory/index.md(domains-only —| Domain | Description |, no inlined per-file column) and everydocs/memory/{domain}/index.md(file rows —| File | Description |) from folder contents + each file'sdescription:frontmatter — content-only, with no dates (noLast Updatedcolumn (ugde) — agit logprojection is HEAD/branch-relative and so not idempotent; the batchedgit logpass serveslog.mdonly). Sub-domain tiers (sx7a): a{domain}/{sub-domain}/directory holding ≥1 non-index.mdgets its own generated{domain}/{sub-domain}/index.md(same file-row contract), and the parent domain index gains a## Sub-Domainstable (| 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-Domainstable). 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'smax_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-runedescription:trim nag, amissing-descriptionadvisory 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 arelog: true-scoped and do not run on generic roots). A blocking content class (xu0k) (mxgu) is distinct from these advisories: fourdescription:/frontmatter signatures — an unclosed frontmatter fence, a quote-strip-failingdescription:, a registry-gated change-id indescription:(log: trueroots), or a gross over-capdescription:(>1000 runes;log: trueroots) — floor the--checkexit at 1 independent of drift (✖findings enumerated to stderr with a fix-the-file remediation), while never being a destructive-loss tier-2 category.--checkis 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 improveddescription:; 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 lacksdescription: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 rootindex.mdbeyond 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-memoryis the orchestrator for all three categories — it relocates tombstone rows itself and dispatches/docs-hydrate-memorybackfill 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--jsonflag (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 thefab pane/migrations-status--jsonconvention) — suppressing the human text; the exit code is unchanged. Themalformedarray (the four blocking kinds) (xu0k) (mxgu) and thewarningsarray (six advisory kinds — the four debt meters (mxgu), the 501–1000-runedescription-lengthtrim nag carrying its rune length incount(which the/docs-distill-memorysurvey consumes as its canonical signal source), andmissing-descriptionfor sparse generic roots) are both additive and always present (empty arrays, nevernull, likelosses);warningsis sampled to ≤5 details per kind across all selected roots withwarnings_totalcounting 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; themalformedJSON key is retained for consumer compatibility even though the internal predicate generalized toIsBlocking();tier/drift/lossesare 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 ininternal/memoryindex(unit-tested likeRenderRoot/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-continuehydrate — 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 providerinteractive_command+ profile) (tykw). Subcommands:tick-start(start-of-tick state update: incrementstick_count, writeslast_tick_atRFC3339 UTC to the server-keyed XDG state file — see "Operator State File" above — outputstick: N\nnow: HH:MM;--diffprobes the tracked items and emits the tick document),time(pure clock query: outputsnow: HH:MM; with--interval <duration>also outputsnext: 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 (defaultwhen the arg is omitted; the six role names and the six stage names accepted — a stage maps through the fixedstageRolestable to its role, and thereview/hydratecollisions are fixed points, so either name resolves identically) and composeproviders.<profile.provider>.interactive_commandwith that role's{model}/{effort}substituted (or Claude-style flags appended) viaspawn.WithProfile. Selector +--provider: re-resolve the selector's role from the named provider's own fills (agent.ResolveRoleWithwith the provider pinned). Provider-addressed (bare--provider <name>, no selector): bypass role resolution and look upproviders.<name>directly viaagent.ResolveProvider, composing itsinteractive_commandwith the--model/--effortvalues through the sameWithProfile(omitted ⇒ empty ⇒ the token-drop rule, so a bare provider invocation results and the CLI's own default model applies).Flag.Changedguards, not value emptiness;--model/--effortare 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--printand-t), and--headless(selectsheadless_commandinstead ofinteractive_command; sink-only — exec is a usage error; a missing capability hard-errors namingproviders.<name>.headless_command). An unknown provider name is a lookup failure listingagent.ProviderNames(cfg)(built-in ∪ project keys, sorted, over the nil-safeconfig.ProviderNames()accessor); an unknown selector errors naming the valid role and stage names.--printprints 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.yamldirectly (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 upwardresolve.FabRoot(). Falls back tospawn.DefaultSpawnCommand(the{model}/{effort}templateclaude --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 inmain.go). There is nofab 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 forbash,zsh, orfish. Equivalent to (and delegated to) Cobra's auto-generatedfab completion <shell>; provided as thetu-style verb users expect. Source:src/go/fab/cmd/fab/shell_init.go. Recommended install: addeval "$(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) (shllskillstandard @ v0.0.23; always-routed,skill ∉ LifecycleCommandsallowlist so no router change, guarded bylifecycle_collision_test.go). Prints the canonical agent-usage bundledocs/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 topictopics(psgm):fab skill topicsenumerates 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 customArgsvalidator (zero args, or exactlytopics; anything else falls through tocobra.NoArgs) with an early-returnRunEbranch — deliberately not a cobra child command, sotopicsappears in neitherfab skill --helpnor the frozenhelp-dumpJSON tree; the validator extends to a topic-name set if topic pages ever ship. Any other positional is a usage error → exit2via the binary-widerun()/markRunReachedclassifier (no new exit code); pinned byTestSkill_TopicsEmptyContract+TestSkill_RejectsArgs. Testable seamrunSkill(stdout io.Writer) errorwrites the embedded bytes verbatim (mirrorsrunStandards/runList). This is fab-go's firstgo:embedusage, done via the sync + drift-guard pattern theshll standardsmechanism established, adapted to a single file: the fab-go module root issrc/go/fab/anddocs/site/sits above it, so//go:embedcannot reach the canonical file directly — a committed copysrc/go/fab/cmd/fab/skill.md(besideskill.go, embedded via//go:embed skill.md) lets a cleango 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.shdirective (five levelscmd/fab→repo root), refreshes the copy; and a drift-guard testTestSkillEmbedMatchesCanonicalcompares the embedded bytes to canonicaldocs/site/skill.mdbyte-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 sharedfindRepoFileinlifecycle_collision_test.go(used by both the collision and drift-guard tests). Source:src/go/fab/cmd/fab/skill.go(skillCmd()+runSkill), registered innewRootCmd();skill_test.go. Docs:_cli-fab.md§ fab skill;docs/specs/architecture.md(config-free roster). Renders atshll.ai/tools/fab-kit/skillfor free (pulleddocs/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/whenclaudeis available byfab sync). See distribution.md § Toolkit Standards Conformance -
fab help-dump— hidden, 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 (viacmd.Commands(), not regex-parsing-h) and writes the frozen shll.ai "command reference" contract JSON to stdout: the envelope is exactly{tool:"fab", version (frommain.versionldflags), schema_version:1, root:Node}whereNode={name=cmd.Name(), path=cmd.CommandPath(), short, usage=cmd.UseLine(), text=cmd.UsageString(), commands[]}. Nocaptured_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, andschema_versionstays1since removing a consumer-owned field is not a breaking change (see distribution.md § Toolkit Standards Conformance) (ptwh). At every level the walk dropscompletion,help, and anyHiddencommand (self-excludinghelp-dump), then sorts surviving children byName()for byte-stable output; leaves emitcommands:[](nevernull). The encoder uses 2-space indent andSetEscapeHTML(false)to preserve raw-hbytes. Because it isHidden, it is absent fromfab --helpand from its own dumped tree. Source:src/go/fab/cmd/fab/help_dump.go(helpDumpCmd(),dumpDoc, recursivebuildNode); 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.go — isArchivable 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.go — ServerReachable / 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 resolve ↔ resolve --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:
| File | Load strategy | Purpose |
|---|---|---|
_preamble.md | Always-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.md | Selective (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.md | Selective (via helpers: [_cli-fab-pane]) | Fab CLI reference — fab pane and fab dispatch families. Used only by fab-operator |
_cli-fab-operator.md | Selective (via helpers: [_cli-fab-operator]) | Fab CLI reference — fab operator and fab agent families. Used only by fab-operator |
_generation.md | Selective (via helpers: [_generation]) | Spec/tasks/intake generation procedures. Used by fab-new, fab-draft, fab-ff, fab-fff, fab-adopt |
_review.md | Selective (via helpers: [_review]) | Review procedures. Used by fab-ff, fab-fff, fab-adopt |
_srad.md | Selective (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.md | Selective (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.md | Selective (via helpers: [_intake]) | Shared pre-boundary Create-Intake Procedure, parameterized by a {questioning-mode} knob. Used by fab-new, fab-draft |
_cli-agents.md | Selective (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.md | Selective (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 knowledge — wt/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
| Contender | progress-map | set-change-type | finish | Startup |
|---|---|---|---|---|
| bash+yq (baseline) | 19.5 ms | 6.8 ms | 39.4 ms | 2.5 ms |
| optimized bash | 4.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
finishoperation (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.