How ainb skill-manager v1.2 actually works
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.
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
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).
The two load-bearing additions on this screen:
- Detail pane gained Usage: N invocations · last used X ago line.
format_time_agoparses the RFC 3339 timestamp anchored tochrono::Utc::now()at render time, so the string updates on every paint without storing anything time-dependent in the view-model. - Units panel gained the rightmost status column with drift glyphs
✓/⚠/▲/⟷(green / amber / cyan / red), with a muted…placeholder until the async poll lands. [s]is now overloaded:ConflictFlipwhen the selected unit has ashadowed_bypeer (old v1.1 behaviour),SkillManagerSyncotherwise. The dispatch readsmanifest.yamlfrom disk on the hot keystroke path — sub-millisecond today but flagged for a follow-up cache.
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?".
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:
git ls-remote --indrift.rs+GIT_TERMINAL_PROMPT=0/GIT_ASKPASS=/bin/true(caught by the live-tmux tripwire freezing for 95s when an unreachable repo prompted for credentials).git clone --inpromote.rs(the v1.1 promote-cache path — same vector, pre-existing).git push -- origin <ref>insync.rs(TO_REPO executor;source.refattack surface).git config -- user.email/.namein bothsync.rsandpromote.rs(M1 from distinguished-engineer review —GIT_AUTHOR_EMAIL=--file=/tmp/evilattack on the identity-setup path). Same fix landed forpromote.rs:640push branch (M2).GIT_TERMINAL_PROMPT=0+GIT_ASKPASS=/bin/trueextended tosync.rs::git_capture/git_with_env(M3 — same freeze class as the drift fix, latent for sync).
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
- Production
GitLsRemoteBackendround-trip — onlyMockBackendis exercised. A realgit ls-remoteagainst a local bare repo would catch env-propagation bugs but adds CI flake risk. - Concurrent
ainb skill syncon the same promote-cache — no file lock. Cron + interactive races are corruption-safe (git's own.git/index.lockprotects) but UX-hostile. Flagged by distinguished-engineer; on the v1.2.1 follow-up list. - Hand-rolled
days_from_civilISO 8601 formatter inainb-usage/src/lib.rs:257-274— zero tests on the date math. One off-by-one and every usage timestamp is wrong forever. Either tests or pull inchrono(already in workspace).
FAQ
- Why is the planner pure and the executor impure?
- Because plan inspection is the entire value of
--dry-run.plan_syncover(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 toapply_to_home/apply_to_repo. - Why does
ContentFetcherlive inainb-skill-coreand notainb-fetch? - To avoid a dep cycle.
ainb-skill-coreis upstream ofainb-fetchin the workspace graph; importing the realFetchertrait intosync.rswould invert the edges. Keeping a minimal trait insideainb-skill-coreand wrapping the real fetcher at CLI call time is the cheapest seam. - Why does
GitLsRemoteBackendcollapse Ahead/Diverged to a sentinel? - Because
git ls-remoteonly gives you the upstream tip — it can't tell you commit counts without a local clone. The sentinelOutdated {{ 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 aDriftStatus::Unknownvariant for honesty. - Why is the channel
unbounded? - Because
detect_allsends exactly one message (the fullBTreeMap) 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
ConflictFlipandSkillManagerSync, the event handler readsmanifest.yamlon every[s]press to check if the selected unit has ashadowed_bypeer. Sub-millisecond on typical manifests, but it's disk I/O in an event handler. v1.2.1 follow-up: cacheselected_unit_has_conflict_peerinSkillsScreenData, invalidate on conflict-flip. - Where does the timestamp formatter live?
ainb-usage/src/lib.rs:257-274. It's a hand-rolleddays_from_civil(Howard Hinnant's civil-from-days). Correct, but untested. The decision to hand-roll was "we don't want to pull inchrono", exceptchronois already in the workspace via the TUI. Either tests or usechrono— 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_versionstays at 1 even after--upgrade-schema). All three on the v1.2.1 follow-up bead.