A compatibility patch. sandermuller/boost-core pinned sebastian/diff to ^7.0, which made it a hard blocker for any consumer that wanted PHPUnit 13 — and therefore Pest 5. The pin reached those projects three ways: a direct require-dev, and transitively through sandermuller/boost-skills and sandermuller/project-boost-laravel.
sebastian/diff constraint widened to ^7.0 || ^8.0 || ^9.0. PHPUnit 13.2 requires sebastian/diff ^9.0, so the old pin left Composer with nothing to resolve. Surfaced by a downstream Pest 4 → 5 upgrade.boost where --diff=<name> now builds its unified diff with StrictUnifiedDiffOutputBuilder instead of UnifiedDiffOutputBuilder, which sebastian/diff 9.0 removed — widening the constraint alone would not have been enough. The --- vendor: / +++ host: header lines come from the builder's fromFile / toFile options rather than being written by hand, so the text of the diff is unchanged; the only visible difference is that those two lines are no longer dimmed. Only options present in both diff 7 and diff 9 are passed, so behaviour is identical on every supported major.main. Composer guesses the root package version from the checked-out ref, so on a feature branch it resolved as dev-<sha> and the extra.branch-alias entry never applied. The self-referential sandermuller/boost-skills dev dependency requires sandermuller/boost-core, so nothing satisfied it and every workflow died at composer install. The four PHP workflows now set COMPOSER_ROOT_VERSION: dev-main, and the alias itself — still pointing at 1.2.x-dev — was bumped to 1.4.x-dev.CurlHttpTransport::get() rejects an empty URL up front with a network-unreachable RemoteFetchException instead of handing '' to cURL, and HttpTransport::get() documents that failure mode. Covered by new tests.SkillAssetCollector and GuidanceWriter for the current Rector rule set.Nothing to do. No public API change, no configuration change, and boost where --diff output is unchanged. sandermuller/boost-skills (^1.4) and sandermuller/project-boost-laravel (^1.0) already accept this release.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.4.1...1.4.2
A single-bug patch, surfaced by production dogfood: an advisory warning was travelling down the fatal channel, so a fully successful sync reported errors. Any project with a Cursor-, Amp-, Kiro- or Junie-targeted command using argument placeholders was affected.
SyncResult::errors — the fatal channel — rather than SyncResult::diagnostics. Two consequences for any project with a command using $ARGUMENTS/$N/$name on one of those agents. First, boost sync, boost where and project-boost:sync exited non-zero, which broke composer install/composer update wherever project-boost-laravel wires the sync into post-install-cmd. Second, the engine reads that same channel as a partial-sync signal, so the clean-slate stale pass, the manifest-orphan reap and the .boost/manifest.json write were skipped on every sync — those projects never built an ownership record. The warnings now route through SyncResult::diagnostics, matching Diagnostic's documented contract and every other lenient warning in the pipeline. They still render under the operator-visible "Diagnostics" section of boost sync and boost where; the exit code and the error-state gate are what change.command-arguments skill and AgentTarget::planCommands()'s docblock, which both described the old (incorrect) routing through SyncResult::errors.boost validate / boost doctor CLI rows.diagnostics), the second-order manifest write, and the CLI exit code for both boost sync and boost sync --check.fanOut() docblock that had to be hand-mirrored on every signature change.Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.4.0...1.4.1
Skills can now declare hard dependencies on other skills. Sync guarantees the dependency ships whenever the dependent does — including pulling a dependency back in when tag filtering would otherwise have dropped it. Designed against the real cross-reference graph of sandermuller/boost-skills, which dogfoods the feature.
Skill dependencies via metadata.boost-requires. A skill lists the skills it hands off to, as bare names in the Agent Skills standard's metadata map (space-delimited, mirroring boost-tags):
---
name: interview
metadata:
boost-requires: "write-spec"
---
Whenever the skill ships, every required name ships too. A dependency that tag filtering would drop is rescued — pulled in despite the tag mismatch, transitively, and surfaced as an INFO diagnostic so the pull-in is always visible. The semantics stay predictable: names bind to whatever provider wins resolution (a host .ai/skills/ override satisfies the dependency), withExcludedSkills() always wins over rescue (warning, never an override), a dependency that exists nowhere warns without failing the sync, cycles simply co-ship, and candidate collisions follow the existing resolver rules (CollidingSkillsException unless --force; same-provider duplicates always throw). sync --check previews rescues identically to a real sync.
boost validate checks dependencies over the same resolved set a sync ships: a malformed boost-requires is an error (fails --strict — unlike sync, which warns and keeps the skill), an unsatisfied dependency is a warning, and a require that crosses a tag boundary is an info-level nudge under -v to confirm the hard dependency is intentional.
boost doctor gained a "Skill dependencies" section reporting unsatisfied or excluded dependencies; silent when no installed skill declares any.
Wrapper-integration surface: the BoostRequires parse seam (parse() / declaresRequires(), mirroring BoostTags — names are not case-folded, and malformed values do not fail closed) and appended Skill::$requires / Skill::$requiresValid constructor properties, per the frozen-constructor append rule. Injected vendor skills participate in rescue on both sides.
SkillDependencyResolver (the rescue fixpoint) with a full unit matrix — transitive chains, cycles, collision and duplicate handling, exclusion fallback, aggregated warnings — plus end-to-end coverage for rescue, --check parity, pruner interaction, and injected + remote skill sources. The tag filter now returns dropped skills grouped by reason so rescue candidates stay available through the whole pipeline.Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.3.1...1.4.0
boost sync --check now previews the deletions a real sync performs. Two destructive passes were invisible to --check: the manifest-orphan reap (a de-selected agent's boost-owned guidance file, or a dormant emitter's output) and the dead-symlink prune. A --check CI gate could report no drift while the next real sync deleted a tracked file. Both now emit WOULD_DELETE under --check, so the dry run predicts what a real sync removes; the ownership manifest is still never written on a check run. One narrow residual — a dead symlink occupying a path the sync also re-emits — is documented in the code.
The remote tarball extractor validates every entry before writing a byte. TarballExtractor now rejects path-traversal (..), absolute-path, and symlink members, and enforces the per-file / total / entry-count caps, from the archive listing before extraction runs — closing a window where those checks only ran after tar had already extracted to a staging directory. Enumeration behaves identically under GNU tar and bsdtar, and a post-extraction scan of the staged tree remains as defense-in-depth. Reachable only via path-mode GitHub tarball fetches; behavior for well-formed archives is unchanged.
boost new (scaffold a skill or guideline) and boost scan (re-run the vendor allowlist picker) in the README CLI reference — both are live, non-hidden commands that were missing from the table.--check parity tests for the orphan-reap and dead-symlink paths..phpunit.cache/ result-cache directory is now gitignored.SkillAssetCollector.Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.3.0...1.3.1
Skills stop being single-file. A nested skill's companion files — scripts/, references/, examples/ — now emit beside the rendered SKILL.md in every configured agent's skill directory, so a skill that ships a runnable script no longer has to embed it in the skill body and have the agent materialize it at runtime.
Skill asset-sibling emission. Every non-SKILL.* file under a nested skill's <name>/ directory (any depth; hidden and backup/editor-temp files excluded) is collected at load time and emitted verbatim to <skillsDir>/<name>/<relativePath> for each configured agent. Applies uniformly to host-authored .ai/skills/, Composer vendor, wrapper-injected, and remote skills — remote bundles' scripts/ siblings, previously extracted into the cache but never fanned out, now emit too. Assets ride the existing sync manifest, clean-slate stale cleanup (removing an asset — or the whole skill — reaps the emitted copies on the next sync), and tag-gating (a filtered skill ships no assets). Flat-layout skills (skills/<name>.md) have no directory to own and collect nothing.
New [@api](https://github.com/api) value type SkillAsset (relativePath, contents) lets a wrapper attach companion files to an injected skill; Skill gains an appended-with-default assets constructor property per its frozen-surface append rule. A wrapper injecting skills with assets must also claim each asset's emit path in BoostWrapperContract::injectedEmitPaths(), or a bare-CLI sync reaps the copies as stale — documented on the contract and in PUBLIC_API.md.
Emitted assets are plain file writes with no executable bit — invoke scripts via their interpreter (node .claude/skills/<name>/scripts/run.mjs), not directly.
<name>.md after writing <name>/SKILL.md keyed on the path shape, so an asset emitted at a deeper …/examples/SKILL.md would have deleted the skill's own just-written examples.md sibling. The prune is now scoped to the skill's entry write explicitly. (Caught in review before this feature ever shipped — no released version was affected.)Upgrade note: none — a strict minor over 1.2.x. Skills without companion files sync byte-identically to before. Skill packages that adopt asset siblings should floor their boost-core constraint at ^1.3. No breaking change to configuration, CLI, hooks, or the 1.0 [@api](https://github.com/api) surface.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.2.1...1.3.0
Paired visible-default conventions tokens. A bare <!--boost:conv …--> token resolves only under boost-core. An engine with no resolver — notably laravel/boost, which installs a package's SKILL.md and preserves HTML comments verbatim — leaves it inert, so an inline token reads as a word gap and its fallback stays hidden inside the comment. The paired form closes that gap:
Run <!--boost:conv path="testing.runner" mode="inline"-->Pest<!--boost:conv:end--> to verify.
boost-core replaces the whole span with the resolved value; the visible default doubles as the inline fallback (an explicit fallback= still wins). A resolver-less engine leaves both comments inert, so the visible default reads as ordinary prose — Run Pest to verify. — no gap. Resolution order is unchanged (declared → schema default → fallback). The ```boost:conv fence takes the same <!--boost:conv:end--> marker and buffers its body as one block, so a multi-line span resolves whole; paired spans resolve before bare tokens so the open comment is never consumed as a stray unpaired token; inline-code and plain-fence examples stay literal; an orphan end marker is inert and keeps the Project Conventions block.
The contract addition is documented in PUBLIC_API.md (Textual / wire formats) and docs/conventions.md; a new authoring skill, conventions-token-migration, guides vendor-skill authors migrating bare tokens to the paired form.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.2.0...1.2.1
Paired visible-default conventions tokens. A bare <!--boost:conv …--> token resolves only under boost-core. An engine with no resolver — notably laravel/boost, which installs a package's SKILL.md and preserves HTML comments verbatim — leaves it inert, so an inline token reads as a word gap and its fallback stays hidden inside the comment. The paired form closes that gap:
Run <!--boost:conv path="testing.runner" mode="inline"-->Pest<!--boost:conv:end--> to verify.
boost-core replaces the whole span with the resolved value; the visible default doubles as the inline fallback (an explicit fallback= still wins). A resolver-less engine leaves both comments inert, so the visible default reads as ordinary prose — Run Pest to verify. — no gap. Resolution order is unchanged (declared → schema default → fallback). The ```boost:conv fence takes the same <!--boost:conv:end--> marker and buffers its body as one block, so a multi-line span resolves whole; paired spans resolve before bare tokens so the open comment is never consumed as a stray unpaired token; inline-code and plain-fence examples stay literal; an orphan end marker is inert and keeps the Project Conventions block.
The contract addition is documented in PUBLIC_API.md (Textual / wire formats) and docs/conventions.md; a new authoring skill, conventions-token-migration, guides vendor-skill authors migrating bare tokens to the paired form.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.1.2...1.2.0
Dead symlinks are now counted in deleted=N. boost sync prunes broken legacy symlinks left by older installs, but previously discarded the removal count — those deletions were invisible in the summary line. They are now recorded as deletions and listed in the delete attribution. The attribution message is also corrected: it previously claimed every deleted path had "a source no longer eligible", which was wrong for a dead-symlink prune. The message now names that cause alongside the source-ineligible ones. (#147)
boost doctor version-gates the project-boost:reconcile offer. The foreign-seeded guidance warning now names php artisan project-boost:reconcile — the guided takeover introduced in sandermuller/project-boost-laravel 1.1.0 — only when the installed wrapper is actually ≥ 1.1.0. On an older wrapper that command does not exist; project-boost:sync is the correct alternative and is shown instead. Dev or unparseable wrapper versions also fall back conservatively to avoid pointing at a possibly-absent command.
^6.4||^7.0||^8.0 (was ^7.0||^8.0). boost-core now installs alongside symfony 6.4 LTS consumers — for example rectorphp/rector-src, which pins symfony/console ^6.4. Runtime code uses only basic Console/Finder/Process/Yaml APIs present in symfony 6.4; the dev toolchain (pest/paratest) continues to require symfony ≥ 7.4 and CI runs on 7.4/8.Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.1.1...1.1.2
A patch over 1.1.0. The new boost doctor coexistence section pointed a foreign-seeded project at php artisan project-boost:reconcile — a guided-takeover command the project-boost-laravel wrapper does not yet provide — so a wrapper user with a laravel/boost-seeded CLAUDE.md was sent to a command they do not have.
boost doctor steers foreign-seeded guidance at project-boost:sync, not an unbuilt command. The coexistence section now points at php artisan project-boost:sync — which exists and re-derives laravel/boost's bundled guidelines into the assembly safely — and calls out that a direct hand-edit of the seeded file is the real at-risk content to move into .ai/guidelines/ first. The named guided-takeover offer can return once the wrapper ships it.Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.1.0...1.1.1
Coexistence transparency for laravel/boost projects. boost-core now explains itself at the laravel/boost ↔ boost-core seam — the source of the most-reported adoption confusion — and guards against silently overwriting guidance another tool authored. Additive over 1.0.0; no public-API change.
boost install explains its skipped pickers. When no installed package publishes boost-core skills/guidelines (a resources/boost/skills ⁄ resources/boost/guidelines directory), or no selected vendor publishes tags, the vendor/tag pickers used to skip silently — which read as "install ignored my vendors/tags." They now print a short NOTE explaining why the step was skipped, so a re-run's behavior is legible.boost install notes laravel/boost coexistence. When laravel/boost is installed, install explains that its bundled skills + guidelines ship through the project-boost-laravel wrapper (php artisan project-boost:sync), not boost-core's vendor allowlist — so it never appears in the picker. The note branches on wrapper presence: without the wrapper it steers you to install it first, rather than at a command that does not exist yet.boost doctor reports the laravel/boost coexistence state. A new "laravel/boost coexistence" section detects laravel/boost with or without the project-boost-laravel wrapper, states the division of labor (laravel/boost owns the MCP server + Laravel docs; boost-core assembles the guidance files and fans skills out to every agent), and steers you to php artisan project-boost:sync rather than a bare vendor/bin/boost sync (which composes a thinner set and would overwrite laravel/boost's guidance). It classifies each guidance file as boost-owned or foreign-seeded — recognizing a file another tool authored even before boost-core has ever synced the project — and offers php artisan project-boost:reconcile to capture that content safely.CLAUDE.md / AGENTS.md / GEMINI.md / the Copilot file. When a sync is about to wholesale-replace a non-empty guidance file boost-core does not own (one a foreign or operator writer authored, whose content differs from the assembly), it now emits a WARNING naming the file before overwriting it. The overwrite still proceeds — behavior is unchanged — but the takeover is no longer silent. Content that reaches boost via .ai/guidelines/ (or a wrapper's injected guidelines) is preserved; anything else in the file is replaced.CoexistenceReporter is the single place that recognizes another tool's guidance marker and names laravel/boost; the sync engine stays tool-agnostic. The guidance-overwrite warning is content-agnostic (it protects against any foreign writer, not just laravel/boost).Upgrade note: none — a drop-in MINOR over 1.0.0. All changes are additive: new advisory output in boost install / boost doctor, and a new warning diagnostic on a guidance-file takeover that already happened silently before. No configuration, CLI, hook, or plugin-contract change.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/1.0.0...1.1.0
The first stable release. boost-core's public surface is now under Semantic Versioning: from here on, breaking changes to it land only in a MAJOR bump, and MINOR/PATCH releases stay backward-compatible. 1.0.0 freezes exactly the surface 0.23.3 shipped — there is no behavioral change to the documented API. The committed surface is enumerated in PUBLIC_API.md.
The SemVer promise covers the public surface only:
BoostConfig::configure(), the BoostConfigBuilder with*() methods, the Agent/Tag enums, and RemoteSkillSource.bin/boost command names, their documented options, and exit codes (0 ok, 1 failure, 2 usage).BoostAutoSync::run / runWithSummary; new parameters are always optional-with-default.FileEmitter, SkillRenderer, BoostWrapperContract and their DTOs, locked at 1.0 (parameterless constructors; a contract method signature change is a major bump).Everything marked [@internal](https://github.com/internal) (the whole engine) and on-disk regenerable state (the sync manifest, remote-skill ledger, user-scope manifests, the .boost/ ⁄ .config/boost/ runtime dir, cache sentinels) is explicitly NOT covered and may change in any release.
A pre-1.0 hardening review of the migration and symlink-cleanup paths — the behavior the 1.0 freeze locks — surfaced a set of edge cases, all fixed here. No public-API change; these are correctness fixes to internal behavior.
boost doctor no longer hides genuinely-unrenderable sources on wrapper projects. The wrapper-aware downgrade (a .blade.php source has no bare-CLI renderer but the wrapper Blade-renders it) classified on a substring of the skip message — and every skip message embeds the advisory example a BladeRenderer for `.blade.php` . So a genuinely-dropped .rst / .mdx source was downgraded to the wrapper note too, suppressing the real data-loss warning. Classification now keys off the actual source extension.CLAUDE.md / AGENTS.md / …) from being mistaken for a stale managed file was scoped to all known agents, so a guidance file for an agent no longer configured lingered indefinitely (the manifest-gated reaper has no entry for it on the first sync after migrating off the pre-0.12 layout). The exemption is now scoped to the guidance files actually emitted this sync, so a dropped agent's guidance is cleaned up..claude/skills); if an ancestor was the symlink (.claude → a shared location, with a real skills dir inside), is_dir() still followed it and the prune could unlink broken links outside the project tree. A canonical-path containment check now refuses to scan anything that resolves outside the project root (a project legitimately reached through a symlink still works)..cursor/skills (etc.) was itself a symlink, the scanner returned early without recording it — so a broken managed-dir symlink was never pruned and never reported by boost doctor. It is now classified like any other link (broken → pruned, live → preserved + reported) while still never being followed.boost doctor scans them all — so a stale link under a non-active agent dir was reported but never cleaned. The prune now spans the full agent set, so sync removes exactly what doctor reports..config/ layout keeps the legacy root .boost/ ignored even when nothing is owned. The permanent legacy-ignore guard (so a teammate's stale root .boost/manifest.json never surfaces as untracked mid-migration) was gated on "this sync writes a manifest"; a migrated project that currently owns nothing dropped the ignore. The legacy ignore is now unconditional on the .config/ layout; only the active manifest dir stays conditional.boost sync and boost doctor hardcoded find .claude .agents .cursor — incorrect when the reported link lives under another agent root. Both now derive the find roots from the actual reported paths.[@internal](https://github.com/internal) UnrenderableSourceScanner now returns UnrenderableSource value objects (path + message) so callers classify on the real extension rather than parsing the message; the symlink scanner exposes a shared cleanupRootsFor() used by both commands. Six regression tests cover the fixes above.Upgrade note: none — a drop-in upgrade from 0.23.3. 1.0.0 introduces no breaking change; it promotes the existing surface to a SemVer-stable contract. See PUBLIC_API.md for the committed surface and UPGRADING.md for future migrations.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.23.3...1.0.0
[@api](https://github.com/api) methods and construct [@api](https://github.com/api) value objects directly, so the PARAMETER NAMES are part of the frozen contract — a rename or reorder is breaking even when the class is unchanged. PUBLIC_API.md now pins the constructor parameters of Skill / Guideline / RenderContext / WrittenFile / EmitterResult / Diagnostic and the method parameters of BoostSync::sync() + BoostConfig::load(). Two value-shape pins are spelled out: Diagnostic::$level stays a string (error/warning/info, not enum-ified within 1.x) and SyncResult::$errors stays list<string>.schema-version for an unpinned project is seeded max(minRequired across allowlisted vendor schemas) ?? 1, and because the host carries a single version, all conventions-vendors allowlisted in one host must share a schema-version major — multi-catalog contribution at different majors is unsupported within 1.x (per-vendor versioning would be a future handshake redesign). Previously implicit engine behavior; now an explicit contract.<name>.<ext> and nested <name>/SKILL.<ext> are both accepted and stay accepted within 1.x — a future minor will not silently drop flat-file acceptance and un-discover a flat catalog.[@api](https://github.com/api) value objects' constructor parameter names and SyncResult's read surface (asserting $errors stays an array and Diagnostic::$level stays a string), so a rename, reorder, level→enum, or errors→non-array trips CI. It closes the gap an import-scanning guard misses: WrittenFile / EmitterResult / Diagnostic are read off SyncResult by property and never imported.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.23.2...0.23.3
is_dir() follows symlinks. So if an agent root (e.g. .claude) was itself a symlink to a shared location, the prune could descend into that external target and unlink its broken links, outside the project tree. The scanner now refuses to descend through any symlinked directory, the root included. Live symlinks are still never touched.boost doctor no longer over-states drift on wrapper projects. The wrapper-aware Drift note claimed a bare-CLI diff was "expected, not real drift" — but a bare diff can't distinguish an expected wrapper-composition delta from genuinely stale output. It now surfaces the count and routes to php artisan project-boost:sync --dry-run as the authoritative check (still without the destructive vendor/bin/boost sync steer).boost doctor no longer claims the wrapper renders a source it can't. The "no renderer" skip downgrade (warning → note on a project-boost-laravel project) applied to every unrenderable source, but the wrapper only registers a Blade renderer. Only .blade.php skips downgrade to a note now; any other unrenderable extension stays a warning — it is genuinely skipped by both entry points.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.23.1...0.23.2
SKILL.md.bak parked beside a real SKILL.md starts with SKILL., so the depth-1 entry rule treated it as a skill source — and sync emitted a spurious "skipped — no renderer for its .bak extension" for each. SkillSourceScope (the single predicate behind the loader and boost doctor) now excludes backup/editor-temp filenames (a trailing ~, or a final .bak/.orig/.tmp/.swp/.swo extension) up front. No more false warnings; the files are never discovered as skills.boost sync --check now fails on a leaked conventions token in emitted output. When a skill's output path is a pre-existing symlink, sync skips writing it (SKIPPED_SYMLINK) and the symlink's target — the raw source — is what the agent reads, so any unresolved <!--boost:conv--> token in that skill leaks verbatim. boost validate --strict and boost doctor already caught this (the symlink-following on-disk leak scan); the gap was sync --check. It now runs the SAME scan (a single shared ConventionTokenLeakScanner::errorDiagnostics) and fails identically, so both CI gates behave the same. Plain boost sync stays lenient. No file is clobbered — boost flags loud and the operator removes the symlink + re-syncs (consistent with 0.23.0's no-clobber symlink handling).Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.23.0...0.23.1
A dogfood-hardening release. A seven-package family adoption of 0.22 + the .config/ layout — real production apps and the wrapper stack — surfaced one data-loss bug, one composer update footgun, a stranded [@api](https://github.com/api) seam, and two cases of boost doctor giving misleading (one actively destructive) advice on wrapper projects. 0.23.0 fixes all of them. Additive — no API break; a strict superset of the 0.22 surface.
BoostConfig::load()'s exceptions are [@api](https://github.com/api). BoostConfigNotFoundException, InvalidBoostConfigException, and AmbiguousBoostConfigException are now part of the frozen surface, catchable by name — a [@throws](https://github.com/throws) of an [@api](https://github.com/api) method is part of its contract, so a wrapper can give a tailored message per failure mode. load() now declares all three with explicit [@throws](https://github.com/throws).BoostTags is [@api](https://github.com/api) — the canonical tag-parse seam. BoostTags::parse(array): array{0: list<string>, 1: bool} + declaresTags(array): bool (pure static, the other half of the already-[@api](https://github.com/api) FrontmatterParser). A wrapper that injects skills/guidelines now computes the SAME [tags, valid] the engine does — including the fail-closed contract (a non-string metadata.boost-tags → valid = false → ships nowhere) — instead of reinventing the parse and diverging on the malformed case. parseString() stays the [@internal](https://github.com/internal) lexer..gitignore block that still lists CLAUDE.md/AGENTS.md. An UNCHANGED guidance file is absent from a run's write-set, so the stale-managed cleanup reaped it — deleting the committed file on the first sync, recreating it only on a second pass. The cleanup now exempts the agents' guidance paths unconditionally (a de-selected agent's guidance is still reaped by the manifest-gated orphan reaper). An [OK] Sync done can no longer silently delete a tracked CLAUDE.md/AGENTS.md.boost.php no longer 500s composer update. A pre-0.20 variadic withTags(Tag::A, Tag::B) call throws a raw TypeError when boost.php is loaded (during require) — before any migration could run. The loader now wraps the require and converts any evaluation failure into the typed, catchable InvalidBoostConfigException, with a migration hint pointing at the array form (withTags([...])) and the original error preserved as the cause. withTags() itself is unchanged (still array-only — the 0.20 break stands).boost doctor is wrapper-aware in two more places. On a project-boost-laravel project: (1) the Drift section no longer reports the wrapper-composed CLAUDE.md/AGENTS.md as drift and no longer steers the operator at vendor/bin/boost sync — which would overwrite the wrapper-composed guidance with a degraded bare-CLI version; it now points at php artisan project-boost:sync --dry-run. (2) A .blade.php guideline with no bare-CLI renderer is downgraded from a "no renderer — data loss" warning to an informational note (the wrapper Blade-renders it upstream).laravel / laravel-cloud and github / github-issues — two distinct tags both shipped by installed skills — are no longer reported as a "possible typo". The genuine signal (a declared-but-unused lookalike of a used tag) still fires..cursor/ after dropping CURSOR), which the old configured-only prune missed. LIVE symlinks are never touched (boost can't prove a resolving link is its own legacy artifact vs an intentional operator link), so the skipped-symlink output is now a NOTE ("preserved by design") rather than a warning, with a hint for converting to copies by hand. boost doctor gains an Agent-dir symlinks section reporting dead (next sync prunes) + live (preserved) links..config/ layout keeps the legacy root .boost/ ignored. On the .config/ layout the managed .gitignore block now retains .boost/ permanently, so a teammate who pulls a freshly-migrated repo before their own sync never sees a stale (never-tracked) .boost/manifest.json surface as untracked.vendor/bin/boost --check (not a valid command)..config/ needs the directory created first (git mv won't make the parent), and that sync rewrites the managed .gitignore block for you.withTags() entry no longer claims boost sync auto-migrates a variadic call (it can't — the require-time TypeError precedes any rewrite); it now states the hand-edit requirement + the clearer 0.23 load-time error.composer update can produce a committable guidance-file diff (expected, not drift; a no-op when nothing changed).Upgrade note: none from 0.22.x. Coming from older 0.x, the only hard break that needs a hand-edit is withTags(...) → withTags([...]) (0.20) — and 0.23 now reports that as a clear, catchable error instead of a raw fatal. This is the last 0.x surface addition before the 1.0 freeze: 1.0.0 will freeze the 0.23.x surface.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.22.0...0.23.0
The final 1.0-readiness release. A family + downstream-consumer review of the public surface — driven by the packages that actually build on boost-core (the Laravel wrapper, the skills source, a production app) — surfaced real gaps a class-by-class annotation pass had missed: contracts that were [@api](https://github.com/api) but unimplementable without [@internal](https://github.com/internal) symbols, a designed integration point left internal, a freeze doc that wasn't shipped, and a discovery bug freezing would have locked in. 0.22.0 closes all of them, so the deepest consumer now runs entirely on frozen surface. All additive — no API break.
SanderMuller\BoostCore\Sync\BoostSync is the [@api](https://github.com/api) façade for a wrapper that drives a sync with injected vendor skills/guidelines and extra renderers: BoostSync::make(?InstalledPackages, ?string $configFile)->sync(projectRoot, checkOnly, injectedVendorSkills, extraSkillRenderers, injectedVendorGuidelines): SyncResult. The engine behind it stays internal and free to evolve. The result type (SyncResult + WrittenFile, EmitterResult, Diagnostic, WriteAction, EmitterAction) and the injection payload types (Skill, Guideline) are now [@api](https://github.com/api).[@api](https://github.com/api) bridges so wrappers never reach engine internals. Agent::target(): AgentTarget (agent value → its target, no concrete *Target classes), AgentTarget::skillRelativePathForName(string) (emit path from a name, no Skill), BoostConfig::load(projectRoot, ?configFile) (read a project's config without the internal loader), and FrontmatterParser + ParsedDocument (reuse the parser for boost-tag parity instead of forking YAML-head parsing).BoostBaseCommand is [@api](https://github.com/api) (narrow): addWorkingDirOption() + resolveProjectRoot() are the frozen helpers a wrapper command extends.RenderContext::$projectRoot. A SkillRenderer can resolve project-relative paths from the render context instead of reaching for a global container. Additive (trailing, nullable).schema-version (a vendor schema whose required range the host doesn't satisfy) now produces an error-level diagnostic and is NOT applied — failing boost validate --strict and boost sync --check. Plain boost sync stays lenient (it reports but doesn't fail, so installs aren't broken). Previously this mismatch was silently ignored.PUBLIC_API.md is no longer export-ignored, so the 1.0 contract travels with the package. It now also enumerates the frozen formats & conventions a class-only freeze misses: vendor publishing paths + extra.boost.* keys, the consumed frontmatter keys, the conventions-schema render annotation, token forward-compat (an unknown token mode is a hard error, never silent corruption), and the emitted-output layout.references/*.md inside a <name>/SKILL.md skill (as shipped by some Laravel-side vendors) was emitted as a bogus top-level skill. Discovery is now scoped to a top-level *.<ext> OR a depth-1 */SKILL.* — and the loader and boost doctor share one rule, so they classify identically. A vendor that happened to rely on the leak will see those phantom skills disappear.Wrapper note: boost-core 0.21's FileEmitter::emit() → iterable change means a FileEmitter still on the ?EmittedFile signature hard-fatals when boost-core 0.21+ loads it. If you ship one, migrate the signature first — see UPGRADING.md. Laravel consumers adopt 0.22/1.0 once the umbrella packages ship emit()-iterable-compat releases.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.21.0...0.22.0
FileEmitter::emit() now returns iterable<EmittedFile> (was ?EmittedFile). An emitter can emit zero (an empty iterable to skip), one, or many files in a single sync — each is validated, written, and reported independently. Update your emitter's return type and wrap a single file in an array:
// before
public function emit(SyncContext $ctx): ?EmittedFile
{
return new EmittedFile(relativePath: '.mcp.json', content: $json);
}
// after
public function emit(SyncContext $ctx): iterable
{
return [new EmittedFile(relativePath: '.mcp.json', content: $json)];
}
Returning null no longer compiles; return [] to skip. See UPGRADING.md. This is the only FileEmitter shape change planned before 1.0 — the signature locks at the 1.0 tag.
.mcp.json plus a sidecar). Orphan reaping is per-FQCN aware: when an emitter stops producing a file it once owned, that dormant file is reaped on the next sync — unless the emitter is fully down this run, in which case its prior files are preserved (never lossy).emit() that throws mid-iteration no longer aborts the sync. If an emitter yields some files and then throws, it is recorded errored and the sync continues with the remaining emitters. Emit is all-or-nothing on failure: a crashed emitter never half-applies — files it yielded before the throw are not written. (Return an array rather than a throwing generator if you want already-computed files to land regardless of a later problem.)0.21.x-dev.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.20.0...0.21.0
The 1.0-readiness release: boost-core now declares and locks its public API surface, so the next step to a 1.0 tag is a decision, not more work. One small breaking change in the config API, and a clear line drawn between what semver covers and what's internal.
withTags() now takes an array. It was the only boost.php collection setter that was variadic; it now matches every other one (withAgents, withRemoteSkills, …):
// before
->withTags(Tag::Php, Tag::Jira)
// after
->withTags([Tag::Php, Tag::Jira])
boost install's tag picker writes the new array form, and boost sync still parses + migrates an existing variadic withTags(...) in your boost.php — but update the call by hand to avoid a TypeError when the config is next loaded directly. See UPGRADING.md.
[@api](https://github.com/api) or [@internal](https://github.com/internal), PUBLIC_API.md enumerates the committed surface, and the README has a new Versioning & stability section. The promise covers: the boost.php authoring API (BoostConfig, the builder, Agent/Tag, RemoteSkillSource), the CLI (command names, options, exit codes), the BoostAutoSync composer hooks, and the plugin contracts. Everything else — the whole sync engine — is [@internal](https://github.com/internal) and excluded. An architecture test fails the build if a new engine class isn't marked, so the boundary can't erode.FileEmitter and SkillRenderer plugin contracts are locked stable ([@api](https://github.com/api)), no longer experimental — including the SyncContext / EmittedFile / RenderContext value objects. Parameterless constructors only; their method signatures won't change within 1.x.--config works on every command. slots, tags, and paths now accept --config <path> like the rest, so a .config/boost.php-layout project can point any command at its config.[@deprecated](https://github.com/deprecated) + a runtime notice) in a minor and removed no earlier than the next major — documented in PUBLIC_API.md.[@api](https://github.com/api) method or property exposes an internal engine type — an architecture test fails the build if one ever does. AgentTarget's [@api](https://github.com/api) surface is narrowed to the path/identity methods wrapper packages use; its planning/formatting methods are internal. The FileEmitter context exposes the installed-package set via InstalledPackages / PackageInfo, both now part of the stable contract.boost --version reports the real installed version instead of a hardcoded placeholder.boost doctor is documented as advisory-only — it exits 0 even when it surfaces drift, leaked tokens, or shadows (non-zero only on a config-load failure). Gate CI on boost sync --check or boost validate --strict, which do fail on findings.convert-conventions command is hidden from the command list (still runnable for stragglers mid-migration; no longer part of the committed CLI contract).boost install and boost scan fail fast with guidance under --no-interaction / no TTY instead of hanging on the interactive picker.[@internal](https://github.com/internal)-annotated behind a pest architecture guard; BoostConfig's positional constructor is internal (build via BoostConfig::configure())..config/boost.php layout for its own configuration.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.19.0...0.20.0
User-scope sync gains the cleanup-on-remove story project-scope has had since 0.14.0, and the remote-skill orphan ledger moves out of the repo root to follow the .config/ layout.
boost sync --scope=user). Globally-installed packages that ship skills now get the same lifecycle reaping project-scope already has. boost-core records a per-package ownership manifest at ~/.boost/manifests/<vendor>__<package>.json (each emitted path → its sha, plus the package's install path), and uses it to clean up on the next sync:
~/.{agent}/skills/<vendor>__<package>/<skill>/ copy is removed. A still-shipped skill is kept on every agent (so syncing with a narrowed agent set never deletes a live copy for an agent it doesn't drive), while a dropped skill is reaped under every agent.--scope=user --all) — a composer global removed package's user-scope copies are reaped and its manifest deleted. "Removed" is decided by the recorded install path being gone on disk, never by mere absence from the discovered set, so running --all from a project-local context can't mass-delete a still-installed package's files. A package that was updated to drop all its skills, or replaced in place by a different package at the same path, is reconciled too.boost sync --scope=user --check reports a pending reap as drift (with a write/reap breakdown) and changes nothing on disk.withRemoteSkills(...), the ledger that tracks remote-managed skills is now .boost/remote-manifest.json (or .config/boost/remote-manifest.json under the .config/boost.php layout) instead of .boost-remote-manifest.json at the repo root. It now follows the active config layout like the sync manifest, and no longer litters the project root. The move is automatic: a pre-0.19 root-level ledger — and a ledger left in the other layout after moving boost.php between root and .config/ — is migrated on the next sync, with the stale copy removed only after the new one is written (so a transient write failure never leaves you with no ledger). No action required; the ledger is gitignored, regenerable engine state.UserScopeManifest, UserScopeReaper, and UserScopeManifestWriter from the sync engine so the new cleanup logic lives in focused, independently-tested units and the engine's complexity budget holds.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.18.3...0.19.0
A bug-fix patch for withRemoteSkills(...) consumers.
withRemoteSkills(...) saw a spurious Deleted 1 file(s) … - /.boost-remote-manifest.json warning on every sync after the first, and remote orphan-pruning was effectively dead — each sync deleted the manifest, so the next sync read an empty one and skipped pruning. Root cause: the managed-file enumerator skipped the sync manifest (.boost/) but not the remote-skill manifest, which is likewise written outside the write pipeline; it was classified as a stale managed file and reaped. StaleFileCleaner::enumerateManagedFiles now skips .boost-remote-manifest.json too. Removing withRemoteSkills entirely still prunes the orphaned skill directories (reported as drift) and cleans the manifest — verified by regression tests in both directions.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.18.2...0.18.3
A maintenance + documentation patch. No behavior change — internal refactor plus README coverage of the 0.18.0 keep-reason observability.
SyncEngine decomposition. Two cohesive, self-contained clusters moved out of the ~2,400-line engine into stateless collaborators, lowering its cognitive-complexity baseline from 263 to 182 (~31%):
SyncManifestWriter — the post-sync ownership-manifest write and the root↔.config/ stale-layout reconcile (including the --check advisory). The engine hands it the enumerated on-disk managed files, so the collaborator never calls back into the engine.StaleFileCleaner — the retired-paths registry sweep, the clean-slate prune of managed files no longer emitted, the shared managed-file enumerator, and the recursive delete. Params-only; the retired-paths registry is passed in rather than back-referenced.Method bodies are verbatim and the full test suite passes unchanged (no test edits), confirming the extraction is behavior-preserving.
## Project Conventions block, boost sync (advisory INFO), boost doctor (a "Project Conventions block" section), and boost where --conventions (a kept / dropped / not applicable / status unavailable block-status line) name the artifact and cause holding it open. The boost doctor and boost where --conventions CLI-reference rows are updated for the keep-reason and runtime-manifest-location surfaces.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.18.1...0.18.2
A small follow-through patch that rounds out the .config/boost/ runtime directory shipped in 0.18.0 — documenting it, surfacing it, and making boost sync --check honest about its one-time migration cleanup. No behavior change for root-layout projects.
boost doctor shows the active runtime-manifest location. Doctor now prints whether the gitignored sync manifest lives at root .boost/ or .config/boost/ (following the config layout), so operators on the .config/boost.php layout can confirm placement at a glance. Shown only when manifest handling is active — with gitignore management disabled (withGitignoreManagement(false) / BOOST_SKIP_GITIGNORE) boost-core never reads or writes a manifest, so the line is suppressed rather than naming a path that won't be used.
boost sync --check reports a stale old-layout manifest a real sync would prune. After moving the config between root and .config/, a real sync prunes the now-stale manifest left at the old location; --check now surfaces that pending one-time cleanup as an advisory (captured before the prune runs, so check and a real sync report it identically). Advisory only — it never registers as drift or fails --check: the manifest is gitignored, regenerable, engine-internal state that boost-core deliberately excludes from drift accounting, and a real sync's deletion of it is invisible to git status, so flagging drift would be a false-positive for benign git-invisible housekeeping. Closes the known-limitation noted in the 0.18.0 release.
.config/boost/manifest.json under the .config/boost.php layout, that a root ↔ .config/ move carries ownership forward and prunes the stale copy, and that .config/boost/ is a reserved emitter path.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.18.0...0.18.1
An observability + config-layout release. Two classes of silent capability loss now surface instead of disappearing, the kept ## Project Conventions block now explains itself, and projects on the .config/ layout get their runtime state grouped under .config/boost/. All additive and backward-safe — no behavior changes for projects that don't hit these cases.
Warn on unrenderable skill/guideline sources instead of dropping them silently (#85). A source whose extension has no registered renderer — e.g. a SKILL.blade.php with no BladeRenderer declared — used to vanish from the sync with no signal. boost sync now emits an advisory warning naming the file, the unclaimed extension, and the fix (register a SkillRenderer, or rename to SKILL.md); boost doctor reports the same across host and allowlisted-vendor sources. A shared UnrenderableSourceScanner is the single source of truth — skills are scoped to SKILL.*, guidelines warn on any file whose extension no renderer claims that isn't a recognized binary/data asset (images, archives, JSON/YAML, etc.). Advisory-only: it never fails boost sync --check.
boost validate advises on dangling legacy $.<root> conventions references. A pre-token $.slot reference (e.g. $.testing.runner) is only ever detected by boost-core — never resolved — so it emits literally; and because the ## Project Conventions block is CLAUDE.md-only, it dangles unresolved for every non-Claude agent. boost validate now surfaces each distinct legacy ref as a warning-level diagnostic pointing at the first emitted file it appears in, advising migration to a boost:conv token or an inlined value. Warning-level by design — it does not fail --strict, since a ref may be mid-migration. Detection is prose-scoped and inline-code-masked, so documented $.slot examples in the shipped migration skills are not flagged.
Keep-reason observability for the Project Conventions block (#87). When the drop gate KEEPS the ## Project Conventions block rather than dropping it, the engine now records WHY — the skill or guidance file carrying the legacy $.<root> ref, unresolved token, or prose pointer that pins it open (or a single no-migration-yet note for a pure-conventions project that hasn't adopted tokens). The gate decision is byte-identical; this is purely an additive provenance channel. Surfaced on three read surfaces: boost sync emits advisory INFO diagnostics (quiet unless -v), boost doctor adds a "Project Conventions block" section listing the reasons when kept, and boost where --conventions prints a kept / dropped / not-applicable / status-unavailable block-status line. An operator who migrated their skills to tokens but still sees the block can now find the one artifact holding it open instead of black-box probing.
.config/boost/ runtime directory for the .config/ layout. When boost.php resolves under .config/ (0.17.0's .config/boost.php), the sync ownership manifest now lives at .config/boost/manifest.json instead of the root .boost/, so all boost artifacts group under .config/. Migration is handled in BOTH directions (root ↔ .config/): the manifest reader prefers the active layout and falls back to the other layout's copy so prior ownership carries forward, and a real sync prunes the now-stale old-layout manifest (file + empty dir) so it never lingers unignored. Root-layout projects are completely unaffected.
DoctorCommand decomposition: the conventions-check and remote-skill report clusters moved into dedicated ConventionsReporter / RemoteSkillsReporter collaborators, dropping the command's cognitive-complexity baseline below 80 (entry removed). Behavior-preserving.ValidateCommand gained an InstalledPackages injection seam for test parity with WhereCommand / DoctorCommand.SyncResult gained conventionsBlockKept, conventionsKeepReasons, and conventionsEvaluated (the last distinguishes "gate ran and dropped" from "gate never ran" so block status is never misreported when a check-only sync carries benign advisories such as an uncached remote skill).boost where --conventions --json shape is unchanged (a top-level list of slot rows); block status is a human-output addition only, to avoid breaking existing automation.$.slot examples) and both-direction config-dir migration.boost sync --check does not report the one-time stale old-layout manifest cleanup after a config-location migration. The manifest is gitignored, regenerable, engine-internal state that has never been part of --check drift semantics; the next real sync resolves it.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.17.0...0.18.0
boost.php can now live under .config/ — the first step toward a tidier repo
root. Additive and fully backward-compatible: a root boost.php behaves exactly
as before, with no migration.
.config/boost.php as an alternative config location. boost-core resolves
its config from boost.php at the repo root OR .config/boost.php, via a single
new BoostConfigPath resolver shared by every reader and writer. Exactly one may
exist — having both is a hard, explanatory error (AmbiguousBoostConfigException)
rather than a silent pick, so you never edit the file boost ignores. Source paths
still resolve against the project root, so the two locations are fully
interchangeable. BoostConfigPath is public, so wrapper packages inherit the same
resolution.
--config <path> on sync, validate, where, doctor,
convert-conventions, scan, and new — point at an explicit config file. A
relative path resolves against the project root (not the process CWD), so it's
stable regardless of where boost is invoked from.
boost install --config-dir scaffolds a new config at .config/boost.php
instead of the root (root stays the default). When a config already exists, it is
edited in place — boost never creates a second one.
boost where prints the resolved config path, and boost doctor reports
the config location up front: a both-files ambiguity surfaces as a clear "Config
location" section, and a .config/boost.php whose explicit __DIR__-relative
source path resolved under .config/ (a silent empty-resolve) is flagged with a
fix hint.
boost.php and the canonical config example no longer use
__DIR__-relative source paths. Defaults are project-root-relative and
location-independent; if you override a source path, use an absolute one —
__DIR__-relative values break if the config file is later moved into .config/.Back-compat: every existing root-boost.php project behaves identically — no
migration, no new required config. The .boost/ state directory and the emitted
agent files (CLAUDE.md, .claude/, …) are unchanged. Wrapper packages that want
to honor .config/boost.php should rebuild against ^0.17; root-config consumers
need nothing.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.16.3...0.17.0
A conventions-token leak-scan fix for symlinked host-shadow skills, a clearer
retired-path cleanup message, and an internal comment cleanup. Patch — ^0.16
compatible, no consumer bump needed.
boost doctor and boost validate --strict scan the emitted skill set for leaked
<!--boost:conv …--> tokens, but the enumeration did not descend directory
symlinks. A host skill shadow served through a symlink (e.g.
.claude/skills/<name> → .ai/skills/<name>) therefore hid any raw token in
its SKILL.md from the scan — boost doctor reported clean while the agent
read the literal token. The scan now follows the immediate skill-directory
symlink, so the file it reports is the one the agent actually reads and a leak
there surfaces. The traversal resolves only the one-hop shadow link and never
follows links recursively, so a cyclic symlink under a skills directory cannot
make the scan loop. boost still never WRITES through these consumer-owned
symlinks; only the read-only scan follows them.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.16.2...0.16.3
A cross-platform determinism fix, a vendor-author migration guide, and an internal decomposition of the sync engine. (Rolls up the never-tagged 0.16.1 docs.)
(vendor, source path). The fix is output-ordering only:
it does not change which guideline wins a name collision, host-override shadowing,
or any reported diagnostic — only the sequence in the written file, which is now
byte-identical on every platform. (Surfaced from production dogfood; pairs with the
wrapper packages' own source-ordering fix.)conventions-token-migration skill — a shipped, author-facing guide
(resources/boost/skills/) for package authors migrating their skills/guidelines
off $.slot conventions references onto render-time <!--boost:conv …--> tokens:
the recipe, the mode×type matrix + prose-vs-fence placement, the authoring
footguns (inline-code-wrapped tokens stay literal; an errored token ships raw; map
sub-keys need ^0.16), dropping the obsolete slot-table scaffolding, and the
verify-before-ship workflow (boost where --conventions → sync both declared and
unset states → boost doctor / boost validate --strict). Pins the consumer-floor
rule for token-bearing skills. (Originally drafted for 0.16.1, which was never
tagged.)ConventionsPass, OrphanReaper, GuidanceWriter) backed by a shared filesystem
utility (ManagedFileOps), cutting the engine's cognitive complexity by ~31% with
no change to observable behavior — every step verified against the full suite +
multi-run side-effect characterization (orphan reap, manifest ownership, the
never-lossy empty-assembly guard). Closes cross-module helper duplication. No API
change.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.16.0...0.16.2
Conventions-token observability. When a <!--boost:conv …--> token does NOT resolve, a raw HTML-comment token lands in an emitted agent file — and the agent then reads the literal token instead of the value, silently. The dominant cause is a token-bearing vendor skill synced by a consumer still on boost-core < 0.15: the old engine has no inliner, so it copies the token verbatim with no error. 0.16.0 makes that leak visible at three escalating surfaces, and resolves a slot-addressing gap found while migrating real skills to tokens.
boost sync warns inline at render time, with a file:line locator — the leak is surfaced where it is born.boost doctor runs an always-on, advisory scan of the emitted set — per-agent guidance files (CLAUDE.md / AGENTS.md / GEMINI.md) and per-agent SKILL.md files, including gitignored copies — and lists every leak with its cause. It never scans .ai/ sources (those legitimately carry tokens).boost validate turns each leak into an error diagnostic, so boost validate --strict fails CI on a leaked token. Canonical CI recipe: run boost sync (or composer install), then boost validate --strict over the post-sync emitted set.```boost:conv fence opener → the fence was never cleanly processed.additionalProperties map — e.g. path="mcp.jira" — now resolves. Previously it errored as "unknown slot" because resolution short-circuited when the schema had no statically-defined leaf for the key, so map sub-keys were unaddressable whether declared or defaulted. The resolver now descends into additionalProperties and sources a sub-key default from the nearest default-bearing ancestor map. Three-state resolution (declared → schema default → fallback) and declared-empty precedence are unchanged.mode="yaml") token lives in a ```boost:conv fence; on clean resolution the engine strips that boost:conv info-string. If a token inside the fence instead errors, the engine now keeps the info-string rather than stripping it — so the unresolved token stays detectable on disk by the same surviving-fence-opener signal that catches a pre-0.15 emit, with no risk of false-positiving on a documentation example in a plain code fence. A cleanly-resolved fence is byte-identical to before.boost:conv line nested inside another fence is correctly treated as content, not an opener.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.15.0...0.16.0
Conventions inlining. Project-convention values can now be resolved into the skills and guidelines boost-core generates — at sync time — instead of being rendered as a separate always-loaded ## Project Conventions block in CLAUDE.md. Once a project's synced content is fully token-based, that block is dropped: the conventions still drive the output, they're just baked into the prose that needs them rather than carried as a standing context tax on every turn.
This is Phase 1 (the engine). It's fully backward-safe: until a vendor skill actually uses a convention token, nothing changes — the block renders exactly as it did pre-0.15. Migrating vendor/host skills to tokens is a per-package, opt-in follow-up; the block auto-drops for each consumer as their skill set converges.
<!--boost:conv path="github.default_base_branch" mode="inline"--> — and boost resolves it against the project's withConventions([...]) declarations at sync time. Resolution is three-state by path existence: a declared value wins; otherwise the schema default; otherwise an inline fallback. A declared false or [] counts as declared (not missing), so an intentional empty list renders as none rather than silently falling through to a default.inline/yaml/json; scalar lists as comma-joined inline, bullets, yaml, or json; maps as yaml/json. A schema may pin the allowed render modes for a slot. A mode that doesn't fit the value's type is a render error.## Project Conventions section. The gate fails toward keeping the block: a legacy $.slot reference, an unresolved token, a prose pointer to "the conventions section above", or any render error all keep it. The scan is ownership-aware — it inspects only content that survives this sync, and it strips boost's own previously-rendered block before scanning so the heading can't keep itself alive.boost where --conventions (with --json). An on-request audit of every convention slot and where its effective value comes from: declared, schema-default, or missing. Surfaces what the inliner will resolve before you sync.--check. An unknown slot, a type/mode mismatch, a multi-line value asked to render inline, or a slot with no resolvable value is reported as an error and keeps the block rather than emitting a half-resolved file — so a broken token can't silently ship degraded guidance.boost:conv info-string); inline-code spans, balanced-backtick runs, and an escaped <!--\boost:conv--> are left literal. Tokens are body-only — frontmatter is never substituted.Spec and implementation were independently model-reviewed across multiple rounds and ratified by the maintainers of the dependent packages before merge. Sourced from production dogfood across the boost stack.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.14.0...0.15.0
Reconcile-on-sync orphan reap. Using the 0.13.0 ownership manifest, boost-core now removes the files it emitted once it no longer emits them — closing the "delete it by hand" gap for project-scope output. Two cases land together: a dormant FileEmitter (its dep was removed, so it emits nothing) and a de-selected agent's guidance file (you dropped the agent from withAgents(...)). Both reap only what boost can prove it owns, and never touch operator-authored content.
FileEmitter's backing dependency is removed (its emit() returns null, or the package is gone), the file it previously wrote — e.g. .mcp.json — is removed instead of left behind pointing at a dead tool. Emitter outputs are recorded in .boost/manifest.json as category: file, provenance: emitter:<fqcn>. Reaping is conservative and never-lossy:
.mcp.json), the divergence is detected and the file is preserved;withDisabledEmitters) or one that errored this run keeps its file (disabling means "stop regenerating", not "delete");withAgents(...) now removes that agent's now-stale CLAUDE.md / AGENTS.md / GEMINI.md — but only when boost owns it (the on-disk sha still matches what boost wrote). A guidance file you've hand-edited is preserved. This closes the gap where a de-selected agent's guidance file lingered indefinitely while its skill directory was already pruned..gitignore, .boost/, any agent's skill/command root, a source directory (.ai/, resources/boost/), or a wrapper-claimed path or its descendants — is rejected with a diagnostic and never written, tracked, or reaped. Emitter paths are canonicalized and case-folded before these checks, so ./CLAUDE.md and claude.md can't slip through..gitignore block. When a wrapper injected skills into a directory boost already ignores at the directory level (e.g. .claude/skills/), boost-core also emitted a line for every individual …/SKILL.md beneath it — pure bloat that re-grew on every sync. The managed block now drops any per-file entry already covered by a directory-level pattern, keeping it compact. (Reported from project-boost-laravel adoption.).gitignore membership, so the delete predicate matches the stated ownership contract by construction. Two-phase lifecycle is unchanged: decisions read the prior manifest, the new one is written last on a fully-successful sync; an absent manifest means exact pre-0.14 behavior (no new reaping).scope field already carries the forward-compatible schema for it.Sourced from production dogfood and downstream adoption feedback across the boost stack.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.13.0...0.14.0
A DX + observability minor anchored by a sync ownership manifest. boost-core now records what it emits — so it knows what it owns — which structurally resolves the ownership-signal tension behind the 0.12.0 empty-guard trade-off. Plus guideline-shadow parity in boost where/boost doctor, so a host guideline that silently shadows a vendor one is finally visible.
.boost/manifest.json). On every successful sync boost-core writes a manifest of the files it emitted — each with a sha256, a category (guidance/skill/command), a provenance (engine or wrapper:<vendor/package>), and a scope (project/user). With ownership recorded, boost-core can safely clear or prune its own files without ever touching operator-authored content — replacing "guess from file content" with "consult the manifest." The manifest is gitignored via the managed .gitignore block (it's regenerable emit-state, not source). Absent a manifest — every pre-0.13 project, a fresh clone, the first 0.13 sync — behavior is exactly 0.12: no new clearing or pruning. The manifest only ever enables safe destructive actions; it never makes the no-manifest path more aggressive.
.ai/guidelines/, where durable content belongs. The prior content is always in git.wrapper:<vendor/package> are never pruned by a bare-CLI sync (which can't reproduce a wrapper's injection set); wrapper-path cleanup defers to the next wrapper-driven sync. The 0.11 BoostWrapperContract stays as the cold-start fallback for the manifest-absent window.boost where guideline-shadow parity. A host .ai/guidelines/<name>.md that shadows an allowlisted vendor guideline of the same name is now annotated in boost where output ((shadows <vendor>)), counted in the shadow NOTE, and diffable via --diff=<guideline-name>. The shadow check respects the active withTags(...) filter: a vendor guideline that wouldn't emit anyway (tag-filtered) is not reported as shadowed, and --diff resolves against the tag-eligible vendor copy — no contradictory false positives. Brings guidelines to parity with the existing skill-shadow surfacing.boost doctor reports guideline shadows. Host→vendor guideline shadows now surface in doctor too, reusing the same computation as where, so the two agree on the shadow story.->withConventions([...]) writes the Project Conventions section to CLAUDE.md even when the Claude agent isn't in withAgents(...) — a Codex/Copilot/Gemini-only project that declares conventions no longer needs Claude active to get them rendered.src/Sync/SyncManifest.php value object owns manifest read/write/compare, the category-specific ownership rule (guidance is listed-and-sha-matched; skills/commands listed), and the source-dir exclusion invariant (a manifest never lists, and a prune never resolves, a path under .ai/ or resources/boost/ — protecting dual-role publisher repos whose shipped product lives there).SyncEngine reads the prior manifest at the start of a sync for all destructive decisions and writes the new manifest last, only on a fully successful, non-render-failed sync — so a first 0.13 sync can't promote a pre-existing file to owned mid-run, and a partial/failed sync leaves the last-known-good manifest untouched. Destructive clear/prune runs only after all non-destructive writes succeed.injectedEmitPaths() (via WrapperEmitDiscovery), not by injection vendor-key — so the manifest tags the injector, the intersection of emitted-and-declared paths, and stays correct under multiple installed wrappers.boost doctor reuses a single drift sync for both drift and shadow reporting.The boost install composer.json script scaffold considered for this release was deferred — safely scaffolding a dev-only plugin's sync into Composer lifecycle hooks proved intractable across --no-dev and bare-CLI install paths, and is largely redundant with the plugin's existing auto-sync. It returns as a focused follow-up if real demand appears.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.12.0...0.13.0
Markerless agent-guidance files. The agent-guidance files boost-core emits — CLAUDE.md, AGENTS.md, GEMINI.md — are now wholesale boost-owned and carry no markers. boost-core regenerates each file in full on every sync from your .ai/guidelines/ sources and boost.php conventions. The <!-- boost-core:guidelines:* --> / <!-- boost-core:conventions:* --> marker pairs are gone: fewer moving parts in the emitted file, and one consistent ownership model across every path boost-core manages.
.ai/guidelines/ (it's assembled into the file on every sync) rather than hand-editing the emission target. On the first 0.12.0 sync of a legacy marker-bounded file, the markers are stripped and any genuine content outside them is preserved once below the generated body, with a warning pointing you at .ai/guidelines/. Nothing is silently lost.boost-core:conventions:* marker block, migrate it into boost.php with vendor/bin/boost convert-conventions before upgrading (while the markers still exist). After a 0.12.0 sync has stripped the markers, copy any preserved conventions YAML into boost.php's ->withConventions([...]) chain by hand — convert-conventions requires the markers and no longer applies once they're gone.CLAUDE.md / AGENTS.md / GEMINI.md in version control so boost-core's wholesale output is reviewable in diffs and recoverable from git. boost-core's managed .gitignore block already keeps them out of the ignore list.CLAUDE.md / AGENTS.md / GEMINI.md is left untouched (an INFO records this) instead of being overwritten with empty content. Adopting boost-core in a repo that already has a hand-written or boost install-generated CLAUDE.md — including via auto-sync on a routine composer update — no longer risks wiping it. Delete the file manually if you genuinely want it empty. Legacy marker-bounded files are exempt: they're provably boost-written, so they still converge.->withConventions([...]) once again writes the Project Conventions section to CLAUDE.md even when the Claude agent itself isn't in withAgents(...) — matching pre-0.12 behavior. A Codex/Copilot/Gemini-only project that declares conventions no longer loses them.GuidanceComposer owns markerless assembly and the one-time legacy-marker migration (strip the guidelines region, unwrap the conventions region preserving its YAML, drop stale inline duplicates, preserve genuine residual once + warn).SyncEngine consolidates the guideline and conventions writes into a single wholesale markerless write per unique guidance file; AgentTarget::plan() now emits skill writes only.boost doctor flags bare-name exclude keys (those missing a vendor/package: prefix) that would otherwise silently no-op.Sourced from production dogfood and adoption feedback across the boost stack.
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.11.0...0.12.0
Drift-comparison wrapper-injection awareness. Closes the correctness half of the wrong-entry-point bug class. 0.10.0 closed the discoverability half (the boost doctor entry-point-mismatch banner); 0.11.0 stops bare-CLI boost sync from false-positive-flagging wrapper-injected files for deletion.
When a wrapper package (sandermuller/project-boost-laravel) is the install pipeline, it injects skills and guidelines at sync time via SyncEngine::sync()'s injectedVendorSkills / injectedVendorGuidelines runtime args. The wrapper's canonical entry point (php artisan project-boost:sync) writes those files to disk.
A bare-CLI boost sync / boost sync --check carries no injection args, so the resolve pass produces an empty vendor-skill set. The generic stale-file cleanup pass (0.9.1) then classifies every previously-injected file as stale-to-delete:
[WARNING] Drift detected: 34 file(s) would change.
- .agents/skills/fluxui-development/SKILL.md
- .agents/skills/inertia-svelte-development/SKILL.md
... (30 wrapper-injected SKILL.md files marked for deletion)
Sourced from production dogfood: a downstream Laravel consumer added a workflow-rule note to their CI explicitly forbidding boost sync invocations — documenting a workaround for what should be an engine guarantee.
BoostWrapperContract — wrapper emit-surface declarationWrapper packages declare a BoostWrapper class implementing SanderMuller\BoostCore\Contracts\BoostWrapperContract at any of their PSR-4 prefixes:
namespace SanderMuller\ProjectBoostLaravel;
use SanderMuller\BoostCore\Contracts\BoostWrapperContract;
final class BoostWrapper implements BoostWrapperContract
{
/** [@param](https://github.com/param) list<string> $activeAgents agent enum values in withAgents(...) */
public static function injectedEmitPaths(string $projectRoot, array $activeAgents): array
{
return ['.agents/skills/some-injected-skill/SKILL.md', /* ... */];
}
}
boost sync reads the declared emit-paths and excludes them from stale-file cleanup, so bare-CLI sync no longer flags wrapper-injected files for deletion. The $activeAgents argument carries the project's withAgents(...) set so wrappers compute the correct per-agent paths (.claude/skills/… for Claude Code, the shared .agents/skills/… pool for Cursor / Copilot / Codex / etc.) using boost-core's AgentTarget API.
The declared paths also land in the boost-managed .gitignore block, so bare-CLI sync doesn't drop them from gitignore tracking (which would leak wrapper-emitted files into the operator's git working set).
BoostWrapper class — bare-CLI sync falls back to strict drift comparison (correct, just noisier). No engine-side wrapper allowlist to maintain; the 0.10.0 entry-point banner remains the discoverability surface.injectedEmitPaths(), returns a wrong type, or declares the class without implementing the contract degrades that one package with a diagnostic, never aborts the sync..agents/skills/foo) preserves every file under it via prefix-match.CLAUDE.md / AGENTS.md / GEMINI.md are filtered from the wrapper exclusion: they use marker-bounded managed regions with operator-tracking, not wholesale replacement, so the engine never routes them through file-level cleanup.The contract covers stale-file-cleanup exclusion only. It does not regenerate wrapper-injected managed-region content (guideline sections injected via injectedVendorGuidelines) on a bare-CLI run — that content is not reproducible without the wrapper's runtime injection args. Bare-CLI runs that need the full injected content must use the wrapper's canonical entry point; boost doctor's entry-point-mismatch banner points operators there.
composer update sandermuller/boost-core to ^0.11. No boost.php or config changes required for direct consumers — the engine-side change is backward-compatible (wrapper-absent projects keep the prior behavior).
Wrapper maintainers opt in by shipping the BoostWrapper class. This is the first boost-core minor that adds a wrapper-contract surface (engine + wrapper coordinate via a discovery contract) rather than a purely engine-internal change — wrapper packages need a paired release to gain the precision; direct consumers absorb without action.
sandermuller/project-boost-laravel ships its BoostWrapper implementation in the paired release; until then, bare-CLI sync on a project-boost-laravel project falls back to strict drift comparison (the pre-0.11.0 behavior, surfaced by the entry-point banner).
SanderMuller\BoostCore\Contracts\BoostWrapperContract: new interface, static injectedEmitPaths(string $projectRoot, array $activeAgents): list<string>.SanderMuller\BoostCore\Sync\WrapperEmitDiscovery: probes each installed package's PSR-4 prefixes for a BoostWrapper class, package-scopes the resolved class via a ReflectionClass file-path check (filters foreign classes occupying the same FQN), unions injectedEmitPaths() across all wrappers, canonicalizes returned paths (forward slashes, collapse ./duplicate-separator segments, reject .. traversal), and degrades per-package with diagnostics on every failure mode.SyncEngine::cleanupStaleManagedFiles(): excludes wrapper-claimed paths (exact + directory-prefix match). SyncEngine::updateGitignore(): includes them in the managed block, filtering guideline-file basenames.Likely cause: wording pin — an exact-string regression assertion on the 0.10.2 residual-warning's non-exhaustive-hypothesis-set framing.Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.10.3...0.11.0
Symlink-aware cleanup. Closes the residual-path failure mode the 0.10.2 observability surfaced. The cleanup pass for retired Copilot paths now correctly handles symlinks left over from pre-0.9.6 boost-core's explicit symlink-emit shape, instead of silently leaving them on disk.
Boost-core's retired-paths cleanup walks .github/skills/ (retired in 0.9.1) on every Copilot-active sync and removes the directory and its contents. Before 0.9.6, boost-core explicitly emitted symlinks at this path for vendor-shipped skills:
.github/skills/mcp-development -> ../../vendor/laravel/mcp/resources/boost/skills/mcp-development
This was the older Copilot path-consumption route for laravel/mcp-shipped skills. Operators who upgraded across the 0.9.x line carry these symlinks until cleanup removes them.
The cleanup walker was failing on those symlinks:
SplFileInfo::isDir() follows symlinks → reports a symlink-to-directory as a directory.[@rmdir](https://github.com/rmdir) on the symlink, which fails because rmdir requires an actual directory..github/skills/ couldn't be removed while still containing the symlink; drift persisted across syncs.0.10.2 made this failure visible by surfacing residual paths in a warning diagnostic. 0.10.3 fixes the underlying cause.
deleteRecursive checks is_link() before is_dir()In both the top-of-method dispatch (defensive — guards against a future caller passing a symlink as the cleanup root) and the iteration loop body, symlinks are now detected before the directory check and removed with unlink() instead of rmdir(). The change preserves PHP's default iterator behavior: RecursiveDirectoryIterator::hasChildren() defaults to $allowLinks=false, so the iterator never descends INTO symlinked directories — vendor content beyond the symlink target stays untouched.
Regression coverage asserts both halves of the contract: the retired symlink IS removed, and the vendor target IS preserved (vendor/laravel/mcp/.../skills/mcp-development/SKILL.md survives with original content).
composer update sandermuller/boost-core to ^0.10. No boost.php or config changes required. No breaking surface.
Operators upgrading from 0.10.0 or 0.10.1 who carry pre-0.9.6 symlinks will see them cleaned up on next sync. Operators on 0.10.2 who saw the residual-warning diagnostic will see the residuals removed and the warning stop firing.
SyncEngine::deleteRecursive(): is_link() check added at top of method + in the iteration loop body, ordered before is_dir() since the latter follows symlinks. Vendor-content-preservation contract pinned by iterator default + regression test..github/skills/<name> symlink pointing at vendor content, asserts symlink removal + vendor preservation + no residual-warning emission.Per the agent-handoff-command-shape discipline:
gh release create 0.10.3 \
--target main \
--title "v0.10.3" \
-F internal/release-notes-0.10.3.md
Full Changelog: https://github.com/SanderMuller/boost-core/compare/0.10.2...0.10.3
How can I help you explore Laravel packages today?