Skip to content

v0.7.0

Choose a tag to compare

@SanderMuller SanderMuller released this 25 May 16:48
Immutable release. Only release title and notes can be modified.

Added

withRemoteSkills(...) — non-Composer skill sources

Declarative consumption of three real-world shapes that don't fit Composer's vendor model:

return BoostConfig::configure()
    ->withAgents([Agent::CLAUDE_CODE])
    ->withRemoteSkills([
        // Bundle mode — fetches the named `.skill` release asset, unzips.
        RemoteSkillSource::githubBundle('peterfox/agent-skills', 'v1.2.0', [
            'composer-upgrade',
            'phpstan-developer',
        ]),

        // Path mode — fetches the repo tarball at the given ref, extracts named subdirs.
        // `'.'` covers whole-repo-is-one-skill layouts (blader/humanizer style).
        RemoteSkillSource::githubPath('mattpocock/skills', 'main', [
            'grill-with-docs' => 'skills/engineering/grill-with-docs',
        ]),
    ]);

Resolved on the next composer install / update through the existing BoostAutoSync hook — no separate command, no separate cache-warm step. First sync hits the network; later syncs are offline-fast (cache lives at <project>/.boost-remote-cache/, auto-added to the managed .gitignore). Removing an entry prunes its agent-dir output on next sync; removing an entire source prunes every skill it last contributed.

Set BOOST_GITHUB_TOKEN to lift anonymous GitHub access from 60 to 5000 requests/hour. CI runs that resolve withRemoteSkills(...) cold should always export the token. BOOST_REMOTE_STRICT=1 escalates any remote-source failure (network unreachable, malformed archive, name-mismatch) to an aborting error; default is warn-and-skip. boost doctor lists every declared remote source, flags moving refs ('main', 'latest', branch names) with a , and reports per-skill cache presence — all offline.

boost sync --check is network-free and side-effect-free: cold-cache sources are surfaced as would-fetch advisories in SyncResult::errors, never touching the network or writing to the cache.

SkillRenderer plugin contract (@experimental)

use SanderMuller\BoostCore\Config\BoostConfig;
use SanderMuller\ProjectBoostLaravel\Rendering\BladeRenderer;

return BoostConfig::configure()
    ->withAgents([Agent::CLAUDE_CODE])
    ->withSkillRenderers([new BladeRenderer]);

Plugin seam for rendering template-flavored skill bodies (SKILL.blade.php, SKILL.twig, …) before per-agent fan-out. The dispatcher matches longest-extension-first (so .blade.php beats .php when both are claimed); the implicit PassthroughRenderer always handles .md and is re-appended after any withDisabledRenderers([FQCN]) deny-list. Render failures default to warn-and-skip (recorded in SyncResult::errors); BOOST_RENDER_STRICT=1 escalates the first failure to an aborting SkillRenderException. The flag is separate from BOOST_REMOTE_STRICT so a project can keep renders lenient (a single broken Blade skill should not abort CI) while making remote-source resolution strict, or vice versa.

The contract is @experimental — pin to an exact boost-core version if building against it. Lock-in happens after a second non-trivial consumer from a different problem domain validates the shape, mirroring the FileEmitter plugin lock-in criteria. Reference consumer: sandermuller/project-boost-laravel ships a BladeRenderer that delegates to laravel/boost's RendersBladeGuidelines trait, so .ai/<pkg>/skill/<name>/SKILL.blade.php files render with the $assist = GuidelineAssist runtime context they expect.

GuidelineLoader reuses the same dispatcher — a BladeRenderer registered for skills also discovers Blade guidelines in .ai/guidelines/. Files whose extension no registered renderer claims are silently skipped, bit-identical to pre-renderer boost-core when no renderer is registered.

Caller-controlled vendor injection on SyncEngine::sync()

$engine->sync(
    projectRoot: __DIR__,
    injectedVendorSkills: ['laravel/boost' => $skills],
    extraSkillRenderers: [new BladeRenderer],
    injectedVendorGuidelines: ['laravel/boost' => $guidelines],
);

