Waypoint –target Flag and Magic capture-example¶
The five trailblaze waypoint subcommands (list / locate / validate / capture-example / graph) now accept --target <pack-id> as a convention shortcut for --root <workspace>/packs/<id>/waypoints/. The same PR rewires capture-example so the common workflow is two arguments — --target <pack-id> --id <waypoint-id> — and the command auto-discovers a matching session log to source the example pair from. Underneath, three smaller fixes round out the change: a lazy LogsRepo so a one-shot CLI invocation no longer keeps the JVM alive on its file watchers, a caller-cwd plumb-through for the /cli/exec daemon path so daemon-forwarded commands still anchor at the user’s interactive cwd, and a classpath-aware error path for capture-example so users hitting JAR-bundled packs get a pointed “pass –root” message rather than a generic “not found.” Roughly 700 LOC of feature + 35 unit tests covering the resolution, ordering, and fail-fast contracts.
The --target flag resolves through the same workspace-pack convention the 2026-04-27 pack-manifest devlog pinned: <workspace.configDir>/packs/<id>/waypoints/. Precedence is --root > --target > silent default of ./trails. --root always wins when both are given so the same launcher binary works equally well for workspace-anchored authoring (--target square) and for ad-hoc directories of *.waypoint.yaml (--root /path/to/downloaded-ci-artifact). The “neither given” case is deliberately silent because list / locate / graph / validate all merge classpath-bundled packs in regardless of --root via WaypointDiscovery — so the no-flags case typically returns 100+ waypoints from the bundled clock/contacts/square packs and a “no –target specified” warning would fire on every successful invocation. Empty-result hints fire from the per-command call site via maybeWarnNoTarget(rootOverride, targetId, resultIsEmpty) instead. The split kept resolveWaypointRoot from regressing the daily waypoint list flow.
The magic capture-example path is two arguments and zero log paths: waypoint capture-example --target <pack-id> --id <waypoint-id> walks every session under the daemon’s resolved logsDir, runs WaypointMatcher.match(def, screen) against each step, and commits the example pair from the most recent step that BOTH matches the waypoint AND has a real screenshot. “Most recent” is decided by the JSON timestamp field on each log, NOT by filename — ATF-produced and CI accessibility-driver runs use hex-hash log names (7d50895f_AgentDriverLog.json) whose alphabetical sort doesn’t match emit order, so filename-sort would silently mis-order and pick the wrong screenshot. SessionLogScreenState.readTimestamp and listScreenStateLogs factor the timestamp regex out of WaypointMigrateTrailCommand (where it had been quietly correct since #2536) so both call sites share one source of truth. Three escape hatches narrow the scope: --session <id> restricts the auto-search to one session, --session <id> --step <n> pins to a specific step (skipping the matcher entirely), and a positional log path takes a file verbatim. --step requires --session — fail-fast usage error rather than the silent fall-through that would otherwise drop the user’s pinned step number.
hasScreenshot is the load-bearing pre-filter for the auto-search: it gates which logs are even matcher-eligible. The implementation has two non-obvious requirements. First, it must not deserialize the heavy viewHierarchy / trailblazeNodeTree subtrees on each probe — kotlinx.serialization with the typed LlmRequestLogProjection would walk those into typed objects on every call and turn the “cheap pre-filter” into something just-as-expensive-as-loadStep on a multi-thousand-step sweep. The fix is Json.parseToJsonElement(jsonFile.readText()).jsonObject["screenshotFile"] plus a single field check, which leaves the heavy subtrees as opaque JSON tree nodes. Second, it must verify that the referenced image actually exists on disk and is non-empty — a log can carry screenshotFile: "x.png" while the binary write failed or the file was pruned, and without the existence check the matcher wastes cycles on a candidate that capture-example would then fail to write. The pre-filter and the matcher’s downstream contract are now aligned: a step that passes hasScreenshot is a step we could actually commit.
capture-example’s annotated-vs-raw screenshot twin search now applies ONLY to _TrailblazeLlmRequestLog.json sources. On Android the framework writes two screenshots per LLM step — one annotated (set-of-mark overlays) and one raw — and the LLM log’s screenshotFile field references the annotated one, so the raw twin is the temporally-closest sibling within ~1 second. _AgentDriverLog.json and _TrailblazeSnapshotLog.json reference the raw image directly; running twin search on those would risk picking a NEIGHBORING step’s image whose filename timestamp landed in the 1s window, silently committing the wrong screen. The dispatch is by filename suffix string today — fragile if a future log type needs annotated/raw twin handling, but consistent with how listScreenStateLogs matches and sufficient for the current three log shapes.
capture-example writes its example pair NEXT TO the resolved YAML on disk, which means it requires a writable file path. Workspace pack waypoints have one; classpath-bundled (JAR resource) waypoints don’t. The original PR draft used WaypointLoader.discover(root) (filesystem walk only) and surfaced a generic “Waypoint id not found” when a user pointed --target <classpath-pack>. The fix probes WaypointDiscovery after a filesystem miss and emits a pointed “this waypoint is bundled on the classpath only — pass –root WaypointDiscovery.discover(root) directly and merge classpath transparently.
The LogsRepo lazy refactor exists for the CLI side. The previous override val logsRepo = LogsRepo(...) constructed eagerly at config-init time, which spawned non-daemon FileWatcher threads — fine for the long-lived daemon but it kept a one-shot CLI JVM alive for ~30 seconds after capture-example completed, making interactive use painful. Two changes restore quick exit without breaking the daemon: the abstract TrailblazeDesktopAppConfig.logsDir field is now a path-only accessor (separate from logsRepo), and CLI code reads that directly via parent.cliRoot.configProvider().logsDir without ever touching logsRepo. For the daemon, MainTrailblazeApp.start() calls setLogsDirectory(logsRepo.logsDir) near the top — that access still triggers eager initialization on the boot thread, so daemon-side behavior is preserved. The kdoc on each logsRepo by lazy site documents this so a future maintainer doesn’t remove the boot-time access thinking it’s redundant: removing it would shift initialization onto whichever thread first touches logsRepo (UI compose render, HTTP handler, coroutine), and a construction failure at that point would be cached for every subsequent access.
Caller-cwd plumbing through /cli/exec is the forward-looking piece. Today, waypoint subcommands are not in FORWARDABLE_SUBCOMMANDS — every invocation runs in a freshly-launched JVM whose Paths.get("") IS the user’s interactive cwd, so TrailblazeWorkspaceConfigResolver.resolve(Paths.get("")) walks up from the right directory. If waypoint is ever added to the forwardable list (the cold-start savings would be 1+s per invocation), the daemon-resident JVM’s cwd is wherever app start was launched from — typically the repo root, which has no workspace anchor. The bash shim now sends $PWD in the CliExecRequest’s new cwd field, the daemon’s executeForDaemon wraps the picocli invocation in CliCallerContext.withCallerCwd(request.cwd), and resolveWaypointRoot’s fromPath parameter defaults to CliCallerContext.callerCwd() (which falls back to Paths.get("") when no thread-local is pinned). Older shims that don’t send cwd produce a null thread-local and the fallback preserves the prior behavior; older daemons that don’t recognize the field ignore it (kotlinx.serialization’s ignoreUnknownKeys = true). Both directions of mismatch silently degrade to the pre-fix state instead of breaking.
docs/CLI.md is generated from the picocli @Option / @Parameters annotations on each command class. The :docs:generator:run Gradle task rewrites it; the OSS docs check (scripts/generate-docs-and-diff.sh) regenerates and asserts git diff --exit-code on every CI run. Manual edits to inline backticks, the Default column wording, or the per-command summary text are silently overwritten on the next regeneration — the lesson learned in this PR is to author CLI doc changes by editing the annotations, not the markdown. CONTRIBUTING.md now states this near the top.
Tests cover the resolution, sorting, and fail-fast contracts in four classes. WaypointCommandSharedTest exercises every branch of resolveWaypointRoot (--root override, --target with workspace pack found, --target without workspace anchor, --target with no matching workspace pack, neither flag) and maybeWarnNoTarget (silent on non-empty, silent when either flag is given, fires hint on empty-and-no-flags), plus a check that the default fromPath reads CliCallerContext.callerCwd() so the daemon-forwarded path resolves correctly. SessionLogScreenStateTest covers hasScreenshot (file present + non-empty, file present + zero-byte, screenshotFile null, file referenced but missing on disk, malformed JSON), readTimestamp (present / absent / malformed / missing-file), and listScreenStateLogs chronological-vs-filename ordering using hex-hash filenames as the regression case. WaypointCaptureExampleBehaviorTest exercises the --step fail-fast contract (positive and negative), the classpath-only pointed error (URLClassLoader-injected synthetic pack so the test doesn’t depend on the real JVM classpath), and the truly-unknown-id fall-through. WaypointValidateBehaviorTest mirrors the --step fail-fast for validate. CliCallerContextTest covers default/override/restore/null/throw/concurrent-thread semantics on the thread-local.
Convention summary for waypoint authors¶
- Pack waypoints live at
<workspace>/packs/<id>/waypoints/and are declared in the same pack’spack.yaml. --target <pack-id>resolves to that location for all fivewaypointsubcommands.--root <path>overrides when you need an explicit path (downloaded CI artifacts, classpath-bundled packs you want to author against from on-disk source).- Classpath-bundled packs (the OSS
clock/contactsframework packs, downstream-bundled packs from a Kotlin module’s resources) are merged in byWaypointDiscoveryforlist/locate/validate/graphregardless of--root.capture-exampleonly writes to filesystem-resolved YAMLs, so for classpath packs you must pass--rootpointing at the on-disk pack source. waypoint capture-example --target <pack-id> --id <waypoint-id>is the headline invocation: walks every session under./logs/, picks the most recent matching step with a screenshot, writes the example pair next to the YAML.- Screen state is committed alongside every waypoint. Treat coverage as a pre-commit gate, not a follow-up.