Skip to content

Pack Manifest V1

pack.yaml is the authored unit for local-first target packs. In v1 it lives at trails/config/packs/<pack-id>/pack.yaml, carries a required id, and may carry a nested target: block plus file lists for toolsets, tools, waypoints, routes, and trails, all resolved relative to the pack file. The target: nesting is intentional: PR #2413 added target-root inline scripted tools: to the live target schema, so packs need a place to preserve that runtime surface without colliding with pack-owned tool-definition files. Composition verbs are reserved directly on the manifest as string lists: use, extend, and replace. target.id is optional and defaults to the enclosing pack id; target.display_name is required when target: is present; platforms.<platform>.app_ids remain ordered for precedence; and platforms.web.base_url is now part of the schema even though placeholder substitution is a follow-up. This PR also adds a built-in clock proof pack alongside the legacy flat target so #2423 can attach first-class waypoints: and routes: without reopening target-shape work.

Downstream consumers that bundle their own targets into a JAR can adopt the same pattern via a dogfood contract: each pack lives at <consumer-module>/src/commonMain/resources/trailblaze-config/packs/<id>/pack.yaml and the corresponding flat targets/<id>.yaml is a generated artifact, regenerated and verified by the trailblaze.bundled-config Gradle plugin (generateBundledTrailblazeConfig / verifyBundledTrailblazeConfig). target stays the runtime concept; the dogfood path keeps the runtime-stable target model while moving authoring to packs. Generated target files carry a # GENERATED FILE banner pointing at their source pack and the regenerate command, and verifyBundledTrailblazeConfig runs as part of the consumer module’s :check task so drift fails the build. Composition semantics (use / extend / replace) and bundled-classpath pack discovery are deliberately deferred until the authoring model proves out across the packs that ship with this contract.