Three new optional parameters for wrapper packages whose source layout VendorScanner cannot reach (laravel/boost's .ai/<pkg>/... is the motivating case — sandermuller/project-boost-laravel uses this seam). Tag-filtered and collision-detected identically to scanned vendors. Same-vendor name collisions between injected and scanned skills throw SkillSourceCollisionException, caught in SyncEngine::sync and converted to a SyncResult::errors entry (lenient) or rethrown (strict). All three default to []; existing call sites are unchanged.

boost where — skill origin tracing

vendor/bin/boost where

Lists every skill that would land in agent dirs, grouped by source:

  • .ai/skills/ (host) — host-authored skills, with (shadows <vendor>) on overrides.
  • <vendor/package> — Composer-allowlisted vendors publishing via resources/boost/skills/.
  • <vendor/package> from a RemoteSkillSource — non-Composer sources declared via withRemoteSkills(...).

Same resolution pipeline as boost sync --check (tag-filtered, collision-resolved). Caller-injected vendor skills (the wrapper pattern, e.g. project-boost-laravel) are NOT visible — those are runtime-only inputs to SyncEngine::sync() and the wrapper package owns its own inspection surface.

Complements boost tags (lists every tag and which would unlock filtered skills) and boost doctor (cache freshness + remote source diagnostics).

SyncResult::renderDeleteAttribution(): ?string

Canonical attribution renderer for destructive deletes. Returns null when nothing was deleted (or in check-mode results), otherwise a multi-line string naming the three possible causes (tag-filter, removed withRemoteSkills entry, stale-source prune) followed by the deleted paths.

if ($attribution = $result->renderDeleteAttribution()) {
    $this->warn($attribution); // Laravel artisan
    // or $io->warning($attribution); // Symfony console
}

Single source of truth for the attribution wording — boost-core's own SyncCommand and wrapper commands (e.g. project-boost-laravel's artisan project-boost:sync, future custom CLIs) use it so the operator-visible delete audit signal stays identical across invocation surfaces. Addresses a gap where wrapper commands that render their own output from $result->writes saw the per-line deleted <path> action but missed the cause-attribution that vendor/bin/boost sync produced. The helper closes the gap symmetrically.

skill-origin-tracing bundled skill

A resources/boost/skills/skill-origin-tracing.md skill that triggers when downstream agents see questions like "why is skill X present", "why is skill Y missing", "where does skill Z come from", or "did host shadow X". Routes them to boost where (and boost tags for tag-filtered cases) instead of grepping vendor/.

Tag enum gains Laravel-ecosystem cases

Livewire, Volt, Inertia, Filament, Flux, Pest, Tailwind. Surfaces the tag vocabulary laravel/boost's bundled skills declare, so withTags(Tag::Livewire, …) autocompletes properly. Non-authoritative — string fallback continues to work for any vocabulary the enum doesn't cover.

