docs(skills): split argent-create-flow into references and add argent-qa-flows - #730
Conversation
| @@ -0,0 +1,29 @@ | |||
| import fs from "node:fs"; | |||
There was a problem hiding this comment.
This file seems misplaced in the stack
There was a problem hiding this comment.
It is here because it is what catches malformed frontmatter on argent-qa-flows that was added here.
| - Interaction tools (`gesture-tap`, `gesture-swipe`, `gesture-pinch`, `gesture-rotate`, `gesture-custom`, `launch-app`, etc.) return a screenshot automatically. | ||
| Call `screenshot` separately only for a baseline before any action or after a delay. | ||
| - Always open apps with `launch-app` or `open-url` — never tap home screen icons. | ||
| - If you may record a flow or QA test, do not interact with the app first: load `argent-create-flow` and start the recorder before the first launch or in-app action. A path already walked cannot be recorded retroactively. |
There was a problem hiding this comment.
| - If you may record a flow or QA test, do not interact with the app first: load `argent-create-flow` and start the recorder before the first launch or in-app action. A path already walked cannot be recorded retroactively. | |
| - If you are going to create a flow or QA test, do not interact with the app first: load `argent-create-flow` and start the recorder before the first launch or in-app action. A path already walked cannot be recorded retroactively. |
I'm not sure about the wording here.
There was a problem hiding this comment.
I agree the wording is off, but I kept the precautionary meaning. Your suggestion implies the decision is already made. I reworded it to:
If there is any chance the task ends in a recorded flow or QA test, do not
interact with the app first: ...
|
|
||
| ### Step directives | ||
| 1. **Record the path live.** The first walkthrough _is_ the recording; never rehearse a path and reconstruct it afterward. [Live authoring](references/live-authoring.md) has the per-platform start order and the discover → echo → `flow-add-step` → inspect cycle. | ||
| 2. **Record each check when its state appears** — immediately after the transition or outcome it proves, before the next action. An echo or raw `screenshot` is diagnostic context, not an executable verdict; a reviewed `snapshot:` baseline is for inherently pixel-level requirements. Record absence as a trio in order — `visible` on the selector, the action that removes it, then `hidden` on the same selector — or the recorder refuses it. |
There was a problem hiding this comment.
Can the agent record await/assert live?
There was a problem hiding this comment.
No it cannot.
flow-add-step only rewrites three things:
gesture-tap->tap:,restart-app->launch:,flow-execute->run:. A check is recorded as anawait-ui-elementcall, stored as a rawtool:step, and converted toawait:/assert:during polish.assert:has no live tool at all.
The core skill never mentioned await-ui-element once, and rule 2 said "Record
each check" while listing snapshot:, which has no recorder form at all. Three
fixes:
- rule 2 now names the route and drops
snapshot: - rule 5 says "raw step" instead of "raw action", so the check conversion is clearly allowed, and names the three insertions that have no recorder form
- rule 4 notes identity is recorded live while readiness is a polish insertion
| ## Proactive recording | ||
|
|
||
| All of it is **visual (frame-based), not tree ancestry** — flow trees are flattened, and "inside the card" / "the switch after this label" mean what the screen shows — the same frame-based reading of "within" that `scroll-to`'s container anchor uses. Every scope needs a **distinct element** (nothing scopes itself, so `{ id: card, within: { id: card } }` needs two nested elements), the synthetic screen root never counts, and a scope only narrows _where_ to look — the selector still needs its own `text`/`id`/`role` naming _what_ to find there, or `any: true`. The nested slot takes every selector form: a bare string keeps the loose identifier-first fallback (`within: profile-card`), the map form stays strict, the regex matcher works (`within: { text: { matches: '^Card \d+$' } }`). Scopes are flow-YAML only; the raw `await-ui-element` tool's selector accepts none of them. Prefer a unique `id` on the target itself when one exists — reach for a scope when the target has no unique locator of its own (repeated row actions, per-card buttons, list cells). | ||
|
|
||
| **`within`** — `tap: { text: Delete, within: { id: profile-card } }` taps the Delete button in the profile card even when other cards show identical ones; `tap: { text: "Pin feed", within: { text: "For You" } }` picks one card's button out of a whole list. Scope to a container with a **tight frame** (a row, card, dialog, toast): a full-screen wrapper contains everything and scopes nothing. It chains outward: `{ text: Save, within: { id: cards, within: Settings } }` reads "Save inside cards inside Settings", each container's frame inside the next. | ||
|
|
||
| **`after` / `next`** — reading order is row-band aware: an element **follows** the anchor when it starts below the anchor's bottom edge, _or_ shares its row band and sits entirely to its right. That is what makes `{ role: Switch, next: { text: Wi-Fi } }` resolve the Wi-Fi row's own switch even though the taller switch's frame starts a couple of pixels _higher_ than the label's. `next` keeps only the nearest follower — a match in the anchor's own row beats anything on the rows below, leftmost first — while `after` keeps them all, so `assert: { hidden: { role: Button, after: { text: Danger zone } } }` holds when nothing button-like appears past that heading. Both union over anchors exactly as CSS does: with three rows on screen, `{ role: Switch, next: { role: AXStaticText } }` yields all three switches, one per label. Note that "follows" is **not transitive** — against a tall anchor, an element can follow something that itself follows the anchor without following the anchor directly — so nesting `after` scopes is not the same as chaining CSS `~`: `{ after: { …, after: … } }` can match elements a single `after` excludes. Nest them only when each link is a container-sized step. An element sitting _inside_ the anchor does not follow **that** anchor — containment is not reading order — so scope by `within` for that. | ||
|
|
||
| **Prefer the map form for a scope's anchor.** A bare-string scope (`next: wifi-row`) keeps the loose identifier-first fallback, and the runner takes the first pass that finds a _visible_ match — so if some unrelated element carries `testID="wifi-row"`, the identifier pass wins and the text pass never runs. Reproduced: with a decoy `testID="Wi-Fi"` elsewhere on screen, `tap: { role: Switch, next: Wi-Fi }` taps the decoy's neighbour and reports a pass. This is the ordinary identifier-first doctrine, but a scope makes a decoy likelier to "succeed": a decoy _container_ only wins if it actually holds a match, while a decoy _anchor_ wins if anything at all sits after it — which on a real screen it usually does. Spell an anchor you care about as a map: `next: { text: Wi-Fi }` or `next: { id: wifi-row }`. | ||
|
|
||
| One way `next` is deliberately **looser than CSS `+`**: where `A + B` matches nothing unless the very next sibling is a `B`, `next` keeps looking and returns the nearest match further on. That is what makes it survive the wrapper and spacer nodes a flattened tree is full of, but it also means a row that is _missing_ the control you asked for silently resolves to the next row's — `{ role: Switch, next: { text: Wi-Fi } }` on a Wi-Fi row rendered without a switch returns the _Bluetooth_ row's switch rather than failing. When a row may legitimately lack the control, assert it first (`assert: { visible: { role: Switch, within: { id: wifi-row } } }`) or scope by `within` instead. | ||
|
|
||
| Scopes compose, and it matters which one carries them. `{ role: Button, next: { text: Name, within: { id: card-b } } }` scopes the **anchor** — one label, so one pick, but that pick may land outside card-b. `{ role: Button, next: { text: Name }, within: { id: card-b } }` scopes the **target** — every label is still an anchor, but only card-b's buttons can be picked. They agree on a well-formed screen and diverge when card-b has no button: the first reaches on to the next card's, the second returns nothing. Scope the target when the container is the thing you trust. Conditions honor scopes like any other selector: `assert: { hidden: { text: Saved, within: { id: toast-area } } }` holds when nothing matching "Saved" is inside the toast area — matches elsewhere on screen don't count, and a missing scope element satisfies `hidden` (and fails `visible`/`exists`). | ||
|
|
||
| Selectors resolve against the **full native hierarchy** (iOS: the UIView tree; Android: the complete accessibility hierarchy including not-important views) — strictly more than `describe` or the raw `await-ui-element` tool see (both use the trimmed tree), with complete `testID`/`resource-id` coverage. So an `id` selector works even when `describe` collapses or omits the element — don't fall back to coordinate taps just because a testID isn't visible in `describe` output. And when several elements match — including wrappers whose native text aggregates descendant content — the action directives (`tap`, `type`, `scroll-to`) pick the **most specific** match: an exact text/identifier match beats a substring hit (for a regex matcher, a pattern consuming the element's whole text counts as exact), then the smallest frame wins. (A universal `any: true` selector has no field to be exact about, so its matches rank by reading order instead — the first element in the scope, which is the element a condition reads too. Where two matches share a top-left corner, an action breaks the tie toward the smaller, more specific one and a condition does not, so those two can name different elements.) | ||
|
|
||
| **Quote strings YAML would mangle.** An unquoted `#` starts a YAML comment — `tap: Order #1234` silently parses as `tap: Order` — and bare `yes`/`no`/`on`/`off`/numbers coerce to non-strings. When a selector or typed text contains `#`, `:`, quotes, or could read as a boolean/number, wrap it: `tap: "Order #1234"`. | ||
|
|
||
| ### `await` and `assert` | ||
|
|
||
| The **condition is the key**, and its value is the selector: | ||
|
|
||
| - `{ visible: Home }`, `{ exists: { id: row } }`, `{ hidden: spinner }` | ||
| - `{ text: { in: <selector>, contains: "Taps:" } }` or `{ text: { in: <selector>, equals: "Taps: 0" } }` — `text` locates an element (`in`) and checks its rendered content against exactly one of `contains` (case-insensitive substring) or `equals` (case-insensitive exact match — use it when boundaries matter: `contains: "Taps: 3"` is also satisfied by "Taps: 30"). Reach for `text` only when the locator is an identifier/role; to assert a string is simply on screen, prefer `{ visible: "Taps: 0" }`. | ||
| - `{ text: { in: total, matches: 'Total: \$\d+\.\d{2}' } }` — the third comparator: a JS regex for dynamic content (counters, prices, dates) that neither literal mode can pin. Unanchored like `contains` (anchor with `^…$` for the `equals` analog) and — unlike the literal modes — **case-sensitive**: the pattern carries its own semantics. An invalid pattern fails at parse time. **Quote the pattern in single quotes**: single-quoted and plain YAML scalars keep backslashes; double quotes would need `\\d`. To assert a dynamic string is simply on screen with no locator, prefer a regex **selector** — `{ visible: { text: { matches: '^Taps: \d+$' } } }` (see Selectors); `text.in` + `matches` is for checking a specific element's aggregated text. | ||
| - A container's text aggregates its descendants' text (space-joined), so `text` can assert what a testID wrapper visibly shows even when the string lives in a child node. That also means `equals` against a wrapper must match _everything_ it shows or exactly the wrapper's own label/value — targeting the leaf holding exactly the value (or using `contains`) stays the clearer spelling. | ||
|
|
||
| This condition-as-key form is the only spelling. `await` also accepts an optional `timeout` sibling key in milliseconds — `- await: { visible: Home, timeout: 15000 }` — for a transition that legitimately needs longer than the default budget. **Omit `timeout` by default**: the default budget covers normal transitions, and a habitual generous override just delays failure reporting on every broken step. Add one only after a step demonstrably needs it — it timed out at the default and the wait is legitimately slow (a cold start, a network round-trip, a long animation). `assert` has no timeout override: a check that needs seconds to become true is a wait — spell it `await`. | ||
|
|
||
| For a custom poll interval or bundleId, drop to an explicit `- tool: await-ui-element` step — but the raw tool polls the trimmed `describe` tree, so a testID it reports as not found can still resolve fine as an `await:` directive (see Selectors). Prefer the directive. | ||
|
|
||
| ### `type` and `scroll-to` | ||
|
|
||
| `type` presses Enter after typing to commit the value and dismiss the keyboard, so it can't cover later targets. For a chained form whose fields feed one explicit submit — e.g. email then password then a `tap: "Log in"` — set `submit: false` on the intermediate fields so a premature Enter doesn't fire the form early: `type: { into: password, text: "hunter2", submit: false }`. | ||
|
|
||
| Never record a real credential into a flow — the YAML is committed to the repo. Use a secret placeholder instead: `type: { into: password, text: "{{secret:APP_PASSWORD}}" }`. The placeholder is stored verbatim (the YAML stays secret-free) and is resolved at run time by the tool-server from the `ARGENT_SECRET_APP_PASSWORD` environment variable — including agent-less `argent flow run` in CI, where the variable comes from the job's secrets. | ||
|
|
||
| `scroll-to` takes an optional `direction` (`up` | `down` | `left` | `right`, default `down` — so the common case is just `- scroll-to: <selector>`) and optionally a `within: <selector>` that anchors the scroll inside a specific container — required to drive a **nested** scroller (e.g. a horizontal carousel inside a vertical list), since the device can't be asked which container to scroll. This step-level `within` (a sibling of `target`) anchors the _gesture_, and it is the **only** scope key the step body takes — `after:`/`next:`/`any:` beside `target` are rejected. It is distinct from a selector's scopes (see Selectors), which `target` may itself carry — `scroll-to: { target: { text: Delete, within: { id: cards } }, within: { id: settings-list } }` scrolls the settings list until the Delete button _inside the cards container_ is visible. It scrolls in bounded momentum-free increments, re-checks after each, and stops if a scroll reveals nothing new (end of the container). `tap`/`type` do **not** scroll — add a `scroll-to` before any target that may be off-screen. It's a no-op when the target is already visible, so a defensive `scroll-to` costs nothing on replay and keeps the flow working on smaller screens. | ||
|
|
||
| ### `snapshot` cropping | ||
|
|
||
| `cropOn: <selector>` narrows a snapshot's comparison to one element's region — `- snapshot: { name: cart-total, cropOn: { id: order-summary } }`. The selector resolves like a directive target (settled tree, auto-wait, the standard not-found failure; selector-only, no point form), and the **cropped** image is what gets compared, stored as the baseline, and reported as the `current` artifact; the baseline filename still keys on the full capture's resolution — plus a `-crop-<hash>` selector suffix — so device-class drift is still caught. A cropped comparison never masks any region — every pixel of the crop is compared — so prefer elements clear of the top status-bar band (a crop overlapping it leans on best-effort status-bar pinning, and the clock/battery may diff). Crop **fixed-size containers**, addressed by `id`: a text selector resolves to the smallest matching node — typically the label itself, whose frame tracks text metrics — and the crop tracks the element's frame, so an element that grew or shrank by a pixel fails on dimensions ("nothing was compared") rather than on content. Baseline storage and seeding are covered under _Standalone runner_. | ||
|
|
||
| ### TV targets (Vega) | ||
|
|
||
| A Vega (Fire TV) device is remote-driven — there is no touch input, so the touch directives (`tap`, `long-press`, `type`, `scroll-to`, `pinch`, `rotate`) fail on it with guidance. Drive focus with `tool: tv-remote` steps and type with `tool: keyboard` instead; everything else (`launch`, `await`, `assert`, `wait`, `snapshot`, `echo`, `run`, selectors) works unchanged — the tree comes from the on-device automation toolkit, which attaches at app launch (the `launch` step waits for it, so a leading `launch` also guarantees selectors resolve). | ||
|
|
||
| ```yaml | ||
| steps: | ||
| - launch: com.example.app.main # the interactive component id from manifest.toml | ||
| - await: { visible: Home } | ||
| - tool: tv-remote | ||
| args: { button: [down, select] } # move focus, then confirm — one step per navigation | ||
| - await: { visible: Explore Screen } | ||
| - snapshot: explore | ||
| ``` | ||
|
|
||
| Since a `tv-remote` path is positional (like a coordinate tap), gate each navigation with an `await` on the destination screen and echo where focus should be — that is what makes the flow diagnosable when the focus order changes. | ||
|
|
||
| ### Standalone runner | ||
|
|
||
| `argent flow run <name> [--device <id>] [--platform ios|android|chromium|vega] [--update-baselines] [--output <dir>] [--json]` runs a flow with no LLM in the loop and exits non-zero on any failure — suitable for CI (e2e flows; a fragment runs against the current device state, useful while authoring). `snapshot` baselines live in `.argent/flows/__baselines__/<flow>/`, keyed by platform + resolution; a `snapshot` step **fails** when no baseline exists for the run's device class, so seed baselines with `--update-baselines` and have the user review and commit `__baselines__/` — and pin the device class in CI (`--device`/`--platform`, same simulator model) so runs compare against the committed key. The status bar is pinned (iOS `simctl status_bar`, Android demo mode) for the run so it doesn't drive visual diffs. `--output <dir>` writes each failed snapshot's baseline/current/diff images to `<dir>/<flow>/` — a stable path for CI artifact upload. | ||
|
|
||
| ## Tools | ||
|
|
||
| | Tool | Purpose | | ||
| | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `flow-start-recording` | Start recording — takes `name` + `project_root` and (fragments only) an optional `executionPrerequisite`; creates the file, truncating any existing one | | ||
| | `flow-add-step` | Execute a tool call live and, if it succeeds, record it into the flow named by `name` + `project_root` | | ||
| | `flow-add-echo` | Add a label/comment that prints during replay, into the flow named by `name` + `project_root` | | ||
| | `flow-finish-recording` | Stop recording the flow named by `name` + `project_root` and get a summary | | ||
| | `flow-read-prerequisite` | Read the execution prerequisite of the flow named by `name` + `project_root`, without running it | | ||
| | `flow-execute` | Replay the saved flow named by `name` + `project_root` | | ||
|
|
||
| Every tool during recording returns the current flow file contents, so you can track what has been recorded. Rules: | ||
|
|
||
| - **Every step runs live.** You see the real tool result (including screenshots) — verify the step worked before continuing. **Only successful steps are recorded**: a failed call writes nothing to the flow file; fix the issue and try again. | ||
| - **Every recording tool takes `name` + `project_root`.** `flow-add-step`, `flow-add-echo`, and `flow-finish-recording` each name the recording they address, repeating the `name` and the absolute `project_root` (an error is returned if the path is not absolute) given to `flow-start-recording`. Nothing is carried over between calls. | ||
| - **Recording _state_ is isolated; the device is not.** A recording is keyed by its output file, `<project_root>/.argent/flows/<name>.yaml`, so several can be open at once — different names, different projects — and one recording's steps never land in another's file. Nothing is isolated on the device: every step runs live, so two recordings driving one device interleave real UI actions, and one flow's recorded `restart-app` resets the app under the other. Give each concurrent recording its own device. | ||
| - **Starting always truncates the `.yaml`.** `flow-start-recording` resets `<project_root>/.argent/flows/<name>.yaml` to an empty flow on every call — including a name that is only a saved file with no recording in progress, so starting under the name of a committed flow wipes it. `restarted: true` is reported only when a LIVE recording of that flow was discarded, so its **absence does not mean nothing was overwritten**. `discardedSteps` counts the `.yaml` as it stood at the reset — a hand-edit made mid-recording is included — and is omitted entirely when that file could not be read or parsed, so `restarted: true` can arrive without it. That is for a project root on the tool-server host; against a remote client the host never sees your `.yaml`, so there the number counts only the steps recorded through the server. Starting a _different_ flow abandons nothing. | ||
| - **Pick a name unique to your task.** The key is `(project_root, name)` with no ownership check: if another agent starts the same name + project while you are recording, your file is truncated and it takes the key. **Usually nothing tells you.** The liveness check runs only while an append is executing, so if you were between calls (the common case) your next `flow-add-step`/`flow-add-echo` simply re-resolves the key and appends into the other agent's recording, reporting success. You are told only in the lucky case where a step happened to be in flight at the moment of the takeover: `Recording of "<name>" in <project_root> is no longer active — it was restarted while this step was running…`. `flow-finish-recording` re-resolves the key the same way, so it finishes and clears whichever recording currently holds it — possibly the other agent's, which leaves _them_ with `Active recordings: none in this project` and no step in flight to have warned them. Either way, restart under a fresh name instead of re-adding the step. | ||
| - **Start before adding.** Calling those tools for a flow with no recording in progress returns `No active recording for flow "<name>" in <project_root>. If you have not started it yet, call flow-start-recording — but note it truncates, so if <path> already holds a take you want (finished, or interrupted by a restart), copy it aside or record under a fresh name instead. Active recordings: ...`. The truncation caveat is there because this same error is what you get when your take was finished, superseded by another agent, or dropped by the concurrent-recording cap — and on those branches the `.yaml` on disk is fully populated, so starting again destroys it. The tail names only the flows live under **the `project_root` you passed** — `"checkout"`, or `none in this project` — and merely counts any others as `(plus N in other projects)`, since a shared tool-server serves callers whose project paths are not yours to see. So a mistyped `name` is spelled out for you; a wrong `project_root` shows up as your flow missing from a project you expected it in. | ||
| - **Mistakes can be edited out.** Edit the `.yaml` file directly to remove or reorder steps. | ||
|
|
||
| ### flow-add-step arguments | ||
|
|
||
| The `command` parameter is the MCP tool name; `args` is a **JSON string** (not an object), omitted entirely for tools with no arguments: | ||
|
|
||
| ``` | ||
| name: "checkout-e2e" project_root: "/Users/dev/MyApp" | ||
| command: "gesture-tap" | ||
| args: "{\"udid\": \"<UDID>\", \"x\": 0.5, \"y\": 0.35}" | ||
|
|
||
| name: "checkout-e2e" project_root: "/Users/dev/MyApp" | ||
| command: "await-ui-element" | ||
| args: "{\"udid\": \"<UDID>\", \"condition\": \"visible\", \"selector\": {\"text\": \"Continue\"}}" | ||
| ``` | ||
|
|
||
| Recording a `flow-execute` step carries **two** `name`s: the top-level `name` is the recording being appended to, `args.name` is the flow being run (captured as a `run:` step). | ||
|
|
||
| ``` | ||
| name: "checkout-e2e" project_root: "/Users/dev/MyApp" | ||
| command: "flow-execute" | ||
| args: "{\"name\": \"login\", \"project_root\": \"/Users/dev/MyApp\"}" | ||
| ``` | ||
|
|
||
| Caveat to the "only successful steps are recorded" rule: if that sibling is a fragment with an `executionPrerequisite`, `flow-execute` returns its prerequisite **notice** instead of running - still a successful return, so `run: login` is recorded even though nothing executed. Add `"prerequisiteAcknowledged": true` to `args` to actually run it. | ||
|
|
||
| Record an `await-ui-element` step to **gate** the next step on a screen transition — it blocks until the element is `visible`/`hidden` (or contains `text`), so the following step runs only once the screen has actually settled; prefer this over a fixed `delayMs`. If its condition is not met before the timeout, replay **stops at that step** (the steps after it assume the transition happened). See the `await-ui-element` section of `argent-device-interact` for the full condition/selector reference. The live call sees only the trimmed `describe` tree — if it can't find an identifier you know exists, gate on visible text to get the step recorded, then retarget the identifier in the `await:` form during polish (the directive resolves the full hierarchy — see Selectors); don't conclude the testID is unusable in the flow. | ||
|
|
||
| ## Recording | ||
|
|
||
| 1. **Start, then launch as the first step (e2e) or set the stage yourself (fragment).** Call `flow-start-recording` with a descriptive name and the absolute `project_root`. For an **e2e** flow, record a `restart-app` of the app under test as the **first** step — it runs live (resetting the device for the rest of the recording) and is captured as the flow's `launch` step. For a **fragment**, bring the device to the entry state _before_ recording and pass an `executionPrerequisite` describing it (e.g. "App on the login screen") to `flow-start-recording` instead. | ||
| 2. **Build step-by-step**: for each action, call `flow-add-step` with the same `name` + `project_root`, plus the tool name and args. The tool runs immediately — check the result before moving on, and gate each navigation with an `await-ui-element` step. | ||
| 3. **Add labels**: use `flow-add-echo` (same `name` + `project_root`) between steps — echo the expected state, not just the action (see _Making flows resilient_). | ||
| 4. **Finish**: call `flow-finish-recording` with the same `name` + `project_root`. It returns the file path where the flow was saved and a summary of all steps. | ||
| 5. **Polish**: **read the saved `.yaml` file** and convert the raw `tool:` steps that have a cleaner directive form (the recorder leaves these as tools): | ||
| - `tool: keyboard` typing into a field → `type: { into: "<field>", text: "…" }`, folding in the `tap` that focused the field. | ||
| - `tool: await-ui-element` gating a transition → `await: { visible: "…" }` / `{ hidden: … }` / `{ text: { in: …, equals: … } }`, carrying a custom `timeoutMs` over as a `timeout` sibling key. Converting also upgrades the wait from the trimmed `describe` tree to the flow's full-hierarchy tree (see Selectors). Keep the raw `tool: await-ui-element` step only when it sets a custom `pollIntervalMs`/`bundleId` the directive can't express. | ||
| - A scroll-to-reach-an-element — a `tool: gesture-swipe` (or its chromium analog, `gesture-scroll`) used to bring a specific element on screen before interacting with it (a `tap`, `type`, `assert`, …) → `scroll-to: { target: "<that element>", direction: … }`, dropping the swipe. This is far more robust than a fixed-distance swipe: it scrolls momentum-free and stops exactly when the target appears, so it survives layout and content changes. (`tap`/`type` do not scroll, so a raw swipe whose fling lands differently on another device leaves the following tap unresolved — always prefer the `scroll-to` rewrite.) Keep a `gesture-swipe` as a raw `tool:` step when it isn't scrolling toward a specific element — especially a velocity-dependent gesture like swipe-to-dismiss, edge-swipe-back, or swipe-to-reveal a row action, which a momentum-free `scroll-to` would not reproduce. | ||
| - `tool: gesture-pinch` → `pinch: { on: "<target>", scale: … }`, deriving `scale` as `endDistance / startDistance`. Set `on:` to the element under the pinch center when the pinch was aimed at one (the map or image being zoomed); omit it for a screen-center pinch. Don't carry the recorded distances/angle over — the directive re-derives the geometry (finger placement, system-edge avoidance, chaining of large scales) at run time, so the conversion swaps device-specific coordinates for a portable selector with auto-wait. Keep the raw `tool: gesture-pinch` step when the pinch is anchored at a specific point _inside_ a large element (zooming toward a particular map location, not the map's center) or deliberately pans via `endCenterX`/`endCenterY` — `on:` takes only a selector and re-centers the pinch on the element's frame center, so converting would silently move the zoom anchor. | ||
| - `tool: gesture-rotate` → `rotate: { on: "<target>", by: … }`, deriving `by` as `endAngle − startAngle` (the tool's `endAngle` > `startAngle` turns clockwise, matching the directive's positive `by`). Set `on:` to the element under the rotation center when the rotation was aimed at one (the map or image being rotated); omit it for a screen-center rotation. Don't carry the recorded `centerX`/`centerY`, radii (`radius` or `radiusX`/`radiusY`), `startAngle`, or `durationMs` over — the directive re-derives the geometry (finger placement, physical-circle radius, system-edge avoidance) and runs at a fixed pace (~90° per 300 ms), so the conversion swaps device-specific coordinates for a portable selector with auto-wait. Keep the raw `tool: gesture-rotate` step when the rotation is anchored at a specific point _inside_ a large element rather than its center (the directive re-centers on the element's frame center, so converting would silently move the pivot), when the gesture's speed itself matters (the directive's pace is fixed), or when the sweep exceeds the directive's ±3000° bound. | ||
|
|
||
| Every other recorded tool (a velocity-dependent `gesture-swipe`, a fixed-distance `gesture-scroll` not aimed at an element, `button`, `screenshot`, …) has no directive form — leave it as a `tool:` step. The recorder already handles the rest: coordinate `gesture-tap`s are captured as portable `tap:` selector steps, a `restart-app` is captured as a `launch:` step, a `flow-execute` of a sibling fragment is captured as a `run: <name>` composition directive, and device ids are stripped. Captured selectors are emitted in the strict map form (`tap: { text: General }`), never as a loose bare string — the recorder verified the exact element the tap hit, and a bare string would re-parse as loose and route through the identifier-first fallback it was never checked against. After editing, re-run with `flow-execute` to confirm the cleaned flow still passes. | ||
|
|
||
| ### Example session | ||
|
|
||
| ``` | ||
| flow-start-recording { name: "open-about", project_root: "/Users/dev/MyApp" } | ||
| flow-add-echo { name: "open-about", project_root: "/Users/dev/MyApp", message: "Start Settings from scratch" } | ||
| flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "restart-app", args: "{\"udid\": \"ABC\", \"bundleId\": \"com.apple.Preferences\"}" } # ⇒ captured as `- launch: com.apple.Preferences` — this is now an e2e flow | ||
| flow-add-echo { name: "open-about", project_root: "/Users/dev/MyApp", message: "On the Settings root list, tapping the 'General' row" } | ||
| flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.35}" } # ⇒ captured as `- tap: { text: General }` (portable selector, no udid) | ||
| flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"About\"}}" } # gate the transition | ||
| flow-add-echo { name: "open-about", project_root: "/Users/dev/MyApp", message: "On Settings > General, tapping 'About'" } | ||
| flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.17}" } | ||
| flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"Model Name\"}}" } | ||
| flow-finish-recording { name: "open-about", project_root: "/Users/dev/MyApp" } | ||
| ``` | ||
|
|
||
| Then polish the saved file: the two `await-ui-element` steps become `await:` directives (see the file below). | ||
|
|
||
| ## Replaying | ||
|
|
||
| Call `flow-execute` with the flow `name` and its `project_root` (both are required — replaying reads no recording state, so an in-progress recording never stands in for them). **Pass `device` explicitly whenever more than one device is booted:** auto-detection resolves only when exactly one booted device matches — optionally narrowed by `platform` — and otherwise throws, listing what is available. (A Chromium e2e flow can boot its own instance and tear it down after, but only when all of these hold: no `device`, the launch resolves to chromium (an explicit `platform: "chromium"`, or a single-key `launch: { chromium: … }` map), and that launch value is a real Electron app path on the tool-server host. With no chromium hint - a bare-string or multi-platform `launch:` and no `platform` - the run auto-detects a booted device instead. **Don't reach for `platform: "chromium"` to force the self-boot on a recorded flow:** it does not fall through, it selects the boot branch, and a bare-string `launch:` - what the recorder always writes - holds an installed-app _bundle id_, which that branch reads as an app path. The whole `flow-execute` call then fails with `Electron boot: path does not exist: …`. Hand-edit the launch to `{ chromium: <app path> }` first.) If the flow has an execution prerequisite, the tool returns a **notice** with the prerequisite text instead of running — verify the prerequisite is met (you can also inspect it beforehand with `flow-read-prerequisite`) and call `flow-execute` again with `prerequisiteAcknowledged: true`. A flow without a prerequisite runs immediately. The run executes all steps in order and returns a structured report: `{ ok, passed, failed, skipped, errored, steps }`. | ||
|
|
||
| **What each step reports.** Raw `tool:` steps include the underlying tool's full `result` (screenshots and other outputs render as usual). The directive steps are summarized: `tap`/`type`/`await`/`assert` report only `status` + `reason`, and `snapshot` adds `artifacts` only when there is something to look at — a failed comparison (baseline/current/diff paths), a missing-baseline failure (`current` only), or a baseline write; a clean pass reports just `status` + `reason`. So converting a `tool: gesture-tap` into a `tap:` directive during cleanup drops only that tap's (uninteresting) raw result — output-bearing tools like `screenshot` have no directive form and stay `tool:` steps, so their results keep flowing through. | ||
|
|
||
| ## Flow file format | ||
|
|
||
| The top-level is an object with `steps` (array) and — fragments only — `executionPrerequisite` (an e2e flow, one beginning with `launch:`, has none). Besides the directives above: | ||
|
|
||
| - `- echo: <message>` — a label printed during replay | ||
| - `- tool: <name>` with optional `args:` — a raw tool call. A tool step may also carry `delayMs: <ms>` to sleep that long before it runs. (`await-ui-element` is an ordinary tool step; see _flow-add-step arguments_ and _Making flows resilient_ for when to gate a transition with one.) | ||
| - **`when:` blocks** handle one-sided divergences (interstitials, coach marks): `- when: { visible: "What's new" }` with a sibling `steps: [...]` list runs the block only if the condition holds — checked once with the short assert grace (~1s), so a skipped block barely costs a clean run. Guards are one condition key (`exists`/`visible`/`hidden`/`text`, the await/assert shapes) or `platform: ios|android|chromium|vega`. **No else** (parse-rejected): a block exists to dismiss the divergence and reconverge, never to test two paths — two paths are two flows. Failures inside an entered block are real failures; a skipped block reports `skip` lines. Tap-if-present is a one-step block (`when: { visible: "Got it" }` + `steps: [tap: "Got it"]`); there is NO per-step `optional:` key — it is rejected at parse with a pointer to `when:`. | ||
|
|
||
| The polished result of the example session above: | ||
|
|
||
| ```yaml | ||
| steps: | ||
| - echo: Start Settings from scratch | ||
| - launch: com.apple.Preferences | ||
| - echo: On the Settings root list, tapping the 'General' row | ||
| - tap: { text: General } | ||
| - await: { visible: About } | ||
| - echo: On Settings > General, tapping 'About' | ||
| - tap: { text: About } | ||
| - await: { visible: Model Name } | ||
| ``` | ||
|
|
||
| Note there is **no device id** anywhere in the file — the recorder strips them and the runner injects the bound device. | ||
|
|
||
| ## When to proactively record a flow | ||
|
|
||
| Proactive recording is part of this skill's scope (see the description). Record a flow without waiting to be asked — telling the user you are doing so — when you recognize any of these patterns: | ||
|
|
||
| - **About to re-profile**: You completed a profiling session and are about to apply a fix and re-profile. Record the interaction steps now so the re-profile replays them identically (see `argent-react-native-profiler` and `argent-native-profiler` skills). | ||
| - **Repeating steps**: You have already performed a multi-step interaction sequence once and the task requires doing it again (comparison, retry, re-test). | ||
| - **Complex path discovered**: You worked through a non-trivial sequence of taps/swipes/navigation to reach a desired app state. Capture it before it is lost. | ||
| - **User says "again" / "one more time"**: Any request to redo what you just did is a signal to record first, then replay. | ||
| Tell the user and start a flow before re-running any path of three or more interactions for re-testing, profiling comparison, or another attempt. If the path already ran once, it cannot be recorded retroactively; start the recorder before the next execution. |
There was a problem hiding this comment.
I don't think I understand this part
There was a problem hiding this comment.
Before re-running a path of three or more interactions - re-testing it, comparing a profile, or retrying it - tell the user, start the recorder, and record that run instead of repeating the path by hand. Replay it from then on.
A path already walked cannot be recorded retroactively: the recorder has to be running during the execution you want to keep, so the decision has to be made before it, not after.
| - await: { visible: { id: home-screen } } | ||
| ``` | ||
|
|
||
| An e2e flow's first non-echo step is `launch:`, and it must not declare `executionPrerequisite` — the combination is a parse error. Put the named start state in a leading `echo:` instead. A fragment has no leading launch and may declare: |
There was a problem hiding this comment.
It can also start with run: which effectively resolves to launch as its first step. Not sure if it's worth mentioning explicitly here.
There was a problem hiding this comment.
You're right that it launches, but the runner still calls it a fragment, and that difference has a real consequence. isE2eFlow is literally "first non-echo step is launch:", so a flow starting with run: may declare executionPrerequisite, and chromiumBootSpec returns null for it - meaning on Chromium the app is never booted. On mobile the nested launch does run inline, so it "works".
Added that to the paragraph. It now reads:
"An e2e flow's first non-echo step is launch:, and it must not declare executionPrerequisite — the combination is a parse error. Put the named start state in a leading echo: instead. A leading run: does not make a flow e2e even when the composed flow launches: that launch does run inline, but the runner classifies only a literal leading launch: as e2e, and on Chromium that classification is what boots the app. A fragment has no leading launch and may declare: [...]"
|
|
||
| 1. On iOS, make an evidenced full-tree probe before keeping the point: query each plausible id/label with `native-find-views`; when there is no useful query term, call `native-full-hierarchy` with narrow `fields` and `maxDepth: 100`. Record the relevant match or no-match result with the exception evidence. `describe` and the leaf-only `native-describe-screen` are accessibility projections and are never sufficient evidence that no flow selector exists. A recorder warning only proves automatic derivation failed; it does not rule out a sibling or child label that can safely receive the tap. | ||
| 2. On other platforms, inspect the deepest available app tree: `debugger-component-tree` for React Native, otherwise `describe`. On Android no tool exposes the runner's tree at all, so step 3 is the only way to confirm a candidate there. Prefer an id on iOS and Android even when trimmed discovery omits it; on Chromium the runner's tree is a [subset of `describe`](flow-yaml.md#the-runner-tree-is-not-the-discovery-tree), so an element absent there has no selector at all. | ||
| 3. Verify candidates in a scratch fragment containing `assert: { visible: <candidate> }`, executed on the valid target screen. If one passes, replace the point. If it fails, inspect the exact reason and try a better id, label, target app, or container; do not assume a visible miss is depth truncation. |
There was a problem hiding this comment.
Should the agent be told to inspect the code if it cannot figure it out from the ui? And possibly recommend adding a stable test ID to the user.
There was a problem hiding this comment.
Good call on both. Nothing in either skill told the agent to read the app source, and the only test-id recommendation was Chromium-specific. Added a step 4 to the gate, after the scratch-fragment check:
"4. If no candidate resolves and you have the app's source, read it for the element's testID / accessibilityIdentifier / resource-id — the code is the one projection no discovery tool trims, and it names ids the others can drop. If the element has none, tell the user which element needs a stable test id and report that as the real fix; a kept coordinate is the workaround."
|
|
||
| A tree-unavailable error makes the candidate run **void** — on iOS an error containing `could not target a native-devtools-connected app` or `native devtools is unavailable`, on Android a failure to reach the devtools helper, on Chromium an unreachable CDP session, on Vega a missing page source. It proves the tree was absent, not that the selector failed, and never authorizes coordinates — and it is the same reason the recorder quotes back in its `selector capture failed` warning, so read that warning before treating it as a verdict about the element. | ||
|
|
||
| Coordinates may remain only when the target is genuinely unlabeled (no id/text/label in available discovery) or all plausible labeled candidates failed against a working flow tree. Precede the kept point with an echo naming the target, follow it with an `await:` or `assert:` that proves the result, and report the evidence. Anything the gate did not clear gets re-recorded against a selector, not annotated. A QA flow may keep such a step only for a genuinely unlabeled target, and every kept coordinate must appear in the report with its evidence; any other kept coordinate is a blocking defect. |
There was a problem hiding this comment.
If the target is unlabeled and selectors cannot match it, what's being awaited/asserted?
There was a problem hiding this comment.
The outcome of the action, not the target - the unlabeled element has no selector to check. The text said "proves the result", which didn't make that clear.
Rewritten:
"Precede the kept point with an echo naming the target, and follow it with an await:/assert: on the outcome — the destination screen, the changed state, the element that disappeared. The target itself has no selector to check, so what makes the step falsifiable is proof that the tap landed, not proof that the target exists."
|
|
||
| ### Worked example | ||
|
|
||
| Ticket: "On iOS in `com.acme.shop`, from signed-in Home, select the Dark theme in Settings and verify that Settings renders in dark mode, Dark is selected, and Light is not." |
There was a problem hiding this comment.
Is it worth adding switching back to light mode? As is, this wouldn't satisfy the "passes twice" requirement.
Not sure how much this influences the llm.
There was a problem hiding this comment.
You're right. I addeed two rows to the example.
| ## 3. Make each verification discriminating | ||
|
|
||
| - **State change:** prove the new state and that the old state is absent when both could otherwise match. | ||
| - **Cancel/persistence:** cross the commit boundary—leave the screen, re-enter it, then verify the stored state. |
There was a problem hiding this comment.
| - **Cancel/persistence:** cross the commit boundary—leave the screen, re-enter it, then verify the stored state. | |
| - **Cancel/persistence:** cross the commit boundary — leave the screen, re-enter it, then verify the stored state. |
There was a problem hiding this comment.
I fixed all of these
| - **Cancel/persistence:** cross the commit boundary—leave the screen, re-enter it, then verify the stored state. | ||
| - **Absence/removal:** first prove the correct containing screen/list loaded, then record the trio in order (`visible` → action → `hidden`); the recorder enforces it. Prefer a positive replacement/empty state alongside. In a scrollable collection `hidden` proves only the loaded/visible tree: use seeded data that fixes the expected row position, a section count/empty state, or other collection-wide evidence instead of claiming global absence from one viewport. | ||
| - **Overlays over the next target:** a `visible` check on the target passes while a toast eats the tap — follow `argent-create-flow`'s [obscured-targets procedure](../argent-create-flow/references/reliability-and-recovery.md#obscured-targets-and-persistent-overlays) whenever a save/follow/delete raises one over the next target. | ||
| - **Repeated controls and section/list membership:** prefer a stable target id; otherwise bind the target/check to its row or card with flow-only `within`. Use `text.in` on a stable container to prove rendered membership, not merely that the same name exists somewhere on screen. |
There was a problem hiding this comment.
One edge case for this - AFAIR, flow uses visual stacking for within since the tree is flattened. When a child overflows the parent completely, this will fail.
I don't think this is actionable; more of a note. Is it worth a mention here?
There was a problem hiding this comment.
I put the caveat where the within semantics live rather than repeating it here. flow-yaml.md now says:
"within means visual containment, so a child whose frame spills outside its parent's an overflowing row, a popover anchored to a card does not match it, however clearly it is that parent's child in the code."
And this line now links to it.
1fb15d0 to
29900cb
Compare
e1d9a9a to
e680fb0
Compare
b440ec7 to
f158f6a
Compare
e680fb0 to
151b8f6
Compare
f158f6a to
eb6c8a5
Compare
78c898b to
5f376c3
Compare
…-qa-flows
`argent-create-flow`'s SKILL.md had grown to roughly 5,000 words — every word of
it loaded on every invocation, whether the task was replaying a two-step
fragment or authoring a regression test. It is now ~900 words that route to
three references read on demand: live-authoring (recording a walkthrough),
flow-yaml (the file format and selector vocabulary), and
reliability-and-recovery (what to do when a step or a replay goes wrong).
`argent-qa-flows` is new: turning a test case, ticket, or acceptance criteria
into a repeatable regression test is a different job from recording a path
worth replaying. It orchestrates create-flow as its engine, records the first
walkthrough live, requires every requested screen and state to be proved with
stable evidence, and completes only after the unchanged flow passes twice
consecutively.
The routing rules gain the distinction the three skills now need, because they
were being confused with each other by name alone: a one-off interactive check
is argent-test-ui-flow, a saved replayable path is argent-create-flow, and a
saved test with acceptance criteria and two-pass proof is argent-qa-flows.
"Record a flow" also stops being ambiguous with screen recording, which is
video.
One new rule is worth calling out: if a flow or QA test may be recorded, do not
interact with the app first — start the recorder before the first launch. A path
already walked cannot be recorded retroactively, and re-walking it was one of
the more expensive mistakes in practice.
Proving a navigation is spelled out here for the first time, and it is now two
element-level checks rather than a route read: an `await:` on something that
exists ONLY on the destination, then `await: { idle: true }`. Neither implies
the other — a dropped tap leaves the source screen perfectly idle, and the
destination's elements enter the tree while the transition is still animating
over them — so both are required after every screen change. The readiness half
is a directive with no live tool behind it, so it joins `snapshot:` on the short
list of insertions allowed during polish.
A test parses the YAML frontmatter of every bundled skill, so a malformed new
one fails here rather than at install time.
`await: { idle: true }` no longer fails a run, so the references stop promising
that it does and start saying what to do with the warning instead. The gate is
still recorded after every navigation — it returns the moment the screen stops,
which is what keeps the following tap from resolving its target against a
transition still in flight.
The instruction the agent needs is the same everywhere: a ⚠ on a passing step is
a finding, not noise. Nothing in the report separates motion that is there by
design from a screen that never finished loading — a stuck spinner reads as
both — so go and look, disclose what was moving, and make sure the next action
rests on a stable element rather than on stillness. Live authoring says it where
the gate is added, so an agent who watched something animate expects the warning
before it appears; the recovery table gets a row for it; QA has to resolve and
report it before finishing, though it does not block the pass streak.
Also corrects an unrelated claim in the same QA file: the recorder does not
enforce the visible → action → hidden order. A `hidden` whose selector never
matched records as a clean pass, flagged only by a note in the tool result, so
the ordering is the author's to keep.
The references and the QA contract described the outcome as a "⚠", which is only how the MCP client and the CLI render it. What an agent can actually act on is a `warning` on a passing step, so say that instead.
…TOCs The core skill told the agent to "record each check" without ever naming what does the recording, and listed `snapshot:` among the things to record even though it has no recorder form. Rule 2 now says a check is recorded as an `await-ui-element` call that polish converts into `await:`/`assert:`; rule 5 says "raw step" rather than "raw action", so that conversion is unambiguously allowed, and names the three insertions that have no recorder form; rule 4 notes that identity is recorded live while readiness is inserted during polish. Also from the review: - a leading `run:` does not make a flow e2e, and on Chromium that classification is what boots the app; - `within` is geometric, so a child overflowing its parent's frame does not match it however clearly it is that parent's child in the code; - the polish table was missing `tool: gesture-rotate` -> `rotate:`; - the coordinate fallback gate now ends at the app's source, and recommends adding a stable test id when the element genuinely has none; - the check following a kept coordinate proves the outcome rather than the target, which the wording never said; - the QA worked example gated no theme baseline and never restored one, so its second consecutive run started Dark and proved nothing the first did. It now asserts Light in setup and restores it at the end. The three reference tables of contents were single lines of up to 490 characters joined by a separator used nowhere else in the skills tree, and are lists now. Proactive recording and the precautionary rule in rules/argent.md both stated the instruction ahead of the reason, and are reworded.
…imeout `waitForIdle` returns ok carrying a warning on every timeout path: readiness is not an acceptance criterion, and healthy screens often never stop moving. The tool description still told the opposite story, so the one place an agent reads about `idle` without loading a skill contradicted both the implementation and the create-flow reference. Only an unreadable or permanently empty tree stops a run, as an errored step, and the description now says so.
…nces The reference split was written against a create-flow SKILL.md that predates `flow-execute`'s second flow source, so moving the body out of SKILL.md would have dropped four things main documents today rather than relocating them. Restored where the split puts that kind of detail, not back into SKILL.md: - Flow YAML, composition: a `run:` target resolves against the directory of the flow file holding the step (so `../shared/login.yaml` reaches a sibling directory), and the `.yaml` suffix is optional. - Live authoring, replay: `flow-execute` takes exactly one source — `name` or an absolute `flow_path` — and `run:` targets and baselines resolve on the tool SERVER's filesystem. `flow_path` needs agent and server co-located; a remote `name` call arrives as an upload of one YAML into a temp directory, so a composed or snapshotting flow fails there. - Live authoring, Chromium: `restart-app` has no Chromium support and only successful calls are recorded, which is *why* a recorded Chromium flow is always a fragment and its launch is written in during polish.
bd3df10 to
dd49a0f
Compare
argent-create-flow: ~5,000 words to ~900 plus three referencesEvery word of that skill loaded on every invocation, whether the task was replaying a two-step fragment or authoring a full regression test. The core skill now routes to three references read on demand:
live-authoring.md- recording a walkthroughflow-yaml.md- the file format and selector vocabularyreliability-and-recovery.md- what to do when a step or a replay goes wrongNothing is dropped in the split itself - it moves behind a router. (Separately, the claims listed above were rewritten to match this base.)
New skill:
argent-qa-flowsTurning a test case, ticket, or acceptance criteria into a repeatable regression test is a different job from recording a path worth replaying, and it was being done by a skill that does not ask for acceptance criteria.
It orchestrates
argent-create-flowas its engine, records the first walkthrough live, requires every requested screen and state to be proved with stable evidence, and completes only after the unchanged flow passes twice consecutively.Proving a navigation
Spelled out here for the first time, and it is two element-level checks rather than a route read:
Neither implies the other. A dropped tap leaves the source screen perfectly idle, and the destination's elements enter the tree while the transition is still animating over them. So both are required after every screen change, including the one
launch:performs, which is where readiness matters most: launch waits for platform automation readiness, not for the app's own loading or splash.The readiness half is a directive with no live tool behind it, so it joins
snapshot:on the short list of insertions allowed during polish.Routing
The three skills were being confused by name alone, so the rules now draw the line explicitly:
argent-test-ui-flowargent-create-flowargent-qa-flows"Record a flow" also stops being ambiguous with screen recording, which is video.
One rule worth calling out
A path already walked cannot be recorded retroactively, and re-walking it was one of the more expensive mistakes in practice.
Test
The YAML frontmatter of every bundled skill is parsed in a test, so a malformed new skill fails here rather than at install time.
Review notes
Every concrete claim about
await: { idle: true }was checked against the implementation: defaults, the parse-time rejection of aminStableMsthat cannot fit insidetimeout, the three-read minimum, the hard failure on timeout against the tool's softsettled: false, the absentassertform, and the warning when no screenshot could be read. All 44 anchored internal links resolve.Duplication was cut where the same rule had accumulated in several files: the "never prove a screen with a shared header / tab bar / positional id" list went from five placements to two, and the "never persist
await-screen-idle" rule from three to one.Three items in
argent-device-interactare restored here rather than lost with #727, which is closed: thekeyboardtool serves every platform (not just iOS and Android), ahiddennote that never matched is a failed check rather than a pass, andawait-screen-idlegets a compact entry because three flow references now point at it.