Research & Learning · feature explainer · pre-merge review

How ainb skill-manager v1.2 actually works

TL;DR — v1.2 adds five new surfaces to the ainb skill-manager: per-unit usage telemetry rendered in the Detail pane, a target_layout schema with a MappingEngine that translates source glob → tool-home path, a pure SyncPlanner plus impure apply_to_home / apply_to_repo executors, a DriftBackend trait abstracting git ls-remote with a Mock for tests, and an async drift poll wire that drains into a drift_cache on every tick. Three CLI commands (ainb skill usage|sync|check) + one new migrate mode (--upgrade-schema). All 22 v1.2 beads closed; PR #144 is currently MERGEABLE pending CI.

Architecture — five new surfaces

Every box in the diagram below is real Rust code on feat/skill-manager. Olive borders mark pure abstractions/traits; clay borders mark impure side-effecting code; gold marks types/data; cornflower marks consumers. The convergence point on the right is the live SkillManager screen.

ainb skill-manager v1.2 — architecture (5 new surfaces) SURFACE 1 — Usage telemetry LockedUnit { usage: UsageRecord } last_used_at, invocations (lockfile v2) detect_invocations() compute_detail() → "12 invocations · last used 3h ago" SURFACE 2 — target_layout mapping SourceEntry { target_layout: Vec<TargetMapping> } resolve_pair() (pure) BOOTSTRAP… (home, repo) consumers: ainb skill install, sync SURFACE 3 — Sync (planner + executor) plan_sync() pure → Vec<SyncAction> apply_to_home() impure ContentFetcher → write tool_home apply_to_repo() impure git add -- · commit -m "sync: …" · push -- origin <ref> action trait ContentFetcher in ainb-skill-core (no dep cycle) SURFACE 4 — Drift detection trait DriftBackend (Send + Sync) compare(source, deployed_sha) → DriftStatus GitLsRemoteBackend (prod) git ls-remote -- + GIT_TERMINAL_PROMPT=0 + GIT_ASKPASS=/bin/true MockBackend (tests) synthesise any DriftStatus variant InSync / Outdated / Ahead / Diverged SURFACE 5 — Async drift poll wire GoToSkillManager event tokio::spawn(spawn_blocking(...)) Arc<dyn DriftBackend> mpsc::unbounded_channel SkillsScreenData.drift_cache (drained on AppState::tick) Coalesces: second GoToSkillManager while in-flight = no-op SkillManager TUI screen (spec §10.1) — the convergence point Sources panel · Units table with new "status" column · Detail pane with new "Usage" line · help bar [i] [u] [c] [r] [s] [/] SURFACE 1 feeds Detail pane · SURFACE 5 feeds Units status column · SURFACE 2 feeds install dst · SURFACE 3 feeds [s] keybind PURE = olive border · IMPURE = clay border · DATA = gold border · CONSUMER = cornflower border
diagram via /fireworks-tech-graph
1 · Usage telemetry ainb-skill-core/src/lockfile.rs:82-126

LockedUnit.usage: UsageRecord { last_used_at: Option<String>, invocations: u64 } — lockfile schema v2 with #[serde(default, skip_serializing_if = "UsageRecord::is_empty")] so v1 lockfiles round-trip byte-stable. Writer is the ainb skill usage CLI, which scans tool session logs via ainb_usage::detect_invocations(tool_home, unit_name) — a pure read. Reader is compute_detail_for_selected in the TUI, which feeds render_detail_pane the line "Usage: N invocations · last used X ago".

2 · target_layout schema + MappingEngine ainb-skill-core/src/{manifest.rs, mapping.rs}