Fixed

  • GuidelineLoader discovers .blade.php (and any renderer-registered extension) in .ai/guidelines/. Previously globbed *.md only — host-authored Blade guidelines were silently dropped, causing content loss in CLAUDE.md for users with templated guidelines.
  • FileWriter refuses to follow user-placed symlinks. A sync write under .claude/skills/<name>/ that would resolve through a user-placed symlink (e.g. .claude/skills/<name>../../.ai/skills/<name>/) now bails with WriteAction::SKIPPED_SYMLINK and surfaces in the summary as skipped-symlink=N. Preserves the "live symlinks owned by consumer" contract SyncEngine::pruneDeadSymlinks() documented but the write path did not honor.
  • boost sync --check is network-free and writes-free. RemoteSkillSyncCoordinator::ingestIntoVendorMap() accepts a $checkOnly flag; sources missing from the offline cache are excluded from the ingest call and surfaced as a would-fetch advisory in SyncResult::errors. Restores the dry-run-purity invariant that pre-companion-refactor consumers relied on.
  • Host-vs-vendor skill shadowing is surfaced. A host .ai/skills/<name>/ that shadows an allowlisted-vendor skill of the same name now produces a <name> shadows <vendor> line in SyncCommand's output. Plumbed via a new SyncResult::$hostShadows field and a &array $shadows out-param on SkillResolver::resolve(). Existing host-wins precedence and CollidingSkillsException semantics unchanged.
  • boost sync lists each deleted path inline + attributes the cause. A delete event (a previously-installed agent-dir skill pruned because its source skill is no longer tag-eligible after a withTags() change, or a removed withRemoteSkills entry, or a stale-source prune) previously surfaced only as a count in the success summary. SyncCommand now emits a warning naming the three possible causes followed by the list of relative paths whenever deleted > 0. Behavior unchanged in --check mode (where would-delete was already listed).
  • TagReporter passes the renderer dispatcher. boost tags and boost doctor now discover renderer-claimed extensions (e.g. .blade.php with a registered BladeRenderer), matching the file set boost sync would emit.
  • RemoteSkillIngester honors BOOST_RENDER_STRICT. Render failures inside the per-skill loop now escalate via SkillRenderException when the flag is set, mirroring SkillLoader's strict path.
  • locateSkillFile prefers the canonical SKILL.<ext> across all globs. A remote-skill cache slot containing both README.md and SKILL.blade.php now correctly loads the latter; previously the first-glob-with-any-match silently ingested README as the skill body.
  • RemoteSkillIngester surfaces same-source different-version skill collisions. Two withRemoteSkills entries pointing at the same repo at different versions both listing the same skill name now produce a clear error (lenient: SyncResult::errors; strict: RemoteFetchException).
  • RemoteSkillSyncCoordinator detects collisions with scanned/injected vendor maps. A remote source sharing a vendor key with an already-populated entry throws SkillSourceCollisionException, caught by SyncEngine::sync and converted to a SyncResult::errors entry.
  • extraSkillRenderers no longer shadow user-registered renderers. Extras are inserted between user renderers and the trailing implicit PassthroughRenderer. Final order: [...user, ...extras, Passthrough]. With first-match-wins dispatch, the user's boost.php registry stays authoritative.
  • TarballExtractor archive-path prefix length corrected. The strip-length for in-archive entry names now includes the phar:// URL prefix that PharData iteration prepends. (Linux CI rendered this as a PharData-vs-tar regression and we pivoted to system tar -xzf via Symfony Process for portability.)
  • BundleExtractor::assertNotSymlink fails closed on unreadable external attributes. A crafted ZIP that suppresses attribute readability would previously bypass the symlink check; now throws RemoteExtractException::SYMLINK.
  • SkillLoader falls back to getPathname() when getRealPath() returns false (broken symlinks, open_basedir restrictions, file disappears between Finder enumeration and the realpath call). Previously hit a TypeError on the false case.
  • BoostConfigBuilder distinguishes implicit vs explicit PassthroughRenderer by object identity. A user passing new PassthroughRenderer() via withSkillRenderers is now treated as a regular renderer for conflict detection.
  • Multi-extension naming fallback fix. SKILL.blade.php with no name: frontmatter now resolves to skill name SKILL (previously would have been SKILL.blade). The dispatcher now returns a MatchedRenderer { renderer, extension } tuple so the loader can strip the full matched extension.

Changed

  • SkillLoader::load() accepts a SkillRendererDispatcher parameter (default = passthrough-only). External direct callers that construct a SkillLoader are unaffected unless they want to register a non-default renderer. SyncEngine builds the per-sync dispatcher from BoostConfig::skillRenderers after config-load.
  • RemoteSkillIngester::ingest() accepts the same dispatcher, so remote .blade.php skills render through the registered renderer too. The internal loadOne was generalized from a hard-coded SKILL.md lookup to walk the dispatcher's claimed extensions, falling back to .md.
  • SyncEngine's injection-merge logic extracted to InjectedVendorMerger to keep the engine's class-level cognitive complexity tractable.

Added (internal — public exception class)

  • SkillSourceCollisionException (src/Sync/SkillSourceCollisionException.php). Thrown by InjectedVendorMerger and RemoteSkillSyncCoordinator when caller-config (injected vendor map / remote source declaration) would silently overwrite an existing entry under the same vendor key. Caught in SyncEngine::sync() and converted to a SyncResult with the message as an error — consumers that wrap sync() and expect it to never throw on user-config issues keep working. Distinct from CollidingSkillsException (which models cross-vendor name collisions detected by SkillResolver).

Upgrade notes

No migration required from 0.6.x. The three additive surfaces (withRemoteSkills, SkillRenderer, SyncEngine::sync injection params) are opt-in; existing projects keep working unchanged. See UPGRADING.md for adoption notes.

@experimental APIs (SkillRenderer, the three injection params): pin to an exact boost-core version ("sandermuller/boost-core": "0.7.0" rather than "^0.7.0") if your project depends on the precise shape. The shape survived the entire rc cycle without churn, but lock-in waits for a second non-trivial consumer from a different problem domain.

Companion package status

sandermuller/project-boost-laravel — the reference Laravel companion that consumes laravel/boost-bundled skills via the injection seam, ships its own BladeRenderer, and exposes a Laravel artisan project-boost:sync command — accepts 0.7.0 stable on its existing ^0.7.0-rc1 constraint without composer.json changes. Cut a composer update sandermuller/boost-core to pick up stable.

Full Changelog: 0.6.2...0.7.0