Trailmap Manifest V1¶
trailmap.yaml is the authored unit for local-first target trailmaps. In v1 it lives at trails/config/trailmaps/<trailmap-id>/trailmap.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 trailmap file. The target: nesting is intentional: PR #2413 added target-root inline scripted tools: to the live target schema, so trailmaps need a place to preserve that runtime surface without colliding with trailmap-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 trailmap 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 trailmap 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 trailmap lives at <consumer-module>/src/commonMain/resources/trails/config/trailmaps/<id>/trailmap.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 trailmaps. Generated target files carry a # GENERATED FILE banner pointing at their source trailmap 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 trailmap discovery are deliberately deferred until the authoring model proves out across the trailmaps that ship with this contract.
The same trailmap-first move now reaches the open-source surface. The wikipedia and contacts framework targets in trailblaze-models get trailmap-shaped authoring proofs alongside their flat targets — matching the clock convention — so all bundled framework targets have a trailmap to grow into once classpath trailmap discovery lands. The ios-contacts and playwright-native example workspaces go further and become trailmap-driven at runtime: each gets a workspace anchor (trails/config/trailblaze.yaml declaring trailmaps:) and a single trailmaps/<id>/trailmap.yaml that carries the target config, including target-root inline scripted tools:. The legacy flat targets at trails/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 trailmap — bundled framework targets through the generated-flat-target dogfood contract, and example workspaces through trailmap-only workspaces.
Clock waypoints move into the trailmap to match. The three *.waypoint.yaml files that previously lived at trails/clock/waypoints/ are now siblings of the bundled clock trailmap at trailblaze-models/.../trailmaps/clock/waypoints/, and the trailmap manifest declares them via waypoints:. The trailmap is now self-contained authoring: target config, waypoints, and (eventually) routes/trails colocate.
Trailmap manifests now load via two sources, and manifest.waypoints is wired through both. Workspace-declared trailmaps (via trailmaps: in trailblaze.yaml) load as before. Framework trailmaps ship on the classpath under trails/config/trailmaps/<id>/trailmap.yaml and are auto-discovered by TrailblazeTrailmapManifestLoader.discoverAndLoadFromClasspath() — no workspace declaration needed. A new TrailmapSource sealed type abstracts the difference so sibling-file reads (waypoints, toolset/tool refs) work uniformly: TrailmapSource.Filesystem(trailmapDir) for workspace trailmaps, TrailmapSource.Classpath(resourceDir) for framework trailmaps. TrailmapSource.readSibling rejects .. segments and absolute paths so a trailmap manifest cannot escape its own directory — defense-in-depth against future use cases where trailmap 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 trailmap waypoints (workspace + classpath) with the legacy --root filesystem walk; trailmap waypoints come first so user-authored shadows by waypoint id are silently dropped. Trailmap-id collisions between workspace and classpath are wholesale: the workspace trailmap shadows the classpath trailmap of the same id so users can locally override framework trailmaps without forking. That precedence is documented on TrailblazeResolvedConfig and tracked for future tightening (sealed trailmaps, opt-in extension) under #2440.
The clock/wikipedia/contacts framework targets ship both a flat targets/<id>.yaml and a trailmaps/<id>/trailmap.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 trailmap 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 trailmap 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 trailmaps ship under trailmaps/<id>/tools/<tool>.yaml:
- Per-file scripted tools. Inlining every scripted tool (
script:/name:/description:/_meta:/inputSchema:) into onetrailmap.yamldoesn’t scale. The trailmap manifest’starget.tools:field now holds a list of trailmap-relative file paths instead of inline objects, and each path resolves to aTrailmapScriptedToolFile(one tool per YAML file undertrailmaps/<id>/tools/). The trailmap loader still producesInlineScriptToolConfigentries for the runtime, so the registration / synthesis side is unchanged. - 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: trueis the per-property default;enum: [...]is supported for string parameters). The trailmap loader translates the flat shape to JSON-Schema-conformant form at load time, validating thatenum:is non-empty along the way._meta.trailblaze/supportedPlatformsvalues are case-insensitive ([web],[WEB],[Web]all canonicalize to uppercase viaTrailblazeToolMeta.fromJsonObject) so authors can match the lowercase TS-side context envelope. ToolYamlConfig1:1 with@TrailblazeToolClass. YAML-defined (tools:mode) tools can now authoris_for_llm,is_recordable, andrequires_hostto mirror the Kotlin annotation’s load-bearing fields. They’re rejected inclass:mode (the annotation is the source of truth there). At runtime, the resolver helpers (getIsRecordableFromAnnotation,requiresHostInstance) read these via a newTrailblazeTool.toolMetadatainterface hook so the lookup doesn’t need aninstanceof YamlDefinedTrailblazeToolcheck; future tool sources can join the pattern by overriding the same hook.
Migration: ioscontacts → contacts¶
The iOS Contacts example workspace trailmap was renamed from ioscontacts to contacts so the same trailmap can later host Android / web Contacts-app tools alongside the iOS ones already wired (the trailmap is the Contacts app trailmap, not just the iOS subset). Effects:
examples/ios-contacts/trails/config/trailmaps/contacts/trailmap.yaml(formerlytrailmaps/ioscontacts/) — trailmap idcontacts, display name “Contacts”.examples/ios-contacts/trails/config/trailblaze.yamlreferencestrailmaps/contacts/trailmap.yaml.- The example’s README mentions the old trailmap id alongside the new one.
Within the iOS Contacts example workspace, the new id wholesale shadows the bundled framework contacts trailmap (Google Contacts) per the standard trailmap-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 trailmap 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¶
TrailblazeTrailmapManifestLoader.discoverAndLoadFromClasspathnow caches per-classloader (mirroringTrailblazeSerializationInitializer.buildYamlDefinedTools) so per-target / per-LLM-request resolutions don’t re-walk the classpath. The cache uses aWeakHashMap<ClassLoader, ...>so test fixtures swappingThread.contextClassLoader(viaClasspathFixture) naturally rediscover from the new classpath without leaking stale state.- Duplicate trailmap 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
getIsRecordableFromAnnotationandrequiresHostInstancelog viaConsole.errorinstead of being swallowed. - The trailmap-resolution failure log includes the cause exception class name so authors distinguish “sibling file missing” from “malformed YAML” from “containment violation” without tracing.
ClasspathResourceDiscoveryJAR-scan exceptions log the jar path + cause instead of being silently absorbed.