ShippedDX · Infra
Shift-left gate — pre-commit + pre-commit.ci
Write-up
A .pre-commit-config.yaml runs ruff, mypy, codespell and the
whitespace fixers before code ever reaches CI, and the free-for-OSS
pre-commit.ci auto-fixes and auto-updates hooks right on the
PR. The foundation the rest of this section plugs into — one config,
many checks, faster feedback.
Complexity S
Impact High
Wow ★★★★
auto-fix PRs
ShippedQuality · Architecture
Layering guard — import-linter
Write-up
Enforce the architecture boost already assumes: core/ must
never import commands/, and the CLI depends inward only.
import-linter turns that contract into a CI check, catching
the cross-layer imports that slowly erode a clean engine — a structural
smell no line-level linter can see.
Complexity M
Impact Med
Wow ★★★★
core/ ↛ commands/
ShippedQuality · Docs
Typo detection — codespell
Write-up
Cheap, high-signal for a project this documentation-heavy: scan the
README, docs/*.html, docstrings and every user-facing CLI
string for misspellings. A typo in boost --help is a bug
users see on their first run.
Complexity S
Impact Med
Wow ★★
user-facing text
ShippedQuality · Docs
Docstring coverage — interrogate
Write-up
Gate CI below a docstring-coverage threshold so the engine stays
self-documenting as it grows. core/ is already well
commented; interrogate keeps it that way and flags the
new public function that shipped without a word of explanation.
Complexity S
Impact Low
Wow ★★
keeps core/ documented
ShippedTesting · Perf
Performance-regression gate — pytest-benchmark
Write-up
Benchmark the hot paths — a catalog scan over a large skills tree, a
registry load — and fail the build when a change regresses them past a
threshold. Catches the performance bugs a correctness suite
waves through: the O(n²) that only bites at scale.
Complexity M
Impact Med
Wow ★★★
scan & registry
ShippedQuality · Smell
Modernization smells — refurb + pyupgrade
Write-up
refurb's unique FURB checks flag dated idioms
the ruff families miss, and pyupgrade rewrites to the
cleanest form the floor allows. Keeps the codebase
reading like modern Python instead of accreting legacy patterns.
Shipped against >=3.9; the floor later moved to
>=3.12, which widens what "cleanest form" means — the
PEP 585/604 sweep that unlocks is its own item.
Complexity S
Impact Low
Wow ★★
shipped under the 3.9 floor; the floor is now 3.12
ShippedTesting · Platform
Coverage dashboard — Codecov (free for OSS)
Write-up
The self-contained diff-cover gate enforces patch coverage in CI; this is
its hosted counterpart — the same coverage.xml the gate already
reads, turned into PR diff-coverage comments, a file sunburst and a trend line
over time. Wired into the existing patch-coverage job (no second
test run) and deliberately non-blocking: codecov.yml marks
every status informational, because boost already enforces coverage
twice offline and a merge should never hinge on a third party's uptime. Inert
until a CODECOV_TOKEN secret exists — the step's if
reads the token through env (a secret cannot be referenced directly
in an if), so with no secret it skips rather than fails.
Complexity S
Impact Med
Wow ★★★
PR decoration
ShippedCI · Reproducibility
Pin the lint toolchain so a release can't redden the gate
Write-up
The lint job installed its tools unpinned
(pip install ruff mypy …), so every run pulled the latest
release. When ruff 0.16.0 shipped it began flagging rule
families this repo deliberately does not select
(UP/BLE/PLW — ~1100 findings of
exactly the pyupgrade-style churn the roadmap already declined),
turning the gate red on every open PR and on main with no
code change. A gate whose meaning can shift out from under you on an
upstream release is not a gate. Froze the eight lint tools to their
known-good versions in one requirements/lint-tools.txt
that both CI and the Makefile install from, so upgrades become a
deliberate, reviewable bump. (The broader runtime/build reproducibility
story is the uv.lock item; this is the lint-gate slice
that was actively on fire.)
Complexity S
Impact High
Wow ★★★
unpinned ruff 0.16 reddened every PR
ShippedSecurity · CI/CD
Runner egress monitoring — StepSecurity Harden-Runner
Write-up
Free for public repos, Harden-Runner watches (and can block) unexpected
network egress from CI runners — the runtime signal that catches a
compromised action exfiltrating a token. The dynamic complement to
zizmor's static workflow analysis, guarding the release
path from both sides.
Complexity S
Impact Med
Wow ★★★★
runtime + static
ShippedCI/CD
Required-status-checks list is prose-only, and already stale
Write-up
Shipped. .github/required-checks.txt is now the source of truth and scripts/check_required_checks.py (in make lint, so in CI) fails when a required name stops matching a job that runs on pull_request. It also caught something the card missed: three check names were ambiguous — lint in ci/markdownlint/theme-lint, audit in lighthouse/pip-audit, analyze in codeql/sonarcloud. GitHub matches required checks by name, so none of those could be required unambiguously, and the colliding jobs are renamed. Original report follows. Branch protection was hand-configured in GitHub Settings and only described in prose in
CONTRIBUTING.md — there is no config-as-code (a repo ruleset export, Terraform, or a CI
check diffing required names against real job names) to catch drift. It has already drifted:
ci.yml's install-smoke job, and the pip-audit/
package-metadata workflows, are never listed as required, so a PR can merge to main
with any of them red.
Complexity S
Impact Med
Wow ★★
ShippedFeature
make boost mcp the whole setup, and put all three kinds behind it
Write-up
Measured on a fresh HOME: boost mcp registers the server, and then
the first thing an agent ever asks it — boost_search("set up code review for a python
repo") — comes back no skills match 'set up code review for a python repo'.
Nothing is wrong; nothing is tapped. But the reply is byte-identical to a real miss, so the
agent learns the catalog is empty and stops asking. boost_doctor agrees with it:
taps: 0 (0 skills available) followed by healthy — no issues found. The
one command a new user is told to run leaves the surface it just registered with nothing to answer
from.
Three kinds, one of them unreachable and one invisible. DEFAULT_TAPS is five
skills-first repos: measured, they yield 302 skills and 41 workflows and zero rules — so
the kind whose whole job is steering toward better paths and away from anti-patterns cannot be
found by a default install at all. And a search hit renders as
name — description (tap) with no kind marker, while boost_install's own
description warns that installing a rule is the more invasive change because it merges into the
context file the agent loads every session. That warning is unactionable: the reply it applies to
never says which hits are rules. Adding one canonical rules repo and one commands/agents repo
takes the default corpus to 946 items — 302 skills, 387 workflows, 257 rules, about 14 MB
more than today's five and measured end-to-end at 14-45s across runs (it is network-bound,
so quote the range rather than either end of it).
Drawn, not forced — and the research says that is a knife edge. Editing only a tool's
description shifts how often models call it by more than 10× (EMNLP 2025, "Tool Preferences
in Agentic LLMs are Unreliable"), and assertive phrasing, examples and name-dropping are precisely
the capture levers. So the trigger stays a test rather than an order: work that touches more than
one file, or leaves something behind that outlives the session, or that you would name in a commit
message — with the skip list kept in plain sight (a question, a one-line edit, a command you were
handed). The moments worth naming are the ones where a choice gets locked in: a new project or
subsystem, an architecture decision, environment and tooling config, linters, tests, CI. The
existing guardrails hold: no coercive framing, no claimed corpus size, the stated 10-15s cost, and
"the task stays yours" — all of them already pinned by tests.
Complexity M
Impact High
Wow ★★★★
measured — a fresh install answers every MCP search with "no skills match", which reads as "boost is empty" rather than "nothing is tapped"
ShippedTest infra
New BDD suite has zero CI wiring
Write-up
The behave suite added under tests/bdd/ (11 features, 47 scenarios) passes cleanly
today, but make bdd is invoked nowhere in .github/workflows/ — only
lint, test, smoke, and mutation run in
ci.yml. Unlike the eval/mutation gates, which degrade cleanly but still execute, this
suite only runs if a human remembers to run it locally, so a CLI change that breaks the step glue
(output-string matching, mocked shutil.which) can rot silently for any number of merges.
Complexity S
Impact Med
Wow ★★
ShippedBug · MCP
A project-scoped install registers its MCP servers machine-wide
Write-up
boost install <skill> --scope project (or --here) is a promise about
blast radius: the skill lands inside this repo, committable, affecting nobody else's machine. The
MCP servers that skill declares do not keep that promise. They are registered at user scope
— globally, for every repo you open — whatever scope the skill itself was installed at.
The path is short and the gap is a missing argument. _offer_mcp() in
commands/pkg.py receives the whole InstallResult, so
res.scope is right there, and never reads it. It calls
_register_mcp_server(name, spec, host), which takes no scope parameter at all, so
mcpdecl.register_argv() falls to its scope="user" default and boost shells
out claude mcp add <name> --scope user …. Nothing warns that the scope the user
asked for was not the scope they got.
The repo-local machinery is already written, and nothing calls it.
mcpdecl.merge_into(existing, rows, skill) merges declared servers into an
.mcp.json document — the committable, project-scoped file agents already read
(mcpdecl.SIDECAR). It returns a new document rather than mutating, never overwrites a
server the user configured by hand, and stamps each entry it adds with a MARKER_KEY
naming the skill that asked for it, precisely so a later uninstall can remove what boost wrote and
nothing else. It is covered by nine assertions in tests/unit/test_mcpdecl.py and has
zero callers in boost_cli/. The install path shells out to the host CLI instead.
That marker matters because of the second half of the bug: uninstall never
unregisters anything. Removing a skill sweeps its files and its symlinks and leaves the MCP
server registered, so a skill you installed once to try out keeps launching for every project you
open afterwards. Wiring merge_into gives cleanup something exact to reverse, which
shelling out to mcp add does not.
What the fix has to get right:
Scope must flow, not be re-derived. Thread res.scope from
_offer_mcp through _register_mcp_server into
register_argv(..., scope=…). Both call sites of
store.declared_mcp_servers (the user-scope and project-scope install paths in
core/store.py) already know their scope; the command layer is where it gets dropped.
Project scope should write the file, not shell out. A registration that lives in
.mcp.json is reviewable in a diff, committable, and shared with the team — which
is the whole point of --scope project. Shelling out to mcp add --scope
project would work for a host that supports it, but produces nothing to review and re-opens
the per-host grammar problem core/mcphost.py exists to contain.
Uninstall has to reverse exactly what was written. Marker-keyed entries only — a
server the user added by hand, or one another skill declared, must survive. Note the asymmetry
already documented in core/mcphost.py: claude mcp remove finds a
user-scope server unaided, while gemini mcp remove defaults to project scope and will
report "not found in project settings" while leaving the user-scope entry in place. Removal needs
the scope it was registered at, which is another reason to record it rather than guess.
The prompt should say where. Today it asks "register N servers with Claude Code now?" and
names no scope. It should say which file or scope is about to change, because that is the decision
being made.
Shipped, all four. Scope now flows: _offer_mcp reads res.scope and
threads it through _register_mcp_server into register_argv, so a
user-scope install passes its scope explicitly rather than silently inheriting the
scope="user" default that caused this. A project-scope install does not shell out at
all — it records into the repo's .mcp.json, which is the committable, reviewable
registration --scope project promises, and it arrives with a teammate's clone where a
host-CLI registration exists only on the machine that ran it. Uninstall reverses exactly those
entries. And the prompt names the scope: it now asks to register "for every project on this
machine", which is the detail the answer turns on.
Wiring was most of it — both halves of the document logic already existed and had no
callers. merge_into and strip_owned were written, tested and unused;
the new code is the file I/O around them, which lives in core/store.py because
core/mcpdecl.py is deliberately I/O-free (the same split core/mcphost.py
keeps).
The properties worth stating, because each is a way to destroy someone else's work: a server the
user configured by hand is never overwritten on install and never removed on uninstall; another
skill's server survives both; unrelated keys in the document survive both; an uninstall that owns
nothing does not rewrite — and so does not reformat — a file it has no claim on; and a
corrupt or absent .mcp.json is never fatal, because by then the skill is already on
disk. A skill declaring nothing registrable creates no file at all.
Verified by disabling the wiring and confirming the end-to-end tests fail without it, rather than
trusting that a passing test proves anything.
Related: [[mcp-aware-skills]] (which added the declaration format),
[[mcp-install-skips-the-injection-scan]] and [[mcp-register-names-server-before-env]].
Complexity M
Impact High
Wow ★★★★
--scope project installs the skill into the repo and its MCP server machine-wide
ShippedAgents · BMAD
boost bmad needed Node and a per-project install before it did anything
Write-up
boost bmad was a scope-aware wrapper around npx bmad-method install.
Every path through it required Node.js 20.12+, a network round trip and a per-project
_bmad/ runtime, and what you got at the end was a SessionStart paragraph
telling the model that skills existed. Nothing routed. The user still had to know which persona
owned the task in front of them and invoke it by hand — so the personas were documentation, not
behaviour.
Shipped: boost bmad on — one command, global by default, pure stdlib. It writes
seven BMAD persona subagents into ~/.claude/agents/ and installs two hooks: the
existing SessionStart briefing, and a UserPromptSubmit router that
classifies each incoming prompt into one of nine tracks and prefixes it with the lead persona, the
support personas to spawn alongside, the canonical BMAD v6 skill for that track, and a definition of
done. The heavy npx path stays exactly as it was behind boost bmad install;
the two compose, and the banner says which case you are in.
The definition of done is read off the repo, not hardcoded.
core/bmad.project_signals() probes for the test directory, the doc paths, the roadmap
items directory and the gate command (make check → make test →
npm test → per-language default), so the banner names tests/,
docs/roadmap/items/ and make check in this repo and says something
different in yours. A checklist that names a path which does not exist teaches the agent to skip the
whole banner, so clauses for a roadmap or a gate appear only when there is one — while tests and
docs are unconditional, because “no doc change needed” is a conclusion to reach, not a
step to skip.
Classification is precedence-ordered, and build loses every tie. Real prompts hit
several keyword tables at once: “add tests for scan_dir” is a build verb and a
testing noun, “add a roadmap item” is a build verb and planning. First-match
ordering sent all three to Amelia and made the other six personas decoration, so
TRACK_ORDER is an explicit tie-break with the catch-all last.
Three upstream facts drove the design, and each was checked rather than assumed.
(1) The orientation text this command shipped was advertising bmad-quick-dev and
bmad-dev-story; both are now deprecated v6-shims that redirect to
bmad-build, so every build task was being routed through a deprecation notice. (2)
It also named bmad-agent-tech-writer as a persona — Paige is a gds
(game-dev-studio) agent and is “on hiatus” in bmm, so that skill never
existed to invoke. (3) BMAD's installer never writes .claude/agents/ — its
claude-code platform entry has exactly one target, .claude/skills, and
sub-agents are a runtime behaviour of bmad-party-mode. The persona subagents duplicate
nothing upstream.
The router must never exit non-zero. On UserPromptSubmit an exit code of 2 blocks
the prompt and erases it from the transcript, so a crash in the router would eat the user's
message. Every failure mode — unreadable stdin, junk that is not JSON, a raising
project_signals — degrades to silence and exit 0, and the tests drive each of those
paths. The same instinct governs when it stays quiet on purpose: acknowledgements, slash commands
(which carry their own instructions), short informational questions and an explicit
no bmad opt-out all produce no banner, because a delegation banner on
“what does scan_dir do?” is worse than no router at all.
Ownership is content-derived, because marker-presence was wrong in both directions.
Persona files carry a <!-- boost:bmad-persona <digest> --> stamp, and a file
counts as boost's only while that digest still matches the rest of the file. The first cut
tested for the marker alone, which looked like the rule claude_settings
applies to hooks but was not: the marker is an HTML comment a user editing the body has no
reason to remove, so an edited persona was silently overwritten by the next
boost bmad on and deleted by boost bmad off — while that
command printed “hand-edited personas were left in place”. Digesting the content
makes both statements true: on reports an edited file as kept, off
leaves it, and the README no longer promises something the code did not do.
The router's never-exit-non-zero rule had a hole above it. _route catches
everything and returns 0, but argparse never reaches it: the hook is installed against
paths.launcher() — whatever boost is on PATH, not necessarily the
build that wrote the hook — so a stale pipx install or a later downgrade hits
bmad route, fails invalid choice, and exits 2. On
UserPromptSubmit that does not merely log: it blocks the prompt and erases it
from the transcript, so every message the user typed would vanish until they found the hook.
The guard has to sit outside any version of boost, so the installed command ends
|| true.
Two keyword-table defects, found by routing real prompts rather than by reading.
\bfix\w*\b matches “fixture” and \bchange\w*\b matches
“changelog” — the exact words the quality and docs tables claim — so “add
fixtures for the sandbox HOME” scored build=2 against quality=1 and went to Amelia.
Because build won on score it never reached TRACK_ORDER, so the
“build loses every tie” invariant was bypassed rather than violated: the fix is to
enumerate inflections, not to reorder. Separately, the interrogative gate anchored on the
first token only, making “Why is test_catalog flaky on Windows? Fix it and add a
regression test.” trivial — a real task, silently unrouted. A question followed by
another sentence is a preamble to an instruction, not a question.
Complexity M
Impact High
Wow ★★★★★
one command, no Node, routes every prompt
ShippedInterop
boost adapt — render a skill as another framework's agent source
Write-up
boost only installs skills as files for editor agents (Claude Code, Cursor, Windsurf); CrewAI / OpenAI-Agents-SDK apps can't consume them — an agent there is a value built in source. boost adapt <skill> --to crewai|agents-sdk renders one SKILL.md into each framework's native Agent(...) as a deterministic, zero-dependency string transform (core/adapters.py). Strings emit via json.dumps so any body (quotes, """, unicode) stays valid Python; 97.6% mutation kill. Docs: adapters.html.
Complexity M
Impact Med-High
Wow ★★★★
ShippedInterop
boost adapt --to langgraph — third framework renderer
Write-up
Extend boost adapt (shipped in #146) with a LangGraph target. Unlike CrewAI / Agents-SDK, a LangGraph agent isn't one Agent(...) constructor — the skill body is the system prompt of a node bound into a StateGraph. Add a render_langgraph to core/adapters.py emitting a node factory (prebuilt create_react_agent, or a prompt-carrying node) + its FORMATS row, with byte-exact golden + compile() tests. Then add a langgraph leg to .github/workflows/adapter-conformance.yml (pip install langgraph, import the emitted file). Follows the pattern in adapters.html: one render_* fn + registry row + golden test + one matrix line.
Complexity M
Impact Med
Wow ★★★
ShippedInterop
boost adapt — multi-agent skills → crews/graphs, not one Agent
Write-up
Today boost adapt (#146, #163) projects a skill into a single framework Agent carrying its instructions/role/model — but a skill can declare tools and a whole subagent graph (e.g. rust-review = worker + dedup-judge + fp-judge). Adapt the richer structure: emit a CrewAI Crew (agents + tasks) or a LangGraph StateGraph when a skill's frontmatter/plugin dir declares subagents, and surface declared tools as stubs. Scope: detect subagent/tool declarations in SKILL.md/plugins/*/agents/*.md; add a crew/graph renderer path in core/adapters.py (single-Agent stays the default for flat skills); golden + compile() tests + a conformance leg. Turns "one skill → one agent" into "one skill → a runnable multi-agent workflow." See adapters.html.
Complexity L
Impact Med
Wow ★★★★
ShippedInterop
boost run — search → adapt → a live agent doing the task, in one command
Write-up
The allure ceiling on boost adapt (#146, #163): it stops at source you assemble, and the adapted agent has a brain (prompt) but no hands (tools). Close both. boost run <skill> [target] = adapt → wire a default toolset (file read, ripgrep/shell, web) → execute on boost's model and stream the result. Opt-in like the conformance path (installs the framework + needs a key, so the zero-dep default holds). Two prerequisites ship with it: (1) tool-wiring — emit/attach the tools a skill needs (or declares) so the agent can act, not roleplay; (2) reuse the adapt renderer for the agent def. Pairs with [[framework-adapter-multi-agent]] so a skill like rust-review runs as a real crew. This is the screenshot: "one command → an expert agent audits your repo, on Claude, that you never wrote." Working preview: examples/boost-run-prototype.sh already does this by hand (adapt → wire a read_file tool → Runner.run on a file with planted bugs).
Complexity L
Impact High
Wow ★★★★★
ShippedInterop · Adoption
MCP — make agents search boost before reinventing a skill
Write-up
The boost MCP server returned no initialize instructions and described its tools by boost's nouns ("Search AI coding skills across the configured tap registries"), so an agent mid-task never mapped "I'll write a code-review workflow" onto "search boost first" — and reinvented the wheel. Add server-level instructions (the field MCP hosts load into the agent's context) framed by the agent's trigger — before you author a reusable skill / rule / subagent, call boost_search FIRST — and rewrite the six tool descriptions to lead with that trigger and a "don't reinvent one that already exists" frame. Follow-up: a self-bootstrapping boost-first meta-skill that boost mcp register / onboard offers to install.
Complexity S
Impact High
Wow ★★★
agent reflex
ShippedCore · Package manager
boost install resolves a skill's requires: closure
Write-up
Skills already declare relationships in frontmatter (requires: /
conflicts:), and boost info / boost deps
show them — but boost install X only ever installed
exactly what you named, never what X depends on. That is the one headline
package-manager behavior boost was missing: installing a skill should pull
in the skills it needs, in the right order. Add a pure, mutation-tested
resolver in core/resolve.py — post-order DFS over the
requires: graph, cycle-safe, deduped, already-installed nodes
pruned — and wire it into cmd_install so a named skill installs
its transitive requires: closure (dependencies first), with a
--no-deps escape hatch. Declared conflicts: against
an installed or co-installed skill surface as an advisory warning, and a
requires: naming a skill in no tap is flagged, not fatal.
Surfaced by mining boost's own catalog: the densest skill cluster is
dependency management, which every package manager treats as table stakes.
Complexity M
Impact High
Wow ★★★★
the Homebrew move
ShippedInterop · MCP
MCP-aware skills — declare and wire an .mcp.json on install
Write-up
Mining boost's catalog surfaces a recurring shape: skills that only work paired
with a Model Context Protocol server (manage-mcp-servers,
mcp-integration, mcp-builder and dozens more). boost
already is an MCP server and registers itself, but a skill could not say
"I need server Y", so it installed cleanly and then failed in the agent for a
reason nothing surfaced. Now a skill declares mcp: github, playwright
in frontmatter and/or bundles a standard .mcp.json; on install boost
states the requirement and offers to register the servers that came with a
runnable spec, the same way boost mcp register wires boost itself.
boost info lists them beside capabilities:, and
--no-mcp opts out. Deliberately flat, not the nested
mcp: block first sketched: boost's frontmatter parser is a stdlib
YAML subset that does not fail loudly on a nested mapping — it hoists the inner
keys to top level and clobbers their siblings — so the declaration meets the
parser where it already works and full specs live in the sidecar that needs no
parser at all. The decision layer is core/mcpdecl.py, pure and
I/O-free like core/resolve.py.
Complexity L
Impact Med
Wow ★★★★
skills that need a server
ShippedDX
roadmap.html goes stale on every rebase, so a card and a merge race redden the whole matrix
Write-up
The data-driven roadmap solved the conflict problem for item files: two loops adding
two cards touch two different files and merge cleanly. But docs/roadmap.html is
still a single committed artifact generated from all of them, and
update-branch merges it textually.
So whenever another roadmap-carrying PR merges first, the rebase brings in the new item
.md cleanly and leaves the generated HTML behind. The board no longer matches
docs/roadmap/items/, and both build_roadmap.py --check (in lint) and
tests/unit/test_roadmap_fresh.py fail — on every test leg, for a PR that
changed no Python at all. It reads as a catastrophic failure and is really a stale generated
file.
This is not hypothetical: one PR hit it twice in a single afternoon, each time needing a
manual regenerate-and-push, and the cost scales with the number of concurrent loops — the
exact workload the item-file split was introduced to support. With strict: true
every merge rebases every open PR, so any PR carrying a card is guaranteed to hit it if it
sits behind one other roadmap PR.
Options, roughly in order of appeal: have CI regenerate and push the board on the PR branch
(fixes it silently, needs a bot token and care with the required checks); a custom merge
driver for the generated boards that reruns the generator instead of merging text; or stop
committing the HTML and build the site at deploy time (biggest change, and it removes the
artifact from review). At minimum, make the --check failure message say
"regenerate after rebasing" so the cause is obvious from the log.
Update 2026-07-29 — the three options were measured, and two of them do not work.
First, the repro, because both halves matter and they fail differently. Two branches each
adding one item to the same section: the cards hard-conflict (same insertion point),
while the counters merge silently to a wrong value — base N, both sides
N+1, git keeps N+1, the truth is N+2. So a clean merge
still fails --check. That is why one root cause reddens ~6 checks.
"Custom merge driver" is dead. GitHub's server-side merge ignores
.gitattributes merge strategies — not just custom drivers, the built-in ones too.
Measured on this repo with four throwaway branches and
POST /repos/…/merges: 409 without the attribute and 409 with
docs/roadmap.html merge=union, while the identical merge locally succeeds
cleanly and one build_roadmap.py run makes it correct. The attribute would help
only whoever resolves the conflict by hand, which is the case that already works.
"CI regenerates and pushes on the PR branch" is dead too, at least with
GITHUB_TOKEN: a push made with it deliberately does not trigger workflows, so the
fix-up commit would land a new head with no check runs at all and the required contexts
would never report — the PR blocks forever instead of for one cycle. It needs a PAT or a GitHub
App, i.e. a new long-lived secret with write access to every branch.
The survivor is the third one — stop committing the generated HTML and build the site at
deploy time — and it has a prerequisite that is a repository setting, not a commit:
Pages is currently build_type: legacy serving main:/, so it publishes
whatever bytes are committed. The artifact cannot leave git until Pages builds it, which means
either an Actions-built site or a generated gh-pages branch (the pattern
ci.yml already uses for the coverage badge, and one that does not re-trigger
ci → publish, so it cuts no extra release). Everything that reads
docs/*.html off disk — html-validate, visual,
lighthouse, check_anchors, a11y_check,
test_docsite_chrome — would need a generate step first, and
test_roadmap_fresh loses its purpose. That is the whole cost, and it is bounded;
what it is not is a change one loop should make to a live public site on its own initiative.
Complexity M
Impact Med
Wow ★★★
two of the three fixes measured dead; one survivor, and it needs a Pages change
ShippedTesting · Bug
make lint reports success when actionlint fails — and says it wasn't installed
Write-up
Makefile:64 guards the workflow linter like this:
@command -v actionlint >/dev/null 2>&1 && actionlint || echo "actionlint not on PATH — skipping (CI enforces it)"
Because || binds to the whole && chain, the fallback fires in
both failure modes — actionlint absent, and actionlint present and reporting
findings. Reproduced with a stub that exits 1:
actionlint: FAKE ERROR
actionlint not on PATH — skipping (CI enforces it)
make exit=0
So a real workflow-lint failure is swallowed, and the message asserts something false — that
the tool is not installed, when it is installed and just failed. make lint and
therefore make check can never fail on actionlint. CLAUDE.md calls
make check "the one gate that matters" and tells every agent to run it before calling
a change done; for this tool it returns green unconditionally, and the developer who reads the
output is told the opposite of what happened.
The local-versus-CI gap is wider than this one line. CI's required lint job runs
three workflow/security tools that make lint has no equivalent for at all:
actionlint (present but unfailable), zizmor and gitleaks (absent from the
Makefile entirely). No CI job invokes make lint, so the two lists are hand-maintained
duplicates with nothing checking they agree — which is how they drifted.
Minimal fix is to stop discarding the exit status:
@if command -v actionlint >/dev/null 2>&1; then actionlint; else echo "actionlint not on PATH — skipping (CI enforces it)"; fi
The stricter option is to install actionlint in make venv and let absence itself
fail, so make check cannot report success while skipping a required gate. The same
masking idiom appears at Makefile:143 (hyperfine) and Makefile:167
(node); neither is reached by check, so they are cosmetic by comparison, but they are
the same bug. Incidental drift found alongside: CLAUDE.md describes the lint recipe
as "18 commands" and it is 19.
Shipped. All three occurrences of the idiom were rewritten to an
if/then/else so the tool's exit status is no longer discarded —
Makefile:64 (actionlint, inside lint and therefore inside
check), plus bench-cli and post-deploy, which had the same
bug without being reachable from the gate. Verified against the patched recipe itself, in all
three states: a failing actionlint now fails make (exit 2) instead of printing
"not on PATH" and returning 0; a passing one still exits 0; an absent one still exits
0 with the skip message intact. The wider local-versus-CI gap this card also names —
zizmor and gitleaks run only in CI and are absent from the Makefile
— is untouched and still open.
Complexity S
Impact Med
Wow ★★★★
fixed — a failing actionlint now fails make, in all three targets
ShippedInterop · Adoption
MCP — check for a skill when a task starts, not only when authoring one
Write-up
The previous pass gave the MCP server
initialize instructions and intent-framed tool descriptions, but
framed both around a single trigger: before you author a new skill. That is the
rarer moment. Observed behaviour matched the framing exactly — agents called
boost_search only when they were already about to write a skill, and never to ask
whether an installed one covered the work in front of them. The common case, starting
ordinary work that a vetted skill already handles, had nothing pointing at it.
Broaden to two declared triggers: starting a task (call
boost_list for what is usable right now, boost_search for what could
be) and the existing authoring one. boost_list is re-framed from "avoid a
duplicate install" to "leverage a capability you already have" — it is the free half of the
check. A closing proportion note bounds it, because an unbounded always check first
turns every trivial turn into a tool call and an agent that learns to ignore the guidance
ignores all of it. Both triggers and the bound are pinned by tests, so dropping either
regresses.
Shipped — the claim was stale, not the work. PR #318 merged and its branch
is gone, but this item sat at inflight under that branch name, so the board advertised
live work as taken. Verified against the code rather than the PR state alone:
mcp.INSTRUCTIONS leads with using an installed skill, names boost_list for
what is usable right now, and carries the proportion bound this card asks for — “Skip it
for a question, a one-line edit, or a command you were just handed.” The trigger is pinned by
test_instructions_lead_with_using_a_skill_not_authoring_one.
One note for anyone reading both cards: mcp-one-benefit-nameable-task (#355,
later) superseded this card's “two declared triggers” framing by demoting
authoring to a clause on boost_search, on the grounds that it was the rarer moment
crowding out the common one. The task-start trigger this card argued for is what survived.
Complexity S
Impact High
Wow ★★★
agent reflex
ShippedTesting · Bug
The second silent skip — actionlint runs, and checks no run: block at all
Write-up
make-lint-masks-actionlint-failures fixed the layer everyone can see: make
lint discarded actionlint's exit status. There is a second layer underneath it, and the
first fix does not reach it. actionlint does not lint run: blocks
itself. It shells out to shellcheck, and when shellcheck is not on
PATH it skips every one of them — no warning, no note, exit 0.
Same workflow file, same actionlint binary, only PATH differs:
without shellcheck -> (no output) exit=0
with shellcheck -> bad.yml:8:9: shellcheck reported issue in this script: SC2001 … exit=1
So the honest description of the gate before this change was: make lint ran a workflow
linter that silently declined to look inside any script, and reported success. That is not
hypothetical either — it is exactly how an SC2001 reached CI from a green local
gate, in the very change that fixed layer one.
CI was not safe, only lucky. Its actionlint step carried the comment "shellcheck is
preinstalled on the runner, so run: blocks are checked too" — true today, and an unpinned,
unversioned, silently revocable dependency on somebody else's base image. If GitHub ever drops it
from ubuntu-latest, CI degrades to the same hollow check with no signal that anything
changed. For a repository that hash-pins its entire lint toolchain precisely so an upstream release
cannot move a gate underneath it, an implicit dependency on a runner image is the same bug in a
different coat.
Shipped. shellcheck-py==0.11.0.1 is declared in
requirements/lint-tools.in and hash-pinned into the generated
lint-tools.txt like every other tool, which puts it on both sides: CI installs it from
that lock, and make venv puts it in .venv/bin. make lint now
prepends .venv/bin to PATH so actionlint can find it, and fails if
it is missing rather than running a check it knows is hollow; CI asserts command -v
shellcheck before invoking actionlint. Verified in four states against the real recipe: the
tool absent (skip message, exit 0), shellcheck missing from the venv (exit 1 with a message naming
the fix), both present and workflows clean (exit 0), and both present with an SC2001 in
a run: block (exit 1 — the case that used to pass). The old recipe on that same
file exits 0.
What is deliberately still skipped. If actionlint itself is absent,
make lint prints a message and continues. Closing that would mean pinning
actionlint-py, which is sdist-only and fetches the binary from the network at install
time, and which tracks a different actionlint version than the URL-pinned one CI uses. Trading a
pinned binary for a build-time download is a worse trade than the skip, so the skip stays and is
documented rather than quietly tolerated. The wider gap named by the earlier card is also still
open: zizmor and gitleaks run only in CI and have no Makefile equivalent.
Complexity S
Impact Med
Wow ★★★★
fixed — shellcheck is pinned in lint-tools and both gates now assert it is there
ShippedBug
boost chat cites its sources by a number it never prints
Write-up
boost chat answers with numbered references to the sources beneath it, and the
source list is rendered unnumbered — so every citation points at nothing:
$ boost chat "how do I review a diff for security problems?"
Use differential-review (#3) … Alternatively, review-swarm (#2) …
sources · ranked by hybrid RRF (BM25 + dense)
code-reviewer workflow (davila7/claude-code-templates)
review-swarm skill (lingxling/awesome-skills-cn)
differential-review skill (vibeeval/vibecosystem)
The numbers are not wrong — #2 and #3 are the right rows by position.
They are simply unresolvable, because the renderer never emits the index the prompt
told the model it could cite. The reader has to count rows to decode an answer that was written
to be scanned. Either number the list or stop citing by number; the two halves currently
disagree about which contract is in force.
Second, smaller defect in the same output. The recommended differential-review
is carried by three taps, so the bare name chat handed back was not directly actionable —
boost info differential-review on it errors with an ambiguity. Since chat already
knows which tap each retrieved row came from (it prints it, in parentheses, on the very next
line), it can hand back the qualified tap:skill form when a name is ambiguous and
the follow-up command will work first time. That form is only worth emitting now that
info accepts it; before that fix,
qualifying the name would have traded one dead end for another.
CORRECTION — "not yet located" was wrong. This card said there is no chat
row in cli.py's COMMANDS table, and told the next reader to start from
pip show -f boost-skill-cli because the behaviour must be coming from an installed
build. The row is at cli.py:93, has been all along, and ./boost --help
lists the command. What misled the search was looking for commands/chat.py: the
COMMANDS row names its module, and this one lives in
commands/intelligence.py alongside the rest of the ai group. The
engine is core/chat.py. Treating "is this command still shipping?" as the first
question was right; the answer was simply yes.
What shipped. Both halves now agree on one contract. chat.source_text
numbers the candidates from 1 and the system prompt says to answer from "the numbered skills",
so the rendered block enumerates reply.skills — the same list, in the same
order, so the indices are the model's rather than a parallel scheme invented at render time.
The extractive answer is numbered too, so the keyless path and the AI path refer to the same
rows the same way.
The ambiguity half is fixed where it is decidable. Every citation now carries a
ref, and the invariant is that a ref can be pasted into
boost info and will resolve. Usually that is just the name; when several taps
carry it — the exact condition catalog.resolve_one refuses on — it is
the qualified owner/repo:name. Verified end to end against the pinned 10,152-entry
corpus: boost info code-reviewer errors with "exists in multiple taps", and the
ChrisWiles/claude-code-showcase:code-reviewer the sources block now prints
resolves. A name repeated inside one tap is deliberately left bare, because
resolve_one already picks a canonical row for that case.
One thing deliberately not done. The qualifier does not reach the prompt. The system
prompt tells the model to name skills "exactly as given" and ungrounded_names
grades the reply against the entries' bare names, so feeding it qualified names would
make a correctly-quoted recommendation look invented and throw the whole answer away. So
_describe takes the ref on the answer path only, and a test pins that the prompt
still names skills bare.
Complexity S
Impact Med
Wow ★★★
observed in the field — the citations point at nothing
ShippedInterop · Adoption
MCP — one benefit, one observable trigger (and stop routing through boost_info)
Write-up
Gemini CLI used the boost MCP server only when asked by hand, never on its own.
Two causes, and neither was fixed by writing more instructions.
Placement: Gemini appends a server's initialize
instructions to the GEMINI.md memory tier
(getMcpInstructions → categorizeMemoryContents), gated on
folder trust — so the block reads as background documentation, sits far from the
tool-call decision, and is dropped entirely in an untrusted folder. Claude Code puts
the same text in the system prompt, which is why the gap only showed on Gemini. The
tool descriptions are the only boost text reliably in context at the decision point,
so each now repeats the trigger, the cost and the miss protocol instead of deferring
upward. Wording: the
previous pass declared three triggers bounded by a proportionality note, and the
bound beat the triggers every time — judging work "non-trivial" takes judgement, while
"this turn looks small" is free, and every turn looks small when it opens. Collapse to
one benefit (find a skill for the task in front of you) and
one observable trigger (does the task have a name?), which an
agent can pattern-match without deciding anything. Authoring drops from co-equal
trigger to a clause on boost_search — it is the rarer moment and it was
crowding out the common one. Three additions are load-bearing, not padding: the stated
cost (read-only, ~1s, installs nothing) kills the unknown-price hesitation; the
miss protocol ("finding nothing is a good outcome") stops one empty search
teaching an agent to quit checking; and "the task stays yours" is what makes an agent
willing to look, since one that expects a hit to seize the work is safer not looking.
Finally, drop boost_info from the advertised flow — boost_search
already returns each hit's description, the only field that changes an install decision,
so the hop bought a round-trip and a decision point and nothing else. It stays registered
for looking up a name from elsewhere, and its description now says so. All of it is
pinned by tests, including negative assertions so authoring and the info hop cannot
creep back.
Shipped — the claim was stale, not the work. PR #355 merged and its branch
is gone, yet this item still read as owned. Every element is verifiable in
mcp.INSTRUCTIONS today: the stated cost (“read-only, take about a second, and
install nothing”), the miss protocol (“Finding nothing is a good outcome, not a wasted
call”), “The task stays yours”, and boost_info absent from the
advertised flow — which test_instructions_route_search_straight_to_install pins
with a negative assertion so the hop cannot creep back.
Complexity S
Impact High
Wow ★★★
agent reflex
ShippedInterop · Adoption
MCP — answer the veto that overruled the trigger ("a skill already matched")
Write-up
A Gemini CLI session was asked to "create a new, simplified app demonstrating RAG
implementation in Python3 using langGraph, langChain, and langSmith" — a new project, an
architecture decision and a dependency choice, which is three of the triggers
boost_search's description names explicitly. It activated two already-installed
skills, built the app, and never called boost. Asked why, it paraphrased boost's own lock-in
trigger list back verbatim.
So the trigger fired and was overruled — it did not fail to persuade. That distinction is
the whole card. Every trigger boost ships is a predicate over the request: "has a name",
"touches more than one file", "outlives the session", "a new project or subsystem". All of them
matched. The gate the model actually applied was a predicate over its own context:
something already matched, so I am covered.
That proposition appears nowhere in boost's agent-facing text. Grepped across
INSTRUCTIONS and all six tool descriptions: already have 0 ·
already loaded 0 · already matched 0 · even if 0 ·
even when 0 · enough 0 · sufficient 0 ·
active skill 0. A clause that does not exist cannot have failed — which is why
the fix is a new proposition rather than a louder one, and why "state the trigger more clearly"
was the wrong instinct.
boost_list was the amplifier. Its description sold installed items as
"capability you own and may not know you own" — purely inward. An agent that stopped because
something had already matched would, on calling the one free tool, have been told only about
the things it already had. The reply confirmed the belief that suppressed the search.
The fix is a defeater, not a fourth trigger — it sits downstream of the existing gate and
removes a spurious veto, so it cannot widen the check and the skip list is untouched. It says what
an active skill is (installed on an earlier day; matched on its own description — what it
covers, not what this request needs; one kind of three, where a rule you never installed cannot
activate and a workflow waits to be called by name) and leaves the conclusion to the reader.
Two computed lines make the claim arithmetic rather than assertion: a per-kind footer on
boost_list — a machine showing 0 rules cannot have loaded the guardrail — and
an overlap note on boost_search, so "none of these is what you already have" is
representable for the first time.
Deliberately excluded: raw catalog totals. An earlier draft printed "the tapped catalog
holds 57,119 skills · 3,016 rules · 11,520 workflows" on every call. Those are un-de-duplicated
index entries, and this repo's own eval work is the refutation — the ranked list de-duplicates on
content hash precisely because 13 distinct skills named code-reviewer collapsing to
one slot was crediting the ranker with a compression that existed only in the scoring code.
It was also the only new text with no bound attached, the closest thing in the surface to a sales
pitch. Cutting it keeps boost_list lock-file-only, so the shipped claim that it is
instant stays true.
Placement is load-bearing, and it is a host fact rather than a preference. The clause is
repeated in boost_search's description rather than left in
INSTRUCTIONS, because Gemini never delivers server instructions in interactive mode
at all: Config.initialize() does not await mcpInitializationPromise, so
getMcpInstructions() returns "", startChat stamps the
context entry once with a stable id, and the later refreshMcpContext() re-renders
Tier 1 only. The failing session's log carries an empty ${environmentMemory} slot and
zero hits for start of server instructions. Claude Code delivers the same text fine.
On Gemini the function declarations are not merely the most reliable carrier — they are the only one.
What this card does not do. It ships on argument, because nothing measures whether an agent
calls a tool; see tool-call-eval-tier. A first-party skill in
~/.agents/skills was spiked as an alternative delivery route and rejected: the roster
rides getCoreSystemPrompt (Tier 0, immune to the race), but a skill's body
only enters context after activate_skill is called, so a defeater placed there fires
only in the worlds where the model was already going to check. The skill does not defeat the
gate; it enters the competition the gate adjudicates.
Complexity M
Impact High
Wow ★★★★
every trigger was a predicate over the request; the veto was a predicate over the agent's own context
ShippedInterop · Adoption
boost-first — the one rule boost authors, offered opt-in at boost mcp register
Write-up
The companion to mcp-already-covered-defeater, and the half of it that survives a
host which never delivers boost's text at all.
The delivery problem, measured. Gemini CLI never delivers MCP server
instructions in interactive mode: Config.initialize() does not await
mcpInitializationPromise, so getMcpInstructions() returns "";
startChat stamps the env-context entry once with a stable id and short-circuits; the
later refreshMcpContext() re-renders Tier 1 only. Worse,
startConfiguredMcpServers() returns early in an untrusted folder — and a
brand-new project directory is untrusted by default — so in exactly the situation the trigger
exists for, boost has no tools at all, not merely no instructions.
~/.gemini/GEMINI.md is loaded unconditionally and is not trust-gated. It is the only
boost surface that survives both failures.
So boost ships one rule of its own. It carries the same defeater as the tool descriptions —
an already-matching skill was installed before this request existed, matched on its own
description, and is one kind of three — plus the shell fallback (boost search "…")
for the untrusted-folder case where no MCP tools exist, and the skip list in plain sight.
It is an ordinary catalog item, and that is the point. boost's whole product is asking
users to accept standing text written by strangers under boost install, reversible
with boost uninstall. Its OWN standing text has to be the same kind of thing, subject
to the same commands — a privileged block that boost list cannot see and
boost uninstall cannot remove is precisely the asymmetry a user is entitled to
resent. So boost-first lives in a real tap, in a real .mdc, and installs
through the same _install_rule path as anything else.
Consent, deliberately expensive. This is the most invasive thing boost can propose — text
in a file the user reads every session, in every project. The body is printed in full
before the question, the target paths are named, the answer defaults to No, and the
reversal command is shown whether they accept or decline. BOOST_NO_RULE is the escape
hatch, checked before out.confirm — which is load-bearing, because confirm
returns True under BOOST_ASSUME_YES or a bare --yes anywhere in argv, and
the test fixtures set exactly that. Without the guard every existing register test, and every
provisioning script, would silently write a standing block into a real CLAUDE.md. The offer is
scoped to hosts where boost actually registered a server, so it never reaches Cursor or Windsurf,
where a block naming boost_search would advertise tools that agent does not have.
The bug this design exists to avoid. An earlier proposal made
registry.get("boost/builtin") return a Tap whose path
pointed inside the installed wheel. That sits one boost untap away from
registry.remove(), which ends in util.rmtree(tap.path) — deleting part
of the user's own package. Here the shipped .mdc is copied out of the wheel
into ~/.boost/repos/boost__builtin/ on first use; the worst case is a recreatable
directory going away. test_the_tap_path_is_never_inside_the_wheel is what catches a
refactor that reintroduces it.
And it must not answer a question it was not asked. mcp.no_results and
boost_doctor both decide "has this user configured anything yet" by counting taps and
print the one-command setup path when the answer is zero. boost's own tap is excluded from that
count via configured_tap_count(): a machine holding nothing but
boost-first has an effectively empty catalog, and suppressing the setup message there
would strand a new user with a search that can never match.
Known cost, stated rather than solved. At register time there is usually no repo yet, so
the install is user-scope — the rule then stands in every project, including ones where boost has
nothing to offer. store.install(scope="project") exists and would bound it; wiring
that to a per-repo offer is a separate card.
Complexity M
Impact High
Wow ★★★★
the tool descriptions only help on hosts that deliver them — this is the surface that survives when none do
ShippedInterop · Adoption
The MCP instructions understated what a search costs by 100x
Write-up
The nameable-task rewrite gave the MCP
server a stated cost, on the reasoning that an unknown-price call with an unknown hit
rate gets skipped. That reasoning still holds. The number was wrong: "Both are
read-only, take about a second, and install nothing." Measured against the real
path, boost_list is instant but boost_search runs
11.7–17.0 s (median ~12) against 0.10 s with the rerank
off — because rag.search defaults smart=True and the MCP
tool called it bare, so every agent search spends an LLM call while the CLI makes a
human ask with --smart.
Ship the cost, not a smaller one. The fix is not to hide the latency: the rerank
is the largest measured quality lever in the retrieval stack. On the 91-query golden set
it moves hit@1 from 0.791 to 0.945 — +14 net queries, against
the ~6-query floor for p<0.05 at this n. For scale, full-content
BM25 beat the old frontmatter search by +0.077, and dense retrieval tied BM25 exactly at
0.000. An agent acts on the top result rather than scanning ten, so it is the caller for
whom those seconds are most clearly worth paying. The instructions now separate the two
tools (boost_list instant, boost_search a few seconds) and say
what the seconds buy, because a wrong cost in the one paragraph whose job is to make the
tool worth reaching for discredits everything around it — and an agent that
budgeted a second gets a surprise instead of a decision.
The default is now written down. smart=True is passed explicitly at
the MCP call site with the measurement in a comment. It was previously inherited from
rag.search's signature, which meant the most expensive behaviour in the
tool was an accident nobody had chosen and nobody could find. Same asymmetry as before,
but now it is a decision someone can revisit.
What is still unmeasured: the lift is over raw BM25, the pipeline that
stopped shipping when RRF fusion landed. Whether it survives over the fused ranking
needs the arm that
step 6 is already committed to.
Shipped in #391 (6dc1600, “state what a search really costs,
and choose the default”). The card sat at inflight after the PR merged, which is
the failure mode a claimed item has: nothing re-checks a status once the work lands, so the board
kept advertising work that was already done.
Complexity S
Impact Medium
Wow ★★
the instructions claimed a second; it is twelve
ShippedCatalog · DX
install dead-ends on a registry that vendors its own skills
Write-up
Found by using boost, not by reading it. Installing
debugging-and-error-recovery failed with exists in multiple taps:
addyosmani/agent-skills, lingxling/awesome-skills-cn, lingxling/awesome-skills-cn,
lingxling/awesome-skills-cn — one tap named three times, under a heading that
says “multiple taps”. Following the hint made it worse, and this is the part that
matters: qualifying by tap re-raised the identical error, now hinting
lingxling/awesome-skills-cn:lingxling/awesome-skills-cn:debugging-and-error-recovery
— a string that can never resolve. The escape route from the error was the error.
Three defects behind one symptom. The message joined e["tap"] across matches
with no dedupe, so a repeated tap read as repeated registries. The hint re-qualified
name rather than the bare name, so an already-qualified input got a second prefix. And
“qualify it by tap” is not advice at all when every candidate shares a tap.
The cause is a registry convention, not a corrupt cache.
lingxling/awesome-skills-cn vendors its own skills into plugin bundles, so the same
skill exists at antigravity/skills/dbg, .../plugins/pack/skills/dbg and
.../plugins/pack-claude/skills/dbg — three paths, one identical
search_blob. boost was asking the user to choose between three rows that render
identically everywhere it displays them. That prompt is unanswerable by construction.
So it now chooses, but only where choosing is safe. When every candidate shares a tap
and is indistinguishable on name, description, version and frontmatter, boost resolves to
the shallowest path — the original, since vendored copies sit deeper by construction —
tie-broken lexicographically so the pick cannot drift with dict order between machines. The earlier
duplicate census is what makes this safe rather than merely convenient: of 29,938 entries, 78.3%
are byte-identical duplicates and zero content-clusters span more than one name, so
collapsing identical rows can never merge two genuinely different skills.
Two cases deliberately still refuse. Identical content in different taps stays an
error — two registries shipping the same text are still two supply chains, and
typosquat.py exists because that distinction is load-bearing. Same tap but genuinely
different rows also stays an error, since the user can tell them apart and the choice is theirs;
that case now names the conflicting paths, because the path is the only thing that
distinguishes them, instead of offering a tap qualifier that cannot help.
Nine tests, written before the fix. Five failed against the old resolver and the two
“still refuses” guards passed from the start, which is what showed the change was
additive rather than a loosening. Verified end to end against the real catalogue: the qualified
form now resolves to antigravity-awesome-skills/skills/debugging-and-error-recovery,
the top-level copy.
Complexity S
Impact Med
Wow ★★★★
found by dogfooding — the fix hint re-raised the error it was fixing
ShippedCLI · DX
boost completions completes command names and nothing else
Write-up
boost install <TAB> should offer skill names. It does not, and what it offers
instead is different in each shell. boost is a package manager, so completing an installable
name is the single highest-value completion it could ship — and it is the one that is wrong in
the most confusing way.
Measured against the generated scripts (79 commands, docs/commands.html as the
flag inventory):
all three shells complete 79 of 79 command names and 0 flags, and for
boost install <TAB> bash re-offers the 79 command names,
zsh offers local filenames, and fish offers nothing.
The command-name half genuinely works — all three emit exactly the 79 names in
cli.COMMANDS, verified equal as a set, and zsh and fish carry the one-line summaries.
Nothing below is a regression; it is scope that was never built.
Each shell fails structurally, not by accident. bash emits
complete -W "<79 names>" boost with no -F function, and a
-W wordlist is position-independent by definition — bash offers the same list at
argument 1 and argument 5, so after boost install it re-offers command names. zsh
guards on (( CURRENT == 2 )) and falls through to _files, which is why it
proposes the contents of your working directory where a skill name belongs — the most
misleading of the three, because it looks like a real answer. fish registers every completion under
__fish_use_subcommand, so it has nothing to say past the first word.
The flag surface is entirely absent. docs/commands.html documents 82 distinct
long flags; the completion scripts contain zero. boost search --<TAB>
completes nothing in all three shells.
The tests pass and prove the wrong thing, which is why this survived. Four tests in
TestCompletions assert the emitted text — that the wordlist equals
COMMANDS, that there are 79 zsh entries, that fish emits 79 lines. Every one is about
what the generator prints; none is about what a shell would propose. A test suite
can be green and thorough about the wrong layer.
Suggested shape: one completer in Python, three thin shims. Add a hidden
boost __complete <words> that takes the current argv and returns candidates, and
reduce each shell script to a delegation (-F for bash, _boost calling it
for zsh, a function for fish). That puts the logic in core/ where the mutation gate
reaches it and where it can be unit-tested in Python, instead of triplicating context rules across
three shell dialects that cannot share a test. It also lets candidates be dynamic:
installed skills for uninstall, catalog names for install and
info, tap names for untap, profiles for profile.
Two constraints worth stating before anyone starts. Completion runs on every keystroke-ish
TAB, so the candidate path must not pay catalog-scan cost — it needs the cached catalog, and a
budget (target <100 ms) measured rather than assumed. And __complete must never
fail loudly: a completer that prints a traceback into the user's prompt is worse than one that
returns nothing, so it should exit 0 with empty output on any error.
Shipped as core/complete.py plus a hidden boost __complete, with the
three shell scripts reduced to shims that call it. Verified by driving real bash and real
zsh rather than by asserting on emitted text — which is precisely the gap that let this
survive: boost install <TAB> now offers the catalogue,
boost uninstall <TAB> offers what is installed, boost untap <TAB>
offers configured taps, and boost search --<TAB> offers that command's own flags
(previously zero in every shell).
A measurement decided the architecture. Completion fires on a keystroke, and
catalog.all_entries() costs 423 ms for 71,655 entries — four times
over the <100 ms budget this card set, before interpreter start. A flat names cache answers
the same question in 1.9 ms, a 220× difference, and a test asserts the
completion path never calls all_entries() at all. Without that number the obvious
implementation would have shipped a TAB that visibly stalls.
Driving a real shell found a bug that no unit test would have. The zsh shim passed
${words[1,$CURRENT]} unquoted, and zsh drops an empty trailing word — so
boost install <TAB> arrived as two words and completed command names,
reproducing the exact defect this card exists to fix, in the new code. "${(@)...}"
preserves it. Bash never showed this because "${COMP_WORDS[@]:0:…}" already preserves
empty elements. A test now pins the quoting.
The layering got stricter, not looser. completions used to import
COMMANDS out of cli.py — the single upward edge the import-linter
contract allowlisted. The registry is now passed into core.complete as data, so the
allowlist entry is gone and boost_cli has no exceptions to its
cli → commands → core rule at all. __complete
is deliberately not a row in COMMANDS: that list generates
docs/commands.html, --help and the command counts, and plumbing belongs in
none of them — so the count stays 79.
The old tests were replaced, not extended. Four tests asserted that the bash wordlist equalled
COMMANDS and that zsh emitted 79 entries. All four passed for years while the feature
was broken, because each asked what the generator printed rather than what a shell would
propose. The replacements pin behaviour and the shim contract.
Complexity M
Impact High
Wow ★★★★
arguments and flags now complete; verified in real bash and zsh, not just asserted
ShippedCLI · Install
install refused an ambiguous name and offered no way to answer it
Write-up
resolve_one already gets this mostly right. Identical vendored copies collapse to the
shallowest path; genuinely different skills sharing a name inside one registry are
refused, on the sound reasoning that boost cannot pick between two real alternatives on
the user's behalf. The error even printed the candidate paths.
What it did not do is give anyone a way to answer it. The hint said "inspect the paths
above and raise it with the tap", and no syntax existed that could act on them — not
tap:name (same tap, so it re-raises the identical error), not
skills/name, not name@path. The user was told exactly what the choice
was and handed no way to make it. Every path out led back to the same message.
Found by cataloguing two registries where it made every item uninstallable.
DietrichGebert/ponytail ships each of its seven items twice — a canonical
skills/x and an .openclaw/skills/x mirror whose description
differs, which is precisely the "genuinely different, user must choose" branch.
JuliusBrussee/caveman is worse: caveman alone matches four paths. All
21 items across both were reachable by search and info, and
installable by nobody. Catalogued-but-uninstallable is the kind of gap a curation PR creates and
never notices, because the row looks correct.
boost install NAME --path P now filters the candidates. Three details carry the
weight:
An exact rel_dir beats a suffix match. Suffix matching exists so users can
paste a fragment rather than a long vendored prefix, but on its own it makes the canonical case
unresolvable: --path skills/ponytail also matches
.openclaw/skills/ponytail, so naming the exact path the error printed still reported
"ambiguous". Exact-wins fixes the shape the flag exists for. The suffix rule stays
segment-anchored — s/dbg must not be satisfied by not-s/dbg.
A --path that matches nothing is an error even when the name is unique. The
first cut skipped filtering unless there was an ambiguity to resolve, so a typo'd path silently
installed the single match — reporting success for the copy the user was trying to steer away
from. Filtering always turns a typo into a message that lists the real paths.
It cannot resolve a cross-tap collision, by construction. Two taps are two supply chains
and a path is not provenance, so narrowing runs before the existing multi-tap refusal and never
merges two of them. Typosquatting makes that distinction load-bearing, so it is pinned by a test
rather than left to the reading.
Traversal is impossible by design and pinned anyway: --path only filters rows
the catalog already holds and never builds a filesystem path, so
../../etc/passwd can only fail to match. The test exists so a future rewrite cannot
quietly turn the flag into a path constructor.
Complexity S
Impact High
Wow ★★★★
the ambiguity error named the paths and no flag could act on them — a dead end
In flightDX · Feature
boost discover <query> asks GitHub, instead of filtering whatever boost index happened to sample
What was wrong. boost discover react read as a question about GitHub and
was answered by a local cache — one built by boost index, which sampled
filename:SKILL.md with no query at all and kept whatever code search ranked
first. So the command could only ever return a subset of an untargeted draw, and its miss
message — "no indexed skills match 'react'" — read as a verdict on GitHub when it
was a verdict on that cache. The MCP tool boost_discover_github had reached
GitHub live the whole time; only the CLI did not.
What it does now. A query goes to GitHub. --local keeps the offline
path, a bare boost discover still browses the cache for free, and a failure
to reach GitHub falls back rather than erroring. boost index accepts a
positional query so a build can be aimed. Results collapse to one row per repository —
naming a repo worth tapping is the whole point, and code search returns a row per file, so
a registry that mirrors its skills into a directory per agent could otherwise fill the
page with itself.
Two error messages in resolve_one came along. The "closest matches"
hint de-duplicates, because that same mirroring made it render
mempalace, mempalace, mempalace — three of nothing — and it now qualifies with
the tap only when a name genuinely spans registries, which is exactly when the bare name
would not resolve either. And tap:path/to/skill now names the grammar error
instead of fuzzy-guessing: tap:skill picks a registry, so a
path-shaped tail is a misread of the syntax, and the hint hands back the
--path form that works.
An adversarial review of the first draft raised 28 findings and confirmed 21. Worth
recording because two of them would have shipped red, and several were the same shape as
the bug being fixed — a promise the code did not keep.
--limit was spent on the wrong unit. It is documented as "max rows",
and a row is a repo, but it was passed through as code search's per_page, so
it bounded files. Collapsing then shrank that further, and the second slice was
provably dead: len(rows) ≤ len(hits) ≤ per_page ≤ limit, so it could never
trim an element. Measured against a mock that honours per_page over a
200-file pool front-loaded the way code search actually ranks:
--limit 25 returned 1 row, --limit 50 returned 3.
The command now fetches the whole 100-hit page and slices to --limit
after collapsing, which is also what makes that second slice load-bearing.
--json destroyed the only signal that said which corpus answered. The
fallback warnings were suppressed under --json to keep stdout parseable, so a
script could not distinguish "GitHub has no matches" from "GitHub was never searched" —
and the two answers arrive in different row shapes. Fixed twice over: the notices
go to stderr, so stdout stays clean and the signal survives, and every row in both paths
carries source: github or source: local-index.
The fallback told users to drop a flag they never passed. A miss after falling back
printed "drop --local to search GitHub itself" directly under a warning
saying GitHub could not be reached — contradicting itself, and advising an action the user
could not take. The wording now depends on which of the two paths reached it.
GitHub-supplied text was rendered into a terminal table raw. A repo name or file
path is attacker-chosen; \x1b[1A\x1b[2K moves the cursor up a line and erases
it, so one crafted field can rewrite rows already on screen — including the row naming the
repo the user was about to tap. output.plain() now strips C0/C1 controls at
the point of display, and both the live and cached tables go through it.
Two CI-reddening misses the local run would not have caught.
docs/index.html carries its own copy of the COMMANDS list, gated
by test_docsite_chrome.py, so updating the summary in cli.py
alone fails the suite. And tests/bdd/features/discover.feature still assumed
a query filters the index: one scenario asserted a message that no longer exists, and two
more would have shelled out to real GitHub on any runner that ships
gh — a required check hostage to someone else's rate limit. Every local-index
scenario now pins gh as absent rather than trusting the runner.
And one test that proved only its own formatting. The --path hint was
asserted as a string, never run. Executing it is what revealed that it dropped the tap
qualifier the user had already typed correctly — so the suggested "fix" resolved in the
wrong registry, or failed as cross-tap ambiguous, for precisely the user who needed the
tap. The test now lifts the arguments back out of the hint and feeds them to
resolve_one.
Complexity M
Impact High
Wow ★★★★
an adversarial review of the first draft confirmed 21 defects, two of which would have reddened CI
ShippedFeature
Share the catalogue instead of making everyone re-tap it
Write-up
Tapping is the slowest thing a new install does, and almost none of the cost is the part anyone
needs. Measured on a real machine with 458 registries tapped: the shallow clones are
12 GB on disk, while the catalogue they produce — the JSON boost actually searches — is
101.1 MB, and 10.9 MB gzipped. Three orders of magnitude, for the artifact
that carries all of the value.
That gap is only worth acting on if the catalogue is genuinely sufficient on its own, so that was
checked before a line was written: a HOME holding one cache file and a config entry, no
clone anywhere, and boost search returned the right skill. The clone is what
install needs, not what search needs.
boost catalog — export, show, import. Verified end-to-end against the real
458-tap cache rather than a fixture:
--export packed 458 taps · 59,972 entries · 10.9 MB. On a brand-new
HOME reporting available 0 (across 0 taps), --import took
0.26 s, boost reindex a further 3.8 s, and
boost search "code review" then returned real ranked hits over all 59,972 items with
zero repositories cloned and 170 MB on disk. The whole path, cold, is under four seconds.
What the format deliberately omits, and why each one is a decision rather than an oversight.
The derived indexes (rag_index.json, rag_postings.sqlite,
rag_vectors.sqlite) are 3.8 GB of that machine's 3.9 GB cache
directory and rebuild from the catalogue in seconds, so shipping them would trade a 350x size
increase for four seconds. Vectors are excluded for a second, stronger reason: they are only
meaningful inside the embedding space that produced them, which is why
dense.export_shard carries provider/model/dim/commit and import_shard
refuses a mismatch outright. A bundle carries none of that, so it must not carry vectors either —
reindex --export-shard is the reviewed path, and this format stays honestly narrower
than it. The repositories are omitted because that is the point: every tap's URL rides in the
manifest so the receiver can clone the one repo it ends up wanting, instead of all 458 up front.
Import merges, and never replaces. The receiving machine may already have taps of its own,
and silently discarding them would be a worse outcome than the slow tap this exists to avoid.
Re-importing the same bundle is idempotent. A configured tap whose catalogue has not been built yet
is skipped and named on export rather than being fatal — taps build one at a time, and
refusing to export the other 457 because one is mid-build would break the feature exactly when it is
most useful.
A bundle is a file people send each other, which makes it untrusted input in the most ordinary
way there is. Tar member names are the classic path-traversal vector, so extraction never lets
the archive choose a destination: members must be regular files directly under
catalog/ with a plain .json basename, and the write path is then rebuilt
here from that basename — validating the name and discarding it, because a check that feeds
its own input forward is one refactor from being decorative. Symlink and hardlink members are
refused (that is how an archive escapes its tree with no .. anywhere in it), member
count and member size are capped against a decompression bomb, and a manifest declaring an unknown
format is refused rather than guessed at. Seven of the nineteen tests are that hostile
archive, and each asserts on where the bytes landed, not merely that the call raised — a
traversal that raises after writing the file has still written the file.
Complexity M
Impact High
Wow ★★★★★
10.9 MB replaces a 12 GB clone — a fresh machine reaches 59,972 searchable items in 4 seconds
In flightInterop · Adoption
boost-first carried the trigger that had already fired and lost — and could never be updated
Two defects, and the second is why the first survived. Reported from a live session:
the rule was installed, materialized into claude-code, and sitting in the agent's
context for the whole conversation — and the agent still built a subsystem, a generator script
and a test file without calling either boost tool.
What the rule actually said. Measured across boost's three agent-facing surfaces, by
phrase, against the evaluated constants rather than the source (these strings are concatenated
across literals, so grepping the file lies):
Each row is trigger · INSTRUCTIONS · boost_search
description · the rule:
the task has a name you could say out loud · yes · yes ·
NO.
touches more than one file · yes · no · NO.
something that outlives this session · yes · no · NO.
ask again when a small task turns out to be a large one · yes · no
· NO.
the lock-in list (new project or subsystem…) · no · yes ·
yes.
the defeater (one kind of three) · yes · yes · yes.
The rule carried exactly one trigger — and it is the one core/mcp.py already
documents as having failed. The forensics in that file are unambiguous: a Gemini CLI session
paraphrased "a new project or subsystem, an architecture decision, environment and tooling
config" back when asked, and had still skipped the call. #479 answered that two ways —
a defeater for the veto, and triggers that are properties of the request rather than a
judgement about work not yet done, because "deciding a task is non-trivial takes judgement while
'this turn looks small' is free, and every turn looks small when it opens."
The rule took the defeater and left the triggers behind. It shipped in #480, after #479,
carrying the losing half on its own. That matters more here than on any other surface: Gemini CLI
never delivers server instructions in interactive mode and starts no MCP servers at
all in an untrusted folder, so on the host where the failure was measured this file is the
only boost text in context — and it was the one surface with no observable trigger on it.
A natural experiment worth recording. In the reporting session two standing instructions
sat within a few lines of each other in the same global CLAUDE.md. The Snyk one was
followed; this one was not. Position did not distinguish them — trigger shape did. Snyk's fires on
a fact the agent can observe ("did I write code?") after writing, when it is already
reviewing. boost's asked it to classify a task's nature before starting, at the moment it
is least oriented. So the fix is not louder wording, and deliberately not: editing only a
description moves call rates >10x (EMNLP 2025), which is exactly why every boost surface stays
invitational and the coercion ban is test-pinned.
What it says now. The two request-readable signals, the cheapest name test, and the
re-entry clause, all worded as their siblings word them — plus one new clause, because the failure
mode was never a refused check but one that was never visibly considered: say which way it
went, name the call you made or the reason you skipped it. A disclosure obligation rather
than an order to search — naming the reason you skipped is a complete answer, and the skip list
stays exactly as wide as it was.
The second defect: none of that could have reached anyone.
ensure_tap() has exactly one caller — the boost mcp register offer — and
that offer never runs twice. So the copy under ~/.boost/repos/boost__builtin/ is
written once, at accept time, and never again. boost update then reached for git
against a directory that is not a clone (is_cloned is false, so it took the
clone branch and handed git the builtin:boost sentinel as a URL), the tap
landed in failures, and every downstream loop skips a tap that is not in
results.
Measured on a sandbox HOME holding the older rule, after both boost update and
boost sync: wheel NEW, tap copy OLD, CLAUDE.md OLD, GEMINI.md
OLD. A rule fixed in the wheel was unreachable on every machine that already had it — and
boost update additionally printed "1 of 1 taps could not be refreshed" on every run,
advising boost untap for a tap behaving exactly as designed.
The fix is one branch. A tap whose URL carries the builtin: scheme refreshes
by re-copying package data instead of pulling. Everything downstream already worked:
_update_materialized hashes file content rather than consulting a git HEAD,
so landing the tap in results is the whole change. Verified end to end — wheel, tap
copy, CLAUDE.md and GEMINI.md all move together, and the module docstring's claim that the rule
"tracks boost's version rather than drifting once written" is finally true of
boost update and not merely of a call nothing makes twice.
And a parity test, which is the part that outlives the wording. Six load-bearing phrases
are now pinned on both the rule and INSTRUCTIONS, failing in either direction. A
phrase added to one and not the other is precisely how the rule came to ship a trigger its
siblings had already retired.
One suspicion measured and dropped. store.source_dir_for requires a
SKILL.md, which suggested every installed rule was silently skipped by
boost update. Built a real git tap with a rule in it, changed it upstream, ran the
command: "refreshed rule driftrule v0.0.0 (source changed)". Rules from a git tap update
correctly — _update_materialized never calls that function. The bug was only ever
the builtin tap.
Complexity M
Impact High
Wow ★★★★
the rule shipped the one trigger boost had already measured as losing, and no revision of it could ever reach a machine that had installed it
In flightDX · Feature
boost serve becomes a searchable, faceted catalogue with a graph of the taps
What it was. A single dark table of the installed items and two JSON links.
On the machine this was measured on that is 147 rows out of 71,695 — the served page
could not show you 99.8% of the catalogue, could not search, and had no notion of a tag. The
one question a catalogue exists to answer, "what is out there and is any of it what I want",
was the one it could not take.
Search reuses the ranker boost already has. catalog.search is the scorer the
required eval gate floors at four metrics; the rows carry the same keys it reads, so
they pass straight through it. Writing a second scorer for this page would have made the
catalogue a third answer to a question boost search and the MCP surface already
answer — and the only one of the three nothing measures.
Tags are facets, and they are namespaced. kind:, topic:,
state:, tag:, tap:. The namespace is not decoration: filters
apply per namespace, and an unprefixed value makes a tap literally named skill
indistinguishable from the kind — which is the sort of collision a third-party registry gets to
choose for you. topic: comes from the curated taxonomy in
registries.json, which is decided from the names of the items a repo ships rather
than its README and is already pinned by
tests/unit/test_registry_categories.py; deriving a second one here would let the
page and boost registries disagree. Frontmatter tags: come through too,
and that field is third-party YAML — a list, a comma string and total junk are all common, so
tags: {a: 1} in one of hundreds of taps reads as "no tags" rather than blanking the
catalogue.
The graph tab draws taps, not items. A node per item is 71,695 nodes — unrenderable, and
it draws the one structure that is already a list two clicks away. A tap-level graph shows what a
table cannot: which registries carry the same things. code-reviewer ships from
thirteen different taps, and that overlap is the edge. Communities come from deterministic label
propagation (sorted iteration, ties on the lowest label) so the same catalogue always draws the
same picture — which is the property that makes the graph testable at all. A repeat inside
one tap is explicitly not an overlap: registries increasingly ship a copy per agent, so that is
the commonest shape in the catalogue and would otherwise bond every node to itself.
And the payload is graphify's actual format, not a lookalike. The first draft emitted
{nodes, edges, stats}, which resembles graphify's graph.json and is not
loadable by it: graphify writes NetworkX node-link JSON — directed,
multigraph, a graph-level attribute dict, nodes, and
links rather than edges, which is the one difference that breaks a
loader. Corrected after checking the real file rather than assuming, and verified by loading the
live 300-node payload with networkx.node_link_graph: Graph named 'boost
catalogue' with 300 nodes and 900 edges, graph attributes intact, 127 connected components.
So the tab that ships is one consumer and graphify, Gephi or a notebook are others.
That check also surfaced the thing the graph exists to show:
awesome-codex-skills and awesome-skills-cn share 867 item names,
and buildwithclaude and claude-code-subagents-collection share 720 —
invisible in any table, obvious as an edge.
Both caps are stated rather than silent. Measured: 300 nodes carry 5,181 overlaps and
55% of those are a single shared name, often a coincidence on a generic one. Everything drawn is a
hairball; the strongest 900 leave an average degree near six, which is a graph you can read. The
payload still reports the true totals (overlaps, taps,
dropped) and the tab prints them, because a cap you cannot see reads as "this is all
of it".
Two things measurement changed. Building the rows costs 0.54s and facetting them
0.12s at real scale — and the search box issues a request per keystroke, so the first draft
was unusable at exactly the catalogue size that makes the feature worth having. Both are now cached
behind a fingerprint of the tap caches and the lock file: boost install in
another terminal changes the installed column without touching a single catalog cache,
so watching only the caches would have served a stale answer that looked live.
No catalogue data is interpolated into the markup. Rows arrive over fetch.
Descriptions and names are third-party text from whatever repos the reader has tapped, and one
containing </script> closes an embedding block and turns the rest of the page
into markup it chose — the same class of defect as the 404 that echoed its request (#489). Not
embedding it removes the class rather than escaping around it, and it keeps the shell a constant
17.8 KB however large the catalogue gets.
And nothing is fetched from anywhere else. No CDN, no font, no remote script — pinned by a
test, because a catalogue that goes blank on a plane is not a local tool, and a page view that
tells a third party which port a developer's machine is serving on is not one either.
The old page's guarantees moved rather than went away. "A rule gets no raw-content link" is
now pinned on the two facts that enforce it — the row says which kind it is, and
/skill/<name> 404s for a rule whatever a client chooses to render.
Complexity L
Impact High
Wow ★★★★★
the old page listed only what was installed — 147 rows out of 71,695 — with no search, no tags and no way to see what a tap actually is
In flightBug
completions --install could delete the config between its own markers
Found by asking a plain question — is boost completions
idempotent? — and testing the answer rather than reading the docstring. For
every ordinary input it is: seven paths reach a fixed point on the second run,
and the managed block is replaced in place rather than appended. Three states
were not ordinary, and each is reachable from one hand-edit of a shell rc file.
A start marker with no end deleted user config. _merge_rc
paired the first start marker with the next end marker found anywhere
after it. With an orphan start above real config, run one appended a second
block, and run two matched the orphan start against that new block's end and
removed everything in between — the user's own lines — while printing
✓ wired boost completions into ~/.zshrc. It converged, to a file
with the user's aliases gone.
The inverse function already refused this exact input, ten lines below:
_strip_rc carried the comment "no end marker: malformed, leave
the file untouched." So the state was recognised and only the writer failed
to handle it. That asymmetry is the whole bug, and it is why this was worth
fixing rather than filing: the reasoning was already in the file.
Uninstall had the same hole from the mirror precondition.
_strip_rc was safe only while the orphan start was the sole marker.
Put a well-formed block below it and uninstall paired the orphan with that
block's end: an rc file of export A=1 plus four user lines came
back as export A=1 alone.
Two blocks made uninstall lie. It removed the first and left the second,
so boost reported removed while the shell went on sourcing
completions — the same shape as the sync defect in
#515, a command reporting success for work it declined to do.
The fix is one scan with the right invariant: a block must close before
the next one opens. Testing only for a missing end marker is the original bug in
a new place, which the first draft of this fix reproduced and a test caught.
Install now collapses to exactly one block, uninstall removes every block, and
both refuse an unclosed one by name instead of guessing. An orphan
end marker stays harmless and is pinned as such, so a later
"tighten the parser" change cannot start rejecting a file that works.
--dry-run makes it answerable before it is written.
boost completions --install --dry-run prints the exact +/- lines
and touches nothing, and it raises on the malformed file too — a validation that
only reports the safe cases is not a validation. Plan and apply are one code
path (plan_install → apply), so the preview cannot
disagree with the write, and a test pins that equality. Applying a no-op change
now also leaves the file's mtime alone rather than rewriting identical bytes.
Verified exhaustively, not just by example. A property test enumerates
every arrangement of up to four fragments — user line, stray start, stray end,
real block — and asserts three invariants over all 672 orderings: a user line
boost does not own is never lost, a successful uninstall never leaves a block,
and a refusal never modifies the file. Against the pre-fix code the same test
reports 220 violations (198 of the second, 22 of the first); against the
fix, zero. It runs in 0.38s.
That sweep also corrected its own first draft. Its initial invariant counted
every user line, and flagged 16 "failures" that were the managed block behaving
exactly as conda, nvm and rbenv do — content between the markers is boost's to
replace. The real invariant is about lines boost does not own, and the
reference parser has to resolve the orphan-start ambiguity the way the fix does
rather than the way the bug did, or it under-reports the data loss it exists to
catch.
An rc file is the user's own hand-written config and the worst file boost owns
an edit to, which is what moves this from tidy-up to High impact. The old tests
covered only the well-formed path — a double install leaving one marker — so
none of the three states had a failing test to find them.
Complexity S
Impact High
Wow ★★★★
two runs of a "no-op" command deleted the lines between two markers
In flightBug · UX
browse could not search for two words, and the fix reshaped the whole browser
The bug was two lines. space toggled multi-select, and the
printable range that fed the query started at 33 — one past space, deliberately.
So a space could never reach the filter, and code review was
unsearchable: you got whatever codereview matched, which is
nothing.
Fixing it forced a decision rather than a patch. Once space types, every
printable key must type — which took i (install) and
q (quit) with it. The keymap moved to what every other picker
uses: ↵ installs, ⇥ selects, esc quits.
Space earns its place by meaning something. The query tokenizes on
whitespace and every token must match, so typing more always narrows.
An any rule would make a second word widen the result set, which
reads as the filter breaking. tdd driven now finds
tdd-workflow with one token from the name and one from the
description.
The logic moved to core/browse.py, which is the part that
matters for the next change. A TUI whose rules live inside the draw loop
has no tests and no mutation coverage, because curses cannot be asserted on. A
layout integer can. Matching, pane geometry, the focus model and the detail
panel are now pure functions with 69 tests over them — and because the draw
helpers take put as a parameter, the frame itself renders into a
text grid, so "the box has four sides" is an assertion rather than a hope.
That immediately caught a real defect. The right-hand border never drew:
the clip bound was x < w - 1, which makes the last column
unwritable, so the frame shipped with three sides. Also caught: the column
divider's ┴ landing on top of the help text and rendering
esc quit as ┴sc quit.
What the browser looks like now. One framed surface — title rule, query,
scope radios, key hints — over a list pane and a detail pane divided by a rule.
The selected row highlights across its full width rather than by a marker
column. The detail pane carries what boost info would tell you
(kind, tap, path, file, installed state) plus the entire frontmatter,
scrollable with the arrows once → moves focus into it. Sorted keys,
no allowlist: an allowlist silently hides exactly the custom key a registry
author cared about.
Arrows cross pane boundaries instead of dead-ending, which is what makes
the query and the list feel like one surface: ↑ from the top row
lands on the query, ↓ comes back, → and
← cross into and out of the detail.
Narrow terminals drop the detail pane rather than crushing it. The first
thresholds (24/28) were guesses and looked it — at 58 columns they kept a
27-column list that ellipsised every description. Measured against the real row,
the minimums are 34 and 32, so 58 columns now gets one full-width readable list.
A second pass on the interface, from a screenshot of the real thing.
Rendering it against 60,047 live entries showed what synthetic fixtures could
not: six hues at once — cyan kinds, violet taps, pink matches, yellow
categories, green states — reading as confetti rather than as one surface. The
fix is Refactoring UI's first rule, that hierarchy comes from weight and colour
is added last for meaning only. The tiers are now bold / normal / dim, and the
palette is one accent (cyan) plus two semantics that earn their place: green for
installed, yellow for the curated star. Colour is layered on the tiers,
so a monochrome terminal keeps the whole hierarchy.
Three affordances were missing, and each was invisible rather than absent.
The detail pane could be focused and scrolled but said nothing about it — it now
brightens its border and captions itself details · ↑↓ scroll. The
match toggles could only be reached by ^T, so a control you could
see but not walk to read as decoration — the arrow ring now includes them, and
left/right pick one while they hold focus. And installing dropped you back to
the shell after the first pick, which is the wrong shape for a browser: it
installs in place now, with the detail pane reporting
◐ installing… → ● installed with the destination, or
✗ with the reason.
Installing in place introduced a race, and a flaky test caught it. Two
Tab-selected skills installed in parallel threads, and every
store.install does a read-modify-write of
.skill-lock.json — so both landed on disk and one vanished from the
lock. It passed alone and failed three runs in five in the suite. Installs are
serial now, drained by one worker so the draw loop still answers keys, and the
regression test asserts on overlap rather than on the outcome, because
the outcome was right two times in five.
The browser was also listing the same skill four times. Registries
render one skill into .claude/, .cursor/,
.gemini/ and a plugin root, and every copy got a row. Measured on
the real catalogue: 22,535 of 60,047 rows (37.5%) are duplicates of another
row, so the list is now 37,512.
Identity is the description, never the name — and that distinction is the
whole risk. code-reviewer appears 75 times with 42 distinct
descriptions, and rule 47 times with 47 distinct. Those are
different skills that happen to share a name, and collapsing them would hide
real results — the same mistake the eval scoring made once, crediting the ranker
with a compression that existed only in the scoring code.
Two passes, and their value is wildly different. Exact signature match
costs 34 ms and does 22,379 of the 22,535 collapses; the fuzzy pass at 95% costs
301 ms and merges the remaining 156. Nine times the cost for under half a
percent more. It stays because 95% is the stated contract and this runs once
when the browser opens rather than per keystroke — but the numbers are recorded
so whoever revisits it decides with data rather than a guess.
Nothing disappears silently. A collapsed row is badged
×5, the counter says how many are hidden, and ^D shows
them all again. A browser that quietly drops a third of the catalogue and
reports a smaller total is indistinguishable from one with a broken filter.
It also got faster than what it replaces. Searching the description as
well as the name is more text per entry, and the first cut cost 125 ms per
keystroke over a 71,700-entry catalogue — slower than the 80 ms draw poll,
so the browser fell behind a typist. Two fixes, both measured: hoist the
haystack into a per-scope index built once (125→112 ms), then replace the
all(ch in iter(hay)) subsequence test with one driven by
str.find, which scans in C instead of stepping a Python iterator
(112→32 ms, a 4.5x win on the inner loop, verified identical on every
input by a brute-force cross-check against the old implementation). The shipped
browser searched a shorter haystack and still cost 32–39 ms, so this is more
search for less time.
Complexity M
Impact High
Wow ★★★★
space was bound to select, so two words could never be searched for
In flightUX · Design
One design system across search and browse
The brief was "make the CLI look good", and the answer was a design pass,
not a paint job. Three design proposals (information-density, brand,
restraint) were judged into one spec with a doctrine budget: hierarchy from
weight, one accent, and exactly one gradient moment per screen.
Search rows now answer the install decision. A result was a meter, a
name and prose; it is now meter · ●installed · name · [kind] · tap ·
description · ★ curated, planned by a pure out.search_layout
with a stated drop order when narrow — tap first (provenance is the first
luxury), then prose shrinks, then the name cap tightens, then the kind column
goes. The meter's magnitude tint (cyan → violet → pink) was extracted to
out.meter_hue and named as the screen's one gradient moment; the
kind text comes from out.kind_label, the same source browse's
badges render, so the two surfaces can never disagree about what a workflow is
called. Every row is assembled by out.format_search_row — pure,
byte-stable under NO_COLOR, and property-tested to fit every
terminal from 40 columns up.
The browser spent its budget on the top rule. The gradient helpers had
sat unused in the draw path since the grayscale-first refactor; the top border
now runs cyan → violet → pink — on terminals that can render the real Aurora
hues. The 8/16-colour fallback keeps a single-hue rule, because cyan/magenta/
magenta reads as confetti, and monochrome keeps plain dim: the glyphs never
change, only their attributes.
Four absences became affordances. A zero-match filter drew nothing —
indistinguishable from a hung draw; it now says ○ no matches for '…' in
name with the keys that widen the net. Badges started wherever the name
ended and wandered per row; they sit in a right-aligned rail that drops
least-important-first (the ×N copies count last — nothing
disappears silently). A 70,000-row list had no scrollbar while the detail pane
did; they now share one geometry (browse.scrollbar). And installs
reported only inside the detail pane; the row's mark now cycles
◐ → ● / ✗ from the same glyph table the pane uses, with a session
chip in the bottom rule (✓ 2 installed) whose precedence is
failed > busy > ok.
The detail pane states the cost of Enter before it is pressed. An
installs line names what lands where, by kind — for a rule that is
each agent's context file, the file the user reads every session, which is
exactly why it earns the line.
Everything new is core logic under the mutation gate. Twelve pure
helpers (four in core/output, eight in core/browse)
with boundary, drop-order, precedence and partition tests; the draw layer only
places what they return. Dead code left with the change: the six-hue
_aurora_theme, a duplicate subsequence matcher, and an unused
fuzzy filter.
Complexity M
Impact High
Wow ★★★★
search rows learned kind/tap/installed with a stated drop order; browse got its gradient, an empty state, a badge rail, a list scrollbar and a session chip
In flightPerformance · MCP
The smart rerank pays the LLM again for a search it already answered
The MCP boost_search path passes smart=True on every
call and mcp-search-cost-was-understated measured it at 11.7-17.0 s —
nearly all of it the LLM rerank, paid again in full for a byte-identical
repeat of the previous search. Agents retry searches constantly (a session
restart, a re-planned task, a second agent asking the same question), so the
honest "10-15 seconds" cost doctrine was billing every ask at first-ask
prices.
Shipped: rag.rerank keeps a small FIFO cache
(~/.boost/cache/rerank_cache.json, 200 entries, registered in
paths.INTERNAL_CACHE_FILES so boost clean spares
it) keyed on a sha256 of exactly what the LLM sees — query, limit, and
the candidate listing — so it self-invalidates on any reindex, ranking drift,
or snippet change without inspecting why. Only the parsed name order is
stored; a hit replays through the same deterministic reorder as a live reply
and keeps the Claude relevance label, because it is the LLM's
ordering. Degrade replies are never cached. BOOST_NO_RERANK_CACHE=1
bypasses read and write — the Tier 2a eval sets it, so a graded rerank is
always live. Both MCP cost surfaces (server instructions and the
boost_search description) now state the repeat-search cost, pinned
by the same agreement test that keeps them from drifting apart. Sibling of
cold-search-reads-the-whole-catalogue, which took the retrieval half of
the same search from 0.94 s to 0.49 s.
Complexity S
Impact High
Wow ★★★
MCP boost_search measured 11.7-17 s per call — every call, even a repeat of the last one
In flightCLI · Output
The box drew 108 columns into an 80-column pane, and --help never asked how wide the pane was
Every one of the 80 commands was run, not just read. All 80 answered
--help with exit 0 and no traceback; ~73 were then driven for their real effect
against a disposable HOME — install, link, quarantine, focus, snapshot, replay,
cohort, hooks and the rest, checking the filesystem after each rather than trusting the success
line. boost focus tdd-workflow was confirmed by listing
~/.claude/skills and finding the other two skills genuinely unlinked with the
canonical store intact, and focus --clear by finding all three back. Seven commands
could not run here and are recorded as blocked rather than passed: serve (the
sandbox denies socket.bind), discover and index (no
gh auth), run live (no Agents SDK — --print was verified
instead), plus the interactive halves of browse, chat and
edit, each of which degraded with a correct hint.
A literal %% was reaching the terminal.
boost cohort printed sha256(user:cohort) %% 100 < rollout and its
--help epilog offered a 50%% rollout. Both strings carry printf
escaping and neither is ever %-formatted, so the escape survived to the screen. The
existing test asserted "membership = sha256(user:cohort)" in r.out — it stops one
character short of the defect, which is exactly how it survived. Four other %% in
the same file are correct: they sit inside real %-format calls, including the
Exec=%s %%u that a .desktop file requires.
panel() sized itself to its content and never asked the terminal. Measured:
boost count drew 108 columns into an 80-column pane. A box is the worst
thing to overflow, because the border wraps and the shape itself breaks. It now clamps to
term_width() - 4 — the four columns the border costs — and clips over-long content,
so every row stays the same width at any pane size.
The help screen was the one screen that never measured the pane.
search already adapts, dropping the tap column and truncating at 60 columns —
term_width() existed and discovery.py was its only caller. Meanwhile
boost --help, the first thing a new user sees, emitted a fixed 102-column
banner at every width. It now fits exactly at 60, 80 and 100. Command names are never
clipped, because the help's whole job is to be an index you can copy a command out of; summaries
and the tagline are. The version never is — it is what a bug report needs.
Two findings were measured and deliberately left for their own items. Eleven commands
still overflow 80 columns, but the remainder are prose hints, and the worst of them is one shared
string that six test files and a BDD feature pin by substring — wrapping it is a cross-cutting
output change, not a rider on this one. See long-hints-overflow-narrow-panes and
bm25-has-no-stemming.
Complexity S
Impact Med
Wow ★★★
an 80-command sweep found a box that drew wider than the pane, a help screen that never measured it, and a literal %% on screen
In flightCLI · Output
The hints still run past the pane, and the worst one is pinned by six test files
Measured, at COLUMNS=80, over 46 commands. After the box and the help screen
were fitted, 11 still emitted a line past 80 columns. Ranked:
doctor's semantic-search hint at 127; the AI-fallback notice at 101,
which is one shared string surfacing in six places; protocol's URL forms at 100;
changelog's unshallow hint at 93; who's footer at 87;
search's extra hint at 82. A re-measure while claiming this found a twelfth the
first pass missed — simulate's quoted trigger line at 97, which clips the
description to 100 characters, a length rather than a width.
Two of them must be left alone. pulse's source= paths and
fingerprint's hash are data, not chrome — clipping them destroys the information the
line exists to carry, and a hash broken across two lines cannot be compared by eye. So a blanket
“no line exceeds term_width()” assertion is the wrong gate: it would
false-positive on exactly the lines that should be long.
What shipped: a code span is one atomic token. out.wrap() is a greedy word
wrap in which a backticked span is a single unbreakable unit even though it contains spaces,
because the spans in these hints are shell commands the user is meant to select and paste. That
is the whole reason this is not two lines of textwrap: doctor and
search both interpolate dense.fix_hint(), whose answers end in
pip install 'boost-skill-cli[rag]', and a wrap that splits it hands the user a
command that does not run. A token wider than the pane is emitted whole and overflows — one long
line the terminal soft-wraps still yields the right text on a copy, which a bisected one does not.
search already wrapped, with a comment conceding it relied on a test that no
_FIX entry holds an over-long token; the span rule makes that structural. It also
wrapped to the full width and then let out.info indent by two, which is
precisely the 82.
Wrapping is opt-in per call site, and that is the design. warn,
info, dim and kv take wrap=True and each pays
for its own prefix — the marker, the indent, the key column — so continuations align under the
message rather than folding back to column zero. Always-on wrapping would have folded the two
data lines this item exists to protect.
Two claims in the first draft of this card did not survive measurement. It said the
101-column offender was pinned by six test files; only one pins it verbatim
(test_ai_fallback_note_verbatim, the mutant-killer, which wraps at the emission
site and so was never touched). The other five pin the substring
using the heuristic fallback, and are re-pinned against whitespace-collapsed output —
an idiom test_cli_discovery.py had already established, for this exact reason. The
BDD feature got a separate should contain wrapped step rather than collapsing inside
the output should contain, which eleven feature files use ~87 times.
Result: eight overflowing lines became two, and the two are the data lines. The gate splits
the commands by whose words are on the line — search, who and
protocol compose their whole output and are swept down to 40 columns; commands that
quote a skill's own rules are swept at 80 and wider, because reflowing someone else's prose is a
different decision from fitting boost's own hints.
Still open, found by the new gate rather than by this card. At 40 and 60 columns
doctor's ·-joined status summaries run to 71, and
protocol's try it: example is a single 59-column command that cannot
fold. Neither is in this item's scope — both are recorded here so the next pass does not
rediscover them as new.
Complexity M
Impact Low
Wow ★★
a code span is one atomic token, so a hint folds to the pane without splitting the command it tells you to run
NextMCP · UX
MCP has no way to read a skill before installing it
boost_info's MCP description promises “the whole picture of one skill, rule or
workflow by name — what it does, its kind, the tap it came from, its version, and whether it is
already installed”. Called on a real hit it returns five fields:
name · version · tap ·
description · installed
— where description is byte-identical to the one-liner
boost_search already returned. There is no body, no excerpt, no first paragraph. So
“what it does” is the same sentence the agent already had, and the tool adds only
installed: no, which the search reply already marks.
The consequence is that installing is the only way to read. An agent deciding whether a skill
is worth adopting has exactly one sentence to go on, written by whoever published it, and its only
route to the actual procedure is to install it into the user's real
~/.agents/skills and read it off disk. That inverts the tool's own pitch: the point of
boost_search costing 10–15 s of LLM rerank is that the top result is
“worth acting on rather than skimming”, and then nothing lets the agent look before it acts.
Why the one-liner is not enough, measured today. A single search for embedding work returned ten
hits, two of which — ai-cost-optimization and rag-patterns, both from
j4flmao/agent-skills — carry the description “To optimize **Skill**, we
enforce the following foundational rules:”. That is an unfilled template. Separately,
superpowers-lab installs to an 868-byte SKILL.md whose entire Instructions
section reads “This skill provides guidance and patterns for lab environment for claude
superpowers.” — the name echoed back in every section. Both indexed, both ranked, both
returned looking exactly like real results. The body is the only thing that separates a written
skill from a generated stub, and MCP cannot reach the body. Note the eval gate cannot catch this
either: golden.jsonl grades by name, and a stub matches its own name perfectly.
The fix is already written — it is just not exposed. Two CLI commands already do this, both
in the info module: boost cat (“Print a skill or rule's contents”,
cli.py:79) and boost explain (“Explain what a skill does in plain
English”, cli.py:82). The MCP server exposes six tools and neither is among them. And
core/mcp.py is built for exactly this: tools self-register on a Registry, so
adding one is “one register() call — no dispatcher edits, no server
changes” (mcp.py:7-10).
Three things to get right, none of them large.
(1) Prefer cat as the default. It is offline, deterministic and free;
explain goes through core/ai.py, costs seconds plus a key, and degrades to
heuristics without one. The cheap answer must not sit behind the expensive one's latency — that is
the same mistake boost_list avoids by never reading the catalog.
(2) Cap the bytes and say so when truncating. A SKILL.md can be large and an MCP
reply lands directly in an agent's context; returning a whole file unbounded is how one lookup costs a
session. The snip = text[:200] convention exists for the same reason.
(3) Keep it read-only. This is the tool that exists so an agent does not have to install
to look; it must not become a second install path.
And fix the description either way. If boost_info keeps returning five fields, it
should stop claiming the whole picture — an overstated tool description costs an agent a wasted
call and teaches it to distrust the rest of the surface.
Complexity S
Impact High
Wow ★★★
boost_info returns the same one line boost_search already gave