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.
First production tuning: evidence has to scale with prompt length. Within an hour of
release, the first real-world false positive arrived — a user pasted their own terminal session
back into the chat (~90 words of boost self-update output) and got a full delegation
banner, because \bupdate\b matches inside “self-update”. One weak
hit in a wall of text. Every existing trivial gate keys on shape — a question, a
slash command, too few words — and a pasted log is none of those, so none of them could
fire. The missing signal was density: in a short prompt every word is deliberate, so one keyword
is strong evidence; in a long one it is easily incidental, while a genuine long request names its
work more than once (“refactor … and update …”). Past
LONG_PROMPT_WORDS (60) the router now needs two distinct keyword hits. Verified
against the real paste and against 80- and 69-word genuine requests, which still route.
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
ShippedDX · Feature
boost discover <query> asks GitHub, instead of filtering whatever boost index happened to sample
Write-up
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
ShippedInterop · Adoption
boost-first carried the trigger that had already fired and lost — and could never be updated
Write-up
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
ShippedDX · Feature
boost serve becomes a searchable, faceted catalogue with a graph of the taps
Write-up
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
ShippedBug
completions --install could delete the config between its own markers
Write-up
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
ShippedBug · UX
browse could not search for two words, and the fix reshaped the whole browser
Write-up
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
ShippedUX · Design
One design system across search and browse
Write-up
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
ShippedPerformance · MCP
The smart rerank pays the LLM again for a search it already answered
Write-up
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
ShippedCLI · Output
The box drew 108 columns into an 80-column pane, and --help never asked how wide the pane was
Write-up
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
ShippedCLI · Output
The hints still run past the pane, and the worst one is pinned by six test files
Write-up
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
ShippedMCP · UX
MCP has no way to read a skill before installing it
Write-up
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.
Shipped as boost_read. It returns the item's own text with a two-line header
— name, kind where it is not a skill, install state, all decisions the Markdown cannot answer
— and reuses boost cat's resolution through info._resolve_text, so
installed copies, tap fallback, tap-qualified names, integrity enforcement and quarantine behave
identically. All three of the card's requirements landed: cat over explain
(offline, deterministic, free); a stated cap; read-only, with a test that fails if the handler ever
reaches store.install.
The cap is measured rather than round. Over the 63,053 items in a real 467-tap install the
distribution is median 6,063 bytes, p90 16,225, p99 35,275, max 567,484
— three orders of magnitude between the middle and the tail, which is what makes an unbounded
read a hazard rather than a theory. READ_LIMIT = 12000 delivers 80.3% of the
catalogue whole against 62.8% at 8,000, and caps the worst case near 3,000 tokens; 16,000 would buy
9.3 points for another third again. Truncation is announced where it happens, cuts on a line
boundary, reports both the cut and the true size, and names the command that returns the rest.
And the stub case was confirmed against the live catalogue, not predicted.
j4flmao/agent-skills:rag-patterns opens # Skill /
“To optimize **Skill**, we enforce the following foundational rules:” — a
template whose placeholder was never filled, 8,934 bytes of it, indexed and ranked like any written
procedure. Its one-liner gives no sign. That is the gap the body closes.
Complexity S
Impact High
Wow ★★★
shipped — boost_read returns the body; boost_info now says what it returns
ShippedCLI · Bug
clean counts failed removals as cleaned, journals the inflated count, and exits 0
Write-up
With 5 surplus lock-history files in a directory chmod'd 555 so unlink fails, boost clean prints five “! could not remove …: [Errno 13] Permission denied” warnings and then “✓ cleaned 6 item(s) · 15B freed”, exit 0 — only 1 item was actually removed. Run it again: “✓ cleaned 5 item(s) · 0B freed”, exit 0, nothing removed, files still present. The rerun repeating “cleaned 5” is the proof the count is fiction.
The mechanism is boost_cli/commands/configuration.py:189-206: the except OSError branch warns and continues, but the summary prints len(items) and the function unconditionally returns 0. Two verified aggravations: the “! could not remove” warnings go to stdout rather than stderr, and journal.log records the same inflated “N items” count (configuration.py:204) — so both the human and the audit trail are told the machine is clean when it is not.
Fix per the verified recommendation: increment a removed counter only after a successful unlink/rmtree, print “cleaned N item(s), M failed” when failures exist, log the real count to the journal, and return 1 when any removal failed. No docs change beyond regenerating docs/commands.html if the summary wording moves into help text (behaviour-only otherwise).
Found by the 2026-08 CLI audit (cluster clean-failure-accounting); repro in the audit log. Verified 2026-08-31: reproduced, high confidence — a wrong count plus exit 0 on failure are both on the audit contract's high-severity list.
Complexity S
Impact High
Wow ★★
5 failed removals still print "✓ cleaned 6 item(s)", journal the lie, and exit 0
PlannedCLI · Bug
config/policy set store type-unchecked values; consumers crash exit 70 and pin_only no freezes installs
Both setters accept any value for any known key and the damage lands later, elsewhere. policy set max_skills abc → “✓ set max_skills = "abc"” exit 0; the next install brainstorming → “Error: boost hit an unexpected error: ValueError: invalid literal for int() with base 10: 'abc'” exit 70 plus a crash report. config set serve.port abc then crashes even serve --help with the same ValueError — before argparse runs. policy set blocked_skills 42 → “TypeError: argument of type 'int' is not iterable” from policy check and install.
The nastiest shape is silent inversion, not a crash: policy set pin_only no stores the string "no", which is truthy — policy check reports “pin-only mode is on — installs/updates are frozen” and install refuses with “environment is pin-only (frozen)”. Same for yes, off, 0, and every boolean policy key (require_version, require_signed_taps, …). Verification narrowed the claim honestly: key names are validated (policy set nonsense_key x exits 1 with the key list) — the gap is value types only, and DEFAULTS tables already exist in both modules to derive them from.
Fix per the verified recommendation: in _parse_policy_value (boost_cli/commands/configuration.py:382-390) and config.set_value (boost_cli/core/config.py:246-258), derive a per-key type from policy.DEFAULTS (boost_cli/core/policy.py:15-34) / config.DEFAULTS: map bools from {true,yes,on,1}/{false,no,off,0} case-insensitively, ints via int(), lists via json.loads, treat max_skills as int|None, and reject mismatches with a BoostError naming the expected type. Wrap the consumers (cmd_serve's port read, policy.load) so a hand-edited bad value is a framed error with a hint, never a traceback. Regenerate docs/commands.html if the help epilogs gain the valid-value lists.
Found by the 2026-08 CLI audit (cluster config-policy-set-validation); repro in the audit log. Verified 2026-08-31: reproduced end to end, including the pin_only inversion and the exit-70 consumers.
Complexity M
Impact High
Wow ★★
`policy set pin_only no` stores the truthy string "no" — pin-only ON, installs frozen
PlannedSafety · Bug
Corrupt settings/config/state JSON silently read as empty, then clobbered on the next write
One shared load pattern across four surfaces: a JSON file that exists but does not parse is read as {} (except (JSONDecodeError, OSError) at boost_cli/core/claude_settings.py:74-75 and boost_cli/core/config.py:129/196-203/246-250), and the next write replaces it. Verified worst case: with a trailing comma in ~/.claude/settings.json, hooks add SessionStart … prints “✓ added SessionStart hook” exit 0 and the file afterwards holds only {"hooks": …} — the user's permissions and model keys are gone, no warning. With a corrupt ~/.boost/config.json, config list silently prints the defaults, and config set ai.enabled true rewrites the file to defaults-plus-that-key — a 20-tap list unrecoverable, no backup. context status/context map and focus --status do the same to state/context.json/focus.json; a corrupt profile is invisible to profile list yet profile delete broken refuses with exit 1, because the existence check parses the file before deleting.
Verification narrowed the hooks half: claude_settings.save already snapshots the prior file into ~/.boost/state/claude-settings-history/ before every write (confirmed byte-for-byte), so that path is recoverable — but silently, and nothing tells the user. config.json has no such net: that loss is real. Degrading a corrupt file to defaults on read is arguably deliberate (the config.py comment says so); silently overwriting it on the next write is documented nowhere and destroys user data. The roadmap's atomic-lock-file-writes item covers the lock file only — related pattern, not a duplicate.
Fix centrally, per the verified recommendation: when a JSON state file exists but fails to parse, warn on read naming the file and the JSON error, and before any save either refuse with a BoostError and a fix-it hint or move the bad file to <name>.corrupt and say so. hooks add should print the claude-settings-history snapshot path it already writes; profile delete should check _profile_path(name).exists() instead of parsing; profile list should show an (unreadable) marker. Docs: note the corrupt-file behaviour in docs/DEBUGGING.md (config/logging section).
Found by the 2026-08 CLI audit (cluster corrupt-json-clobbered); repro in the audit log. Verified 2026-08-31: all four surfaces reproduced.
Complexity M
Impact High
Wow ★★
a trailing comma in settings.json costs the permissions/model block on the next hooks add
PlannedCLI · Bug
Five exit-70 crashes on bad paths/data: catalog --export, serve --port, count, replay, infer -o
Five user-reachable conditions escape their guards and become “Error: boost hit an unexpected error: …” + “a crash report was written to …”, exit 70, where a framed BoostError belongs — the same class as the shipped two-crashes-that-should-have-been-messages item, five new sites, all verified. catalog --export /nonexistent-dir-zz/b.tgz → PermissionError (the mkdir at boost_cli/core/catalogbundle.py:119 sits before the except OSError at :127). serve --port 99999 (or -1) → “OverflowError: bind(): port must be 0-65535.” (serve.py:931 catches OSError, and OverflowError is not one). count with a list-shaped cache/discovery.json → “AttributeError: 'list' object has no attribute 'get'” (commands/discovery.py:1839-1841 guards JSONDecodeError/OSError only — and the verifier's probe {"items": 3} also crashes with a TypeError, so both container levels need validating). replay show/rollback on an unparseable snapshot → JSONDecodeError from lockfile.py:254, while replay list silently skips the file (:226-233) so the user cannot see why an id vanished. infer -o /dev/null/nope/SKILL.md → NotADirectoryError from the unguarded mkdir/write in commands/intelligence.py:128-136.
Why it matters: each is an expected condition — a typo'd path, an out-of-range flag, one stale derived cache file — and each currently costs a crash log and exit 70. count is the quick always-safe summary and must not crash on one derived cache; a corrupt discovery.json that is invalid JSON already degrades correctly to discovery: null, so valid-JSON-wrong-shape crashing is pure guard-shape accident.
Fix as one hardening sweep, per the verified recommendation: move the mkdir inside catalogbundle's OSError guard; validate --port in argparse (an ArgumentTypeError converter, exit 2) or catch OverflowError beside OSError in serve_http; in cmd_count check isinstance(data, dict) and isinstance(items, list) → discovery None; wrap history_read's json.loads in a BoostError (“lock history entry … is unreadable”) and have replay list print one dim “N unreadable snapshot(s) skipped” line; wrap _write_generated's mkdir/write_text in except OSError → BoostError. Unit-test each site. Docs: regenerate docs/commands.html (no flag/summary change).
Found by the 2026-08 CLI audit (cluster crashes-should-be-messages); repro in the audit log. Verified 2026-08-31: all five reproduced with exit 70 and a crash report.
Complexity M
Impact High
Wow ★
five user-reachable bad paths/values end in exit 70 + a crash report instead of one line
ShippedCLI · Bug
distill's heuristic merge drops repeated ``` fences/braces, writing a structurally corrupt SKILL.md
Write-up
_distill_merge keeps a global seen set of every stripped non-blank line
and drops all repeats — so structural lines (closing ``` fences, },
});, --- rules, table separators) vanish after their first occurrence.
Verified: distill brainstorming test-driven-development on the heuristic path wrote a
SKILL.md with 4 fence lines (one bare ```, then unclosed ```dot /
```typescript / ```bash) where the TDD source alone has 56; the
"NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST" block never closes and the rest of the skill
renders inside an unterminated code block. Exit 0 — and the CLI then offers the corrupt output to
boost import.
The docstring says "dedupe exact-duplicate body lines"; the intent is dedupe, but corrupting
Markdown structure is not a defensible reading of it. A guard exists only for the degenerate case:
distill X X refuses ("needs at least two distinct skills"), so the corruption hits
exactly the normal two-distinct-skills invocation.
Fix, localized to boost_cli/commands/intelligence.py:225-237: track fenced-block state
and never dedupe inside a fence, and whitelist pure-syntax lines (```,
---, }, );, table rules) from the seen-set. Add a unit test
asserting the merged body has balanced fences. Docs: refresh
docs/carousel/tapes/distill.tape if the demo output changes; no
docs/commands.html summary/flag change.
Found by the 2026-08 CLI audit (cluster distill-merge-corruption); repro in the audit
log.
Complexity S
Impact High
Wow ★★
merged SKILL.md keeps 4 fence lines where one source alone had 56 — blocks never close
PlannedSafety · Bug
create/distill/infer/absorb --install silently replace an installed (unpinned) skill and flip its lock provenance to local
The generated-skill install path never checks whether the name is already taken. Verified: after
install brainstorming (sickn33 tap), create brainstorming --install
printed "✓ installed brainstorming → ~/.agents/skills/brainstorming" and the
store SKILL.md became the TODO scaffold (description: "TODO: describe when this skill should
trigger"), lock now tap=local v0.1.0. distill --install -o
brainstorming … likewise: lock tap=local v1.0.0 with
source_dir pointing at a deleted /tmp/…/boost-gen-…
tempdir. Exit 0, no warning either time, and repeating any --install run re-copies
over the store with no notice. Only a pinned skill is protected — the unpinned default is
silently destroyed, with provenance flipped so the loss is invisible to list.
The gate belongs in the callers, not the store: store.install_from_path's docstring
(store.py:1175-1177) deliberately omits the already-installed refusal because it
serves re-import/reinstall. Neither _install_generated
(intelligence.py:103-123) nor cmd_create
(configuration.py:370-371) checks lockfile.get_skill.
Fix: in _install_generated and cmd_create, check
lockfile.get_skill(name) before store.install_from_path: refuse with a
hint ("already installed from <tap>; boost uninstall first, pass
--force, or pick another name") or out.confirm; print "replaced" rather
than "installed" when overwriting is confirmed. Keep install_from_path itself
unchanged. Regenerate docs/commands.html if a --force flag is added to
create/distill/infer/absorb.
Found by the 2026-08 CLI audit (cluster generated-install-overwrites); repro in the
audit log.
Complexity S
Impact High
Wow ★★
a tap skill becomes the TODO scaffold, lock flips to local — and the message says installed
ShippedCLI · Bug
hooks remove -n cannot find hooks boost itself added (unknown events skipped, embedded # boost: mangles the name)
Write-up
Two ways a boost-added hook becomes unremovable by its own name. First, unknown events:
hooks add Bogus -c 'echo bogus' -n b1 warns and adds, but hooks remove -n
b1 prints "! no boost hook named 'b1' in project scope" — exit 0, hook stays —
because with no event given _remove iterates hookhost.events(host)
(hooks.py:147-148), so an event that add accepted-with-warning is never visited. The
hook is removable by naming the event positionally (hooks remove Bogus -n b1),
but nothing says so and the error text is false. Same for a mis-cased sessionstart
added on gemini.
Second, embedded markers: add PostToolUse -c 'echo x # boost:zzz' -n h9 stores
echo x # boost:zzz # boost:h9, and claude_settings._hook_name splits on
the first marker (claude_settings.py:119-123, 253-260) — so
hooks list shows name zzz # boost:h9 and command echo x, and
remove -n h9 fails, exit 0. Both leave settings.json in a state boost wrote but cannot
remove by name, and the failed remove reports success to scripts.
Fix: in hooks._remove iterate the events actually present in load(scope,
host=host).get('hooks', {}) when no event is given, and exit non-zero on "no boost hook
named"; in core/claude_settings.py use rsplit(MARKER, 1) in
_hook_name and the list display (or refuse marker-bearing commands in
add_hook). Unit tests for both. No docs/commands.html summary/flag change
unless behaviour text is reworded — regenerate if so.
Found by the 2026-08 CLI audit (cluster hooks-remove-name-miss); repro in the audit
log.
Complexity S
Impact High
Wow ★★
remove -n says "no boost hook named" about a hook boost wrote, and exits 0
PlannedCLI · Bug
install --dry-run promises agents the real install never writes (antigravity-cli copy, antigravity materialize) and omits the MCP plan
Three dry-run plans diverge from what install actually does. Project scope: install
brainstorming --local --dry-run prints five "copy →" lines including an
invented …/antigravity-cli/skills/brainstorming at the repo root; the real
install reports "✓ copied into this repo → claude-code · windsurf ·
cursor · gemini" and find shows four skills dirs, no
antigravity-cli/. Rules/workflows: the dry run says "materialize → …
gemini · antigravity", the real install materializes four (Antigravity reads GEMINI.md
via the gemini agent). And no dry run at either scope mentions MCP, though the real user-scope
install prompts "register 1 server with Claude Code · Antigravity CLI …?"
and the real --local run writes .mcp.json.
Verified: the user-scope skill dry run is correct — the shipped
dry-run-promised-a-link-nobody-makes fix covered it — so the same class persisted
unshipped in the project-skill branch, the rule/workflow branch, and the missing MCP plan line.
The cause is which table each branch reads: pkg.py:435 and
pkg.py:419/452 iterate agents.enabled_agents(), while the real
installers iterate agents.agents_for_scope (store.py:627) and
agents.materializing_agents (store.py:863) — both helpers already exist
(agents.py:82-100).
Fix: derive dry-run targets from the installers' own tables — agents.agents_for_scope(pbase)
at pkg.py:435, agents.materializing_agents for the rule/workflow list at
pkg.py:419/452 — and add one mcp plan line per declared server (via
mcpdecl on the entry's SKILL.md + sidecar) naming the scope-specific action: offer
<host> mcp add at user scope, record → .mcp.json at project
scope. Docs: note in docs/roadmap/items/dry-run-promised-a-link-nobody-makes.md that
the class persisted at project scope and for rules/workflows; no docs/commands.html
flag change.
Found by the 2026-08 CLI audit (cluster install-dry-run-plan-gaps); repro in the audit
log.
Complexity S
Impact High
Wow ★★
dry run invents an antigravity-cli/skills copy and never mentions the .mcp.json write
PlannedCLI · Bug
Rules/workflows reported as 'skills': uninstall claims a store dir that never existed
All three kinds install by design, but five commands describe every kind as a skill — and
uninstall goes further, narrating actions that never happened. Uninstalling the
rule benchmarking prints “✓ unlinked ← claude-code ·
windsurf · cursor · gemini” / “✓ removed
~/.agents/skills/benchmarking” / “Uninstalled 1 skill” — verified live
with the store dir empty: no such directory ever existed and nothing was unlinked; CLAUDE.md blocks and
rule files were deleted. The install side had said “Installed 1 new rule”, so the
tool contradicts itself across one round trip.
The same kind-blindness runs through the rest. bundle install resolves a
skill dotnet-build Boostfile line to a rule, edits CLAUDE.md/GEMINI.md, and reports
“Installed 1 skill” — then bundle dump says “1 rule not
captured — Boostfiles carry skills only”, so a dump/install round trip does not agree
with itself. taps puts 11 rules under a SKILLS header and footers
“20 taps · 10152 skills” (3,000+ of those are not skills; the JSON key is
"skills"), although cmd_tap deliberately prints items for exactly
this reason (taps.py:145-147). trending lists a rule and a workflow beside a
skill with no kind marker. And protocol open boost://tap/… prints
“tapped pbakaus/impeccable (42 skills)” (team.py:430) for a cache
holding 17 skills + 25 workflows, where boost tap prints “(42 items)”
for the same count.
Verified fix, one sweep: have store.uninstall return the kind and branch
pkg.py:550-562 on it (skip the store-dir line and say “removed from” rather
than “unlinked” for rules/workflows, pluralise per kind as cmd_reinstall
already does); count _bundle_install by entry['kind'] and make
dump and install agree; rename taps' column/footer/JSON to items (keep
skills as a deprecated JSON alias); change team.py:430 to
(%d items); add a kind column to trending and fix its COMMANDS summary in
cli.py:73. Docs: regenerate docs/commands.html (trending's summary changes in
COMMANDS), and update README.md and docs/index.html where they show taps/bundle output.
Found by the 2026-08 CLI audit (cluster kinds-reported-as-skills); repro in the audit log.
Complexity M
Impact High
Wow ★★
rule uninstall prints 'removed ~/.agents/skills/…' for a dir that never existed
PlannedCLI · Bug
install --local writes the repo's .mcp.json silently — the 'recorded N servers' report never runs
Installing a skill that declares an MCP server with --local prints
“✓ copied into this repo → claude-code · windsurf · cursor ·
gemini / ✓ project lock updated (.boost/skill-lock.json) / commit .boost/ to share these with
the team” — and nothing about MCP. Yet the repo's .mcp.json now contains
mcpServers.demo-echo {command: npx, args, env, x-boost-skill}. Verified live, both
directions: uninstall --local empties it to {"mcpServers": {}} with equally
zero output. A file the user is explicitly told to commit is edited without a word, and the commit
hint doesn't name it.
The report exists and is dead code. _offer_mcp's project branch — the
“recorded N MCP servers in .mcp.json” lines at pkg.py:137-157 — is
only called at pkg.py:259, but _report_result's
SCOPE_PROJECT branch returns early at pkg.py:243, so the write
(store.py:711 register_project_mcp, emptied at :740) always
happens unreported. The shipped roadmap card mcp-servers-ignore-install-scope presents
this report as working.
Verified fix, one call-ordering change: in _report_result, call
_offer_mcp(res, no_mcp=no_mcp) before the early return in the
res.scope == SCOPE_PROJECT branch (pkg.py:243); add a functional test
asserting the “recorded 1 MCP server in .mcp.json” line for a
--local install of a skill with a .mcp.json sidecar, plus a line on the
uninstall side. Docs: fix docs/roadmap/items/mcp-servers-ignore-install-scope.md, which
describes the recorded-report as reachable; no flag change, so docs/commands.html only
needs regenerating if a summary moves. Found by the 2026-08 CLI audit (cluster
mcp-project-scope-report); repro in the audit log.
Complexity S
Impact High
Wow ★★
a file the user is told to commit is written and emptied with zero output
PlannedSafety · Bug
mcp register's boost-first consent names one file but writes every agent
After a real Gemini registration (mcp register --host gemini --no-seed, Gemini CLI on
PATH), the boost-first offer prints “boost can also add its own rule, `boost-first`, to your
agents' standing instructions:” followed by one path,
$HOME/.gemini/GEMINI.md. Then “✓ installed boost-first” — and
on disk: ~/.gemini/GEMINI.md, ~/.claude/CLAUDE.md (managed block verified),
~/.cursor/rules/boost-first.mdc, ~/.windsurf/rules/boost-first.md; lock
materializations [claude-code, windsurf, cursor, gemini]. Verified live. A rule edits a
file the user reads every session — CLAUDE.md's own rule calls it more invasive than a skill — and
here the write reaches Cursor and Windsurf, which the code comment and the shipped
boost-first-rule roadmap card explicitly promise it never reaches. Under
BOOST_ASSUME_YES=1 no question is even shown: the env var flips a default-No consent.
The cause is one missing argument: _offer_boost_first
(configuration.py:1611-1671) prints targets filtered by AGENT_FOR_HOST but
calls store.install() with no only_agents (line 1666), so
_install_rule (store.py:820+) materialises into all enabled agents —
although store.install already accepts only_agents
(store.py:524).
Verified fix: pass the printed scope into the install —
store.install(…, only_agents=[AGENT_FOR_HOST[h] for h in hosts if
AGENT_FOR_HOST.get(h)]) — so the write matches the named targets, and add a test pinning
consent-list == lock materializations. Separately, consider whether BOOST_ASSUME_YES
should flip this default-No consent (BOOST_NO_RULE is currently the only guard). Docs:
docs/roadmap/items/boost-first-rule.md, README.md and docs/index.html where
they describe the offer's scope. Found by the 2026-08 CLI audit (cluster
mcp-rule-consent-scope); repro in the audit log.
Complexity S
Impact High
Wow ★★
consent screen prints 1 path; disk gains 4 files across claude/gemini/cursor/windsurf
In flightCLI · Bug
catalog.resolve_one's duplicate-name hint advertises --path to commands that reject it
When a name matches two skills inside one tap, every catalog.resolve_one caller prints
the same hint: “that registry ships one name twice — pick one with
--path <one of the above>”. Only install,
recommend and infer actually take --path. Verified live:
boost adapt ultrawork --to agents-sdk prints the hint, and
adapt ultrawork --to crewai --path .agents/workflows answers
“Error: unrecognized arguments: --path .agents/workflows”, exit 2 — same for
run --print, log, home and explain, while
install ultrawork --path .agents/workflows --dry-run works. A bonus wrinkle:
csharp-reviewer is a workflow, and the message calls the matches
“skills”.
Why it matters: this is the CLI contradicting itself — the error's one actionable line produces a
usage error when followed, so a duplicate-name skill in an uninstalled tap cannot be adapted, run or
read at all through those commands. The shipped install-path-disambiguation item added
--path to install only; the shared hint at
boost_cli/core/catalog.py:555-556 was never parametrised by caller, so this is residual
scope of that work, not a duplicate.
Fix (verified recommendation): let catalog.resolve_one take the calling command's
disambiguation option (or None) — callers without --path get a hint
that works for them, e.g. read one copy with boost cat or
install with boost install NAME --path …; alternatively add
--path passthrough to adapt/run/log/home/explain/cat
(mirroring cmd_install, see boost_cli/commands/pkg.py:1711-1723). Use the
entry's kind in the message so a workflow is called one. Docs: regenerate
docs/commands.html if --path is added to any command; update
docs/adapters.html. Found by the 2026-08 CLI audit (cluster
path-hint-unactionable); repro in the audit log.
Complexity S
Impact High
Wow ★
Following the CLI's own hint earns `unrecognized arguments: --path`, exit 2
PlannedCLI · Bug
adapt/run/stats/edit/tag/export reject the tap:name qualifier that info/install accept and adapt's own hint recommends
boost adapt test-driven-development --to crewai answers “exists in multiple
taps … hint: qualify it, e.g.
NeoLabHQ/context-engineering-kit:test-driven-development” — and typing
exactly that string answers “Error: invalid skill name
'NeoLabHQ/context-engineering-kit:test-driven-development'”, exit 1. Same for
run --print. info with the identical string prints the full card, exit 0.
Two more shapes of the same defect, both verified: stats given the bare ambiguous name
silently picks the first tap and reports its version/upstream as if unambiguous (where
info refuses and asks to qualify), and given the qualifier says “invalid skill
name” with no hint; edit, tag and export pass the whole
qualified string to the lock lookup and answer “is not installed” for a skill
that is installed from that very tap (explain/log/home accept it).
The verification found two rejection mechanisms behind one symptom:
adapt/run/stats hit store.skill_store_dir's
“invalid skill name” (boost_cli/core/store.py:63-69) before
catalog.resolve_one is ever reached, while
edit/tag/export hand the unsplit string to the lock. The shipped
info-rejects-the-qualified-name-it-recommends item fixed info/install
only; these six are residual scope, and the ambiguity hint they emit is now actively wrong for them.
Fix (verified recommendation): in pkg._resolve_skill
(boost_cli/commands/pkg.py:1683-1701), probe store.skill_store_dir only when
the name is a safe bare component, else fall through to catalog.resolve_one, which already
parses tap:skill. In cmd_edit/cmd_tag/cmd_export/cmd_stats,
split with catalog.split_name, look the bare name up in the lock and check the tap
matches. Route all six through one shared resolver helper so the next command cannot regress alone.
Docs: docs/adapters.html; regenerate docs/commands.html if usage lines change
(e.g. stats' positional becoming “skill name or owner/repo:name”).
Found by the 2026-08 CLI audit (cluster qualifier-rejected-elsewhere); repro in the audit log.
Complexity M
Impact High
Wow ★
adapt's hint says type tap:name; typing exactly that string is "invalid skill name"
PlannedSafety · Bug
tap --dry-run is silently ignored outside --catalog: SPEC and --defaults clone for real
On a pristine HOME, boost tap --dry-run expo/skills printed ✓ Tapped expo/skills (26 items), exit 0 in 1.56 s — and the clone exists and config.json gained the row. tap --defaults --dry-run cloned five registries (✓ tapped trailofbits/skills (124 items) …) and grew the config from 21 to 26 taps. The multi-spec path is the same: tap --dry-run expo/skills anthropics/skills answered with already tapped lines, proving registry code ran. Help does say “with --catalog: print what would be tapped, tap nothing” — documented-narrow, but a flag literally named --dry-run that clones repos and writes config is a safety hole regardless of wording.
The source confirms the shape: taps.py consults args.dry_run only inside _tap_catalog (boost_cli/commands/taps.py:38); cmd_tap's defaults, single-spec and multi-spec branches (taps.py:144-166) never read it. Not a duplicate of the roadmap's dry-run-promised-a-link-nobody-makes, which is about install --dry-run.
Fix, per the verified recommendation: in cmd_tap, when args.dry_run and (args.spec or args.defaults), print the parsed (name, url) per spec — or the DEFAULT_TAPS list — and return 0 before registry.add/_tap_all; update the --dry-run help text; add a unit test that --dry-run SPEC writes nothing. Docs: regenerate docs/commands.html after the help-text change.
Found by the 2026-08 CLI audit (cluster tap-dry-run-ignored); repro in the audit log. Verified against source 2026-08-31.
Complexity S
Impact High
Wow ★★
tap --dry-run expo/skills clones for real; --defaults --dry-run taps five registries
PlannedCLI · Bug
out.warn defaults to stdout: infer/absorb corrupt > SKILL.md; search/explain/context warnings pollute piped stdout
With stderr discarded, four commands still print their warnings on stdout: search --smart 2>/dev/null and explain brainstorming 2>/dev/null begin ! AI features need one of `claude` or `gemini` on PATH…; infer 2>/dev/null prints that warning before the --- frontmatter; context apply 2>/dev/null prints ! context is disabled … — applying anyway. The worst case is data corruption, not noise: boost infer > SKILL.md writes a file whose first line is the warning, and absorb's stdout carries the ==> recurring patterns heading and PATTERN/SEEN table ahead of the SKILL.md payload.
The cause is central: out.warn (boost_cli/core/output.py:221) defaults stream=None → stdout, and _note_fallback (boost_cli/commands/intelligence.py:47-52) and the explain/context warn calls pass no stream. The stream parameter exists precisely for commands whose stdout is machine-read (the output.py:224 docstring), and the --json paths already use it — search --smart --json 2>/dev/null emits pure parseable JSON. Only the human/text stdout paths are affected, which is what marks this an oversight rather than a design.
Fix, per the verified recommendation: flip out.warn's default stream to stderr and audit the few callers relying on stdout (JSON paths already pass it explicitly); at minimum pass stream=sys.stderr in _note_fallback and have cmd_infer/cmd_absorb (intelligence.py:859, intelligence.py:1146) route the heading/table to stderr when stdout carries the SKILL.md. Add a test that infer under BOOST_NO_AI writes only frontmatter+body to stdout. No doc changes.
Found by the 2026-08 CLI audit (cluster warnings-on-stdout); repro in the audit log. Verified against source 2026-08-31.
Complexity S
Impact High
Wow ★★
boost infer > SKILL.md writes the AI warning as line 1 of the generated file
PlannedCLI · UX
AI degrade note blames PATH/API keys regardless of cause; several commands fall back with no note at all
boost simulate prints ! AI features need one of `claude` or `gemini` on PATH, or ANTHROPIC_API_KEY set — using the heuristic fallback in two situations where that diagnosis is false: under BOOST_NO_AI=1 with claude on PATH (AI was disabled by env, not missing), and with AI enabled while ~/.boost/logs/boost.log records ai: claude CLI call failed: exit 1: …workspace has not been trusted… — the backend ran and failed. A controlled fake claude exiting 3 reproduces the same misblame for evolve. Meanwhile a second group degrades with no note at all when the call was attempted and failed: explain shows the extractive summary with stderr empty, conflict lists pairs as (heuristic) silently, impact and one-shot chat say nothing, and search --smart spent 4.62 s then reported 60 matches · ranked by full-content BM25 with no rerank-failed warning.
The cause: ai.fallback_note() (boost_cli/core/ai.py:62-71) is one static string; enabled() (ai.py:39-41) and the failure recorded by _log_failure (ai.py:97) are never consulted. The shipped ai-bridge-silent-failure-logging roadmap item added only the debug log line — the user-facing attribution is the unshipped follow-on. A user with an expired login or untrusted workspace is told to fix a PATH that is fine.
Fix, per the verified recommendation: add ai.unavailable_reason() returning one of disabled (BOOST_NO_AI/ai.enabled=false), no backend (no CLI, no key), or backend-failed (the last _log_failure reason, e.g. claude CLI failed (exit 1) — see ~/.boost/logs/boost.log). Route fallback_note() through it, and emit the note on stderr in every command where ai.available() was true but ask() returned None: explain, conflict, impact, chat one-shot, search --smart. Docs: regenerate docs/commands.html only if help strings change; update README's AI fallback paragraph if it quotes the note text.
Found by the 2026-08 CLI audit (cluster ai-fallback-misattributed); repro in the audit log. Verified against source 2026-08-31.
Complexity M
Impact Med
Wow ★★
one static string blames PATH/keys while boost.log records "claude CLI call failed: exit 1"
PlannedCLI · UX
Declined confirms never name -y/BOOST_ASSUME_YES; snapshot, clean, infer/distill/absorb and sync reject --yes
One pattern across seven commands. snapshot restore, piped without BOOST_ASSUME_YES: output is exactly cancelled, exit 0 — no prompt shown, no reason, no bypass named — and snapshot restore ID --yes answers Error: unrecognized arguments: --yes (exit 2). clean --deep --yes hits the same error, and its declined path claims ✓ nothing to clean when a snapshot was in fact kept. sync --prune declined tells the user to run boost sync --prune — the command just run. untap and bmad uninstall print bare cancelled/aborted lines (bmad exits 0 with nothing removed); infer -o/distill abort with no hint and reject --yes. BOOST_ASSUME_YES appears in zero command help texts.
The mechanism makes it one fix, not seven: out.confirm (boost_cli/core/output.py:788-806) already honours --yes/-y off sys.argv, but argparse rejects the flag in every command that does not declare it — only five parsers do (taps.py:181, configuration.py:605, bmad.py:106, pkg.py:522, pkg.py:990) — so docs/DEBUGGING.md:218's claim that --yes/-y auto-confirm is false for snapshot, clean, infer and sync. One narrowing from verification: uninstall's silent non-TTY proceed is documented-deliberate (roadmap item uninstall-has-no-confirmation-prompt — non-TTY must not break CI callers), so only the missing hint stands there.
Fix, per the verified recommendation: add a shared cliparse -y/--yes option to the confirming parsers that lack it (snapshot, clean, infer/distill/absorb, sync); have out.confirm's declined/non-TTY branch append pass -y or set BOOST_ASSUME_YES=1 once so every caller inherits the hint; return 1 from destructive commands that declined; and stop clean printing nothing to clean when items were skipped. Docs: fix docs/DEBUGGING.md line 218, docs/bmad.md, and regenerate docs/commands.html after the new flags.
Found by the 2026-08 CLI audit (cluster confirm-bypass-hints); repro in the audit log. Verified against source 2026-08-31.
Complexity M
Impact Med
Wow ★
only 5 of the confirming parsers declare -y; BOOST_ASSUME_YES appears in 0 help texts
PlannedCLI · Bug
Dry-runs disagree with the real run: compact, heal and onboard previews mispredict
A dry-run's one job is to say what the real run will do, and five previews demonstrably don't.
Sharpest case: with an untracked 1 MiB scripts/junk.bin planted in a tap clone,
compact minio/skills --dry-run prints “would free 1.0MB” — then
compact minio/skills prints “✓ every tap is already compact” and the
file is still there. _freight_bytes (boost_cli/commands/configuration.py:268-275)
counts by rglob, but git sparse-checkout reapply only drops tracked paths
outside the cone, so untracked bytes are promised and never freed. compact --dry-run --reclone
compounds it: identical output with or without --reclone, although a reclone would drop the
clone's whole .git.
heal mispredicts its own branch: with a store copy deleted, heal --dry-run says
“would restore brainstorming … (or drop it from the lock)” while the live run on the
same state prints “✓ reinstalled missing brainstorming from sickn33/…” — the
“drop” alternative never fired. And on a fresh HOME it says only “would create 4 missing
directories”, the one heal action that never names its paths, so nothing in the preview says
~/.agents/skills and the agent skill dirs are what get written.
onboard --dry-run truncates each file preview at 24 lines with no marker
(configuration.py:630 is splitlines()[:24]) — the lock preview ends mid-object
— and --dry-run --pr on a directory that is not a git repository exits 0 with no PR plan and
no precondition failure, because the dry-run early return at configuration.py:624 sits before
the git-repo check at :636. The repo already treats preview/apply divergence as a correctness
defect: the shipped item dry-run-promised-a-link-nobody-makes (PR 460) fixed the same class for
install --dry-run.
Fix per the verified recommendation: compute _freight_bytes from the git ls-files
intersection so the dry run predicts what reapply removes, and estimate .git bytes
when --reclone is given; word heal's restore line from the branch sync_apply will take
and name the directories; have onboard print “… N more lines” past 24 lines and run the
read-only --pr precondition checks before the dry-run return. Docs: README.md lines 268-274
(boost compact --dry-run). Found by the 2026-08 CLI audit (cluster dry-run-fidelity); repro in
the audit log.
Complexity M
Impact Med
Wow ★★
compact --dry-run promises "would free 1.0MB"; the live run frees nothing
PlannedCLI · UX
Name slugging is inconsistent: distill -o accepts what import rejects; create/profile slug silently
Three commands treat the same problem — a user-typed name that is not a valid slug — three
different ways, and one of them contradicts itself. distill -o "Bad Name!!" writes
…/Bad Name!!/SKILL.md and hints “install it with boost import ./Bad
Name!!” (unquoted); following the hint fails with “Error: invalid skill name 'Bad
Name!!'”. Meanwhile infer --name "My Conventions!!" slugifies to
my-conventions — the raw pass-through is intelligence.py:172 against the
slugify at intelligence.py:316, in the same module.
create slugifies silently with a fallback (configuration.py:350):
create '___' → ✓ created …/skill/SKILL.md, create ''
→ “Error: …/skill/SKILL.md already exists” — a path the user never typed
— and create 'Ünïcode Skill ✓' → n-code-skill/ with no
note that the name changed. profile save slugifies for the filename only
(team.py:192): profile save '!!!' lands on the slug-fallback file
skill.json, so '!!!' and a profile literally named skill silently share
one file; profile list (team.py:248-253) shows raw names but sorts by the slugged
filename, so the rows print as daily, mixed, !!!, Work Profile — an order that matches
nothing on screen. "My Daily" and "my-daily" would overwrite each other without a
word.
Fix per the verified recommendation: one shared helper — slugify, refuse an empty or fallback-only
slug (“name has no letters or digits”), and print the slug whenever it differs from what
was typed. Apply it to distill -o (plus shlex.quote in the import hint),
create, and profile save; sort profile list by the displayed name.
Regenerate docs/commands.html if create/distill help text changes. Found by the 2026-08 CLI audit
(cluster generated-name-slugging); repro in the audit log.
Complexity S
Impact Med
Wow ★
distill accepts "Bad Name!!", then its own import hint rejects it
PlannedCLI · Bug
git ops never set GIT_TERMINAL_PROMPT=0, so a 404/private repo prompts for credentials
gitutil.run (boost_cli/core/gitutil.py:19-41) passes
os.environ | {"GIT_LFS_SKIP_SMUDGE": "1"} and nothing else —
grep -rn GIT_TERMINAL_PROMPT boost_cli has zero hits. So when a clone or fetch hits a repo
that does not exist or is private, git falls back to an interactive credential prompt. Verified across
all three surfaces: tap nosuchowner-zz/nosuchrepo-zz-404 → “Error: git clone
failed: fatal: could not read Username for 'https://github.com': Device not configured” (exit 1);
same text from import https://github.com/nosuch-owner-xyz-123/… and, after a tap's
origin was rewritten to a 404 repo, from update minio/skills (“git fetch failed”).
“Device not configured” means git tried to open /dev/tty and the sandbox had none
— on a real terminal, a typo'd tap blocks boost tap, import,
bundle install and update on Username for 'https://github.com':. A
deleted-or-private upstream is exactly the case registry.update's failures branch exists for,
and it never gets reached interactively.
Fix per the verified recommendation: add GIT_TERMINAL_PROMPT="0" (optionally
GIT_ASKPASS="") to the env dict in gitutil.run — one line that fixes tap,
import, bundle install and update at once; in clone_shallow/fetch, map stderr containing
could not read Username / Repository not found / Authentication failed to a
BoostError naming the spec (“repository not found or private”); pin the env with a
unit test. With the flag set, git's own text becomes “…: terminal prompts disabled”
— already verified as the failure mode to translate. Docs:
docs/roadmap/items/one-dead-tap-broke-every-update.md gains a line (it never mentions the
credential-prompt failure mode). Found by the 2026-08 CLI audit (cluster git-credential-prompt); repro
in the audit log.
Complexity S
Impact Med
Wow ★
a typo'd tap makes git ask "Username for 'https://github.com':"
PlannedDocs · Drift
cli.py COMMANDS summaries and parser help contradict behavior across ~11 commands
The one-line summaries in boost_cli/cli.py's COMMANDS table — the strings
boost --help shows and docs/commands.html is generated from — contradict what
the commands do, in four recurring ways. Wrong default: search says
“(AI-ranked)” while every default run prints “ranked by full-content
BM25”, and --smart's prerequisites (a claude/gemini CLI or
ANTHROPIC_API_KEY) surface only as a runtime warning. Overselling:
impact claims “Measure a skill's influence on code quality”; the live output
is a COMMITS SINCE / EVENTS table captioned “correlation, not causation” — no quality
signal is computed. onboard promises “& open a PR” that only
--pr opens. Missing targets/hosts: adapt says “(CrewAI, Agents
SDK)” while its own --to lists langgraph (also stale in
docs/adapters.html:346); mcp names two of its three hosts (agy
missing) and --host help omits the accepted all value
(mcphost.resolve() takes it, configuration.py:1683). Wrong kind:
pin/unpin/quarantine/reinstall say
“skill” though all three kinds apply, tap says “GitHub repo” though the spec
takes a git URL or local directory, outdated says “skills” yet lists
code-signing (rule), uninstall names a phantom “config” kind
store.uninstall does not have, and untap -f promises to skip a confirmation that
per the audit log rarely fires. reindex's summary and parser description are two different
sentences for no reason.
Help that lies is a defect, not polish: it is the only interface documentation most users read, and
three of these (search's default, impact's claim, onboard's PR) misstate what running the command does.
Verified live for impact/mcp/adapt; the rest confirmed verbatim
against the COMMANDS rows (cli.py:57, :60-61, :64,
:67, :88-91, :102, :126, :130) and documented
behavior.
Fix per the verified recommendation: one sweep of the COMMANDS rows plus matching parser
descriptions — impact → “Correlate a skill's install date with repo activity”;
search → “(BM25; --smart reranks with Claude)”; adapt → add LangGraph; mcp →
name all three hosts and document --host all; pin/unpin/reinstall/quarantine/tap/outdated
→ “skill, rule or workflow” / “items”; onboard → “(optionally open a PR
with --pr)”. Then regenerate docs/commands.html (make generate) and
update docs/adapters.html, the docs/index.html command table, and README.md
(search example, mcp section). Found by the 2026-08 CLI audit (cluster help-claims-wrong); repro in
the audit log.
Complexity S
Impact Med
Wow ★
search says "(AI-ranked)" and every default run prints "ranked by full-content BM25"
PlannedCLI · Bug
info/stats/explain render a smaller shape for rules and workflows than for skills
All three item kinds install, but the reporting commands treat two of them as second-class.
info dotnet-build --json (a rule) returns
{"name","kind":"rule","installed":{…}} — three keys — while
info brainstorming --json (a skill) returns thirteen (description, latest, tap, store,
quality, size, files, capabilities, …) and no kind key at all: the rich envelope
is the one that cannot say what it describes. The human rule card omits the description the catalog
has. A not-installed workflow is worse: info actix-expert prints no kind badge or line and
shows source agents — the tap's whole agents/ directory, not
agents/actix-expert.md — because the path comes from cat['rel_dir'] instead
of cat['skill_md'].
stats dependency-management (a rule) ends at “activity 1 installs · 0 updates
· 0 uninstalls” with no latest … (up to date), description or
upstream section although catalog.find found the entry, and its agents line is
sorted while the skill path prints lock order (discovery.py:1766-1768 vs
:1800) — the separate kind != skill branch at
discovery.py:1754-1775 renders a strictly smaller field set. And explain
dependency-management after install loses the description it printed before install, with
Outline: now starting at dependency-management — the CLAUDE.md managed-block
header, not a real heading.
Fix per the verified recommendation: fold the kind != skill branches of
cmd_stats and cmd_info (boost_cli/commands/info.py:389-544) into the
main path, omitting only store-dir/size facts that truly don't apply; add kind +
description to the skill JSON envelope (info.py:454-465) and the materialized
envelope symmetrically; use cat['skill_md'] for a not-installed rule/workflow's source; pick
one (sorted) agents ordering; and have cmd_explain fall back to
cat['description']/the lock's description and strip the # <name> block
header before the heading scan. Found by the 2026-08 CLI audit (cluster info-kind-parity); repro in
the audit log.
Complexity M
Impact Med
Wow ★
info --json is 13 keys for a skill, 3 for a rule — and only the rule says its kind
PlannedCLI · Bug
taps/outdated/decay/policy/snapshot --json emit display strings as machine fields
Five --json outputs leak the table renderer's strings into the machine-readable
document, because each command builds one record and feeds it to both the table and
json.dumps. Verified, all five: taps --json mixes
"updated": "2026-07-24" and "updated": "11h ago" in one array (cloned
vs imported taps) with "pin": "" for unset · outdated --json's
latest is a composite — "0.0.0 (b36e082)",
"source missing", "x (content changed)" — and the table spells one
state two ways ((b36e082) for skills, (content changed) for rules) ·
decay --json's last_activity is humanised in two formats
("1m ago" / "2026-07-02") · policy check --json folds
the kind into the name: {"skill": "dotnet-build (rule)"} ·
snapshot list --json emits "skills": "?" (a string) beside sibling rows'
"skills": 1 when a sidecar is missing, a type change across rows of one key.
Why it matters: a consumer must parse display text back apart — regex the sha out of
latest, guess whether updated is a date or a relative age, strip
" (rule)" off a field named skill — and type-unstable values break
any typed loader. Sibling commands already do it right: impact --json emits
null for unknown.
The fix is one sweep with one rule: records carry machine values, and the table branch alone
applies rel_time(), placeholders and composites. Concretely: ISO timestamps or
null for updated/last_activity; separate
name/kind in policy violations (keep skill as a deprecated
alias if compatibility matters); latest + reason: version|content|source-missing
+ latest_commit in outdated, unifying the two content-changed spellings while there;
numeric-or-null counts in snapshot list. Sites: boost_cli/commands/taps.py:208-221,
taps.py:239-242, taps.py:300, taps.py:308,
taps.py:331, taps.py:341-347, plus the decay/policy/snapshot row builders.
No flag or summary changes, so no doc regeneration needed. Found by the 2026-08 CLI audit
(cluster json-display-strings); repro in the audit log.
Complexity S
Impact Med
Wow ★
one array holds "updated":"2026-07-24" and "updated":"11h ago" — same key, two formats
PlannedCLI · Bug
--json accepted but ignored: cohort/config/policy set, focus, profile, replay rollback, who empty state
The mirror image of the missing---json sweep: these parsers accept
--json, then whole branches print human text to stdout and exit 0 — a script
that asked for JSON gets unparseable prose and no error. Verified live on every member:
cohort apply --json → "==> cohort everyone … applied: 0 installed,
1 already present" (create/delete too) · config set ai.enabled true --json
→ "✓ set ai.enabled = true" (unset too; only list/get honour the flag) ·
policy set/unset --json → the same ✓ line ·
focus brainstorming --json → "⌑ focus: brainstorming (other 1 skills
sidelined)", and --clear --json likewise — only --status emits
JSON · profile save/use/delete --json → "✓ saved profile daily
(2 skills)" · replay rollback <id> --json → the human rollback
transcript · who --json on an empty journal → "○ no journal
activity yet…" where pulse --json prints [] in the same state.
update --json without --shards prints tap prose the same way, though its
help does say "with --shards" — accept-and-ignore rather than an undocumented lie.
Every one of the six unqualified commands advertises a bare "machine-readable output" help
string, so the contract is broken silently: exit 0, wrong content. The fix is one pattern across the
seven: each action either emits a small JSON object under --json (action, key/name,
outcome lists; who/focus empty states emit {} /
[]) or the parser rejects the combination with a usage error naming the actions the flag
supports — and each --json help string gets qualified the way
update's and schedule's already are. Sites:
boost_cli/commands/team.py:69-300, intelligence.py:945-1050,
configuration.py, pkg.py. Help strings change, so regenerate
docs/commands.html. Found by the 2026-08 CLI audit (cluster
json-flag-ignored); repro in the audit log.
Complexity M
Impact Med
Wow ★
seven commands take --json, then print "✓ set ai.enabled = true" prose with exit 0
PlannedCLI · UX
Missing --json on doctor, test, health, changelog, trending, log, hooks list and friends; bundle install lacks --dry-run
Eleven read-only reporting commands reject --json that their group siblings all have:
doctor, test (and quarantine --list), health,
changelog, trending, log, hooks list,
clean, compact, context (top-level —
context status --json works, bare context means status, yet
context --json is rejected) and index. Each prints
"Error: unrecognized arguments: --json" with exit 2, verified against positive controls
(lint --json, context status --json emit valid JSON). The sharpest cases:
doctor is the check CI would poll and the MCP server exposes a
boost_doctor tool, yet the CLI offers only prose; test is the command a CI
gate would parse; log --json is rejected while pulse --json works over the
same journal — and log also drops the key=value event fields pulse
shows. Separately but in the same sweep: bundle install has no --dry-run
although sibling install documents one, and bundle install taps registries, installs
skills and can edit CLAUDE.md via rules with no way to preview.
The fix is one consistency sweep, not new computation: each command already holds the facts —
emit them. doctor: {checks: [{name, status, message, hint}], issues, verdict}
with exit codes unchanged · test: per-skill rows with failed checks ·
health: the kv dict plus status · changelog/log: parsed
entries ([{sha, date, author, subject}] via a separator-based --pretty
format; journal events verbatim) · trending: rows including kind so
rules/workflows are distinguishable · hooks list: the
cs.list_all_hooks rows (host, scope, event, name, matcher, command, timeout) ·
clean/compact: the items list with path/kind/bytes plus totals ·
top-level context --json forwards to status. Add --dry-run to
cmd_bundle (skip registry.add/store.install, print what would
happen). New flags mean regenerating docs/commands.html; README.md and
docs/DEBUGGING.md mention doctor/health output and need a pass. Found by the 2026-08
CLI audit (cluster missing-json-flags); repro in the audit log.
Complexity M
Impact Med
Wow ★
doctor — the check CI would poll, mirrored by an MCP tool — offers only prose
PlannedCLI · UX
Name-miss errors: unknown tap qualifier never named, no close-match hint, tap tokens pollute suggestions
Three gaps in one resolver make name misses unhelpful across install, info,
cat, preview, deps, log, home,
explain and changelog. First: install nosuch/tap:brainstorming
→ "Error: no skill named 'nosuch/tap:brainstorming' in any tap" — brainstorming
exists and is even installed; the tap is what is unknown, and nothing says so. Second, the
hint inversion: install brainstormng (one letter off) gets no hint, while
definitely-not-a-skill-xyz gets three suggestions — the hint is BM25
search() over the input, so nonsense sharing a token scores and a near-miss does not.
Third: on a qualified miss the search runs over the whole qualified string, so
log NeoLabHQ/context-engineering-kit:brainstorming suggests
context-engineering items scored on the tap's tokens and never
brainstorm, which that tap ships; the verifier confirmed the same pollution with a valid
tap and a typo'd name. Separately, lint/verify/drift's
installed-name lookup has the same gap: lint brainstormin → "not installed:
brainstormin / hint: see what is with boost list" with exactly one skill installed,
one character away.
Verified in source: catalog.resolve_one's miss branch
(boost_cli/core/catalog.py:486-531) handles only the path-shaped-tail case — it
never checks the qualifier against registry.list_taps(), builds the hint from
search(name) over the full string, and has no difflib fallback when search is empty;
_common.py:46/:75 raise "not installed" with a fixed hint. The fix is two
resolvers: in resolve_one's miss branch, split_name first — if the
qualifier matches no configured tap, raise "no tap named 'X'" listing the taps that do ship
the bare name; otherwise run search(bare) (optionally tap-restricted) instead of
search(full); and add difflib.get_close_matches over catalog names when
search comes back empty. In _common's not-installed error, append
get_close_matches over lockfile.all_installed() names. No flag or summary
changes, so no doc regeneration. Found by the 2026-08 CLI audit (cluster
name-miss-error-hints); repro in the audit log.
Complexity S
Impact Med
Wow ★★
a one-letter typo gets no hint while nonsense input gets three suggestions
PlannedCLI · Bug
Six count flags (tap/chat/absorb/lint/changelog/hooks) accept 0 and negatives
The same defect class the shipped negative-limit-inverts-log-pulse-output card fixed, at six sites
that kept bare type=int although util.positive_int exists and sibling
flags in discovery/info/team already use it. The failures are not cosmetic — three fabricate
false answers and one writes a broken config. Verified live: chat -k 0 →
"Nothing in the tapped catalogue matches that. Try boost tap --defaults…"
exit 0, although search --limit 5 finds 60 matches; chat -k -1 prints every
match (116 rows on the verifier's corpus). absorb --limit 0 falsely reports "no
recurring patterns" and --limit -1 silently drops one. changelog -n 0
claims "no history found" plus the shallow-clone hint, exit 0, while -n -1
means unlimited. tap --catalog --limit -1 silently drops the last registry
(entries[:args.limit]: 463 of 464). lint --min 500 fails every skill
— scores cap at 100. And hooks add --timeout -5 prints "✓ added Stop
hook" and writes "timeout": -5 into settings.json; the Gemini variant writes
"timeout": 0 — in milliseconds, fed to setTimeout, a hook that times
out before it runs.
The fix is mechanical: set type=util.positive_int
(boost_cli/core/util.py:119-129) at taps.py:120,
intelligence.py:534, intelligence.py:1157, quality.py:1136 and
hooks.py:53; give lint --min a 0–100 range type
(quality.py:712). Keep the "no matches"/"no patterns" messages gated on genuinely empty
untruncated results, so the empty-state text can never again be produced by a limit of
zero. Help strings change, so regenerate docs/commands.html; docs/chat.html
documents -k and needs the same line. Found by the 2026-08 CLI audit (cluster
numeric-option-validation); repro in the audit log.
Complexity S
Impact Med
Wow ★
chat -k 0 fabricates "nothing matches"; hooks --timeout -5 lands in settings.json
PlannedCLI · Bug
Project scope seams: uninstall/verify/list/info/reinstall disagree with what install --local wrote
The project-scope-across-every-command item shipped, and the 2026-08 CLI audit found its seams: the
writers and readers resolve "the project" differently. install --local uses
scopes.resolve_base, which falls back to the cwd
(scopes.py:83-104), while uninstall's project fallback
(store.py:1249) and verify/doctor/list all go
through scopes.project_root, which requires a VCS marker (scopes.py:45).
So from a plain directory, install anthropics/skills:pdf --local writes
.boost/skill-lock.json and .claude/skills/pdf and reports success —
then verify pdf answers “Error: not installed: pdf”, plain
uninstall answers “brainstorming is not installed”, and
doctor/list show no project row. After mkdir .git the same
commands find everything. All six findings reproduced.
Four more seams, each confirmed in source. The already-installed error hints
“boost reinstall brainstorming --local to force” — a flag
reinstall does not have; following the hint exits 2 with
“unrecognized arguments: --local” (store.py:615-617).
verify <project-only name> ignores the filter and grades every user-scope item
— cmd_verify already passes [] for project-only names
(safety.py:355-356) but _iter_installed_all treats [] as
“everything” (_common.py:66, if names:), so the run can
fail on a rule the user never named. list --local --kind rule prints
“○ no rules installed” although project scope holds skills only. And
info on a project-scoped skill shows the not-installed card — no version,
installed date, commit or agents rows — though the project lock records them all and
--json returns them under project.
The verified fix, one follow-up card: make _iter_installed_all treat []
as nothing (_common.py:66) · change the hint to
boost install NAME --local --force (matching README ~328) or add
--local to reinstall · unify the uninstall fallback on
resolve_base or hint --local (store.py:1249) · have
install --local warn outside a VCS root, or teach project_root to accept
.boost/skill-lock.json as a marker · refuse
list --local --kind rule|workflow the way the existing --tag guard does
(info.py:274-281) · render the plock identity rows in cmd_info.
Docs: README ~304–328 (uninstall/reinstall routes), a follow-up note on
docs/roadmap/items/project-scope-across-every-command.md, and regenerate
docs/commands.html if reinstall gains --local. Found by the 2026-08 CLI
audit (cluster project-scope-readers); repro in the audit log.
Complexity M
Impact Med
Wow ★★
install --local writes a lock that uninstall, verify, doctor and list then cannot find
PlannedSafety · Bug
export -o, cohort create and profile save silently overwrite existing outputs and still say created/saved
Three commands replace an existing archive/cohort/profile with no warning, no delta and no
--force. export -o same.tar.gz twice: both runs print
“✓ exported 1 skill → …/same.tar.gz (2.9KB)” and the first
file is replaced without a word. cohort create pilot over an existing cohort prints
“✓ created cohort pilot (100% rollout, 1 skills)” while
cohorts.json shows the old 50%/2-skill spec gone and the created
timestamp reset — a replacement misreported as a creation. profile save daily
over an existing profile likewise says “✓ saved profile daily” and
rewrites the file. No exists check anywhere: team.py cohort-create assigns
cohorts[name] unconditionally (team.py:95-108), profile-save
write_texts unconditionally (team.py:269-277), and pkg.py
export opens the destination 'w'/'w:gz'
(pkg.py:1650-1663). A typo in the name destroys saved state.
It also breaks the file's own conventions: cohort delete in the same module confirms
before destroying, and the shipped onboard-overwrites-generated-files-without-confirm item
(PR 287) already established the exists-check precedent for generated outputs — which is
what makes this a defect rather than taste.
Verified fix, per command: export refuses when the destination exists without
--force, naming the file; cohort create refuses or prints
“updated cohort X (was N% / M skills)” and preserves the original created;
profile save prints “updated profile X (was N skills)” —
snapshot-like semantics make delta wording the better fix than --force there.
Regenerate docs/commands.html for the new export --force flag. Found by the 2026-08
CLI audit (cluster silent-output-overwrite); repro in the audit log.
Complexity S
Impact Med
Wow ★
export -o same.tar.gz twice — both runs say "exported", the first archive is gone
PlannedCLI · Bug
Stray positionals and inapplicable flags silently ignored across import, config, policy, trust, log, schedule, hooks, snapshot
An argparse hygiene gap, uniform across eight commands: optional positionals swallow words the
action never reads, and inapplicable flags are accepted without comment.
import fx/multi --all --name ab-testing imports all three skills —
“Imported 3 skills”, exit 0 — because pkg.py:1389 reads
if name and not do_all:, so --name is dropped.
trust list extra1 extra2 prints the full listing, exit 0.
schedule status --interval daily exits 0 with the plain status.
config list extra, policy list extra positional,
snapshot list extra-arg, log --diagnostics --crashes NAME and
hooks list SessionStart (all seven rows, unfiltered) all behave as if the extra words
were absent. The worst consequence is the config-get variation: config get ai.enabled
false — a typo for set — prints “true” and
exits 0, so a mistyped set reads as a confirmed set.
No help text blesses ignoring arguments, and sibling commands (conflict extra,
health extra) already reject strays — the codebase's own convention says error.
Individually low-severity; med as a cluster because import --all --name and
config get KEY VALUE change or misrepresent real outcomes. Verified across
boost_cli/commands/pkg.py:1370-1395 and the action parsing in
boost_cli/commands/configuration.py (cmd_config/cmd_policy/cmd_schedule).
Verified fix, one sweep: after parse_args, call p.error() when an action
received a positional or flag it never reads — config list KEY,
get/unset VALUE, policy list/check strays,
trust list/remove extras, snapshot list arg,
log NAME --diagnostics, and schedule --interval outside
enable (use default=None to detect an explicit flag) · make
import --all/--name a mutually-exclusive group · have
hooks list EVENT filter rather than ignore, matching the user's evident intent.
Regenerate docs/commands.html if help strings gain “(enable only)”-style annotations.
Found by the 2026-08 CLI audit (cluster stray-args-ignored); repro in the audit log.
Complexity S
Impact Med
Wow ★
import --all --name X imports everything; config get KEY VALUE reads as a confirmed set
PlannedCLI · Bug
out.table clips data columns to an assumed 80 columns when stdout is a pipe; narrow TTYs clip IDs/hashes
One root cause, twelve findings across ten commands: out.table always fits rows
through _fit_widths(term_width()), and term_width() answers 80 when
stdout is detached — so a pipe, where there is no pane to fit, gets data columns ellipsised.
Piped list: “test-driven-develop… 0.0.0
NeoLabHQ/context-en… claude·windsurf·cur…”; piped
taps: “NeoLabHQ/context-engineering… 92
@555b952 https://github.com/NeoLabHQ/…” — so
boost list | grep test-driven-development matches nothing, and the clipped NAME is
exactly the identifier untap/update need. With COLUMNS=200
the same pipes print full names. The same mechanism clips fingerprint --verbose
sha256s to 38 chars, drift's remedy hint
(“boost reinstall brainstorming to discard loc…”),
trust verify's only explanation of an invalid status, attest names,
hooks list commands and discover URLs.
Narrow TTYs hit the second half: at COLUMNS=60, snapshot list renders
every ID as the identical stub “snap-20260831-…” — and the id is
the only argument snapshot restore accepts — while taps spends
half the pane on identical “https://github.c…” cells and clips NAME.
This contradicts both the CLAUDE.md rule that only chrome may be wrapped and
out.table's own docstring (“scripts that parse table output never see the
ornament”). The shipped width-aware-table items covered TTY fitting, not pipe clipping.
Scope is broader than one call site: 11 of 12 findings share
out.table→_fit_widths with no isatty gate
(output.py:308-310, 726-743, 773), and piped
boost --help is a second emit site — cli.py:169,199-209 uses
out.truncate/term_width directly, truncating command summaries in
--help | grep too.
Verified fix: in out.table skip _fit_widths when stdout is not a TTY and
COLUMNS is unset · add a per-column no-clip marker for data columns (snapshot
ID, digests, hook commands) so narrow TTYs shrink chrome (WHO/WHEN/URL) first · give
cli.print_help its own not-a-TTY branch emitting full summaries · unit-test a
60-column render asserting full IDs survive. Found by the 2026-08 CLI audit (cluster
tables-clip-data-columns); repro in the audit log.
Complexity M
Impact Med
Wow ★★
boost list | grep NAME matches nothing — piped tables clip to an assumed 80 columns
In flightCLI · Bug
output._wrap_tokens splits punctuation off backtick spans, inserting a stray space
CLAUDE.md's wrap law makes a backtick span one atomic token so pasteable commands survive folding
— but output._wrap_tokens (boost_cli/core/output.py:383–391) also makes the
punctuation next to the span its own token, and wrap() rejoins tokens with a space. The
source strings are glued (dense.py:320,325 ends …[rag]'`;, doctor wraps the hint in
(%s)); the space is manufactured entirely by the wrapper. Observed verbatim: doctor —
(install the extra: `pip install 'boost-skill-cli[rag]'` ); reindex --dense —
`pip install boost-skill-cli[rag]` ; using the BM25 full-content engine; profile --help —
--prune with `use` : fully uninstall skills not in the profile; replay --help —
id history entry id (from `boost replay list` ).
The reach is wider than three commands: verification showed the stray space is width-independent
(reproduced at COLUMNS=60 under a TTY), and cliparse._BoostHelpFormatter._split_lines
(cliparse.py:38–44) delegates to out.wrap, so every help screen that puts
punctuation beside a code span shares the one defect. Cosmetic — nothing broken pastes — but it is the
output layer undoing its own typography, in the hints users are most likely to copy.
Fix, verified against the source: in _wrap_tokens, extend each span token to absorb adjacent
non-whitespace — match \S*`[^`]*`\S* (or merge a span with the preceding/following fragment
when the source had no whitespace between them). The span stays atomic and pasteable; doctor, reindex
--dense and all _BoostHelpFormatter help text are fixed in one place. Add a unit test in
tests/unit asserting wrap('(see `x y`)') keeps the paren glued. Help text renders into
docs/commands.html, so regenerate it (make generate). Roadmap item BOOST-D27 lists other
wrap gaps but not this one. Found by the 2026-08 CLI audit (cluster backtick-span-punctuation); repro in the
audit log.
Complexity S
Impact Low
Wow ★
the wrapper rejoins span + ")" with a space; one fix covers doctor, reindex and all help
PlannedCLI · UX
Sweep: positionals/--json lack help strings and no command help shows examples (~30 cmds)
Across roughly thirty commands the help screens end at the options table with undocumented arguments.
Observed verbatim: help cohort prints {list,create,delete,status,apply} with no help
string for the action; test --help shows positional arguments: then NAME
with nothing after it; conflict --help and attest --help each list --json
with an empty help line; edit/explain/home give name no text
(so docs/commands.html renders <code>name</code><span></span>).
No help screen in the audit shows an Examples block. The gaps hide real contracts: run never
mentions its SDK/key prerequisites (only the runtime error does), discover --help never says a
query hits GitHub live while bare/--local read the cache, conflict's exit-1-on-findings
is undocumented, cohort status is an unadvertised alias of list, and policy's
11 valid keys appear only in the error hint after a wrong set.
Verification confirmed this is omission, not style: the mechanism works and is used exactly once —
the only epilog= in all of boost_cli is cohort's membership-hash paragraph
(team.py:74), and team.py:77 adds the action positional with no help=.
Nothing in CLAUDE.md declares terse help deliberate, and the content gap is unchanged at
COLUMNS=60 under a TTY, so it is not a rendering artifact.
Fix as one sweep PR: add help= to every bare positional (action choices, NAME,
--json) and an Examples epilog per parser — cliparse.parser forwards
**kwargs to argparse, so no plumbing is needed. Extend
scripts/build_command_reference.py to render parser.epilog (lines 119/137 render
description and per-arg help but currently drop the epilog) and fail --check on empty help
strings, then regenerate docs/commands.html. Document the defaults (cohort/profile default action
= list) and the cohort status alias while there; the list summary in
cli.py COMMANDS should also say "skills, rules and workflows" to match its own description.
Found by the 2026-08 CLI audit (cluster help-examples-sweep); repro in the audit log.
Complexity M
Impact Med
Wow ★
exactly one epilog= exists in all of boost_cli; ~30 commands ship bare positionals
PlannedCLI · Polish
Singular/plural misses (“1 skills”, “1 issue need”, “1 skill pass”) across six commands
Six commands hand-roll plurals and get the singular wrong. Reproduced verbatim on a fresh HOME with one
installed skill: doctor — ● 1 issue need attention — see the suggestions
above; lint — ✓ 1 skill pass lint (min 40); policy check —
✓ policy check passed (1 skills); cohort create — ✓ created cohort
everyone (100% rollout, 1 skills) — you are IN. The remaining two are confirmed in source:
count's breakdown prints 1 skills · 1 rules · 1 workflows (surfacing once
a rule and workflow are also installed — the same line pluralises tap%s correctly), and
focus prints other 1 skills sidelined (team.py:105,
configuration.py:475).
It is an internal-consistency miss, not a style choice: a shared plural helper already exists
(_s, boost_cli/commands/_common.py:15) and is applied inconsistently —
quality.py:637 and :777 pluralise the noun but leave the verbs
need/pass unagreed, while boost list and uninstall on the same HOME
say 1 skill installed correctly, and profile use hedges with
sidelined 1 skill(s).
Fix as one sweep PR: reuse _s at configuration.py:475 and team.py:105
(cohort create, profile save, the skill(s) strings) and in count's breakdown; the verbs at
quality.py:637/777 need a word pair chosen on n == 1
(needs/need, passes/pass) — _s alone cannot fix verb agreement. Add a small
unit test asserting the n == 1 strings. No docs change — these are runtime messages only.
Found by the 2026-08 CLI audit (cluster pluralisation-sweep); repro in the audit log.
Complexity S
Impact Low
Wow ★
a shared _s helper exists at _common.py:15 and six commands hand-roll around it
PlannedCLI · Polish
log/simulate/changelog headings echo the raw qualified argument, printing the tap twice
Give any of the three a tap-qualified name and the heading repeats the tap. Reproduced verbatim:
changelog — ==> changelog for anthropics/skills:pdf (anthropics/skills);
log — ==> sickn33/antigravity-awesome-skills:brainstorming — history in
sickn33/antigravity-awesome-skills; simulate — ==> simulating
sickn33/antigravity-awesome-skills:test-driven-development (tap sickn33/antigravity-awesome-skills),
which then carries the raw argument into prose: With
sickn33/antigravity-awesome-skills:test-driven-development active, Claude would:. The bare-name
variation prints the clean form (==> changelog for brainstorming (sickn33/antigravity-awesome-skills)),
so the redundancy is exactly scoped to qualified arguments.
Verification pinned it as an inconsistency, not a choice: all three headings interpolate raw
args.name (quality.py:1154, info.py:844,
intelligence.py:256 and :274) with the tap appended, while sibling
cmd_info already splits via catalog.split_name (info.py:401) and shows the
bare name in its headings (info.py:355). split_name was built for exactly this
(shipped item info-rejects-the-qualified-name-it-recommends fixed resolution, not display —
related, not a duplicate).
Fix: in cmd_log, cmd_simulate and cmd_changelog, split the argument once
(_, bare = catalog.split_name(args.name)) and use bare in the heading and in
simulate's With … active lead-in, keeping the single tap suffix. Optional polish while there:
bare boost log starts straight in with event rows (47s ago jonny install
brainstorming) while its history and diagnostics modes print a ==> heading — an
==> activity heading restores parity. No docs change. Found by the 2026-08 CLI audit
(cluster qualified-name-headings); repro in the audit log.
Complexity S
Impact Low
Wow ★
changelog for anthropics/skills:pdf (anthropics/skills) — the tap printed twice
PlannedDocs · Drift
Stale prose after shipped changes: catalog --export, live discover, 464 count, Apache-2.0
Four patches of prose describe the repo as it was before a shipped change, each verified against the current
tree. A documented command that does not run: docs/security-design.md:35 says
a boost catalog export tarball; running it prints Error: one of the arguments
--export --import --show is required and exits 2 — the flag is --export.
Pre-live-search discover: docs/index.html:877 (boost discover indexes
10,000+ skills across GitHub) and :940 (build the GitHub-wide index … browse it with
boost discover) predate the shipped change that made a query hit GitHub Code Search live; the
index now only backs bare discover and --local, exactly as the command's own help says.
A hard-coded count, off by one and spreading: README:151, semantic-search.md:64 and
quickstart.py:39 say --catalog taps 463 registries;
registries.json holds 464 non-list_only rows of 487, and quickstart --dry-run
--catalog on the 20-tap fixture prints would tap 445 registries (445 + 19 already
tapped = 464). Verification found more copies than the auditor: README:211,
quickstart.py:65 and CLAUDE.md:183–184 and :500. A licence rule contradicting every
file: CLAUDE.md:276 still says headers open with SPDX-License-Identifier: GPL-3.0-only, but
every source file greps to Apache-2.0, LICENSE is the Apache License 2.0, and
scripts/add_spdx_headers.py already writes Apache-2.0 — the CLAUDE.md paragraph (and its
-only→-or-later example) is the sole stale statement.
Fix as one doc-only PR: security-design.md:35 → boost catalog --export; rewrite
the two index.html sentences to live-GitHub-search plus boost index/--local;
replace the literal 463 with every catalogued registry (or 464) in README.md:151/211,
semantic-search.md:64, quickstart.py:39/65 and CLAUDE.md:183/500 — a number
scripts/build_registries.py changes should not be hard-coded in prose; and correct the CLAUDE.md
licence paragraph to Apache-2.0. security-design.md and semantic-search.md are already
in prose-lint.yml's vale list, so no lint wiring is needed; docs/commands.html is
untouched (no parser changes). Found by the 2026-08 CLI audit (cluster stale-prose-docs); repro in the audit log.
Complexity S
Impact Low
Wow ★
a documented command that exits 2, 463 vs 464 in eight places, a GPL rule over Apache files
PlannedCLI · Bug
boost ROOT: CLI audit findings (2026-08)
EPIPE leaks past main's own handler. boost --help | (exec 0<&-; sleep 0.3)
exits 120 with “Exception ignored on flushing sys.stdout: BrokenPipeError”
— 3/3 runs, and count, taps and --version leak the same way.
cli.py:327-331 deliberately catches BrokenPipeError and returns 0, but stdout is never
flushed inside the try, so any output that fits the stdio buffer raises at interpreter exit — and
the --help/--version early returns (cli.py:292-302) sit before
the try with no handler at all. Fix: cover the early returns, flush sys.stdout inside the
try, and os.dup2 a devnull fd over stdout in the handler so the exit flush cannot raise.
Help routing rejects main's own aliases. boost help --help →
“unknown command: --help / hint: did you mean: heal?” exit 2;
boost help version suggests verify while boost version works;
even boost help help fails. The aliases live only in cli.main
(cli.py:292-302) and print_command_help resolves against COMMANDS alone,
then difflib-guesses any token — dash-prefixed ones included. And boost --help
documents none of the working global flags (-V/-v/--debug/-q).
Fix: short-circuit the aliases in print_command_help before difflib, say
unknown option for dash tokens in _unknown (cli.py:231-236), and add
one dim Options line under Usage in print_help.
The ./boost launcher still gates at Python 3.9 — tuple
(boost:27), hint text (boost:37) and header comment (boost:5) all
say 3.9+ while pyproject.toml:22 requires >=3.12. On a stock macOS whose
only python3 is 3.9 the launcher selects it and the user gets a SyntaxError from
core/workflows.py's match statements instead of the friendly hint. The shipped
python-floor-moves-to-312 item enumerated every floor touchpoint and missed this one. Fix:
bump all three sites to 3.12 and add a unit test pinning the launcher's floor to
requires-python. README already says 3.12+ — no doc change needed; none of the three
fixes touches a COMMANDS row, so docs/commands.html is unaffected.
Found by the 2026-08 CLI audit (clusters broken-pipe-exit,
help-routing-aliases, launcher-python-floor); repro in the audit log.
Complexity S
Impact Low
Wow ★
EPIPE exits 120 with stderr noise; `help version` suggests verify; launcher gates at 3.9
ShippedCLI · Consistency
boost absorb: CLI audit findings (2026-08)
Write-up
cmd_absorb never calls journal.log. After two
absorb --install runs, boost log lists “infer
project-conventions”, “distill brainstorming-distilled” and an
“import absorbed-patterns” entry — the last written by
install_from_path (core/store.py:1228), not by absorb — and the verb
absorb never appears. Without --install (stdout mode) the command leaves
zero journal trace, while its siblings journal in both output branches: distill at
commands/intelligence.py:187, infer at :326/:330. An omission,
not a choice — absorb is the only generated-skill command with no journal.log call.
The auditor's second half — that --install output differs from
install's report — reproduced but was declined on design review:
_install_generated (intelligence.py:103-125) is the deliberate shared
renderer for all generated skills (distill/infer/absorb), and reusing store.install's tap
report would misreport a local re-import as a fresh tap install. Fix is the journal line alone: add
journal.log("absorb", name, patterns=len(patterns), files=len(files)) after generation in
both branches of cmd_absorb (intelligence.py:528-580), mirroring distill's
placement. No doc changes.
Found by the 2026-08 CLI audit (cluster absorb-parity); repro in the audit log.
Complexity S
Impact Low
Wow ★
absorb is the only generated-skill command with no journal.log call at all
PlannedCLI · Bug
boost adapt: CLI audit findings (2026-08)
A subagent named after a declared tool renders modules that compile but cannot run. With a
subagent grep and tool Grep: crewai emits @tool("grep") def
grep then grep = Agent(...), so reviewer_1 = Agent(tools=[read,
grep]) hands the Agent where a tool belongs (stub run: TypeError); langgraph assigns
grep = create_react_agent(...) inside build_mycrew, making it local, so the
earlier tools=[read, grep] raises UnboundLocalError. _unique_idents
(core/adapters.py:188-202) dedups only among agent specs and _unique_tools
(:205-212) allocates stub names independently. Fix: pass the tool ident set into
_unique_idents as pre-reserved names (or prefix stubs tool_<name>) in
render_crew/render_graph, plus a golden test that executes a colliding
render against stubs.
docs/commands.html brackets required options as optional. Line 369 shows
boost adapt [--to FRAMEWORK] … while adapt --help prints an
unbracketed --to FRAMEWORK and omitting it exits 2 — and the verify pass found it
is broader than adapt: evolve's required --feedback and catalog's required
mutually-exclusive group render all-optional too. Pure generator bug:
scripts/build_command_reference.py:122-126 brackets every option unconditionally. Emit
required options unbracketed (a required group as (--a | --b)), prefer the short flag
like argparse, then make generate — the --check gate holds it after
that.
Colon-form model ids get double-prefixed for the LiteLLM targets.
--model anthropic:claude-x — the form langgraph accepts and emits —
renders llm=LLM(model="anthropic/anthropic:claude-x") for crewai and the same for
agents-sdk; multi-agent crews inherit it via adapters.py:376.
_litellm_model's docstring says a provider-qualified value passes through, but the code
checks only /. Fix: replace the first : with / before deciding
to prefix (mirror of _langchain_model); document accepted syntaxes in
docs/adapters.html's --model paragraph (~line 344, slash form only today),
and regenerate docs/commands.html only if the argparse help changes.
adapt -o and run --print -o write generated source mode
0600 — and re-rendering over an existing 0644 file silently downgrades it, unlike a shell
redirect (-rw------- vs -rw-r--r-- under umask 022).
util.atomic_write_text (core/util.py:91-116) inherits mkstemp's 0600, right
for the lock/config it was written for, wrong for source the user asked boost to write. Fix: add an
optional mode parameter (fchmod the temp fd before os.replace), keep 0600
the default, and have cmd_adapt (pkg.py:1753-1761) and cmd_run
(run.py:62) pass the umask default.
Found by the 2026-08 CLI audit (clusters adapt-ident-collision,
docs-required-flag-synopsis, adapt-model-id-syntax,
generated-file-mode); repro in the audit log.
Complexity M
Impact Med
Wow ★★
a subagent named after a tool renders a crew that compiles but cannot run
PlannedCLI · UX
boost bmad: CLI audit findings (2026-08)
An edited persona is reported as not installed (cluster bmad-edited-persona, med).
After appending one line to ~/.claude/agents/bmad-ux.md, bmad on says
“✓ BMAD autopilot ON (global) — 6 persona subagent(s)” and
bmad personas lists “bmad-ux Sally, UX Designer (bmm) — not
installed” — but seven files are on disk and Claude Code loads all seven. The
ownership model is right (the edited file is skipped, never deleted); only the reporting reuses
“managed” as “installed”: installed_personas()
(boost_cli/core/bmad.py:673-677) counts stamp-managed files only, the correct set for
deletion and the wrong one for counts. Fix: a three-state helper (managed / edited / absent) in
core/bmad, render edited files as “installed (edited)” at
commands/bmad.py:344 and count managed+edited at :254 and in status;
docs/bmad.md gains the edited state.
Four status-surface polish defects (cluster bmad-status-polish, low), all
source-confirmed: bmad startup bogus silently falls through to status
(bmad.py:474) although help promises on | off | status; bmad startup
on writes the orient hook without the || true guard bmad on
applies (bmad.py:459-461 vs :247 — _never_fails's own
docstring explains why the guard exists); a second bmad off claims “removed 0
persona(s) and both hooks” (:281 hardcodes the clause); and status prints
“installed=False” beside “autopilot=off”. Fix per the
verified recommendation: raise BoostError for a bad startup value, wrap the startup hook
with _never_fails() and pin it in a test, return a real count from
_remove_hook_everywhere, and format the two booleans with the on/off helper.
No-op bmad on churns the settings history (cluster
settings-history-churn, low). Three runs in a fresh HOME left four byte-identical
575-byte global-*.json snapshots (md5 791156bf…) — each no-op
run consumes 4 of the HISTORY_KEEP=50 slots (2 hosts × 2 writes), so ~13 no-op runs
evict every real pre-change snapshot. claude_settings.save()
(boost_cli/core/claude_settings.py:80-102) snapshots unconditionally; fix is to serialise
first and return early when the content equals the current file, which also collapses the double write
per host.
Found by the 2026-08 CLI audit; repro in the audit log. Behaviour-only fixes —
regenerate docs/commands.html only if a summary changes; docs/bmad.md
must document the edited-persona state.
Complexity M
Impact Med
Wow ★
an edited persona reads "not installed"; each no-op bmad on burns 4 of 50 history slots
PlannedCLI · Bug
boost browse: CLI audit findings (2026-08)
A curses init failure crashes with a report instead of falling back (cluster
browse-curses-fallback, med). On fds that claim isatty but are not a pty
(IDE run consoles, script, TERM=dumb), browse emits raw
alternate-screen escapes ([?1049h[1;40r…) and then “Error: boost hit an
unexpected error: error: nocbreak() returned ERR / hint: a crash report was written to
…”, exit 70, terminal never restored. discovery.py:1663-1666 catches
only ImportError for the _browse_plain route; the _browse_tui
call at :1667 has no curses.error handler. Fix: wrap it in
except curses.error as e: return _browse_plain(entries, 'the terminal does not support curses
(%s)' % e) — curses.wrapper already calls endwin(), so the
fallback prints on a restored screen. README's browse section should mention the plain fallback.
The non-TTY fallback is a 765 KB dump that misdescribes itself (cluster
browse-plain-dump, med). browse < /dev/null piped emits 10,157 lines:
00-andruia-consultant appears 3× (per-agent mirrors, nothing distinguishing them),
rules and workflows carry no kind marker, the curated-★ column is an empty header with a
trailing │, and the footer says “10152 skills · install with
boost install <name>” over a catalog that is skills+rules+workflows. The
TUI itself already dedupes (browse.dedupe, discovery.py:1179) and renders
kind badges (:952-965), so the fallback lags browse's own intended presentation. Fix in
_browse_plain (discovery.py:926-933): reuse browse.dedupe, add
a kind column, drop the curated column when nothing is curated, and word the footer
“N items: N skills · N rules · N workflows” with a hint toward
boost search.
Found by the 2026-08 CLI audit (clusters browse-curses-fallback,
browse-plain-dump); repro in the audit log. Docs: README.md browse section
(~line 343); docs/commands.html regenerates only if the summary changes.
Complexity S
Impact Med
Wow ★
curses init failure = exit-70 crash + hosed terminal; piped fallback dumps 765 KB
PlannedCLI · UX
boost bundle: CLI audit findings (2026-08)
Local skills vanish into comments with no console notice (cluster
bundle-dump-local-notice, med). With 2 imported (tap=local) skills plus one tap skill
installed, bundle dump prints “✓ wrote Boostfile.local (1 tap, 1
skill)” and warns about rules/workflows — but says nothing about the local skills,
which appear only as # local skill (no tap source): ab-testing comments in the file.
Fires on any dump with local skills, not just the all-local edge. Fix in _bundle_dump
(boost_cli/commands/pkg.py:1211-1234): count the local entries and
out.warn “N local skills have no tap source and were written as comments” on
both paths, parallel to the existing rules/workflows notice.
The present check ignores tap and version (cluster bundle-present-check,
med). With brainstorming installed from sickn33/antigravity-awesome-skills v0.0.0, the
lines skill nosuch/tap:brainstorming and
skill sickn33/…:brainstorming@9.9.9 both yield exactly “Installed 0
skills, 2 already present”, exit 0, no warning — presence is
have_installed.get(sname) on the bare name and tapq/sver are
parsed then discarded (pkg.py:1281-1290). The code's own comment says a Boostfile is
“meant to be reproducible” (:1298), and the “Boostfile wants @X, tap
has Y” warning already exists on the fresh-install path (:1306-1308). Fix: compare
the lock entry's tap/version against the Boostfile line before counting present; warn on mismatch,
don't reinstall.
The dump omission notice is styled differently on the two paths (cluster
bundle-dump-warn-style, low). TTY bundle dump prints the notice as a bare
uncoloured print (pkg.py:1219) while bundle dump Boostfile sends the same
text through out.warn, yellow (:1233). One-line fix:
out.warn(msg, stream=sys.stderr) on both, keeping stdout a clean artifact.
Two small install-message gaps (cluster bundle-install-messages, low).
bundle install with no Boostfile reports the tautology “Error: no Boostfile at
Boostfile” — pathlib normalises ./Boostfile to the bare name
(pkg.py:1241-1244), and ./nosuch/Boostfile likewise loses its
./. And bundle install - < /dev/null (or a comment-only file) reports
“Installed 0 skills”, exit 0, with no hint that nothing was parsed. Fix: display
the resolved path through the existing _tilde(), and warn when zero tap/skill lines were
read (keeping exit 0).
Found by the 2026-08 CLI audit; repro in the audit log. All behaviour-only — regenerate
docs/commands.html only if the bundle summary in cli.py
COMMANDS changes.
Complexity M
Impact Med
Wow ★
mismatched tap/version lines count "already present" — the Boostfile stops being reproducible
ShippedCLI · Bug
boost catalog: CLI audit findings (2026-08)
Write-up
catalog --show silently truncates the tap table at 20 rows (cluster
catalog-show-row-cap, med). Reproduced offline with 22 taps: the heading says
“built … · 22 taps · 10,162 entries”, then a
TAP/ENTRIES/COMMIT table of exactly 20 rows and nothing after —
boost_cli/commands/taps.py:392 slices (manifest.get("taps") or [])[:20]
with no remainder line, so the heading's own count contradicts the table. --json
already carries all rows. Fix: when len(taps) > 20 print
out.dim("… and %d more (use --json for the full list)"), or drop the cap —
--show is explicit and bundles are usually small.
catalog --export packs local-directory taps with sender-machine paths as
URLs (cluster export-local-tap-urls, low). After tapping a local directory, the
bundle's manifest row carries url: /private/tmp/…/minio-copy; the receiver imports
it with no warning, and once the sender path is gone, boost update minio-copy fails with
“git clone failed: fatal: repository '/private/tmp/…' does not exist”
— leaking the sender's directory layout. catalogbundle.py's own docstring
(:24-27) says the URL exists “precisely so the receiving machine can clone”,
so this breaks the module's stated contract. The imported catalogue itself still searches fine, which
is why the verified fix flags rather than skips: mark scheme-less URLs local: true in
export_bundle and have import_bundle warn that N taps point at directories
on the exporting machine. README.md's bundle section (lines ~229-247) should note the
limitation.
Found by the 2026-08 CLI audit (clusters catalog-show-row-cap,
export-local-tap-urls); repro in the audit log. No docs/commands.html
regeneration unless the summary changes.
Complexity S
Impact Med
Wow ★
--show says "22 taps" then tables exactly 20; --export ships sender-machine paths as URLs
PlannedCLI · Bug
boost changelog: CLI audit findings (2026-08)
The shallow-clone hint fires on complete clones (cluster changelog-shallow-hint,
med). changelog cowboy-coding on a full local fixture clone prints its one commit and
then “(shallow clone: run git -C ~/.boost/repos/fixture-tap fetch --unshallow
for full history)” — but the clone has no .git/shallow file, and that
command fails on a complete clone. boost_cli/commands/quality.py:1159 gates the hint on
len(lines) < 3 — output length, not clone shape — so any short history
triggers it; it is only accidentally true for default remote taps, which really are shallow. Fix:
gate on (tap.path/'.git'/'shallow').exists() as well, plus a unit test with a full
fixture clone.
Rules and workflows are addressed by their directory, and changelog ignores the lock
entry (cluster rule-file-vs-directory, med; also hits home).
changelog csharp-reviewer — an installed workflow — fails with
“Error: 'csharp-reviewer' matches 3 different skills in affaan-m/ECC”, because
quality.py:1140-1153 resolves via lockfile.get_skill (skills only) then
catalog ambiguity, although the lock already records "source_file":
"ci-cd/dotnet-build.mdc"-style entries and lockfile.find_any
(lockfile.py:195, whose docstring names this exact failure class) sits unused. When
resolution does succeed, the log runs over rel_dir — the containing directory
(catalog.py:121-122) — so git log -- ci-cd covers every sibling rule,
masked today only by the depth-1 clone; following the command's own unshallow hint would surface it.
home --print dependency-management has the same shape: it links
…/tree/HEAD/dotnet-sdk (a folder of 11 rules) and actix-expert links
a folder of 138 workflows (info.py:865-891). One fix covers all three findings: resolve
installed names with lockfile.find_any, and for kind != skill pass
entry['source_file'] (catalog: entry['skill_md']) to
log_for_path and build /blob/HEAD/<file> URLs in cmd_home,
keeping rel_dir for skills; add a unit test with a two-commit repo touching two rules in
one directory.
Found by the 2026-08 CLI audit (clusters changelog-shallow-hint,
rule-file-vs-directory); repro in the audit log. Regenerate
docs/commands.html only if the home/changelog summaries change.
Complexity M
Impact Med
Wow ★
fetch --unshallow advised on complete clones; rules/workflows logged at directory granularity
PlannedCLI · Bug
boost chat: CLI audit findings (2026-08)
Referential follow-ups retrieve unrelated skills — including the suggestions chat itself prints.
Turn 1 "how do I review a diff?" ranks orch-review; chat then suggests
"what does orch-review actually do?", and typing that ranks orch-refine-code
above orch-review. Its other suggestion "which of these should I install
first?" returns teach, mercury-mcp, write-concisely —
nothing from the previous turn, and at 7 words it never even hits expand_query's
≤6-word gate (core/chat.py:111-128). Not AI-dependent: retrieval bounds any answer.
Fix: resolve referential follow-ups ("the second one", "which of these") against the previous
reply's retrieved skills instead of re-querying; on the no-AI path boost the previous hit set or
stop printing suggest_followups() questions the extractive path cannot answer
(core/chat.py:266-288, :380); rank an exactly-named skill first.
Found by the 2026-08 CLI audit (cluster chat-followup-retrieval); repro in the audit log.
The interactive "> " prompt is written to stdout when stdin is piped. Verified with
streams separated: three "> " lines in the stdout capture, stderr empty, and
chat < /dev/null ends "…Ctrl-D to exit\n\n> \n" — so a script
capturing answers gets prompt chrome mixed in. _chat_session calls
input("\n> ") unconditionally (boost_cli/commands/intelligence.py:1211);
gate it on sys.stdin.isatty() and add a functional test asserting no
"> " in piped stdout. Found by the 2026-08 CLI audit (cluster
chat-prompt-echo); repro in the audit log.
chat is the only command that accepts -k. search "…" -k 5
fails with "Error: unrecognized arguments: -k 5" while chat -k 5 works
(intelligence.py:1157); every other retrieval-limit sibling takes --limit
only. Add -k as an alias of --limit to cmd_search
(discovery.py:97) — and optionally the other limit commands — then regenerate
docs/commands.html and update docs/chat.html. Found by the 2026-08 CLI
audit (cluster search-k-alias); repro in the audit log.
Complexity M
Impact Med
Wow ★★
chat's own printed suggestion retrieves the wrong skill; its "> " prompt leaks into piped stdout
PlannedCLI · Bug
boost cohort: CLI audit findings (2026-08)
cohort apply drops not-found members from its summary, exits 0 even when nothing
applied, and journals no event. A cohort whose only skill exists in no tap prints
"! nosuchskill-zzz not found in any tap — skipped" then "applied: 0 installed, 0
already present" and exits 0 — the one member the cohort had is in neither count. Verified
broader than filed: a mixed cohort (brainstorming + a bogus name) reports
"applied: 0 installed, 1 already present", silently dropping the missing member, because
team.py's entry is None branch continues with no counter and
apply returns 0 unconditionally (boost_cli/commands/team.py:125-159).
Apply also writes no journal event, so pulse/who never show a
rollout happened — while create (team.py:103), delete (:121) and the
profile ops all journal, making the asymmetry plainly unintentional. Fix: count a
missing counter in the not-found path, print "applied: N installed, N already
present, N not found" (suppressed when 0), add journal.log("cohort", cname, op="apply",
installed=…, present=…, missing=…) per applied cohort, and return 1 when
missing>0 and nothing was installed or present. Drive-by from verification:
repeated --skills flags replace rather than append, so --skills a --skills
b creates a one-skill cohort. No doc changes. Found by the 2026-08 CLI audit (cluster
cohort-apply-reporting); repro in the audit log.
Complexity S
Impact Low
Wow ★
apply counts a mixed cohort as 1 of 2, exits 0 when nothing applied, journals no event
PlannedCLI · UX
boost completions: CLI audit findings (2026-08)
Generated completions never offer subcommand choices or policy keys.
__complete boost policy "", … policy set "", … schedule ""
and … completions "" all exit 0 with zero output, while the controls work
(… policy "--" → --json; … boost "poli" →
policy). Verified broader than filed: every command with a static
choices= positional — config, protocol, tag too
— completes nothing, because complete._source_for
(core/complete.py:123-161) knows only catalog/installed/tap sources. Not a duplicate of
the shipped completions-complete-only-command-names: that item's scope was names +
flags, and the choices tuples are exact, so offering them contradicts nothing in
complete.py:44-50's no-guessing rationale. Fix: scrape positional
choices=(…) tuples from the command source the way _flags_for scrapes
flags, with per-position sources so policy set <TAB> offers the
policy.DEFAULTS keys, degrading to nothing for non-literal choices. Found by the
2026-08 CLI audit (cluster completions-choices); repro in the audit log.
Shell detection fails silently. With SHELL=/usr/local/bin/nu,
boost completions prints the full bash script and bash install hint with no
warning; with $SHELL unset it still prints the bash script, and
--install errors with "no one-shot install for yet" — an empty shell
name and no mention that $SHELL is unset or that a shell can be passed positionally.
Root: configuration.py:769 falls back to "bash" uncommented and
:740 makes detected empty. Fix once at the detection site: error when
detected is empty ("cannot detect your shell ($SHELL unset) — pass bash, zsh or
fish"), warn before the bash fallback for a real unsupported shell, keep _rc_path's
message for the install path. No doc changes beyond the follow-up note in the shipped completions
item. Found by the 2026-08 CLI audit (cluster completions-shell-detection); repro in
the audit log.
Complexity M
Impact Low
Wow ★
TAB after `policy set ` offers nothing; unset $SHELL gets a bash script with no warning
ShippedCLI · Bug
boost config: CLI audit findings (2026-08)
Write-up
config unset on a defaulted key always reports success and rewrites the file.
A second config unset ai.enabled — the key already gone from
config.json — prints "✓ unset ai.enabled", exits 0, and rewrites the
file (mtime verified moving); only a key with no default gets "not set". Verification
found it worse than filed: on a pristine HOME with no config.json at all,
config unset telemetry prints "✓ unset telemetry" and creates
config.json with 797 bytes of the current DEFAULTS materialised into it — freezing them against
future default changes. Root: core/config.py:263's unset() walks the
DEFAULTS-merged load() (its own docstring promises "False (no write) if absent"), so
every defaulted key is present forever and save() writes the merged view. Fix: walk
the raw on-disk overrides, save only that dict, return False with no write when the key is absent
from the file; pin the docstring parity with a unit test.
Sibling: with ~/.boost removed, config list prints the defaults
followed by ~/.boost/config.json as their source — a path that does not exist
(commands/configuration.py:78 prints it unconditionally). Vary the dim trailer when
paths.config_path() is missing: "defaults — ~/.boost/config.json not created
yet". No doc changes. Found by the 2026-08 CLI audit (cluster
config-state-reporting); repro in the audit log.
Complexity S
Impact Low
Wow ★★
`config unset` on a pristine HOME creates config.json and freezes all defaults into it
PlannedCLI · Bug
boost context: CLI audit findings (2026-08)
context and impact conflate "git missing from PATH" with "not a git
repo", and impact's note claims a repo outside one. With git removed from PATH and the cwd a
real git repository, context status prints "branch (not in a git
repository)", context apply says "not inside a git repository — nothing to
apply", and JSON gives "branch": null with no hint — all exit 0.
gitutil.run itself distinguishes the missing binary ("git is required but was not
found on PATH"), so the information exists and is dropped: _current_branch
(boost_cli/commands/intelligence.py:139-145) returns None for both
not has_git() and a non-repo cwd, and every caller words None the same
way.
The other half: outside any repo, impact brainstorming still prints
"correlation, not causation — commits since install in this repo" (identical in JSON, with
commits_since: null), because _IMPACT_NOTE
(intelligence.py:1087-1090) is emitted unconditionally although in_repo
is computed right above it (:1050-1054) — and the — placeholder
sits left-aligned under a right-aligned numeric column. Fix: branch the note on
in_repo (text and JSON, e.g. "not inside a git repository — commit counts
unavailable"), right-align the placeholder, and make _current_branch/impact
distinguish has_git()==False — print "(git not found on PATH)" and expose
"git": false in JSON. No doc changes. Found by the 2026-08 CLI audit (cluster
git-state-misreport); repro in the audit log.
Complexity S
Impact Med
Wow ★
git missing from PATH is reported as "not in a git repository", even inside a repo
PlannedCLI · Bug
boost deps: CLI audit findings (2026-08)
A transitive unmet requirement is shown as ✗ but exits 0, and the two JSON modes disagree on
shape. deps dep-child prints ↳ ghost-skill ✗ not installed yet
exits 0, while deps (all-installed mode) exits 1 for the same fact — in
cmd_deps, problems (info.py:915-917) tests only direct
requires while the renderer walks one level deeper, so the command shows a problem its exit code
denies (single-name --json has the same hole). The envelopes were written separately
and share nothing: deps --json gives
{"unmet":[{"skill","requires"}],"conflicts":[[a,b]]} where single-name mode gives
{"name", "requires":[{"name","installed","requires"}], "conflicts":[{"name","installed"}]}
— same facts, keyed skill vs name, conflicts as bare pairs vs objects,
and nested requires are bare strings with no installed flag. Fix in
info.py:897-973: fold displayed sub-requirement states into problems,
emit nested requires as {name, installed} objects, unify the two envelopes on one
requirement/conflict record shape, and print a one-line summary with a
boost install <missing> hint when problems exist.
deps prints requires: (none) for the only requires: shape the shipped
corpus actually uses. seismic-automation's SKILL.md declares requires: /
mcp: [rube] (the audit counted 832 composio SKILL.md files using this mapping form,
and none in the 20 taps using a name list); deps seismic-automation answers
requires: (none) exit 0 while info seismic-automation shows
mcp servers rube — the two commands contradict each other about what the skill
needs, because _as_list (info.py:177-183) handles only string/list
forms. Fix: when meta["requires"] is a mapping, surface its mcp list
(reuse store.declared_mcp_servers, which cmd_info already reads at
info.py:448) as e.g. requires: mcp rube (not registered) and emit an
mcp key in --json; keep plain name lists unchanged. No flag changes, so
docs/commands.html is untouched. Found by the 2026-08 CLI audit (clusters
deps-exit-and-json, deps-requires-mcp); repro in the audit log.
Complexity S
Impact Med
Wow ★★
deps shows ✗ not installed yet exits 0; the only real-world requires: shape reads as (none)
PlannedCLI · UX
boost discover: CLI audit findings (2026-08)
The empty-result footer says "GitHub could not be reached" whatever the real reason.
With gh absent: "! GitHub search needs the `gh` CLI" then "this searched
a local sample of 300 entries because GitHub could not be reached"; with gh present but
rate-limited (a real HTTP 403 — GitHub answered), the same footer. discovery.py:820-821
hardcodes the phrase for every non---local fall-through although
_fall_back already holds the real reason (gh missing at :722, code
search failed at :727). Fix: pass the fall-back reason into the footer —
"because the `gh` CLI is not installed" / "because GitHub code search failed" —
or the neutral "because GitHub was not searched (see above)".
discover --json with no index prints [] silently. Fresh HOME:
stdout [], stderr empty, exit 0 — a script cannot tell "nothing indexed" from "no
matches", while the text path explains itself and the live-fallback path already warns on stderr
under --json. _fall_back's own docstring
(discovery.py:704-713) states the convention: its lines go to stderr "so it
survives --json: suppressing it was the defect". Fix: in the not dpath.exists()
branch (discovery.py:781-784) emit the same "discovery index has not been built yet /
build it with boost index" lines via out.info(…, stream=sys.stderr)
before printing [].
The local-index table is per file where the live table is per repo. Local, no query:
25 rows over 7 repos, agent mirrors included (acme/skills skills/skill-0/…,
dave/mono .claude/skills/s10/SKILL.md); live, same fake corpus: 7 collapsed rows
(acme/skills (15)). Verification found it broader than reported: on a queried live
search that fell back (rate-limited), one repo filled the entire --limit with itself
(6/6 rows = acme/skills) — exactly the pathology _by_repo's docstring
(discovery.py:671-698, "the repo is the unit that belongs on screen") says discover
exists to avoid. Fix: run the local branch's hits through _by_repo() before slicing
to --limit, render the same (N) repo-count cell, reword the footer to
count repos; keep --json rows per-file with source:"local-index"
unchanged, and update tests/bdd/features/discover.feature row assertions. No flag
changes, so docs/commands.html is untouched. Found by the 2026-08 CLI audit (clusters
discover-fallback-reason, discover-json-empty-note,
discover-table-shapes); repro in the audit log.
Complexity S
Impact Low
Wow ★
footer blames the network when gh is missing; --json prints [] silently with no index
PlannedCLI · Bug
boost edit: CLI audit findings (2026-08)
edit rewrites the lock sha, so drift misclassifies a local edit as upstream-moved. After an
edit, boost warns “local edits diverge from the tap source — boost drift will flag
this” — and then boost drift prints
brainstorming upstream-moved boost update, recommending the one
command that would discard the edit, although the tap is pinned and unchanged.
cmd_edit (info.py:601-611) overwrites lock["sha256"] with the
post-edit store hash, so staleness.drift_state's store≠lock test
(staleness.py:52-77) can never fire LOCAL_EDITS and falls through to
UPSTREAM_MOVED. Fix: stop rewriting the lock sha — record the edit in the journal
only (already done) or under a separate local_sha256 key.
edit exits 0 with a green success line after the editor itself failed. With
EDITOR=false: “! editor exited with status 1” immediately followed
by “✓ no changes”, exit 0 — a failed editor launch reported as a
clean no-op. In cmd_edit (info.py:596-611), when rc != 0
return 1 right after the warning and skip the sha compare and success messaging.
Both fixes change edit's documented behaviour, so regenerate docs/commands.html.
Found by the 2026-08 CLI audit (clusters edit-drift-classification, edit-editor-failure-exit); repro
in the audit log.
Complexity S
Impact Med
Wow ★
editor fails → green "✓ no changes", exit 0; drift calls a local edit upstream-moved
PlannedCLI · Bug
boost evolve: CLI audit findings (2026-08)
evolve accepts empty --feedback and has no stdin/file form.
evolve brainstorming --feedback "" exits 0 and diffs in
+## Feedback (2026-08-31) followed by nothing, plus a bump to
version: 0.0.1 — with --apply that empty section lands in the store
and the lock. --feedback - becomes the literal bullet +- -. and
--feedback @/dev/null becomes +- @/dev/null.. In cmd_evolve
(intelligence.py:663-713) raise BoostError when
args.feedback.strip() is empty before calling the AI or heuristic; treat
- as read-from-stdin and @path as read-from-file, documented in
--help (then regenerate docs/commands.html).
evolve --apply leaves the revision unpinned, so a later
boost update can silently overwrite it. After --apply the lock holds
the evolved sha and pinned: false, and evolve prints only
“✓ evolved brainstorming”; pkg.py's update loop
(pkg.py:1035-1039) skips only pinned/quarantined/local entries, so once the tap moves,
store.install(entry, force=True) (pkg.py:1063-1067) replaces the revision
with no warning. On --apply set entry["pinned"] = True (or a
local_revision flag the update loop honours) — at minimum print
“boost pin <name> to keep this across boost update” after the success line.
After evolve, info claims an update to a lower version while
outdated says up to date. With the lock at 0.0.1 and the tap at 0.0.0,
info prints “[update available] … version 0.0.1 / latest 0.0.0 (update
available)” — info.py:477-479 and 498-501 test
latest != inst_v (string inequality) where cmd_outdated
(taps.py:285, 340) correctly uses util.semver_gt for the same
decision. Replace both checks with util.semver_gt(latest, inst_v) and label the
locally-ahead case (e.g. local revision, tap has an older 0.0.0).
Found by the 2026-08 CLI audit (clusters evolve-feedback-input, evolve-revision-unpinned,
naive-version-comparison); repro in the audit log.
Complexity M
Impact Med
Wow ★
empty --feedback writes an empty section + version bump; the revision is left unpinned
PlannedSafety · Bug
boost explain: CLI audit findings (2026-08)
The faithfulness guardrail scores a fully fabricated summary 1.0 when the fabrications are proper
nouns. With a canned claude replying “This skill provisions Kubernetes
clusters with Terraform and migrates PostgreSQL schemas through Flyway. It triggers whenever the
agent sees a Dockerfile” — none of it in brainstorming's SKILL.md —
explain brainstorming printed the reply verbatim with no caveat, exit 0:
faithfulness.score() returned 1.0 because salient_terms() came back empty.
Only backtick spans, code-punctuated/digit tokens, --flags and ALL-CAPS acronyms count
(faithfulness.py:37-42, 50-67), so Kubernetes, Terraform, Flyway and
Dockerfile pass as general English — although the module's own docstring
(faithfulness.py:14-16) says the failure worth catching is a model that “names
a command, flag, tool, or file the SKILL.md does not contain”. Verification found it
broader than reported: one grounded backtick term (brainstorming) in the reply also
scores 1.0, so a single real term whitelists any amount of proper-noun fabrication.
Fix: extend salient_terms() with capitalised non-sentence-initial tokens
absent from a small stop-list, and pin a test that the Kubernetes/Terraform reply scores below the
0.5 threshold — or always print a one-line AI summary caveat so a shown explanation is
never mistaken for source text. Update
docs/roadmap/items/runtime-explain-faithfulness-guardrail.md (the shipped guardrail this
follows up) to record the scope limit. Found by the 2026-08 CLI audit (cluster
explain-faithfulness-gap); repro in the audit log.
Complexity M
Impact Med
Wow ★★
fabricated Kubernetes/Flyway summary scores faithfulness 1.0 and prints verbatim
PlannedCLI · Bug
boost export: CLI audit findings (2026-08)
export picks the archive format from --zip alone, so -o x.zip writes a
gzip tarball named .zip. export brainstorming -o byname.zip produces
“gzip compressed data” per file(1); --zip -o byflag.tar.gz
produces “Zip archive data” — both report “✓ exported 1
skill” with no hint, and the mislabelled archive fails downstream tools with an opaque
error. pkg.py:1649 computes the extension but uses it only for the default filename;
pkg.py:1654 branches the writer on args.zip alone. Infer the format from
dest.suffix when -o is given and --zip absent, warn when they
contradict, and add a functional test asserting zipfile.is_zipfile on a
-o x.zip export; regenerate docs/commands.html.
export's “repair with boost sync” hint drops a local skill from
the lock instead of repairing it. With a tap=local skill's store dir missing but
its recorded source_dir still on disk, export says “hint: repair with `boost
sync`” — and sync then prints “✓ dropped ab-testing from lock
(store dir missing, source gone)”, a message that is itself false while the source
exists. store.sync_apply gates repair on tap_name != "local"
(store.py:1639), yet boost reinstall in the same state already performs
the exact repair (pkg.py:1153-1168) — the hint names the one command that
destroys instead of the one that fixes. Teach sync_apply's missing-store branch to call
install_from_path when tap=='local' and source_dir has a
SKILL.md, and branch cmd_export's hint (pkg.py:1644-1646) to name
boost reinstall <name> for local skills.
The hand-built Boostfile member carries inconsistent metadata in both archive
formats. tar tzvf shows -rw-r--r-- 0 0 0 … Boostfile beside
drwxr-xr-x 0 jonny wheel … brainstorming/ — extracting as root yields a
root-owned Boostfile next to user-owned files — and the zip branch gives the Boostfile mode
0o600 against the skill files' 0o644. Verification prefers the opposite normalisation to the
auditor's: pass a filter to tf.add (pkg.py:1654-1670) that zeroes
uid/gid and blanks uname/gname on every member to match the Boostfile —
deterministic archives that stop leaking the local username — and in the zip branch write the
Boostfile via a ZipInfo with external_attr = (0o644|S_IFREG)<<16.
Found by the 2026-08 CLI audit (clusters export-archive-format, sync-local-source-repair,
export-tar-ownership); repro in the audit log.
Complexity M
Impact Med
Wow ★
-o x.zip writes a gzip tarball; the repair hint drops the skill from the lock instead
PlannedCLI · Bug
boost import: CLI audit findings (2026-08)
import loses provenance, both ways (med). Importing over a tap-installed skill
prints the normal four ✓ lines and rewrites the lock to tap='local',
commit='' with no notice that the skill just lost its update source. And a URL import records
the temp clone as source_dir — cmd_import
(pkg.py:1350-1367) clones to a mkdtemp and rmtrees it in
finally — so boost info shows a dead path and
boost reinstall fails: “local source … is gone — skipped /
Reinstalled 0 skills”, exit 1. The URL and cloned HEAD commit are known at import time and
simply dropped. Fix: pass them into store.install_from_path, teach reinstall's local
branch (pkg.py:1154-1162) to re-clone when source_dir is gone but a URL is
recorded, and warn when a non-local lock entry is replaced. Regenerate docs/commands.html
only if the import help changes. (Cluster import-provenance-loss.)
--agent narrows the declaration but leaves the links (low).
Re-importing an installed skill with --agent cursor prints ✓ linked →
cursor and sets only_agents=['cursor'], but the other three symlinks stay; the very
next sync --diff reports “linked outside declared scope (3)”.
Emit one warn after link_agents naming the out-of-scope links and the
boost sync remedy — pruning can stay sync's job
(pkg.py:1372-1376). (Cluster import-agent-scope-links.)
The multi-skill table pre-cuts descriptions at 60 chars (low). At
COLUMNS=200 rows end mid-word — “implement an A/B tes” — with ~120
spare columns unused, because pkg.py:1413 slices
(e["description"] or "")[:60] before out.table gets to fit and ellipsise
the column (output.py:746-777). Drop the pre-slice; one line.
(Cluster import-desc-truncation.)
Errors print above the tables they refer to (low, shared with
policy check). out.err (output.py:240-244) writes to
stderr without flushing block-buffered stdout, so any piped capture shows “Error: multiple
skills found” before the listing it refers to. One central fix:
sys.stdout.flush() at the top of out.err/warn, covering
cli.py:317-321, configuration.py:497-503 and every future caller. Found by
the 2026-08 CLI audit (cluster stderr-stdout-ordering); repro in the audit log.
Complexity M
Impact Med
Wow ★
import turns a tap install into "local" silently; a URL import records a deleted temp path
PlannedCLI · Bug
boost index: CLI audit findings (2026-08)
The live progress bar is never cleared before an error (med). On a TTY a failed
GitHub search prints “▓▓▓░░░ 1/3 searching GitHub for
SKILL.mdError: GitHub code search failed” on one line, and a failed page 2 glues
“! page 2 failed” onto the bar the same way. spin.progress
(spin.py:69-82) clears the line only when current >= total, and
cmd_index raises and warns mid-loop (discovery.py:578-598) without
clearing. Fix: add a spin.progress_clear() helper and call it before the raise and the
warn — other spin.progress callers with early exits get it too.
(Cluster index-progress-clear.)
Zero results gets a ✓ and destroys the previous index (low). After a
150-entry build, index zzzznomatch prints “✓ indexed 0 skill files
across 0 repos (GitHub reports 0 total)”, exit 0 — and discovery.json
now holds 0 items, so boost discover --local shows nothing until the next successful
build. discovery.py:619-627 writes unconditionally and prints out.ok even
for an empty result. Fix: on not items, warn “no SKILL.md files match …
— keeping the previous index of N entries” and return 0 without writing (write an
empty index only when none exists). (Cluster index-empty-overwrite.)
A rate-limit failure echoes raw multi-line gh stderr as the hint (low).
The hint is gh's own prose plus a JSON blob, with continuation lines at column 0 and no
boost-native advice — while sibling paths in the same function do map their errors
(discovery.py:567-569, :601-603), the returncode!=0 branch
(discovery.py:590-595) passes the last three stderr lines straight through. Fix: detect
rate limit/HTTP 403/gh auth login and raise with a one-line hint
(“GitHub rate limit hit — wait a minute or authenticate: gh auth login /
GH_TOKEN”), and make out.err indent hint continuation lines. Found by the 2026-08
CLI audit (cluster index-ratelimit-hint); repro in the audit log.
Complexity S
Impact Med
Wow ★
index: errors glue onto the live progress bar, and 0 results overwrites a 150-entry index
PlannedCLI · Bug
boost install: CLI audit findings (2026-08)
Rule/workflow lock entries are name-keyed across scopes (med). With the
benchmarking rule at user scope, install benchmarking --local in a project fails
“Error: benchmarking is already installed / hint: boost reinstall benchmarking
to force” — the project has no copy, and the hint would reinstall the user one. The
reverse direction blocks too, and skills coexist fine (separate project lock). Worse, the
--force escape overwrites the user-scope lock entry with the project one, orphaning the
user materializations so uninstall can no longer clean them. _install_rule
(store.py:836-839) and _install_workflow (store.py:1074) gate
on a name-only lookup with no scope/base comparison. Fix: key entries by scope (or compare
existing scope/base before raising), word the error “already installed at user
scope”, and refuse a cross-scope --force overwrite without cleanup. Docs:
README's install-scope section (~301-328) and
docs/roadmap/items/install-scope-user-or-project.md.
(Cluster cross-scope-name-block.)
--path says “under path” but matches suffix-only
(low). --path plugins/tdd/skills is refused while the error's own hint lists
plugins/tdd/skills/test-driven-development — a path that is under it.
Suffix matching is the shipped design (install-path-disambiguation, PR 483); the wording
is the defect. Reword the raise in catalog.py:~502 to “no copy of X whose path ends
with Y” and hint “pass a trailing segment of one of: …”.
(Cluster install-path-prefix-match.)
The MCP offer never shows the runnable command (low). The server row prints
only demo-echo npx though the sidecar declares npx -y
@example/demo-echo-mcp plus env, and on decline the hint is a literal elided
claude mcp add …; the full argv only prints when the host CLI is
missing. _offer_mcp renders how from spec['command'] alone
(pkg.py:161-164) and mcpdecl.register_argv already exists
(pkg.py:201) — render command+args, print the joined argv on decline, and indent
the confirm prompt to match its neighbours. (Cluster mcp-offer-command-detail.)
The typosquat warning prints three times (low).
install NeoLabHQ/context-engineering-kit:test-driven-development --dry-run prints the
identical “closely resembles test-driven-development
(sickn33/antigravity-awesome-skills)” warning 3×, one per mirror copy in the
look-alike tap. De-duplicate find_confusions on (name.lower(), tap)
(typosquat.py:79-87) so the [:3] slice in _warn_confusions
covers three distinct look-alikes. Found by the 2026-08 CLI audit (cluster
typosquat-warning-dupes); repro in the audit log.
Complexity M
Impact Med
Wow ★
a rule at user scope blocks the same name --local, and --force orphans the first scope
PlannedCLI · Bug
boost log: CLI audit findings (2026-08)
The diagnostic trail journals a crash code for exits that were fine. After
boost edit --help (real exit 0) and boost edit (a usage error, real exit 2),
boost log --diagnostics shows “WARNING boost: done: boost edit --help -> rc=70 in
5ms” and “WARNING boost: done: boost edit -> rc=70 in 4ms”. The mechanism is
exact: cli.py:313 presets rc=70 “until a handler proves otherwise”, argparse
raises SystemExit, and none of the except clauses at cli.py:317-339 catch it (they
catch Exception; SystemExit derives from BaseException) — so the
finally at cli.py:340-344 journals 70 at WARNING for every help and usage exit, while
BoostError and success runs log their true rc.
The trail's whole job (docs/DEBUGGING.md:52-53) is truthful rc lines, and this stamps the
one code reserved for genuine unexpected errors onto the most common benign exits — anyone grepping the
journal for real crashes wades through a WARNING per --help. Fix: in cli.py
_run, add except SystemExit as e: rc = e.code if isinstance(e.code, int) else 1;
raise before the finally, so completion logs the real exit and WARNING stays meaningful.
Update docs/DEBUGGING.md where it describes the trail. Found by the 2026-08 CLI audit
(cluster log-records-rc70); repro in the audit log.
Complexity S
Impact Med
Wow ★
every --help and usage error is journaled as rc=70 at WARNING — real exits 0 and 2
PlannedCLI · Bug
boost mcp: CLI audit findings (2026-08)
Three truthfulness gaps in one command, all verified against the real CLIs.
boost mcp has no --dry-run/--print: register shells straight into
claude mcp add … / agy mcp add …, and the argv boost will run is
only visible in the one case where the child CLI is missing — though
mcphost.argv() makes printing it trivial and siblings (clean,
compact, onboard, self-update) all offer --dry-run.
Add one to cmd_mcp (boost_cli/commands/configuration.py:1674-1779) that prints
the resolved argv and installed-or-not per host, then exits 0 without running or seeding.
Second: mcp --host gemini with no gemini on PATH prints
“! `gemini` CLI not found — run this yourself: …” and exits 0 having
registered nothing — fine for auto, which says what it looked for, but a script naming
one host gets success for a no-op. Return 1 when an explicitly named host's CLI is missing.
Third: with nothing registered, mcp unregister --host gemini prints
“✓ unregistered boost as an MCP server for Gemini CLI (scope: user)” exit 0, while
Gemini's own argv (Gemini CLI 0.57.0) prints Server "boost" not found in user settings.
— on stderr, which _run_mcp_host
(configuration.py:1553-1568) drops while mapping any rc-0 child to “ran”. Scan
stdout+stderr for a not-found marker on unregister and report “not registered — nothing to
do”, mirroring register's already registered path. Docs: regenerate
docs/commands.html for the new flag and update README.md's mcp section. Found by
the 2026-08 CLI audit (cluster mcp-command-truthfulness); repro in the audit log.
Complexity M
Impact Med
Wow ★
no --dry-run; a named missing host exits 0; unregister claims success Gemini denies
PlannedSafety · Bug
boost onboard: CLI audit findings (2026-08)
boost onboard commits the machine's global lock file into the repo verbatim:
cmd_onboard (boost_cli/commands/configuration.py:593-675) writes
json.dumps(lockfile.read(), …) as the repo's .skill-lock.json
(configuration.py:621-622) with no path sanitization. Verified in the
--dry-run preview: with the rule dotnet-build installed, the file carries
rules.dotnet-build.materializations[].path values like
…/.claude/CLAUDE.md and …/.windsurf/rules/dotnet-build.md rooted at
the absolute $HOME — on a real machine, /Users/<name>/… —
and --pr pushes exactly that to GitHub.
A repo inventory needs names, taps and commits, not one contributor's username and dotdir layout;
every teammate who runs onboard afterwards would churn the file with their own paths. Fix: in
cmd_onboard, project the lock before writing — strip
materializations[].path or rewrite it ~-relative — so the committed file is
portable. docs/carousel.html (line 362) describes the onboard flow and must match. Found by
the 2026-08 CLI audit (cluster onboard-lock-path-leak); repro in the audit log.
Complexity S
Impact Med
Wow ★
onboard --pr pushes absolute /Users/… paths from the global lock file to GitHub
PlannedCLI · Bug
boost outdated: CLI audit findings (2026-08)
boost outdated handles the same condition two ways depending on item kind. With the skill
brainstorming installed and its source tap untapped, outdated prints
“✓ everything up to date” — the skill is silently omitted. Do the same to a
rule and it is reported honestly: “code-signing (rule) 0.0.0 source missing
Aaronontheweb/dotnet-cursor-rul…”, with the footer “1 outdated · `boost
update` upgrades (pinned items stay put)” — a promise boost update cannot keep
for an item whose source is gone.
The asymmetry is mechanical and looks unintentional: the skill loop
(boost_cli/commands/taps.py:273-280) silently continues when
catalog.find has no row for the tap, while the rule/workflow loop
(taps.py:318-344) catches the failure and appends a source missing row — the skill
path has no comment justifying the skip. Fix: in the skill loop, append a source missing result
instead of continue, matching the rule path, and word the footer per reason so it never sends
source-missing rows to boost update. No flag changes, so docs/commands.html needs no
regeneration. Found by the 2026-08 CLI audit (cluster outdated-untapped-skill); repro in the
audit log.
Complexity S
Impact Med
Wow ★
an untapped skill vanishes from outdated; the same untapped rule shows "source missing"
PlannedCLI · UX
boost preview: CLI audit findings (2026-08)
Preview strips markdown markers with no substitute when piped, and leaks raw ** when a bold span straddles a wrap boundary. Piped preview brainstorming shows 0 lines containing ** where piped cat shows the raw 22 — the markers are removed and no colour replaces them, --- prints literally, and a wrapped > quote continuation loses its >; neither raw Markdown nor a faithful render. On a TTY at COLUMNS=60 the reverse bug: 10 lines leak literal ** (0 at 120 columns) because _render_markdown wraps first and runs _inline per chunk, and out.wrap's atomic-span protection (output.py:369-390, _CODE_SPAN_RE) covers only backtick spans. Fix per the verified recommendation: in cmd_preview (boost_cli/commands/info.py:614-677) emit the raw body when not sys.stdout.isatty(), mirroring boost cat; and extend the atomic-token handling in out.wrap to treat **…** like a backtick span (or apply _inline before wrapping with bold state carried across chunks). Regenerate docs/commands.html.
The title bar shows v? where info/list/search show 0.0.0 for the same skill. Observed: ● ● ● brainstorming · v? · sickn33/antigravity-awesome-skills while info prints version 0.0.0 — and verification found it broader than filed: every skill whose SKILL.md omits version: disagrees, installed or not, because cmd_preview reads raw frontmatter while catalog.scan_dir normalises a missing version to "0.0.0" (core/catalog.py:117). The same titlebar line already falls back to the lock/catalog for the tap; version simply missed the pattern. One-line fix at info.py:673: meta.get("version") or (lock or cat or {}).get("version") or "?", plus a unit test asserting preview and info agree.
Found by the 2026-08 CLI audit (clusters preview-markdown-render, preview-version-placeholder); repro in the audit log.
Complexity S
Impact Med
Wow ★
piped preview strips ** with no substitute; at 60 cols 10 lines leak raw markers
PlannedCLI · Bug
boost profile use: CLI audit findings (2026-08)
profile use omits the version-drift warning diff reports, and a declined --prune still reports a clean switch with extras fully linked. Reproduced: profile diff daily prints ~ brainstorming (version differs) but profile use daily says only ✓ switched to profile daily — team.py:335 discards the _changed tuple element that diff prints at team.py:315-316. Worse, with an extra skill installed and the --prune confirm declined via EOF, the output is kept extras installed then the same unconditional ✓ switched to profile daily, and the extra's symlink was confirmed still present in .claude/skills/ — the non-prune path would have sidelined it, so the checkmark claims a state the machine is not in.
Verified fix (boost_cli/commands/team.py:333-367): print a warn line per entry in _changed, mirroring diff's "~ NAME (version differs)" wording; and after a declined --prune confirm either fall through to the sideline/unlink branch or drop the trailing checkmark and say the switch was partial. No flag changes, so no docs regeneration needed.
Found by the 2026-08 CLI audit (cluster profile-use-drift-prune); repro in the audit log.
Complexity S
Impact Low
Wow ★
declined --prune leaves extras fully linked yet still prints "✓ switched"
PlannedCLI · Bug
boost protocol: CLI audit findings (2026-08)
protocol status on macOS reads as registered when only the handler script exists. Verified on real Darwin: protocol register writes only ~/.boost/state/boost-protocol-handler.sh plus four manual Automator steps — no Launch Services or URL-scheme call (team.py:433-467) — yet status then prints handler ~/.boost/state/boost-protocol-handler.sh in the same slot whose negative form reads handler not registered. Presence reads as "registered", but a boost:// link in a browser does nothing until the user builds Boost.app. Fix (boost_cli/commands/team.py:482-498): on Darwin print the script path under a distinct script key and a separate registered key defaulting to "no — build Boost.app (see boost protocol register)". Update the protocol entry in docs/commands.html (regenerate; no flag change) and README.md if it describes one-click install on macOS.
protocol open boost://install/… bypasses install's reporting and via= journaling. Reproduced: the install verb prints three bare lines — no summary box, no Gemini line, no quality score — and pulse -n 2 shows the install event with only tap=/version= extras, no via=protocol. The asymmetry sits inside one function: the tap branch adds journal.log(..., via='protocol') (team.py:429) while the install branch (team.py:406-414) hand-rolls its output and store.install's journal call (store.py:587) takes no via kwarg. Route the install branch through the reporting helper pkg.cmd_install uses and thread an optional via kwarg to the journal call so one-click installs can be told apart the way taps already are.
Found by the 2026-08 CLI audit (clusters protocol-darwin-status, protocol-install-parity); repro in the audit log.
Complexity S
Impact Med
Wow ★
macOS status reads "registered" though register never calls Launch Services
PlannedCLI · UX
boost pulse: CLI audit findings (2026-08)
pulse --action and who <name> claim "no activity yet" when only the filter is empty. Reproduced with a populated journal: pulse --action nosuch prints ○ no activity yet — events appear as you install and manage skills and who nosuchskill-zzz prints ○ no journal activity yet — expertise builds as people install, edit, and evolve skills — while pulse.jsonl held 35 events at audit time (23 on re-verification; both matched verbatim). Both commands compute a filtered events list and print the same global empty state when it is empty, with no branch distinguishing filter-empty from journal-empty — so the output asserts a state ("nothing has happened on this machine") that is simply false, and hides that the user's filter value matched nothing.
Verified fix (boost_cli/commands/team.py:518-522 for cmd_pulse, team.py:692-702 for cmd_who): when args.action / the skill argument is set and the unfiltered journal is non-empty, print a filter-specific empty state naming the filter value — e.g. ○ no events with action "nosuch" (35 events in the journal) — ideally listing the distinct actions present; for who, name the subject and say "not installed" or offer a did-you-mean when the name is close to a known one. Keep the current message only for a truly empty journal. No flag changes, so no docs regeneration needed.
Found by the 2026-08 CLI audit (cluster filtered-empty-state); repro in the audit log.
Complexity S
Impact Med
Wow ★
a filter matching 0 of 35 events prints the same line as an empty journal
PlannedCLI · Bug
boost quickstart: CLI audit findings (2026-08)
Without the [rag] extra, quickstart taps unpinned at HEAD — and the rerun it promises cannot fix it. cmd_quickstart only fetches the manifest (the source of pins) when want_vectors is true (boost_cli/commands/quickstart.py:145-152), so on a machine without a dense backend the six new taps land with pin: null while the output ends “…install the extra…, then boost quickstart again”. The second run prints <tap> already tapped for all seven (registry.add_many skips existing taps, never re-pins), and once the extra is present shards.sync refuses every mismatched commit: refused (tap is at X, shard is for Y). That contradicts the module's own docstring — “Pinning is the whole point”. Fix: fetch the manifest and pin regardless of dense.have_backend() (pinning is a network-and-config operation, not an embedding one), and on rerun retarget already-tapped registries via shards.ingest instead of skipping them. Update README.md (quickstart section, ~line 145) and docs/semantic-search.md (~line 63).
The [rag] install hint has three different wordings. quickstart says pipx inject boost-skill-cli "boost-skill-cli[rag]" (hard-coded at quickstart.py:175-177 and 202-204); reindex's embed.fallback_note (boost_cli/core/embed.py:170-179) says unquoted pip install boost-skill-cli[rag], which fails in zsh (no matches found); doctor and search say quoted pip install 'boost-skill-cli[rag]' via dense.fix_hint(). CLAUDE.md's rule is that doctor and search read one table so they cannot contradict — these two surfaces bypass it. Fix: have embed.fallback_note() and both quickstart paths call dense.fix_hint(); if pipx wording is wanted, put install-method detection inside fix_hint so every caller inherits it. docs/semantic-search.md is already quoted — keep it as the reference.
Found by the 2026-08 CLI audit (clusters quickstart-pinning, rag-hint-drift); repro in the audit log.
Complexity M
Impact Med
Wow ★★
without [rag] quickstart taps unpinned at HEAD, and a rerun can never pin them
PlannedCLI · Bug
boost recommend: CLI audit findings (2026-08)
The curated fallback repeats one name, JSON omits it entirely, and sibling commands disagree on whose entry wins. With curated taps and an unrecognised project, recommend prints “no stack-specific matches — curated picks instead:” followed by 8 rows carrying only 2 distinct names (python-patterns ×6 — its es/ja/tr/zh mirrors from one tap — react-patterns ×2): the fallback list-comps raw entries with no dedup (boost_cli/commands/discovery.py:899-906) while the keyword path dedups by name at :876 and trending at :1707. In the same directory recommend --json returns "recommendations": [] because the as_json branch (:885-889) returns before the fallback runs. And for one name shipped by several taps, trending shows the last tap's description (dict comprehension) where recommend keeps the first (agg.setdefault) — verified with python-patterns showing two different descriptions. Fix: dedup the curated fallback by name or content digest before slicing to --limit; compute the shown set (curated included) before the JSON/text split so both modes carry the same list, tagged because: ["curated"]; in cmd_trending prefer the lock's tap (or catalog.find(name)[0]) over last-entry-wins.
recommend --json and search --json dump raw catalog entries including the internal search_blob — ~36% of the payload. Measured: 15 search items = 22,855 B with search_blob in all 15 (mean 401 / max 881 B per item); 8 recommend rows = 10,559 B, 36.1% blob. The codebase already classifies it as internal: serve.public_row() (boost_cli/core/serve.py:335-337) strips it with the comment “index fuel and not display data”. Fix: move that projection into core (catalog.public_entry()) and apply it in both --json branches (discovery.py:135, :886); keep content, tap, skill_md; pin with a unit test asserting no search_blob key.
The stack line omits keywords the because: column then cites. In the boost worktree: stack: javascript, python · frameworks: pytest followed by because: ci, python — ci is matched at discovery.py:874 but the line at :890-892 only prints languages and frameworks. Fix: append the extra keywords, e.g. · also: ci.
Found by the 2026-08 CLI audit (clusters recommend-trending-provenance, json-internal-blob, recommend-stack-line); repro in the audit log.
Complexity M
Impact Med
Wow ★★
curated picks repeat one name 6 of 8 rows; --json returns [] while text prints them
PlannedCLI · Bug
boost replay: CLI audit findings (2026-08)
replay list's ID and WHEN describe two different instants on one row. replay list --json after two installs 4 s apart: {"id": "20260831T140213Z", "updated": "2026-08-31T14:02:09Z"}. lockfile.write() (boost_cli/core/lockfile.py:78-99) stamps the history filename with now — the moment the outgoing lock becomes historical — but that snapshot's own updated field was stamped by the previous write, and cmd_replay list (boost_cli/commands/team.py:592) prints the two side by side as if they were one instant; replay show reuses the earlier one. Fix: stamp the history filename with the lock's own updated so the id equals the state time, or relabel the column (e.g. STATE FROM) and note the distinction in replay show's heading.
replay rollback reports success it did not deliver, and never converges. Rolling back to a snapshot naming a skill no tap carries prints ! vanished-skill-zzz is gone from every tap — cannot restore and then ✓ rollback to 20200101T000000Z complete, exit 0 — and a second run replans install 1, warns again, and says complete again, indefinitely. In cmd_replay rollback (team.py:650-687) an unresolvable name gets out.warn + continue with no tracking, and the function unconditionally reaches the journal log and return 0. Fix: track names that failed _resolve_entry; when any exist, say finished with N skill(s) not restored and return non-zero; treat unrestorable-only differences as already-at-snapshot on subsequent runs.
Found by the 2026-08 CLI audit (clusters replay-id-time-semantics, replay-partial-rollback); repro in the audit log.
Complexity S
Impact Low
Wow ★
rollback says "complete" (exit 0) with a skill unrestored, and replans it forever
PlannedCLI · Bug
boost run: CLI audit findings (2026-08)
run --print's provenance banner names the wrong command. The first line of the emitted runner reads # Generated by `boost adapt brainstorming --to agents-sdk`. Do not edit by hand. — but that command produces a 9-line Agent module, while this is a 55-line runner with _boost_brain, read_file/list_dir/grep tools and Runner.run_sync. The banner's stated purpose is provenance (“Do not edit by hand” — regenerate instead), and the command it names cannot regenerate the file: adapters.render_runner (boost_cli/core/adapters.py:539-562) returns render_agents_sdk(…) plus tools and a main, inheriting the agents-sdk banner from line 267 verbatim. Fix: in render_runner, replace the first line with # Generated by `boost run NAME [TARGET] --print`. Do not edit by hand. (including TARGET when given), and add a unit test asserting the runner's banner names boost run. No flag or summary changes, so docs/commands.html needs no regeneration. Found by the 2026-08 CLI audit (cluster run-print-banner); repro in the audit log.
Complexity S
Impact Low
Wow ★
the 55-line runner's banner names a command that produces a 9-line file
PlannedCLI · Bug
boost schedule: CLI audit findings (2026-08)
With a launchd plist that lacks a StartInterval key, boost schedule status prints "platform darwin (launchd) · scheduled yes · interval every None · next run unknown" — the kv line interpolates a bare None (the --json output is fine: "interval": null). Verification found the same unguarded parse is worse than cosmetic: with <integer>0</integer> as the interval, the next-run loop while nxt < now: nxt += timedelta(seconds=secs) never advances and schedule status hangs forever — the repro run was killed at 30 s (exit 124). The control case 43200 renders correctly as "every 12h".
Boost's own schedule enable only writes the _INTERVALS values (6h/12h/daily), so a zero or absent interval means a hand-edited or third-party plist — but a status command that can hang forever on one is still a real defect. One guard fixes both: in boost_cli/commands/configuration.py:878-889, compute interval/next-run only when the regex matched and int(m.group(1)) > 0, otherwise print "interval unknown (plist has no usable StartInterval)" at the kv line (configuration.py:911-913). Add a unit test over both plist shapes. No doc changes.
Found by the 2026-08 CLI audit (cluster schedule-interval-display); repro in the audit log.
Complexity S
Impact Med
Wow ★★
'interval every None' without StartInterval — and StartInterval 0 hangs status forever
PlannedCLI · Bug
boost search: CLI audit findings (2026-08)
Truncation counts code points, so CJK rows overflow the pane. At COLUMNS=60, 14 search rows measure exactly 60 cells but prompt-optimizer [skill] 分析原始提示,识别意图和… measures 72 cells (60 codepoints, East-Asian-width W/F = 2). out.truncate (core/output.py:313-329) clips with len() and slicing while the same module already owns _char_width/visible_len (output.py:341-364) — and the defect is wider than search: truncate also budgets columns in list/preview/browse (discovery.py:202,913,1572,1590,1716,1727) and search_layout sizes the name column with len(n) (output.py:576-616). Fix once in truncate — walk chars accumulating cell width, reserve the ellipsis — and size columns with visible_len; pin with a W-width fixture at COLUMNS=60.
search --json silently ignores --smart. search code review --json and search code review --json --smart are byte-identical, with nothing on stderr either — a script cannot even detect the dropped flag. The cause is ordering: cmd_search's if args.as_json: print(...); return 0 (discovery.py:134-136) sits above the if args.smart: rerank branch (discovery.py:152-159). Move the rerank (and its ai-unavailable fallback warn, on stderr) above the JSON branch and add a ranker field to the JSON; test that --json --smart under BOOST_NO_AI=1 warns on stderr with stdout still valid JSON.
The 'N matches' footer reports the retrieval cap, not the match count. One query, three limits: --limit 1 → "60 matches", --limit 16 → "64 matches", --limit 1000 → "2648 matches" — exactly max(60, limit*4), because cmd_search retrieves with k=max(60, args.limit*4) (discovery.py:130) and prints len(scored) (discovery.py:181-182), which rag.py:903-910 caps at k. Word the footer from what is known: below k it is the true count; at the cap say "top N of K+ retrieved" (or have retrieve return the total hit count). Keep the ranker label unchanged — the eval baseline pins it. No flag changes, so docs/commands.html needs no regeneration.
Found by the 2026-08 CLI audit (clusters cjk-cell-width, search-json-smart, search-match-count); repro in the audit log.
Complexity M
Impact Med
Wow ★★
CJK rows 72 cells in a 60 pane; --json drops --smart; '60 matches' is the cap, not the count
PlannedCLI · UX
boost simulate: CLI audit findings (2026-08)
The rule list prints 'nEVER'. simulate test-driven-development --task "fix a flaky test" renders "• nEVER test mock behavior", "• nEVER add test-only methods to production classes", "• nEVER mock without understanding dependencies" — norm_rule (boost_cli/core/imperative.py:40-47) lowercases only t[:1] of an all-caps NEVER, against its own docstring's goal of a "stable, comparable rule string". Fix at imperative.py:47: lowercase the whole leading modal token (never/always/must/do not/don't) — safe for dedup (it can only merge more duplicates), and since norm_rule is the shared extractor consumed by explain and conflict too, the fix reaches them for free. Optional: reword the "Claude would:" lead-in so "• do not treat the output as a substitute…" bullets read grammatically.
The trigger description clips mid-word at 100 chars with no ellipsis. likely triggers when the task involves: "Use when implementing any feature or bugfix, before writing implementation code - write the test fir" — verification showed the clip is width-independent (at COLUMNS=200 the line fits yet still ends at char 100 with a trailing space before the closing quote) and shows piped and TTY alike. core/chat.py:184 already truncates on a word boundary with " …"; replace desc[:100] at boost_cli/commands/intelligence.py:287 with the same desc[:100].rsplit(' ', 1)[0] + ' …' pattern. No doc changes for either fix.
Found by the 2026-08 CLI audit (cluster simulate-text-polish); repro in the audit log.
Complexity S
Impact Low
Wow ★
norm_rule turns NEVER into 'nEVER'; trigger desc clipped mid-word at char 100
PlannedCLI · Bug
boost sync: CLI audit findings (2026-08)
Repairing a missing store dir silently skips a blocked agent link — only a second run reports it. With the store dir deleted and a foreign dir at ~/.windsurf/skills/brainstorming: sync --diff showed only missing-store plus 4 stale links, nothing about windsurf; the first sync printed "✓ reinstalled missing brainstorming from sickn33/antigravity-awesome-skills" with no windsurf mention (the link was not created — lock agents ended as claude-code, cursor, antigravity); only a second sync said "! 1 agent link could not be created: brainstorming → windsurf (~/.windsurf/skills/brainstorming in the way)…". Cause: sync_plan (core/store.py:1471-1473) continues past link classification when the store dir is missing, and sync_apply (store.py:1651) discards install()'s InstallResult, whose .conflicts names the refused link. Surface those conflicts as blocked-link warnings and still classify agent links for a missing-store entry (or re-plan after repair). This is the residual missing-store case of the shipped fix — note it in docs/roadmap/items/sync-reported-success-for-a-link-it-refused.md. Unit test: missing store + foreign dir reported in one run.
--diff renders blocked links as a raw Python tuple, and the two modes disagree on formatting. Observed: "==> agent links blocked by a foreign file (1)" then ('brainstorming', 'windsurf', '/private/tmp/…/.windsurf/skills/brainstorming') — _PAIR_KEYS (commands/pkg.py:581-582) covers only 2-tuples, so the 3-tuple falls to the str() else branch (pkg.py:620-625). Also apply says "removed stale link /private/tmp/…/ghost" where --diff shows ~/.claude/skills/ghost (store.py:1634 uses raw absolute paths), and sync --diff --json is indent=2 (pkg.py:603) while sync --json is one line (pkg.py:660-662). Fix in cmd_sync: a blocked_links branch printing "brainstorming → windsurf (~/.windsurf/skills/brainstorming in the way)", _tilde paths in apply's action strings, one json.dumps style. No flag changes, so docs/commands.html needs no regeneration.
Found by the 2026-08 CLI audit (clusters sync-blocked-link-report, sync-diff-formatting); repro in the audit log.
Complexity M
Impact Med
Wow ★★
First sync run hides a blocked link; --diff prints it as a raw Python tuple
PlannedCLI · Bug
boost tag: CLI audit findings (2026-08)
boost tag swallows unknown flags and misreads them as operands. tag brainstorming --verbose prints the current tags and exits 0 — the flag is consumed as a removal of the tag -verbose; tag --verbose gives "Error: --verbose is not installed" (the flag becomes a skill name); verification found a third hole: tag brainstorming --list silently discards the skill-name operand and lists all tags. Cause: cmd_tag's manual split (boost_cli/commands/info.py:988-993) whitelists only --list/--json/-h/--help; every other --x token falls through as an operand. Every sibling command rejects unknown options with "unrecognized arguments" exit 2.
And the mutation path has no before/after check. tag brainstorming -nosuch removes a tag that was never present — silent, exit 0; tag brainstorming +x -x prints ✓ and writes the lock plus a journal event for a net no-op (changed is set per-token at info.py:1027-1041, never compared to the before set); "+with space" is accepted as #with space; +Design and #design coexist. The shipped roadmap item robust-tag-argument-parsing (PR 94) built this manual split — these are residual holes in it, not a duplicate.
Fix in cmd_tag: hand any token starting with -- (or -letter that is not a tag operand) to argparse so it errors; compute changed = sorted(tags) != sorted(before); print a one-line notice for removing an absent tag; reject whitespace in tags; document or fold case; error when a name is given with --list. Regenerate docs/commands.html if the help text gains the tag grammar.
Found by the 2026-08 CLI audit (cluster tag-arg-parsing); repro in the audit log.
Complexity S
Impact Med
Wow ★★
tag brainstorming --verbose exits 0 as a remove of '-verbose'; +x -x writes lock + journal
PlannedCLI · Bug
boost tap: CLI audit findings (2026-08)
tap misdiagnoses local paths (med). A path-shaped SPEC that is not an existing directory falls
through registry.parse_spec's '/' in spec branch
(registry.py:106-107) and goes to the network: tap /private/tmp/claude-501/nonexistent-dir-zz
→ “git clone failed: fatal: repository 'https://github.com//private/tmp/claude-501/nonexistent-dir-zz/'
not found” — and verification showed ./relative paths hit the same fall-through.
An existing directory that is not a git repo gets git's “repository '…/plain-skill-dir'
does not exist”, which is false (the dir exists and holds a SKILL.md), while the help promises
“a local directory”. Fix: in parse_spec, a spec starting with /,
./, ../ or ~ whose expanded path is not a directory raises
no such directory: … before the owner/repo branch; an existing dir without
.git raises is not a git repository with a git init /
boost import hint. Regenerate docs/commands.html only if the spec help gains
the git-repo caveat.
tap --at validates the SHA only after cloning (low). tap --at deadbeef
obra/superpowers answers “'deadbeef' is not a full commit SHA” after
1.62 s — registry.add calls clone_shallow
(registry.py:196-198) before checkout_commit runs the pure-string
_is_sha check (gitutil.py:314). Cleanup is correct; the cost is one wasted
clone per typo. Validate at with gitutil._is_sha before
clone_shallow, keeping the in-checkout check as a backstop.
Single-SPEC and multi-SPEC paths disagree (low). Fresh: “✓ Tapped
obra/superpowers” vs “✓ tapped pbakaus/impeccable”; re-tap:
single errors exit 1 (“Error: tap minio/skills is already configured”), multi
prints a muted “already tapped” and exits 0. The split keys purely on
len(spec)==1 (taps.py:152-166), so any 2+ SPEC argv takes the idempotent
path — defeating the file's own stated goal that xargs boost tap be correct. Route
the single-spec branch through the same skip logic (lowercase verb, muted line + update hint,
exit 0), except --at on an existing tap, which must keep erroring — a
skipped pin is silent staleness (registry.py:200-205). Update
tests/functional/test_cli_taps.py:46,:91.
Found by the 2026-08 CLI audit (clusters tap-local-path-diagnosis,
tap-at-late-validation, tap-path-inconsistency); repro in the audit log.
Complexity S
Impact Med
Wow ★
a missing local dir is cloned as https://github.com//private/tmp/… before any check
PlannedCLI · UX
boost taps: CLI audit findings (2026-08)
The UPDATED column mixes @sha, ISO dates and relative times, unexplained (low).
One real table reads “anthropics/skills 18 2026-07-24”,
“0xfurai/… 138 11h ago”,
“NeoLabHQ/… 92 @555b952” — three formats under one header
and nothing saying @sha means pinned. The rendering is half-deliberate: the comment at
taps.py:248-251 says a pinned tap “should say why on the line the user is already
reading”, but nothing actually says why; and _tap_updated
(taps.py:208-221) emits git dates for cloned taps but rel_time for
cache-only taps, two formats by accident. Verification found it broader than the audit stated:
taps --json's updated field mixes the same two time formats, so scripts get
an unparseable field too.
Fix: print a dim footer in cmd_taps whenever any row is pinned
(@sha = pinned; boost update skips it), and make _tap_updated's cache
fallback return the generated date instead of rel_time — one format,
which also fixes the --json field. No doc changes.
Found by the 2026-08 CLI audit (cluster taps-updated-column); repro in the audit log.
Complexity S
Impact Low
Wow ★
one UPDATED column shows "@b29e7cf", "2026-07-24" and "11h ago" with no legend
PlannedCLI · UX
boost unpin: CLI audit findings (2026-08)
unpin prints its trailer before the line it qualifies (low). After
pin brainstorming --commit, unpin brainstorming prints
“released the commit pin too” first and then
“✓ unpinned brainstorming (v0.0.0) — updates apply again” —
the dim note qualifies a line that has not appeared yet. cmd_pin shows the intended
order: main ✓ line first, dim commit-pin trailer after. The cause is sequencing in
cmd_unpin (pkg.py:1439-1449): it prints the out.dim note
before calling _set_pin(name, False), and _set_pin
(pkg.py:1470-1477) is what prints the main unpinned line.
Fix: call _set_pin(args.name, False) first, then print the commit-pin trailer,
mirroring cmd_pin's order (pkg.py:1421-1436) —
clear_commit_pin can still run before; only the out.dim is deferred. No
doc changes.
Found by the 2026-08 CLI audit (cluster unpin-trailer-order); repro in the audit log.
Complexity S
Impact Low
Wow ★
unpin prints 'released the commit pin too' before the unpinned line it qualifies
PlannedCLI · UX
boost untap: CLI audit findings (2026-08)
untap accepts only one NAME while tap accepts several SPECs. untap minio/skills anthropics/skills answers “Error: unrecognized arguments: anthropics/skills” (exit 2, usage boost untap [-h] [-f] name), while tap a b c clones three in parallel — the reverse operation needs one invocation per tap. Verified: cmd_tap declares spec with nargs='*' (taps.py:110-112) and cmd_untap declares a bare single positional (taps.py:178); the per-tap dependent-item warning at taps.py:187-200 already operates per tap, so looping is mechanical, not a redesign. Fix: make name nargs='+', loop the existing dependent/confirm/remove body over each name, and return non-zero if any iteration failed. Regenerate docs/commands.html for the usage line. Found by the 2026-08 CLI audit (cluster untap-single-name); repro in the audit log.
Complexity S
Impact Low
Wow ★
tap takes several SPECs in parallel; untap still errors on a second name
PlannedCLI · Performance
boost update: CLI audit findings (2026-08)
update pulls all taps serially with no progress indicator: ~14 s no-op over 20 taps (med). A plain update over 20 taps with nothing to fetch took 13.93 s under TTY (verifier re-measured 13.89 s), update --force 17.82 s; each line appears only after its ~0.7 s pull, nothing on screen between lines. registry.update() (registry.py:465-525) is a plain for tap in targets: loop calling gitutil.pull/clone_shallow serially — the exact latency-bound pattern registry.add_many already parallelises for clones. Fix: pull in a ThreadPoolExecutor mirroring add_many, keep catalog rebuilds and the single config write serial on the caller's thread, and print a refreshing N taps… line or spinner while pulls run.
--force clears pins with no per-line notice; an all-pinned run claims “everything up to date” (low). After update --force, config.json went from 20 pin keys to 0 with no line mentioning a pin — and the verifier's follow-up probe showed the cost: the next plain update fetched all 20 taps, 0.13 s → 13.32 s. Clearing on --force is deliberate (CLAUDE.md, registry.py:518-522); the silence is the defect — registry.py's own comments call a silent state change “the failure that looks like nothing at all”. Separately, a fully pinned environment prints 20 × ✓ <tap>: pinned at <sha7> (skipped) then ✓ everything up to date in 0.13 s with zero network contact and no --force hint. Fix (verified recommendation): in registry.update append (pin cleared) to a tap's summary when force unpinned it; in cmd_update (pkg.py:1080-1084) count pinned skips, print a muted hint that boost update --force moves them and drops their pins, and word the trailer nothing to refresh — all taps pinned when nothing was checked. No doc changes needed. Found by the 2026-08 CLI audit (clusters update-serial-pulls, pin-clearing-messaging); repro in the audit log.
Complexity M
Impact Med
Wow ★
a no-op update over 20 taps takes ~14 s serial; --force drops 20 pins without a word
PlannedCLI · Bug
boost who: CLI audit findings (2026-08)
who's SKILLS column (and JSON skills) counts every journal subject, not skills. On the audit machine the table read “USER jonny · EVENTS 35 · SKILLS 29 · INSTALLS 5” — and those 29 “skills” were 20 tap names, the string “10152 passages” (a reindex event), 5 cohort names and 3 real items; who --json lists “anthropics/skills”, “pilot” and “10152 passages” under skills. The verifier reproduced it on a narrower run: 25 entries, of which 19 tap owner/repo names from update --force plus “pilot” and “all”, against only 2 real installed items. Verified mechanism: cmd_who's aggregate branch (team.py:735-744) does if e.get('subject'): u['skills'].add(e['subject']) unconditional on action, while the per-skill focus branch just above (team.py:708-718) already filters to the expertise tuple ('install','edit','evolve','distill','tag'). Fix (verified recommendation): apply that same action filter in the aggregate loop — or, if the broader meaning is intended, rename the column and JSON key to SUBJECTS. No doc changes needed. Found by the 2026-08 CLI audit (cluster who-subject-counting); repro in the audit log.
Complexity S
Impact Med
Wow ★
29 "skills" = 20 tap names + "10152 passages" + 5 cohort names + 3 real items
ShippedCLI · Audit
August 2026 full-CLI audit: every one of the 81 boost commands exercised and verified
Write-up
Coverage record of the August 2026 CLI audit. All 81 commands in cli.py COMMANDS were exercised across 18 batches (b01–b18), each in a fresh sandbox HOME against 20 pinned taps / 10,152 catalog items — ~2,000+ logged invocations in total, run both piped and under a TTY, with JSON output validated, timings recorded, and results cross-checked against on-disk state (store, lock file, config, caches).
Each batch produced a findings file plus an independent non-issue verification pass that re-tested disputed calls: 310 findings and 57 disputed records, 367 in all (30 high · 159 med · 178 low). Deduplication folded them into 161 clusters, and every cluster then went through an adversarial two-lens verification — a live repro lens and a code-reading lens — yielding 160 confirmed, 0 not confirmed, 0 unverified; the repro verdicts across all 160 came back 158 reproduced / 2 partially. The confirmed clusters were drafted into 107 board cards (one card may carry several clusters).
Two bookkeeping notes. The one known cluster, wrap-law-remaining-spots, was folded into the existing narrow-pane item instead of a new card: its contribution is the concrete list of 13 unwrapped call sites (sync/doctor warnings, install/count panels, info tags, import/bundle warns, adapt note, chat rows, recommend rows, impact's fixed-76 fill, replay/cohort footers, rollback warns). And any cluster that fails verification is recorded in the audit artifacts (batch logs, clusters.json, cards.json, per-cluster verify files), not on the board — the board carries confirmed work only. Found by the 2026-08 CLI audit; repro commands for every finding are in the per-batch audit logs.
Complexity L
Impact High
Wow ★★
81 commands, ~2,000+ invocations, 367 findings, 161 clusters, 107 cards on the board
In flightCatalog · UX
Per-item categories in search/browse/info — not just a ★ curated bool
From a user request: “proper categories for skills (can't have all of them listed as just
curated)”. They are right about the item level: the only taxonomy a catalog entry carries is
a boolean. A boost search row shows name, kind, tap, description and at most a
★; boost recommend's no-match fallback is literally headed
“curated picks”; boost info prints no category at all. Across a
real install of tens of thousands of items, “starred or not” is the entire
classification a user can see or filter by.
What the code confirms. catalog._make_entry stamps "curated": curated
onto every entry (boost_cli/core/catalog.py:119, signature at 105–106) — and that
bool is per-tap, from Tap.curated (core/registry.py:23), set by
tap --defaults or by anyone passing --curated
(commands/taps.py:126) — a trust star, not a classification. Category-like data does
exist, but only per tap: data/registries.json rows carry one (487 registries, 21
values; general alone covers 127), and exactly two surfaces read it —
browse's row badge via _tap_categories
(commands/discovery.py:936–941, whose own docstring says “catalog
entries themselves carry no category, only their tap does”; badge appended last in
_row_badges, discovery.py:961–963, so narrow panes drop it first, and taps
outside the bundled 487 get none) — and boost serve's web facets
(core/serve.py:65). cmd_search renders only the star
(discovery.py:179) and takes no filter flag; info shows frontmatter tags when present
(commands/info.py, the meta.get("tags") kv) but no category, and its
--json has no such field. An item's own frontmatter category/tags
ride along invisibly in entry["meta"] and the substring search_blob
(catalog.py:131, 621–627), so they can match a query yet can never be displayed or filtered.
Proposed fix. Stamp a first-class category on each entry at scan time in
_make_entry (catalog.py:105–132): the item's frontmatter category
(or first tag) when declared, else inherited from its tap's registry category — and bump
catalog.CACHE_FORMAT so hundreds of existing tap caches backfill without a re-tap, per
the versioned-cache rule. Then surface it where a category would live: a badge in
search rows and a --category filter on
search/browse/recommend, a kv row plus JSON field in
info, and browse's existing badge switched from tap-level to the entry
field (which also gives un-bundled taps' items a label for the first time). ★ keeps meaning
curation/trust only. Consumers must degrade cleanly when category is absent (old
caches, synthesised entries), same as the content digest rule.
Docs: regenerate docs/commands.html for the new flags; no other doc names categories.
Found by the 2026-08 CLI audit (cluster catalog-categories-beyond-curated, filed from
the user's request); repro in the audit log. Verified against source 2026-08-31.
Status (2026-09-01). Landed: the category stamp at scan time
(catalog._entry_category, own frontmatter category → first
tags entry → tap's registry category), CACHE_FORMAT bumped to 2 so
existing caches backfill on next scan, a --category filter on
search/browse/recommend
(catalog.matches_category/filter_by_category), info's kv row
and --json field, and browse's row badge switched from the tap-level
lookup to the entry's own field (falling back to the tap lookup for a cache not yet rescanned).
Not done: the badge in plain boost search rows. That row's column widths
(out.search_layout/format_search_row) are a tuned, heavily-pinned budget
system (drop order, per-cap name shrinking, a reserved curated tail) — working it out safely needs
its own pass rather than a bolt-on inside this PR. Left inflight rather than
shipped for that reason; the next claim on this item is scoped to exactly that piece.
Complexity M
Impact Med
Wow ★★
landed everywhere except the search-row badge — see PR for what remains