Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .trailblaze-sync
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6e278dc4acbede2988c75123f499d10b0cf7714f
8141286d5a206c6ee3b16169f1b7224ac2d881aa
2 changes: 1 addition & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ trailblaze run [OPTIONS] [<<trailFile>>]
| `--secret` | Pre-populate trail memory with a SENSITIVE KEY=VAL before any step runs. Same shape as --memory; the value is redacted in logs (via `rememberSensitive`), excluded from the scripting envelope, and omitted from the session-start snapshot. Only the KEY appears in `Started.sensitiveMemoryKeys` so replay knows it must re-supply the value. Repeatable. Use for passwords, tokens, API keys, PII. | - |
| `--arg` | Supply a value for a parameter the trail DECLARES under `config.args:`. Repeatable (`--arg recipient=sam@example.com --arg retries=3`). Unlike --memory, args are declared and typed: the value is coerced to the arg's declared type, a missing required arg fails before the run starts, and an undeclared arg is rejected. Referenced as `{{args.name}}` in prompts and tool params. Overrides an --args-file entry with the same key. The value is always a string here; declaration-driven coercion turns `retries=3` into a number for an `integer` arg. Use --args-file for structured (array/object) values. Arg values are logged in cleartext, persisted into logs/recordings, and surfaced to the LLM — args are non-sensitive by design. Route passwords, tokens, or other sensitive data through --secret memory instead. | - |
| `--args-file` | Read parameter values from a YAML or JSON file (a map of arg-name to value). Applied BEFORE --arg, so a --arg KEY=VAL overrides the file entry with the same key. A YAML-null value is rejected (args have no null) — use '' for an empty string. Arg values are logged in cleartext, persisted into logs/recordings, and surfaced to the LLM — args are non-sensitive by design. Route passwords, tokens, or other sensitive data through --secret memory instead. | - |
| `--max-llm-calls` | Cap the number of LLM calls per objective for the legacy TRAILBLAZE_RUNNER agent. Useful on metered or expensive providers to cut off a stuck self-heal loop. Must be a positive integer. Default: 50 (the runner's built-in cap). Not compatible with --agent MULTI_AGENT_V3. | - |
| `--max-llm-calls` | Cap the number of LLM calls per objective for the legacy TRAILBLAZE_RUNNER agent. Useful on metered or expensive providers to cut off a stuck self-heal loop. Must be a positive integer. Default: 25 (the runner's built-in cap). Not compatible with --agent MULTI_AGENT_V3. | - |
| `--no-report` | Skip HTML report generation after execution | - |
| `--full-report-payloads` | Embed full event payloads in the after-run HTML report even for sessions that passed, instead of applying the report size budgets (which truncate large successful network bodies and elide repeated intermediate snapshots to keep the report small). Failed sessions always embed full payloads regardless. The on-disk events/ artifacts are never budgeted, so an existing session can also be regenerated in full later via `trailblaze report --full-report-payloads`. Applies to in-process runs; a run delegated to an already-running daemon doesn't generate a report from this process. | - |
| `--save-recording` | Save the recording back to the trail source directory after a successful run. Default: on. Use --no-save-recording to skip. Even when on, the recording is only saved when --self-heal was enabled OR this device isn't recorded yet — deterministic re-runs no-op the write so they can't clobber a hand-edited source. See --unified-recordings for the on-disk format. | - |
Expand Down
107 changes: 107 additions & 0 deletions docs/devlog/2026-08-06-all-universal-root-classifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
title: "`all:` — the universal root classifier"
type: decision
date: 2026-08-06
---

# `all:` — the universal root classifier

Every classifier lineage now ends at a single universal root, `all`. An entry
keyed `all:` in any classifier-keyed map — step `recordings:`, the trailhead's
recordings, waypoint blocks, `config.devices:` driver pins, `config.skip:` —
resolves for **every** device, at the **lowest** priority.

## Background

The [classifier lineage](2026-06-28-classifier-lineage-primitive.md) resolves
classifier-keyed maps closest-wins: walk the device's chain from most specific
(`ios-iphone`) up to its family root (`ios`) and take the first declared entry.
Each chain ended at its **platform family**, so there was no key that reached
every device.

That forces cross-platform trails whose platforms genuinely share an entry to
declare it once per platform. The trailhead is where this bites hardest: it is
one tool call per device, and a target whose trailhead tool is itself
cross-platform (`supportedPlatforms: [android, ios]`, same args on both) still
needs byte-identical `android:` and `ios:` blocks in every trail — a copy that
can silently drift.

```yaml
trailhead:
step: Launch signed in on the target screen
recording:
android:
app_launchSignedIn: { route: /settings }
ios:
app_launchSignedIn: { route: /settings } # byte-identical duplicate
```

## What we decided

### `all` is the implicit ancestor of every classifier

`TrailblazeClassifierLineage` appends `all` as the final entry of every
non-empty chain, in both `chainFor` (single classifier) and `resolutionChain`
(a device's broad-first segments):

```
android-phone → android → all
ios-iphone → ios → all
[ios, iphone] → ios-iphone, ios, iphone, all
```

The duplicated trailhead above becomes one block:

```yaml
trailhead:
step: Launch signed in on the target screen
recording:
all:
app_launchSignedIn: { route: /settings }
```

### A default, not a straitjacket

`all` sits **strictly last** on every chain — after the compound identity, its
ancestors, and every bare-segment fallback. Any explicitly-declared classifier
outranks it, so a platform that genuinely diverges keys its own entry and wins
on that platform while the others keep the shared one:

```yaml
recordings:
all: [...] # what the platforms share
ios: [...] # iOS diverges; wins on iOS only
```

In `resolutionChain` the append happens once at the end of the merged chain —
not inside each per-segment expansion — so `all` can never ride the compound
identity's lineage in ahead of a lower-priority segment fallback (an
`iphone:`-keyed entry still beats `all:`).

### The vocabulary was already reserved

`all` is already the format's "every one of them" meta-key: a target manifest's
`drivers:` list accepts `all` for every driver type (`DriverTypeKey`). This
change gives the same word the same meaning in the classifier namespace.
`registerParentOverride` now rejects `all` as a child — the universal root
cannot be given a parent.

### One consumer adjusted: filename→platform backfill

`TrailIndexBuilder.platformFromFileName` derived a legacy recording's platform
by taking the **last** entry of the filename stem's chain and checking it
against the platform roots. The last entry is now always `all`, so it takes the
first platform-rooted ancestor on the chain instead — same result for every
real stem, robust to the new root.

## What did not change

- **The recorder never writes `all:`.** A recording session keys its slot by
the device it ran on, as before. `all:` is an author-side move — typically
collapsing two platform slots after verifying both replay the same tools.
- **Legacy per-platform filename candidates** (`ios-iphone.trail.yaml`) are
computed by segment truncation, not the lineage — no `all.trail.yaml` is ever
probed.
- **Chain totality.** `chainFor` still returns a non-empty chain whose first
element is the input classifier; an empty/blank input still yields an empty
chain (`all` is a fallback for a device identity, not a substitute for one).
1 change: 1 addition & 0 deletions docs/devlog/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Entries tagged as **Decision** record significant architectural or technical cho

| Date | Title | Type |
| :--- | :--- | :--- |
| 2026-08-06 | [`all:` — the universal root classifier](2026-08-06-all-universal-root-classifier.md) | Decision |
| 2026-07-03 | [Batched tool-execution scope: one context + one snapshot frame per recording](2026-07-03-batched-tool-execution-scope.md) | Decision |
| 2026-07-01 | [Type-checking trail recordings by transpiling them to TypeScript](2026-07-01-trail-recording-type-validation.md) | Decision |
| 2026-06-30 | [assertWaypoint tool replaces the per-step postcondition field](2026-06-30-assertwaypoint-tool-replaces-postcondition-field.md) | Decision |
Expand Down
2 changes: 1 addition & 1 deletion docs/generated/LLM_MODELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Trailblaze ships with the following built-in models. When you reference a model
| `gemini-3.1-flash-lite-preview` | 1M | 65K | $0.25 | $1.50 | $0.03 | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |
| `gemini-3.1-pro-preview` | 1M | 65K | $2.00 | $12.00 | $0.20 | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |
| `gemini-3.1-pro-preview-customtools` | 1M | 65K | $2.00 | $12.00 | $0.20 | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |
| `gemma-4-26b-a4b-it` | 262K | 65K | free | free | free | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |
| `gemma-4-31b-it` | 262K | 65K | free | free | free | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |

## Ollama

Expand Down
2 changes: 1 addition & 1 deletion docs/generated/external-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ Toolsets are declared in `trailmaps/<id>/toolsets/*.yaml`. They are pure YAML gr
| `android_primitives` | Yes | `android-ondevice-accessibility`, `android-ondevice-instrumentation` | 7 |
| `compose_core` | No | `compose` | 6 |
| `compose_verification` | No | `compose` | 3 |
| `core_interaction` | Yes | `android-ondevice-accessibility`, `android-ondevice-instrumentation`, `ios-axe`, `ios-host` | 20 |
| `core_interaction` | Yes | `android-ondevice-accessibility`, `android-ondevice-instrumentation`, `ios-axe`, `ios-host` | 21 |
| `memory` | No | `all drivers` | 8 |
| `meta` | Yes | `all drivers` | 1 |
| `mobile_primitives` | Yes | `android-ondevice-accessibility`, `android-ondevice-instrumentation`, `ios-axe`, `ios-host` | 5 |
Expand Down
9 changes: 6 additions & 3 deletions docs/generated/functions/custom/wait.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

# `wait`

Wait for a specified amount of time. Use when you see a loading screen — prefer this over
pressing the back button.
Settle on a loading screen: block until the UI goes quiet, up to a ceiling. This returns as soon
as the UI is idle, so on an already-static screen it returns almost immediately rather than
waiting the full time — it is a ceiling, not a duration. Use when you see a loading screen —
prefer this over pressing the back button. If you are waiting for something specific to appear,
assert on that element instead: a quiet UI does not mean the thing you expect has arrived.

## Source

Expand All @@ -23,7 +26,7 @@ pressing the back button.
### Optional parameters

- `timeToWaitInSeconds` — `Integer`
Unit: seconds. Default Value: 5 seconds.
Ceiling on how long to settle for, in seconds — not a guaranteed duration. Default Value: 5 seconds.

## Output

Expand Down
6 changes: 4 additions & 2 deletions docs/generated/functions/custom/waitForChange.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
Wait until the UI has settled after your action. Use this instead of a fixed-duration wait when
you've triggered an action (a new screen loads, content updates, a list scrolls) and want to block
until the UI is quiet again. Returns immediately if the UI is already settled when this runs.
Known limit: it cannot wait for a delayed async change that hasn't started yet — for that, use a
specific-element wait (e.g. assertVisible on the element you expect to appear).
Known limit: it returns as soon as the UI is quiet, so it cannot wait out a change that has not
started yet. For that, poll the actual state — assertNotVisibleWithText to wait a loading indicator
out, or take a fresh snapshot and assertVisible a ref that is present (assertVisible checks the
current snapshot and does not itself wait for an element to appear).

## Source

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,13 @@ export const contacts_ios_openContact = trailblaze.tool<OpenContactArgs>(
openFirstResult: true,
});
// Confirm we actually reached the contact DETAIL screen before trusting the
// heading. A name-only check is NOT sufficient: when the contact doesn't exist
// the row tap lands on the search field (no navigation) and the typed query keeps
// `name` visible, so a heading assert still passes — a false "opened". The detail
// heading. A name-only check is NOT sufficient: the typed query keeps `name`
// visible in the search field, so if navigation silently failed a heading
// assert would still pass — a false "opened". (The search tool's row tap is
// label-scoped so its node-selector path can no longer resolve the search
// field; the framework's Maestro fallback lowers it to a legacy text match
// that still could, which is why this anchor stays as the independent
// destination check.) The detail
// screen's top-right "Edit" button is the reliable detail-only anchor (the
// list/search screens surface "Add"/"Cancel" there, never "Edit"). We resolve it
// via `findMatches` against the iOS accessibility tree — `assertVisibleWith-
Expand Down
Loading
Loading