SourceEntry.target_layout: Vec<TargetMapping { glob, home, repo }> declares a per-source mapping from unit paths to tool-home and repo-cache paths. MappingEngine::resolve_pair(source, unit_path) -> Option<(PathBuf, PathBuf)> is a pure first-glob-wins translator. When target_layout is empty, the resolver falls through to BOOTSTRAP_DEFAULT_MAPPINGS — a const covering agents/{eng,universal,orchestrators,design,meta,swarm}/*.md, top-level agents/*.md, skills/*/SKILL.md, and commands/*.md. Consumers: ainb skill install uses the home half; ainb skill sync uses both.

3 · SyncPlanner + SyncEngine (pure / impure split) ainb-skill-core/src/sync.rs

plan_sync(source, mappings, units) -> Vec<SyncAction> is the pure planner — given an immutable view of mappings + units, it returns each unit's direction: ToRepo | ToHome | NoOp with a human-readable reason string. The executors are impure: apply_to_home fetches via a ContentFetcher trait (deliberately defined inside ainb-skill-core to avoid a dep cycle with ainb-fetch) and writes to the tool home; apply_to_repo copies to the promote-cache, runs git add --, git commit -m "sync: <unit>", and git push -- origin <ref> behind the AINB_SYNC_SKIP_PUSH=1 env gate.

4 · DriftBackend trait + GitLsRemoteBackend / MockBackend ainb-skill-core/src/drift.rs

trait DriftBackend: Send + Sync { fn compare(&self, source, deployed_sha) -> Result<DriftStatus> }. The Send + Sync super-bound is load-bearing — without it, Arc<dyn DriftBackend> can't cross the tokio::spawn_blocking boundary in Surface 5. Production GitLsRemoteBackend shells out to git ls-remote -- with GIT_TERMINAL_PROMPT=0 + GIT_ASKPASS=/bin/true so an unreachable / private remote fails fast instead of freezing the TUI on a credential prompt. MockBackend::expect(source_name, sha, status) synthesises any of InSync | Outdated{behind} | Ahead{ahead} | Diverged{ahead,behind} for tests.

5 · Async drift poll wire ainb-core/src/app/state.rs · skill_manager_screen.rs

The GoToSkillManager event fires tokio::spawn(spawn_blocking(...)) over an Arc<dyn DriftBackend>, sends the resulting BTreeMap<declared_uri, DriftStatus> over an mpsc::unbounded_channel, and the receiver is drained on every AppState::tick into SkillsScreenData.drift_cache. Until the cache lands, the Units panel renders the muted placeholder in the status column. A second GoToSkillManager while a poll is in flight is a no-op (coalesce via is_some() guard).

CLI surface — 3 new commands + 1 new migrate mode

Every new TUI capability also has a scriptable CLI subcommand. JSON output is wired where it's useful for piping into jq.

# Refresh per-unit invocation counts + last-used in the lockfile.
# Pure read of session logs under each tool home; writes lockfile only.
$ ainb skill usage --verbose
commit       claude   12 invocations  last 2026-05-29T08:14:00Z
review       codex    3 invocations   last 2026-05-27T16:02:00Z
test         gemini   0 invocations   never
# Scope to a single unit:
$ ainb skill usage commit
# Bidirectional reconcile: home edits → repo, or vice versa.
# Default direction is decided per-unit by SyncPlanner.
$ ainb skill sync --dry-run
plan: 3 actions
  commit   ToRepo   home modified since deploy
  review   NoOp     deployed_sha matches upstream tip
  test     ToHome   upstream tip differs from deployed_sha

# Apply (gated on confirmation in TUI; --yes in CLI):
$ ainb skill sync commit
sync: commit → pushed to gh:stevie/skills@main
# Drift report — what's behind upstream tip?
$ ainb skill check --json
[
  { "unit": "gh:stevie/skills@main/skills/commit", "status": "in-sync" },
  { "unit": "gh:stevie/skills@main/skills/review", "status": "outdated", "behind": 4 },
  { "unit": "gh:other/repo@main/skills/test",      "status": "unknown" }
]
# Scope to one source:
$ ainb skill check --source stevie-skills
# Idempotent backfill: write BOOTSTRAP_DEFAULT_MAPPINGS into existing
# manifest sources that have empty target_layout. Second run = no-op.
$ ainb migrate --upgrade-schema
stevie-skills: 4 mappings added
core-tools:    0 mappings added (already up to date)
total: 4 mappings written to ~/.agents-in-a-box/manifest.yaml
All three new commands honour the AINB_TOOL_HOME_<TOOL> sandbox so tests never touch the real ~/.<tool>/. ainb skill sync's push path is additionally gated by AINB_SYNC_SKIP_PUSH=1 for hermetic CI.

TUI surface — what changed on the SkillManager screen

The SkillManager screen gained one new column, one new Detail line, and one new event arm. The diagram below shows every keypress on the screen and the code path it triggers — olive arrows are pure state mutations; clay arrows are impure (disk / network / subprocess / async wire).

SkillManager TUI — keypress → event arm → handler SkillManager screen (spec §10.1) Sources ✓ stevie-skills Units name kind ref status ▶ commit gh main ✓ review gh main ⚠ 3 test gh main … Detail (commit) URI: gh:stevie/skills@main/skills/commit Deployed: ~/.claude/skills/commit/SKILL.md Usage: 12 invocations · last used 3h ago [i] [u] [c] [r] [s] [/] [?] [q] [M] from HomeScreen GoToSkillManager reload_from_disk start_drift_load (async) [↑] [↓] [j] [k] [g] [G] SkillManagerMove(Prev/Next/First/Last) arrows / vim mutate selected · recompute detail [s] (DUAL) reads manifest.yaml — hot path if has shadowed_by peer: ConflictFlip else: SkillManagerSync → apply_to_* [/] · [d] search · discovery toggle [Enter] on discovery banner import candidates writes manifest.yaml [Esc] · [q] GoBack / ExitToHome return to HomeScreen [i] [u] [c] [r] install · update · check · remove → subcommand dispatch [c] kicks DriftDetector (non-blocking on tick drain) notes • Detail pane render is the result of selection — not its own event. compute_detail_for_selected runs on every move. • Status column glyphs (✓/⚠/▲/⟷/…) come from drift_cache (populated async via Surface 5). "…" until cache lands. • The [s] hot-path manifest read is flagged for a follow-up cache (selected_unit_has_conflict_peer in SkillsScreenData). • Colour key: olive arrow = pure state mutation · clay arrow = impure (disk / network / subprocess / async wire) • Every keypress here is covered by either an in-process tripwire (TestBackend) or a live tmux tripwire — see test pyramid above. ★ tripwire_core_skill_manager_screen_opens caught the GIT_TERMINAL_PROMPT freeze via the [M] path.
diagram via /fireworks-tech-graph

The two load-bearing additions on this screen:

Test pyramid — what each tier proves

v1.2 tests live at three tiers with different cost / fidelity tradeoffs. The pyramid below is the primary mental model for "where would I add a new test for this feature?".

ainb skill-manager v1.2 — test pyramid (what each tier proves) TIER 1 — Pure-function unit tests cargo test -p ainb-skill-core ~80 tests · 7 files · proves type contracts + serde + pure algorithms CHEAP, MANY, FAST TIER 2 — CLI integration tests cargo test -p ainb-cli ~17 tests · 5 files · proves argv → exit code → output MEDIUM COST, MEDIUM CONFIDENCE TestBackend usage_renders (1) drift_column (5) background_poll (2) in-process ratatui Live tmux screen_opens ★ esc / q / conflict discovery_banner real subprocess TIER 3 — TUI tripwires EXPENSIVE, FEW, BUT HIGH-CONFIDENCE Tier 1 files mapping_tests.rs (16) — glob first-wins manifest_tests.rs (23) — target_layout serde sync_tests.rs (12) — plan_sync directions sync_to_repo_tests.rs (6) — incl push integration ★ auto_infer_gh_layout_tests.rs (6) — bootstrap fallback drift_tests (14) — all 4 DriftStatus variants Tier 2 files skill_check_tests.rs (4) — tabular + JSON + scope skill_sync_tests.rs (5) — dispatch + flags skill_sync_roundtrip_tests.rs (2) — home↔repo tripwire_cli_skill_install_respects_target_layout tripwire_cli_migrate_upgrade_schema Tier 3 highlight screen_opens caught the GIT_TERMINAL_PROMPT freeze: drift poll → git ls-remote → cred prompt → TUI frozen for 95s. Fix: env=0 on backend. → tripwire green after f34d851 landed. ⚠ What was NOT tested • Prod GitLsRemoteBackend round-trip (only MockBackend) • Concurrent sync race on promote-cache · days_from_civil math cost / fidelity
diagram via /fireworks-tech-graph

Tripwires green twice consecutive per F.3 acceptance. Workspace: 711 pass / 9 fail (9 = pre-existing docker::agents_dev_tests::*, gated under bd agents-in-a-box-3fq). Independent scoped verify on the v1.2 slice: 62 result lines, exit 0.

Security hardening (incidental, caught during review)

Three argv-smuggling vectors + one credential-prompt regression caught during cherry-pick / merge / distinguished-engineer review:

Pattern is uniform: reject leading-dash on user-controlled positional + insert -- argv terminator before positionals + disable interactive credential prompts so subprocess fails fast instead of freezing.

What was deliberately NOT tested

FAQ

Why is the planner pure and the executor impure?
Because plan inspection is the entire value of --dry-run. plan_sync over (source, mappings, units) is deterministic in its inputs, so the dry-run output is exactly what the apply path will do — no clever divergence, no "but the executor would have…". The impure surface area shrinks to apply_to_home / apply_to_repo.
Why does ContentFetcher live in ainb-skill-core and not ainb-fetch?
To avoid a dep cycle. ainb-skill-core is upstream of ainb-fetch in the workspace graph; importing the real Fetcher trait into sync.rs would invert the edges. Keeping a minimal trait inside ainb-skill-core and wrapping the real fetcher at CLI call time is the cheapest seam.
Why does GitLsRemoteBackend collapse Ahead/Diverged to a sentinel?
Because git ls-remote only gives you the upstream tip — it can't tell you commit counts without a local clone. The sentinel Outdated {{ behind: 0 }} means "tip differs, count unknown". Distinguished-engineer review flagged this as a Tony-Hoare-grade null — on the v1.2.1 follow-up list to add a DriftStatus::Unknown variant for honesty.
Why is the channel unbounded?
Because detect_all sends exactly one message (the full BTreeMap) and exits. The coalesce guard ensures at most one task is in flight, so the channel will have at most one buffered message. Note: distinguished-engineer review classified this as "ship as-is".
What's the [s] hot-path read about?
To decide between ConflictFlip and SkillManagerSync, the event handler reads manifest.yaml on every [s] press to check if the selected unit has a shadowed_by peer. Sub-millisecond on typical manifests, but it's disk I/O in an event handler. v1.2.1 follow-up: cache selected_unit_has_conflict_peer in SkillsScreenData, invalidate on conflict-flip.
Where does the timestamp formatter live?
ainb-usage/src/lib.rs:257-274. It's a hand-rolled days_from_civil (Howard Hinnant's civil-from-days). Correct, but untested. The decision to hand-roll was "we don't want to pull in chrono", except chrono is already in the workspace via the TUI. Either tests or use chrono — both fixes in v1.2.1.
What's NOT in v1.2 that you might expect?
Drift count for Ahead (production backend can't compute it without a local clone). Concurrent sync file locks (per-source mutex). Schema version bump (Manifest.schema_version stays at 1 even after --upgrade-schema). All three on the v1.2.1 follow-up bead.