The same pack-first move now reaches the open-source surface. The wikipedia and contacts framework targets in trailblaze-models get pack-shaped authoring proofs alongside their flat targets — matching the clock convention — so all bundled framework targets have a pack to grow into once classpath pack discovery lands. The ios-contacts and playwright-native example workspaces go further and become pack-driven at runtime: each gets a workspace anchor (trailblaze-config/trailblaze.yaml declaring packs:) and a single packs/<id>/pack.yaml that carries the target config, including target-root inline scripted tools:. The legacy flat targets at trailblaze-config/targets/*.yaml are removed in those workspaces. Inline scripted tool script: paths still resolve from the JVM working directory (typically the repo root), so paths are preserved verbatim during the move. With this PR, every authored target in the repo except the deliberately-flat default (renamed from none by #2439 after this PR was first opened) ships as a pack — bundled framework targets through the generated-flat-target dogfood contract, and example workspaces through pack-only workspaces.

Clock waypoints move into the pack to match. The three *.waypoint.yaml files that previously lived at trails/clock/waypoints/ are now siblings of the bundled clock pack at trailblaze-models/.../packs/clock/waypoints/, and the pack manifest declares them via waypoints:. The pack is now self-contained authoring: target config, waypoints, and (eventually) routes/trails colocate.

Pack manifests now load via two sources, and manifest.waypoints is wired through both. Workspace-declared packs (via packs: in trailblaze.yaml) load as before. Framework packs ship on the classpath under trailblaze-config/packs/<id>/pack.yaml and are auto-discovered by TrailblazePackManifestLoader.discoverAndLoadFromClasspath() — no workspace declaration needed. A new PackSource sealed type abstracts the difference so sibling-file reads (waypoints, toolset/tool refs) work uniformly: PackSource.Filesystem(packDir) for workspace packs, PackSource.Classpath(resourceDir) for framework packs. PackSource.readSibling rejects .. segments and absolute paths so a pack manifest cannot escape its own directory — defense-in-depth against future use cases where pack authors are not commit-trusted. Resolution returns a new TrailblazeResolvedConfig(projectConfig, waypoints) wrapper rather than stashing transient runtime artifacts on the YAML schema class — TrailblazeProjectConfig stays a pure schema and resolution adds-on go on the wrapper. The trailblaze waypoint list/locate/validate CLI commands now combine pack waypoints (workspace + classpath) with the legacy --root filesystem walk; pack waypoints come first so user-authored shadows by waypoint id are silently dropped. Pack-id collisions between workspace and classpath are wholesale: the workspace pack shadows the classpath pack of the same id so users can locally override framework packs without forking. That precedence is documented on TrailblazeResolvedConfig and tracked for future tightening (sealed packs, opt-in extension) under #2440.

The clock/wikipedia/contacts framework targets ship both a flat targets/<id>.yaml and a packs/<id>/pack.yaml for now. The flat path stays load-bearing during the transition so existing classpath flat-target discovery continues to work for downstream consumers that have not yet migrated. The flat copies will be retired in a follow-up once classpath pack discovery has been the runtime source of truth for at least one release cycle and :check reliably catches drift between the two — tracked alongside the broader pack workstream in #2422.

Author conventions added during review

PR review surfaced three conventions that were inconsistently applied between the original schema and the per-file scripted-tool YAMLs the example packs ship under packs/<id>/tools/<tool>.yaml:

  1. Per-file scripted tools. Inlining every scripted tool (script:/name:/description:/_meta:/inputSchema:) into one pack.yaml doesn’t scale. The pack manifest’s target.tools: field now holds a list of pack-relative file paths instead of inline objects, and each path resolves to a PackScriptedToolFile (one tool per YAML file under packs/<id>/tools/). The pack loader still produces InlineScriptToolConfig entries for the runtime, so the registration / synthesis side is unchanged.
  2. Flat author-friendly inputSchema. Authors no longer write JSON Schema’s { type: object, properties: { ... } } wrapper. The per-file YAML declares parameters flatly (top-level keys are property names; required: true is the per-property default; enum: [...] is supported for string parameters). The pack loader translates the flat shape to JSON-Schema-conformant form at load time, validating that enum: is non-empty along the way. _meta.trailblaze/supportedPlatforms values are case-insensitive ([web], [WEB], [Web] all canonicalize to uppercase via TrailblazeToolMeta.fromJsonObject) so authors can match the lowercase TS-side context envelope.
  3. ToolYamlConfig 1:1 with @TrailblazeToolClass. YAML-defined (tools: mode) tools can now author is_for_llm, is_recordable, and requires_host to mirror the Kotlin annotation’s load-bearing fields. They’re rejected in class: mode (the annotation is the source of truth there). At runtime, the resolver helpers (getIsRecordableFromAnnotation, requiresHostInstance) read these via a new TrailblazeTool.toolMetadata interface hook so the lookup doesn’t need an instanceof YamlDefinedTrailblazeTool check; future tool sources can join the pattern by overriding the same hook.

Migration: ioscontactscontacts

The iOS Contacts example workspace pack was renamed from ioscontacts to contacts so the same pack can later host Android / web Contacts-app tools alongside the iOS ones already wired (the pack is the Contacts app pack, not just the iOS subset). Effects:

  • examples/ios-contacts/trails/config/packs/contacts/pack.yaml (formerly packs/ioscontacts/) — pack id contacts, display name “Contacts”.
  • examples/ios-contacts/trails/config/trailblaze.yaml references packs/contacts/pack.yaml.
  • The example’s README mentions the old pack id alongside the new one.

Within the iOS Contacts example workspace, the new id wholesale shadows the bundled framework contacts pack (Google Contacts) per the standard pack-id collision rule. That’s intentional: the example workspace exists to demonstrate Contacts-app testing on iOS, not to also surface Google Contacts. Outside this workspace the bundled contacts pack is unaffected.

CLI users who pinned --target ioscontacts should update to --target contacts. There is no automatic alias.

Hidden-from-LLM convention for deprecated tools

The seven @Deprecated by-text/by-id maestro-style tools (tapOnElementWithText, tapOnElementWithAccessibilityText, longPressOnElementWithText, longPressElementWithAccessibilityText, assertVisibleWithText, assertVisibleWithAccessibilityText, assertVisibleWithResourceId) are now @TrailblazeToolClass(isForLlm = false) so the LLM stops being shown them. They remain dispatchable by name from .js scripted tools via client.callTool(...) because the global tool-name dispatch isn’t gated on isForLlm — only LLM-context listing is (KoogToolExt.toKoogTools filter). Authors using tap (by ref id) and assertVisible (by selector) are unaffected.

Operational hardening surfaced in PR review

  • TrailblazePackManifestLoader.discoverAndLoadFromClasspath now caches per-classloader (mirroring TrailblazeSerializationInitializer.buildYamlDefinedTools) so per-target / per-LLM-request resolutions don’t re-walk the classpath. The cache uses a WeakHashMap<ClassLoader, ...> so test fixtures swapping Thread.contextClassLoader (via ClasspathFixture) naturally rediscover from the new classpath without leaking stale state.
  • Duplicate pack ids across classpath jars now log a warning identifying both source paths instead of silent last-wins.
  • Reserved-but-unwired manifest fields (use, extend, replace, routes, trails) log a one-line note when populated, so a typo in a real field name doesn’t silently parse into a no-op slot.
  • Reflection failures in getIsRecordableFromAnnotation and requiresHostInstance log via Console.error instead of being swallowed.
  • The pack-resolution failure log includes the cause exception class name so authors distinguish “sibling file missing” from “malformed YAML” from “containment violation” without tracing.
  • ClasspathResourceDiscovery JAR-scan exceptions log the jar path + cause instead of being silently absorbed.