verikun 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -233,6 +233,14 @@ like permission dialogs) and **bounded loops** (`repeat … until`, e.g. scroll
233
233
  row appears) — control flow a flat [`batch`](#batch) script can't express. Loops carry
234
234
  a hard iteration cap and stop early if the screen stops changing.
235
235
 
236
+ An `if-present` guard **waits for its selector to settle** before deciding the optional UI
237
+ isn't there, so a dialog that animates in a beat after the transition is still caught. The
238
+ window guarantees at least two looks at the screen (wall clock alone isn't a usable unit —
239
+ a UI dump ranges from ~200ms on a fast phone to ~2.5s on an emulator), so an absent guard
240
+ costs about one extra dump. `VERIKUN_GUARD_SETTLE_MS` tunes it; `0` restores the old
241
+ single-shot probe. A loop's own exit check never pays this window — it's absent on every
242
+ iteration by construction, which is what makes it a loop.
243
+
236
244
  - **Progress streams to stderr** (so a CI job never goes silent); **stdout is the
237
245
  report path** (or a JSON summary with `--json`). The compiled plan is logged to the
238
246
  run before it executes, for troubleshooting.
@@ -367,7 +375,11 @@ class:Button simplified type ("Button") or full class ("android.widget.Button
367
375
  ```
368
376
 
369
377
  Modifiers: `--contains` makes text/desc matches substring-based; `--index N`
370
- selects the Nth match (0-based) when a selector intentionally matches several.
378
+ selects the Nth match (0-based) when a selector intentionally matches several;
379
+ `--enabled` matches only a control that is **actionable right now** — use it for a
380
+ Submit/Check button the app disables until a form is valid, since such a button is
381
+ present long before it is usable and tapping presence taps a dead control (with
382
+ auto-wait this reads as "wait until it is pressable").
371
383
  If a selector for an action matches more than one element and no `--index` is
372
384
  given, the command fails with exit code 2 and lists the candidates — it never
373
385
  taps a guess.
@@ -35,6 +35,12 @@ class ClaudeProvider {
35
35
  JSON.stringify(input.seed, null, 2));
36
36
  }
37
37
  parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
38
+ if (input.retryFeedback) {
39
+ // Last, so it is the freshest thing in context: a previous compile of this same
40
+ // test lost something the prose stated. Naming it beats hoping the retry differs.
41
+ parts.push('YOUR PREVIOUS ATTEMPT AT THIS TEST WAS REJECTED. Fix this and emit the whole plan again:\n' +
42
+ input.retryFeedback);
43
+ }
38
44
  const { json, usage } = await this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA, 8192);
39
45
  return { plan: (0, ir_1.parsePlan)(json), usage };
40
46
  }
@@ -156,6 +156,12 @@ class CliProvider {
156
156
  JSON.stringify(input.seed, null, 2));
157
157
  }
158
158
  parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
159
+ if (input.retryFeedback) {
160
+ // Last, so it is the freshest thing in context: a previous compile of this same
161
+ // test lost something the prose stated. Naming it beats hoping the retry differs.
162
+ parts.push('YOUR PREVIOUS ATTEMPT AT THIS TEST WAS REJECTED. Fix this and emit the whole plan again:\n' +
163
+ input.retryFeedback);
164
+ }
159
165
  const json = this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA);
160
166
  // usage:{} — a CLI is billed to the user's subscription, not per token, so cost is $0
161
167
  // (documented no-op for --max-cost-usd). The run is still bounded by maxRepairs + --timeout.
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_RUN_TIMEOUT_MS = void 0;
3
+ exports.DEFAULT_GUARD_SETTLE_MS = exports.DEFAULT_RUN_TIMEOUT_MS = void 0;
4
4
  exports.runPlan = runPlan;
5
+ const node_crypto_1 = require("node:crypto");
5
6
  const selector_1 = require("../ui/selector");
6
7
  const errors_1 = require("../errors");
7
8
  const ir_1 = require("./ir");
@@ -13,23 +14,120 @@ const describe = (leaf) => [leaf.command, ...leaf.positionals, ...leaf.flags.map
13
14
  * fails (a device hiccup on screencap) must never turn a green run red — see
14
15
  * the guard in execLeaf. */
15
16
  const isScreenshotLeaf = (leaf) => leaf.command === 'screenshot' || leaf.command === 'shot';
16
- /** A structural fingerprint of the screen: sorted id+text+type set. Used for the
17
- * loop no-progress check — deliberately NOT the raw hierarchy (its node ordering
18
- * is nondeterministic between identical states, which would false-trip). */
17
+ /** A structural fingerprint of the screen: a sorted id+text+desc+type set. Used for the
18
+ * loop no-progress check — deliberately NOT the raw hierarchy (its node ordering is
19
+ * nondeterministic between identical states, which would false-trip).
20
+ *
21
+ * BOTH content fields are sampled, and that is load-bearing rather than belt-and-braces.
22
+ * Sampling `text` alone made this blind on Flutter apps, which map `Semantics(label:)` to
23
+ * Android's `contentDescription` — i.e. to `desc`, never to `text`. Measured on a live
24
+ * Flutter screen: 14 elements, **0** carrying `text`, 8 carrying `desc`. The fingerprint
25
+ * degenerated to `id||type` for the entire screen, so two completely different questions
26
+ * hashed byte-identically and a loop answering them correctly was declared stalled.
27
+ *
28
+ * Uses the full `id`, not `idShort` (the suffix after the last '/'), so elements from
29
+ * different packages/namespaces cannot collide in what is meant to be a fingerprint.
30
+ *
31
+ * When in doubt, sample MORE: an over-sensitive hash only costs a loop running to its cap;
32
+ * an under-sensitive one fails a passing test, since a stalled loop is now fatal. */
19
33
  function structuralHash(els) {
20
34
  return els
21
- .map((e) => `${e.idShort}|${e.text}|${e.type}`)
35
+ .map((e) => `${e.id}|${e.text}|${e.desc}|${e.type}`)
22
36
  .sort()
23
37
  .join('\n');
24
38
  }
39
+ /** An unresolvable {{...}} placeholder. Terminal and never healed: the model cannot
40
+ * repair a missing value, and substituting one would be inventing test data. */
41
+ class CtxError extends Error {
42
+ }
25
43
  function isHealable(outcome) {
26
44
  return (!!outcome.error &&
27
45
  (outcome.error instanceof errors_1.SelectorNotFoundError || outcome.error instanceof errors_1.AmbiguousSelectorError));
28
46
  }
29
47
  /** Default wall-clock ceiling for a whole `vk ai` run (overridable via --timeout). */
30
48
  exports.DEFAULT_RUN_TIMEOUT_MS = 15 * 60 * 1000;
49
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
50
+ /** How long a CONDITIONAL guard (`if-present`) waits for its selector to show up
51
+ * before concluding "absent". Interstitials animate in: a permission dialog or promo
52
+ * panel typically lands a few hundred ms after the transition that triggers it. Every
53
+ * selector-resolving leaf command already auto-waits ~5s (cli.ts resolveOneWaiting),
54
+ * so before this window existed a guard was strictly LESS patient than a bare `tap` —
55
+ * and an optional dialog could be missed by the very construct meant to catch it.
56
+ * Kept far below the leaf 5s because an ABSENT guard pays this window, every time, and
57
+ * skipping fast is the common case.
58
+ *
59
+ * NOTE the floor in present(): any non-zero window buys at least TWO looks at the screen.
60
+ * Wall clock alone is not a usable unit here — a uiautomator dump measured ~2.4s on
61
+ * emulator-5554 and can be ~10x faster on a physical device, so a pure time box gives a
62
+ * fast phone a dozen looks and a slow emulator none. Set 0 to restore the old
63
+ * single-shot probe. Override per run with VERIKUN_GUARD_SETTLE_MS (see cli.ts). */
64
+ exports.DEFAULT_GUARD_SETTLE_MS = 1500;
65
+ /** Re-dump cadence inside a guard's settle window. */
66
+ const GUARD_POLL_MS = 150;
67
+ /** Consecutive identical screen snapshots before a loop is believed to be stuck.
68
+ *
69
+ * This check is a TIME SAVER and nothing more. A loop already fails when its exit
70
+ * selector never appears, so stopping early buys no safety — it only decides how long we
71
+ * wait before reaching the same verdict. That asymmetry is the whole design: a false
72
+ * positive fails a passing test, while a false negative costs `cap` iterations of runtime.
73
+ *
74
+ * Tuned from a measured false positive, twice over. At 2 strikes the same plan answered
75
+ * 17 questions on one run and bailed after 4 on the next — the loop bodies drive screen
76
+ * transitions, and a single dump lands mid-transition often enough to produce a
77
+ * coincidental pair of matching hashes.
78
+ *
79
+ * 4 discriminates well because the two cases differ in character, not just degree: a loop
80
+ * genuinely at rest (a scrolled-to-the-bottom list — the case this exists for) yields
81
+ * identical hashes indefinitely, whereas an animating screen rarely yields four in a row. */
82
+ const NO_PROGRESS_STRIKES = 4;
31
83
  async function runPlan(plan, deps) {
32
84
  const maxRepairs = deps.maxRepairs ?? 3;
85
+ const guardSettleMs = deps.guardSettleMs ?? exports.DEFAULT_GUARD_SETTLE_MS;
86
+ // The run's mutable state, scoped to THIS runPlan call — never module-level. `vk suite`
87
+ // runs every test in one process, so a shared store would hand two sign-up tests the
88
+ // same {{uuid}} and recreate the collision it exists to prevent. Per-leaf generation
89
+ // would be equally wrong: a sign-up needs the same address in the email field, the
90
+ // confirm field, and a later assert.
91
+ const ctx = new Map(Object.entries(deps.initialCtx ?? {}));
92
+ const generated = new Map();
93
+ const runId = deps.runId ?? 'run';
94
+ /** Resolve {{...}} placeholders. A closed set on purpose — this is a template
95
+ * substitution, not an expression language, so there is nothing to sandbox.
96
+ * Unknown placeholders are left verbatim rather than blanked: a silent empty string
97
+ * is how a typo becomes a false green. */
98
+ function interpolate(s) {
99
+ if (!s.includes('{{'))
100
+ return s;
101
+ return s.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (whole, name) => {
102
+ if (name.startsWith('ctx.')) {
103
+ const key = name.slice(4);
104
+ const v = ctx.get(key);
105
+ if (v === undefined)
106
+ throw new CtxError(`{{${name}}} is not set — no earlier step stored it`);
107
+ return v;
108
+ }
109
+ if (name.startsWith('env.')) {
110
+ const key = name.slice(4);
111
+ const v = process.env[key];
112
+ // Loud, not empty: a missing CI secret must fail the step, not type "" into a field.
113
+ if (v === undefined || v === '')
114
+ throw new CtxError(`{{${name}}} is not set in the environment`);
115
+ return v;
116
+ }
117
+ if (name === 'run_id')
118
+ return runId;
119
+ // uuid/timestamp are generated ONCE per run and memoized by placeholder name.
120
+ if (name === 'uuid' || name === 'timestamp') {
121
+ const existing = generated.get(name);
122
+ if (existing !== undefined)
123
+ return existing;
124
+ const v = name === 'uuid' ? (0, node_crypto_1.randomUUID)() : String(Date.now());
125
+ generated.set(name, v);
126
+ return v;
127
+ }
128
+ return whole;
129
+ });
130
+ }
33
131
  const overDeadline = () => deps.deadline !== undefined && Date.now() >= deps.deadline;
34
132
  const improvements = [];
35
133
  let modelRepairs = 0;
@@ -44,24 +142,22 @@ async function runPlan(plan, deps) {
44
142
  return [];
45
143
  }
46
144
  };
47
- const present = async (selector) => {
48
- // Re-fetch on a dump FAILURE (uiautomator can throw transiently) so a flaky dump at
49
- // a guard check doesn't silently read as "absent" and skip a body that should run.
50
- // Once a dump SUCCEEDS (even if empty) we trust it — no slow re-poll, so a genuinely
51
- // absent guard still skips fast (the common if-present case).
52
- let els;
53
- for (let i = 0; i < 2 && els === undefined; i++) {
54
- try {
55
- els = await deps.getElements();
56
- }
57
- catch {
58
- /* transient dump failure retry once before concluding "absent" */
59
- }
60
- }
61
- if (els === undefined)
62
- return false;
145
+ /** Is `selector` on screen? `settleMs` is how long to keep re-dumping while it is
146
+ * absent before answering "no" see DEFAULT_GUARD_SETTLE_MS.
147
+ *
148
+ * Two retry behaviours here are deliberately ORTHOGONAL, and conflating them is the
149
+ * bug this signature exists to prevent:
150
+ * - a dump that THROWS is always retried once, even at settleMs=0, so a flaky
151
+ * uiautomator call never silently reads as "absent" and skips a body that
152
+ * should run;
153
+ * - a dump that SUCCEEDS but does not match is re-polled only while settleMs
154
+ * remains. At settleMs=0 that means exactly one pass — the fast, single-shot
155
+ * probe a loop-exit check needs.
156
+ * So one dump attempt always happens regardless of the window. */
157
+ const present = async (selector, settleMs) => {
158
+ let sel;
63
159
  try {
64
- return (0, selector_1.matchElements)(els, (0, selector_1.parseSelector)(selector)).matches.length > 0;
160
+ sel = (0, selector_1.parseSelector)(selector);
65
161
  }
66
162
  catch (e) {
67
163
  // A guard selector that won't parse is a compiler/plan bug — surface it (then treat
@@ -69,14 +165,68 @@ async function runPlan(plan, deps) {
69
165
  deps.log(`[ai] guard selector '${selector}' did not parse (${e.message}) — treating as not present`);
70
166
  return false;
71
167
  }
168
+ const deadline = Date.now() + Math.max(0, settleMs);
169
+ // A non-zero window must buy at least one SECOND look, independent of the clock.
170
+ // Measured on emulator-5554: one uiautomator dump costs ~2.4s, which already exceeds
171
+ // a 1.5s window — so a purely time-boxed loop returns after a single dump and the
172
+ // window is a silent no-op on exactly the slow devices that need it most. Dump cost
173
+ // swings ~10x across devices, so "how long to wait" cannot be expressed in wall clock
174
+ // alone without making the guard's patience device-dependent.
175
+ let looks = 0;
176
+ const minLooks = settleMs > 0 ? 2 : 1;
177
+ for (;;) {
178
+ let els;
179
+ for (let i = 0; i < 2; i++) {
180
+ try {
181
+ els = await deps.getElements();
182
+ }
183
+ catch {
184
+ els = undefined; // transient dump failure — retry once before concluding "absent"
185
+ }
186
+ // An EMPTY tree is not a screen, it is a bad read: a live app always has nodes, and
187
+ // this device routinely returns a partial/blank dump mid-transition (measured: `ui`
188
+ // reported zero elements while `tap`'s auto-wait found its target 3.1s later).
189
+ // Trusting it means "absent" — which silently skips a guard, or sends a loop round
190
+ // again to tap something that already went away. Retry once even at settleMs=0,
191
+ // which is what the loop-exit check runs at.
192
+ if (els !== undefined && els.length > 0)
193
+ break;
194
+ }
195
+ looks++;
196
+ if (els !== undefined && (0, selector_1.matchElements)(els, sel).matches.length > 0)
197
+ return true;
198
+ const remaining = deadline - Date.now();
199
+ if (looks >= minLooks && remaining <= 0)
200
+ return false;
201
+ await sleep(Math.max(0, Math.min(GUARD_POLL_MS, remaining)));
202
+ }
203
+ };
204
+ const runLeaf = (leaf) => {
205
+ const flags = (0, ir_1.leafToFlags)(leaf);
206
+ for (const k of Object.keys(flags))
207
+ flags[k] = interpolate(flags[k]);
208
+ return deps.exec(leaf.command, leaf.positionals.map(interpolate), flags);
72
209
  };
73
- const runLeaf = (leaf) => deps.exec(leaf.command, leaf.positionals, (0, ir_1.leafToFlags)(leaf));
74
210
  /** Execute one leaf, healing a selector miss/ambiguity via the model up to the cap.
75
211
  * `replace` writes a repaired leaf back into the plan so it persists on green. */
76
- async function execLeaf(leaf, where, replace) {
212
+ async function execLeaf(leaf, where, replace, guard) {
77
213
  deps.log(`[ai] ${where}: ${describe(leaf)}`);
78
214
  let current = leaf;
79
215
  let outcome = await runLeaf(current);
216
+ // Guard raced the body: `if-present X { … tap X … }` checked X, X was there, and by
217
+ // the time the tap ran it had gone. That is not drift and there is nothing to repair —
218
+ // it is exactly the transient the guard exists to tolerate, and on a fast-transitioning
219
+ // app it happens routinely. Checked BEFORE the heal loop, because otherwise it costs
220
+ // three model calls to conclude that a thing which was optional is absent.
221
+ // Deliberately narrow: only the leaf that targets the guard's own selector, and only
222
+ // once the guard is confirmed false again.
223
+ if (guard !== undefined &&
224
+ outcome.error instanceof errors_1.SelectorNotFoundError &&
225
+ current.positionals.some((p) => interpolate(p) === interpolate(guard)) &&
226
+ !(await present(interpolate(guard), 0))) {
227
+ deps.log(`[ai] ${where}: '${interpolate(guard)}' disappeared after the guard matched — ending the guarded body`);
228
+ return { status: 'guard-gone' };
229
+ }
80
230
  let attempts = 0;
81
231
  while (isHealable(outcome) && attempts < maxRepairs) {
82
232
  if (deps.cost.exceeded()) {
@@ -154,49 +304,189 @@ async function runPlan(plan, deps) {
154
304
  const reason = outcome.error ? outcome.error.message.split('\n')[0] : `exited ${outcome.code}`;
155
305
  return { status: 'fail', where, reason };
156
306
  }
157
- async function walkBody(body, parentWhere) {
307
+ async function walkBody(body, parentWhere, guard) {
158
308
  for (let j = 0; j < body.length; j++) {
159
- const res = await execLeaf(body[j], `${parentWhere}.body[${j}]`, (l) => (body[j] = l));
309
+ const res = await walkNode(body[j], `${parentWhere}[${j}]`, (l) => (body[j] = l), guard);
310
+ // 'guard-gone' ends this body, not the run: the thing the guard matched went away
311
+ // mid-body, which is precisely the situation the guard exists to tolerate.
312
+ if (res.status === 'guard-gone')
313
+ return { status: 'ok' };
160
314
  if (res.status !== 'ok')
161
315
  return res;
162
316
  }
163
317
  return { status: 'ok' };
164
318
  }
165
- async function walkNode(node, where, replace) {
319
+ /** Capture a value from the live tree into ctx. Not routed through `exec`: the
320
+ * ExecFn contract returns only {code,error}, so a leaf could never hand a value back. */
321
+ async function execRead(node, where) {
322
+ const selector = interpolate(node.selector);
323
+ let sel;
324
+ try {
325
+ sel = (0, selector_1.parseSelector)(selector);
326
+ }
327
+ catch (e) {
328
+ return { status: 'fail', where, reason: `read selector '${selector}' did not parse: ${e.message}` };
329
+ }
330
+ // Same patience as a conditional guard: the value may not have rendered yet.
331
+ const deadline = Date.now() + Math.max(0, guardSettleMs);
332
+ let looks = 0;
333
+ const minLooks = guardSettleMs > 0 ? 2 : 1;
334
+ for (;;) {
335
+ const els = await safeElements();
336
+ const { matches } = (0, selector_1.matchElements)(els, sel);
337
+ looks++;
338
+ if (matches.length > 0) {
339
+ const value = String(matches[0][node.field] ?? '');
340
+ ctx.set(node.into, value);
341
+ deps.log(`[ai] ${where}: read ${node.field} of '${selector}' → ctx.${node.into} = ${JSON.stringify(value)}`);
342
+ return { status: 'ok' };
343
+ }
344
+ const remaining = deadline - Date.now();
345
+ if (looks >= minLooks && remaining <= 0) {
346
+ // Terminal, not healable: a missing source value would silently propagate an
347
+ // empty string into every later {{ctx.*}} use, which is a false green waiting
348
+ // to happen. Fail where the information is.
349
+ return { status: 'fail', where, reason: `read found no element matching '${selector}' (nothing to store in ctx.${node.into})` };
350
+ }
351
+ await sleep(Math.max(0, Math.min(GUARD_POLL_MS, remaining)));
352
+ }
353
+ }
354
+ async function walkNode(node, where, replace, guard) {
355
+ try {
356
+ return await walkNodeInner(node, where, replace, guard);
357
+ }
358
+ catch (e) {
359
+ // An unresolvable placeholder is a plan defect, not a device failure: report it
360
+ // as this step's terminal failure rather than letting it abort the whole run as
361
+ // an "unexpected error" (exit 3).
362
+ if (e instanceof CtxError)
363
+ return { status: 'fail', where, reason: e.message };
364
+ throw e;
365
+ }
366
+ }
367
+ async function walkNodeInner(node, where, replace, guard) {
166
368
  switch (node.type) {
167
369
  case 'command':
168
- return execLeaf(node, where, replace);
370
+ return execLeaf(node, where, replace, guard);
371
+ case 'read':
372
+ return execRead(node, where);
169
373
  case 'if-present': {
170
- if (await present(node.selector)) {
171
- deps.log(`[ai] ${where}: if-present '${node.selector}' → present, running ${node.body.length} step(s)`);
172
- return walkBody(node.body, where);
374
+ const selector = interpolate(node.selector);
375
+ if (await present(selector, guardSettleMs)) {
376
+ deps.log(`[ai] ${where}: if-present '${selector}' → present, running ${node.body.length} step(s)`);
377
+ return walkBody(node.body, `${where}.body`, selector);
173
378
  }
174
- deps.log(`[ai] ${where}: if-present '${node.selector}' → absent, skipping`);
379
+ deps.log(`[ai] ${where}: if-present '${selector}' → absent, skipping`);
175
380
  return { status: 'ok' };
176
381
  }
382
+ case 'when': {
383
+ for (let b = 0; b < node.branches.length; b++) {
384
+ const selector = interpolate(node.branches[b].selector);
385
+ // Ordered: first present branch wins, and only it runs. The settle window is
386
+ // per NODE, spent on the first branch checked — re-polling every branch would
387
+ // cost cap x branches x window inside a loop, and would let a slower-appearing
388
+ // earlier branch beat an already-present later one (making dispatch depend on
389
+ // timing rather than on order).
390
+ if (await present(selector, b === 0 ? guardSettleMs : 0)) {
391
+ deps.log(`[ai] ${where}: when → branch ${b} '${selector}' matched`);
392
+ return walkBody(node.branches[b].body, `${where}.branches[${b}].body`);
393
+ }
394
+ }
395
+ if (node.else) {
396
+ deps.log(`[ai] ${where}: when → no branch matched, running else (${node.else.length} step(s))`);
397
+ return walkBody(node.else, `${where}.else`);
398
+ }
399
+ // No branch, no else => FAIL. Skipping here is the false-green this node exists
400
+ // to prevent: inside a loop it would spin to the cap doing nothing and pass.
401
+ const tried = node.branches.map((b) => `'${interpolate(b.selector)}'`).join(', ');
402
+ return {
403
+ status: 'fail',
404
+ where,
405
+ reason: `when: no branch matched (tried ${tried}) and no else was given — the screen is one this test does not handle`,
406
+ };
407
+ }
177
408
  case 'repeat': {
178
409
  let prevHash = '';
179
- for (let i = 0; i < node.cap; i++) {
410
+ let stalled = 0;
411
+ let satisfied = false;
412
+ let i = 0;
413
+ for (; i < node.cap; i++) {
180
414
  if (overDeadline()) {
181
415
  deps.log(`[ai] ${where}: run timeout reached — stopping repeat after ${i} iteration(s)`);
182
416
  return { status: 'timeout' };
183
417
  }
184
- if (await present(node.selector)) {
418
+ // settleMs=0 on purpose: this guard is absent on EVERY iteration by construction
419
+ // (that is what makes it a loop), so a settle window here would be paid `cap`
420
+ // times — 25 × 1.5s ≈ 37s of dead wall-clock per loop — to discover something
421
+ // we already expect. The interstitial case that needs patience is `if-present`.
422
+ if (await present(interpolate(node.selector), 0)) {
423
+ satisfied = true;
185
424
  deps.log(`[ai] ${where}: repeat reached '${node.selector}' after ${i} iteration(s)`);
186
- return { status: 'ok' };
425
+ break;
187
426
  }
188
- const hash = structuralHash(await safeElements());
189
- if (i > 0 && hash === prevHash) {
190
- deps.log(`[ai] ${where}: repeat made no progress (screen unchanged) stopping after ${i} iteration(s)`);
191
- return { status: 'ok' };
427
+ // No-progress detection over a SINGLE un-settled dump is unreliable on a real
428
+ // app: mid-transition frames read as empty (measured — `vk ui` reported zero
429
+ // elements on a screen where `tap`'s auto-wait found its target 3.1s later).
430
+ // Two such frames hash identically and look exactly like a stuck loop. Since a
431
+ // stalled loop is now a FAILURE, a false positive here fails a passing test, so
432
+ // this needs two independent guards:
433
+ // 1. an empty dump is UNKNOWN, not "unchanged" — never a progress sample;
434
+ // 2. require consecutive identical NON-EMPTY snapshots before believing it.
435
+ const els = await safeElements();
436
+ if (els.length === 0) {
437
+ deps.log(`[ai] ${where}: screen read as empty (mid-transition?) — not counting it as progress either way`);
438
+ }
439
+ else {
440
+ const hash = structuralHash(els);
441
+ stalled = i > 0 && hash === prevHash ? stalled + 1 : 0;
442
+ prevHash = hash;
443
+ if (stalled >= NO_PROGRESS_STRIKES) {
444
+ deps.log(`[ai] ${where}: repeat made no progress across ${stalled + 1} checks — stopping after ${i} iteration(s)`);
445
+ break;
446
+ }
192
447
  }
193
- prevHash = hash;
194
448
  deps.log(`[ai] ${where}: repeat iteration ${i + 1}/${node.cap}`);
195
- const res = await walkBody(node.body, `${where}#${i + 1}`);
449
+ const res = await walkBody(node.body, `${where}#${i + 1}.body`);
450
+ if (res.status !== 'ok')
451
+ return res;
452
+ }
453
+ // A loop that stopped without ever seeing its target did NOT do its job. Both
454
+ // exits (cap exhausted, and the no-progress bail) used to return ok, which let a
455
+ // loop whose body did nothing report green — the reachable false green.
456
+ if (!satisfied && !(await present(interpolate(node.selector), guardSettleMs))) {
457
+ return {
458
+ status: 'fail',
459
+ where,
460
+ reason: `repeat stopped after ${i} iteration(s) without '${interpolate(node.selector)}' ever appearing`,
461
+ };
462
+ }
463
+ return { status: 'ok' };
464
+ }
465
+ case 'while-present': {
466
+ if (node.bind)
467
+ ctx.set(node.bind, '0');
468
+ let ran = 0;
469
+ for (let i = 0; i < node.cap; i++) {
470
+ if (overDeadline()) {
471
+ deps.log(`[ai] ${where}: run timeout reached — stopping while-present after ${i} iteration(s)`);
472
+ return { status: 'timeout' };
473
+ }
474
+ const selector = interpolate(node.selector);
475
+ // First check gets the settle window (the list may still be rendering); later
476
+ // checks are single-shot, since by then the list is up and we are just walking it.
477
+ if (!(await present(selector, i === 0 ? guardSettleMs : 0))) {
478
+ deps.log(`[ai] ${where}: while-present '${selector}' → absent, loop done after ${ran} iteration(s)`);
479
+ return { status: 'ok' };
480
+ }
481
+ deps.log(`[ai] ${where}: while-present iteration ${i + 1}/${node.cap} ('${selector}')`);
482
+ const res = await walkBody(node.body, `${where}#${i + 1}.body`, selector);
196
483
  if (res.status !== 'ok')
197
484
  return res;
485
+ ran++;
486
+ if (node.bind)
487
+ ctx.set(node.bind, String(Number(ctx.get(node.bind) ?? '0') + 1));
198
488
  }
199
- deps.log(`[ai] ${where}: repeat hit cap ${node.cap} without '${node.selector}' (continuing)`);
489
+ deps.log(`[ai] ${where}: while-present hit cap ${node.cap}`);
200
490
  return { status: 'ok' };
201
491
  }
202
492
  }
@@ -34,13 +34,75 @@ Each step is one of three node types:
34
34
  permission dialogs, "rate us" popups, cookie banners, A/B variants. This is how you
35
35
  keep a flow from breaking when an extra screen sometimes appears.
36
36
 
37
- 3. REPEAT — { "type":"repeat", "selector":<sel>, "cap":<n>, "body":[<command leaves>] }
38
- Repeat body until the selector appears, up to cap iterations. Use for "scroll until X
39
- is visible". Always set a sane cap (e.g. 10). The engine also stops early if the screen
40
- stops changing.
37
+ 3. REPEAT — { "type":"repeat", "selector":<sel>, "cap":<n>, "body":[<nodes>] }
38
+ Repeat body UNTIL the selector appears, up to cap iterations. Use for "scroll until X
39
+ is visible", or "keep answering until the results screen". Always set a sane cap (e.g.
40
+ 10). The engine also stops early if the screen stops changing. A repeat that finishes
41
+ without its selector ever appearing FAILS the test — it did not do its job.
41
42
 
42
- NESTING: control-node bodies hold COMMAND leaves only do NOT nest if-present/repeat
43
- inside another control node.
43
+ 4. WHEN { "type":"when", "branches":[{ "selector":<sel>, "body":[<nodes>] }, ...],
44
+ "else":[<nodes>] (optional) }
45
+ Ordered n-way dispatch: the FIRST branch whose selector is on screen runs, and only it.
46
+ Use when a screen is one of several KINDS that each need different handling —
47
+ "the question is multiple-choice, or match-the-pairs, or arrange-the-words".
48
+ If no branch matches and there is no "else", the test FAILS (the app showed something
49
+ this test does not handle — that is a real result, not something to skip past).
50
+ Use "else": [] to say explicitly "if none match, do nothing".
51
+ WHEN vs IF-PRESENT: if-present = "this may or may not be there, carry on either way".
52
+ when = "it is one of these; if it is none of them, that is a failure".
53
+
54
+ 5. WHILE-PRESENT — { "type":"while-present", "selector":<sel>, "bind":<name>,
55
+ "cap":<n>, "body":[<nodes>] }
56
+ Repeat body WHILE the selector is present. With "bind", the named counter starts at 0
57
+ and increments after each iteration, and you reference it as {{ctx.<name>}} inside the
58
+ selector and the body. This is how you walk an index-addressed list whose LENGTH you
59
+ cannot know when compiling:
60
+ { "type":"while-present", "selector":"id:word_bubble_container_id_{{ctx.i}}",
61
+ "bind":"i", "cap":20,
62
+ "body":[ { "type":"command","command":"tap",
63
+ "positionals":["id:word_bubble_container_id_{{ctx.i}}"],"flags":[] } ] }
64
+
65
+ 6. READ — { "type":"read", "selector":<sel>, "field":"text"|"desc"|"id"|"idShort",
66
+ "into":<name> }
67
+ Capture a value off the live screen into {{ctx.<name>}} for a later step to use. Use it
68
+ when the test must ACT ON a value it cannot know in advance — e.g. read the correct
69
+ answer's text, then type that text into a field.
70
+
71
+ PLACEHOLDERS — any positional or flag value, and any control-node selector, may contain:
72
+ {{ctx.NAME}} a value stored by read, or a while-present counter
73
+ {{env.NAME}} an environment variable (use for credentials; never inline a secret)
74
+ {{uuid}} a fresh id, generated once per run (same value everywhere in the run)
75
+ {{timestamp}} epoch ms, once per run {{run_id}} this run's id
76
+ Use {{uuid}} when the test needs data that must be unique per run, e.g. a signup email
77
+ like "user-{{uuid}}@example.com" — never a hard-coded literal, which collides on rerun.
78
+
79
+ NESTING: control nodes may nest ONE level — a control node inside a control node, whose
80
+ body is command leaves. A repeat containing a when is the shape for "until the flow ends,
81
+ handle whichever screen is showing", and it is legal. Three levels is not.
82
+ EXCEPT: if-present and while-present may go one level deeper (their bodies are leaves), so
83
+ repeat { when { while-present { tap ... } } } — walk an index-addressed list
84
+ repeat { when { if-present { tap ... } } } — an optional step inside a branch
85
+ are both legal. Use them rather than approximating.
86
+ In particular: "if X appears, tap it" inside a branch is an if-present. Do NOT turn it
87
+ into a bare wait + tap — wait FAILS the test when X never appears, and "if" means it
88
+ might not. That mistake reads as a passing plan and fails on the first run where the app
89
+ skips that step.
90
+
91
+ Inside a "repeat until X" loop, GUARD a tap whose target is the thing that brings X about:
92
+ repeat until <form> { if-present <button> { tap <button> } ; screenshot }
93
+ Once the transition starts, <button> is gone — so on the final iteration an unguarded tap
94
+ misses and fails the whole run, even though the loop did its job. The guard makes that
95
+ last lap a no-op instead of an error. Apply this whenever the prose says "tap ... until"
96
+ or "repeatedly until".
97
+
98
+ Do NOT flatten a branch into an unconditional sequence: emitting the taps for ALL the
99
+ kinds of screen one after another is wrong — on any given iteration most of them are not
100
+ there, and the test will fail on the first one that is missing. Use when.
101
+
102
+ Do NOT hard-code a run of indices you were not told the length of. If the prose says
103
+ "tap each pair", "until every pair is matched", or "tap the bubbles in order", the COUNT
104
+ varies per run — emit a while-present over {{ctx.i}} rather than tap _0, _1, _2, _3.
105
+ A hard-coded list is right only when the prose states the exact count.
44
106
 
45
107
  SELECTORS (the engine auto-heals case/whitespace/partial, so prefer stable identifiers):
46
108
  @login resource-id 'login' (shorthand for id:login)
@@ -51,6 +113,11 @@ SELECTORS (the engine auto-heals case/whitespace/partial, so prefer stable ident
51
113
  "Sign in" bare string == text:Sign in
52
114
 
53
115
  RULES:
116
+ - --enabled on a tap makes it match only a control that is ACTIONABLE right now, and (with
117
+ auto-wait) wait until it becomes so. Use it for any button that the app disables until
118
+ something else is done — a Check/Submit/Continue that only lights up once an answer is
119
+ selected or a form is valid. Without it the step taps a dead control, does nothing, and
120
+ the failure surfaces later as a confusing timeout on the NEXT step.
54
121
  - assert is for VERIFICATION only and is terminal — never use it as a step you expect to
55
122
  fail. Put genuinely-optional UI behind if-present.
56
123
  - Prefer resource-id / accessibility selectors over visible text where possible.
package/dist/agent/ir.js CHANGED
@@ -18,10 +18,25 @@
18
18
  // structured-output schemas can't express an arbitrary-key object. A pure boolean
19
19
  // flag is value:"true" (flagBool reads 'true' as true; flagStr reads the string).
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.InvalidPlanError = exports.REPAIR_DECISION_JSON_SCHEMA = exports.PLAN_JSON_SCHEMA = exports.DEFAULT_LOOP_CAP = exports.KNOWN_COMMANDS = void 0;
21
+ exports.InvalidPlanError = exports.REPAIR_DECISION_JSON_SCHEMA = exports.PLAN_JSON_SCHEMA = exports.DEFAULT_LOOP_CAP = exports.KNOWN_COMMANDS = exports.isControlNode = exports.MAX_CONTROL_DEPTH = void 0;
22
+ exports.bodiesOf = bodiesOf;
22
23
  exports.validateNode = validateNode;
23
24
  exports.parsePlan = parsePlan;
24
25
  exports.leafToFlags = leafToFlags;
26
+ /** Max control-node nesting. 1 = a control node at top level whose body is leaves
27
+ * (the v1 rule). 2 = a control node inside that body, whose own body is leaves —
28
+ * i.e. `repeat { when { … } }`, the loop-that-branches shape a real dynamic flow
29
+ * needs. The schema is hand-unrolled to exactly this depth, so raising it means
30
+ * editing controlSchema()'s nesting as well as this constant. */
31
+ exports.MAX_CONTROL_DEPTH = 2;
32
+ const isControlNode = (n) => n.type === 'if-present' || n.type === 'when' || n.type === 'repeat' || n.type === 'while-present';
33
+ exports.isControlNode = isControlNode;
34
+ /** Every body a control node owns (a `when` owns one per branch, plus `else`). */
35
+ function bodiesOf(n) {
36
+ if (n.type === 'when')
37
+ return [...n.branches.map((b) => b.body), ...(n.else ? [n.else] : [])];
38
+ return [n.body];
39
+ }
25
40
  /** Commands a leaf step is allowed to carry — the agent-emittable ACTION/assertion verbs
26
41
  * the grammar offers, a SUBSET of cli.ts's dispatch (inspection/diagnostic commands are
27
42
  * excluded; see the note in the set). The engine validates every step — including a
@@ -47,10 +62,115 @@ exports.KNOWN_COMMANDS = new Set([
47
62
  exports.DEFAULT_LOOP_CAP = 25;
48
63
  /**
49
64
  * JSON Schema for `output_config.format` so the model returns a guaranteed-valid
50
- * Plan. Deliberately NON-RECURSIVE: control-node `body` arrays reference only the
51
- * leaf schema, so there is no `Plan -> node -> Plan` cycle (structured output
52
- * rejects recursive schemas). One nesting level, by design.
65
+ * Plan. Deliberately NON-RECURSIVE: instead of a `Plan -> node -> Plan` cycle
66
+ * (structured output rejects recursive schemas) the control levels are HAND-UNROLLED
67
+ * to MAX_CONTROL_DEPTH. Raising the depth means adding another unrolled level here.
53
68
  */
69
+ const stepItems = (bodyItems) => ({
70
+ anyOf: [
71
+ leafSchema(),
72
+ readSchema(),
73
+ {
74
+ type: 'object',
75
+ additionalProperties: false,
76
+ required: ['type', 'selector', 'body'],
77
+ properties: {
78
+ type: { type: 'string', enum: ['if-present'] },
79
+ selector: { type: 'string' },
80
+ body: { type: 'array', items: bodyItems },
81
+ },
82
+ },
83
+ {
84
+ type: 'object',
85
+ additionalProperties: false,
86
+ required: ['type', 'branches'],
87
+ properties: {
88
+ type: { type: 'string', enum: ['when'] },
89
+ branches: {
90
+ type: 'array',
91
+ items: {
92
+ type: 'object',
93
+ additionalProperties: false,
94
+ required: ['selector', 'body'],
95
+ properties: { selector: { type: 'string' }, body: { type: 'array', items: bodyItems } },
96
+ },
97
+ },
98
+ else: { type: 'array', items: bodyItems },
99
+ },
100
+ },
101
+ {
102
+ type: 'object',
103
+ additionalProperties: false,
104
+ required: ['type', 'selector', 'cap', 'body'],
105
+ properties: {
106
+ type: { type: 'string', enum: ['repeat'] },
107
+ selector: { type: 'string' },
108
+ cap: { type: 'integer' },
109
+ body: { type: 'array', items: bodyItems },
110
+ },
111
+ },
112
+ {
113
+ type: 'object',
114
+ additionalProperties: false,
115
+ required: ['type', 'selector', 'cap', 'body'],
116
+ properties: {
117
+ type: { type: 'string', enum: ['while-present'] },
118
+ selector: { type: 'string' },
119
+ bind: { type: 'string' },
120
+ cap: { type: 'integer' },
121
+ body: { type: 'array', items: bodyItems },
122
+ },
123
+ },
124
+ ],
125
+ });
126
+ /** The innermost level: leaves, reads, and the two SIMPLE control nodes — `if-present`
127
+ * and `while-present` — restricted to leaf-only bodies so they cannot open a further
128
+ * level.
129
+ *
130
+ * Both were added from measured failures, and both are the same failure: when the model
131
+ * needs a construct one level deeper than the grammar allows, it does not give up — it
132
+ * DEGRADES to an unconditional approximation, which is exactly the bug #33 is about.
133
+ * - Without `while-present` here, a compile of the dynamic-lesson repro produced
134
+ * `repeat { when { … } }` correctly but HARD-CODED the index lists inside each branch
135
+ * (pairs 0..3, bubbles 0..5) — broken the moment a question has a different count.
136
+ * - Without `if-present` here, the same compile turned the prose "IF a Continue button
137
+ * appears, tap it" into an unconditional `wait id:continue_button_id` inside every
138
+ * branch. The app auto-advances and shows no Continue, so all three runs failed on
139
+ * that wait — a conditional flattened into an assertion, one level down.
140
+ *
141
+ * A full third level for every node would ~3x a 28KB schema (bad for OpenAI strict
142
+ * limits, worse for cursor, which injects the schema as prompt text). Granting it to the
143
+ * two leaf-bodied nodes is the cheap, bounded version. `when` and `repeat` stay at
144
+ * MAX_CONTROL_DEPTH: they are the nodes whose bodies want to be deep, and allowing them
145
+ * here is what would actually cost the schema. */
146
+ const innerControls = () => {
147
+ const leafBody = { type: 'array', items: { anyOf: [leafSchema(), readSchema()] } };
148
+ return [
149
+ {
150
+ type: 'object',
151
+ additionalProperties: false,
152
+ required: ['type', 'selector', 'body'],
153
+ properties: {
154
+ type: { type: 'string', enum: ['if-present'] },
155
+ selector: { type: 'string' },
156
+ body: leafBody,
157
+ },
158
+ },
159
+ {
160
+ type: 'object',
161
+ additionalProperties: false,
162
+ required: ['type', 'selector', 'cap', 'body'],
163
+ properties: {
164
+ type: { type: 'string', enum: ['while-present'] },
165
+ selector: { type: 'string' },
166
+ bind: { type: 'string' },
167
+ cap: { type: 'integer' },
168
+ body: leafBody,
169
+ },
170
+ },
171
+ ];
172
+ };
173
+ const LEAF_ONLY_ITEMS = { anyOf: [leafSchema(), readSchema(), ...innerControls()] };
54
174
  exports.PLAN_JSON_SCHEMA = {
55
175
  type: 'object',
56
176
  additionalProperties: false,
@@ -61,32 +181,7 @@ exports.PLAN_JSON_SCHEMA = {
61
181
  platform: { type: 'string', enum: ['android', 'ios'] },
62
182
  steps: {
63
183
  type: 'array',
64
- items: {
65
- anyOf: [
66
- leafSchema(),
67
- {
68
- type: 'object',
69
- additionalProperties: false,
70
- required: ['type', 'selector', 'body'],
71
- properties: {
72
- type: { type: 'string', enum: ['if-present'] },
73
- selector: { type: 'string' },
74
- body: { type: 'array', items: leafSchema() },
75
- },
76
- },
77
- {
78
- type: 'object',
79
- additionalProperties: false,
80
- required: ['type', 'selector', 'cap', 'body'],
81
- properties: {
82
- type: { type: 'string', enum: ['repeat'] },
83
- selector: { type: 'string' },
84
- cap: { type: 'integer' },
85
- body: { type: 'array', items: leafSchema() },
86
- },
87
- },
88
- ],
89
- },
184
+ items: stepItems(stepItems(LEAF_ONLY_ITEMS)),
90
185
  },
91
186
  },
92
187
  };
@@ -107,6 +202,19 @@ exports.REPAIR_DECISION_JSON_SCHEMA = {
107
202
  reason: { type: 'string' },
108
203
  },
109
204
  };
205
+ function readSchema() {
206
+ return {
207
+ type: 'object',
208
+ additionalProperties: false,
209
+ required: ['type', 'selector', 'field', 'into'],
210
+ properties: {
211
+ type: { type: 'string', enum: ['read'] },
212
+ selector: { type: 'string' },
213
+ field: { type: 'string', enum: ['text', 'desc', 'id', 'idShort'] },
214
+ into: { type: 'string' },
215
+ },
216
+ };
217
+ }
110
218
  function leafSchema() {
111
219
  return {
112
220
  type: 'object',
@@ -141,11 +249,49 @@ function isFlagSpecArray(v) {
141
249
  return (Array.isArray(v) &&
142
250
  v.every((f) => f && typeof f === 'object' && typeof f.name === 'string' && typeof f.value === 'string'));
143
251
  }
144
- /** Validate a single node (used for both compile output and a spliced repair). */
145
- function validateNode(node, where) {
252
+ const READ_FIELDS = new Set(['text', 'desc', 'id', 'idShort']);
253
+ /** ctx keys are interpolated into selectors as {{ctx.NAME}}; keep them boring so a
254
+ * name can never smuggle regex/template metacharacters into a selector. */
255
+ const CTX_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
256
+ /** Validate a single node (used for both compile output and a spliced repair).
257
+ *
258
+ * `depth` is how many control nodes enclose this one. It is the ONLY thing bounding
259
+ * nesting at runtime, and it must agree with PLAN_JSON_SCHEMA's hand-unrolled levels
260
+ * — the schema stops a compliant model, this stops everything else (a repair, a
261
+ * hand-edited plan, a poisoned cache entry). */
262
+ function validateNode(node, where, depth = 0) {
146
263
  if (!node || typeof node !== 'object')
147
264
  throw new InvalidPlanError(`${where}: not an object`);
148
265
  const n = node;
266
+ /** Validate one control body. `allowEmpty` is true only for `when`'s `else`. */
267
+ const validateBody = (raw, label, allowEmpty = false) => {
268
+ if (!Array.isArray(raw))
269
+ throw new InvalidPlanError(`${label}: body must be an array`);
270
+ if (raw.length === 0 && !allowEmpty) {
271
+ // Silence must never read as success — same rule parsePlan applies to the plan.
272
+ throw new InvalidPlanError(`${label}: body is empty (a control body must do something; use when's "else": [] to mean "do nothing")`);
273
+ }
274
+ return raw.map((b, i) => validateNode(b, `${label}[${i}]`, depth + 1));
275
+ };
276
+ /** `if-present` and `while-present` are allowed one level deeper than the branching
277
+ * nodes: they are the conditional and the index-walk an innermost branch body needs,
278
+ * and because their own bodies are leaves they cannot open a further level. Blocking
279
+ * them here does not make the model simplify — it makes it emit an unconditional
280
+ * approximation instead. See LEAF_ONLY_ITEMS. */
281
+ const guardDepth = (extra = 0) => {
282
+ const limit = exports.MAX_CONTROL_DEPTH + extra;
283
+ if (depth >= limit) {
284
+ throw new InvalidPlanError(`${where}: control nodes may nest ${limit} deep at most (got ${depth + 1}) — flatten this or lift it to an outer step`);
285
+ }
286
+ };
287
+ const selectorOf = () => {
288
+ if (typeof n.selector !== 'string' || !n.selector)
289
+ throw new InvalidPlanError(`${where}: ${n.type} needs a selector`);
290
+ return n.selector;
291
+ };
292
+ // OpenAI/codex strict mode forces every property into `required` and nullifies the
293
+ // optional ones, so an absent `else`/`bind` arrives as null rather than missing.
294
+ const optional = (v, pick) => v === undefined || v === null ? undefined : pick(v);
149
295
  switch (n.type) {
150
296
  case 'command': {
151
297
  if (typeof n.command !== 'string' || !exports.KNOWN_COMMANDS.has(n.command)) {
@@ -158,22 +304,50 @@ function validateNode(node, where) {
158
304
  throw new InvalidPlanError(`${where}: flags must be {name,value}[]`);
159
305
  return { type: 'command', command: n.command, positionals: n.positionals, flags: n.flags };
160
306
  }
161
- case 'if-present':
162
- case 'repeat': {
163
- if (typeof n.selector !== 'string' || !n.selector)
164
- throw new InvalidPlanError(`${where}: ${n.type} needs a selector`);
165
- if (!Array.isArray(n.body))
166
- throw new InvalidPlanError(`${where}: ${n.type} body must be an array`);
167
- const body = n.body.map((b, i) => {
168
- const leaf = validateNode(b, `${where}.body[${i}]`);
169
- if (leaf.type !== 'command')
170
- throw new InvalidPlanError(`${where}.body[${i}]: only leaf commands allowed (no nesting in v1)`);
171
- return leaf;
307
+ case 'read': {
308
+ const selector = selectorOf();
309
+ if (typeof n.field !== 'string' || !READ_FIELDS.has(n.field)) {
310
+ throw new InvalidPlanError(`${where}: read needs field one of ${[...READ_FIELDS].join('|')} (got ${JSON.stringify(n.field)})`);
311
+ }
312
+ if (typeof n.into !== 'string' || !CTX_NAME_RE.test(n.into)) {
313
+ throw new InvalidPlanError(`${where}: read "into" must be a plain identifier (got ${JSON.stringify(n.into)})`);
314
+ }
315
+ return { type: 'read', selector, field: n.field, into: n.into };
316
+ }
317
+ case 'if-present': {
318
+ guardDepth(1);
319
+ return { type: 'if-present', selector: selectorOf(), body: validateBody(n.body, `${where}.body`) };
320
+ }
321
+ case 'when': {
322
+ guardDepth();
323
+ if (!Array.isArray(n.branches) || n.branches.length === 0) {
324
+ throw new InvalidPlanError(`${where}: when needs at least one branch`);
325
+ }
326
+ const branches = n.branches.map((b, i) => {
327
+ const br = b;
328
+ if (!br || typeof br !== 'object' || typeof br.selector !== 'string' || !br.selector) {
329
+ throw new InvalidPlanError(`${where}.branches[${i}]: needs a selector`);
330
+ }
331
+ return { selector: br.selector, body: validateBody(br.body, `${where}.branches[${i}].body`) };
172
332
  });
173
- if (n.type === 'if-present')
174
- return { type: 'if-present', selector: n.selector, body };
333
+ const els = optional(n.else, (v) => validateBody(v, `${where}.else`, true));
334
+ return { type: 'when', branches, ...(els ? { else: els } : {}) };
335
+ }
336
+ case 'repeat':
337
+ case 'while-present': {
338
+ guardDepth(n.type === 'while-present' ? 1 : 0);
339
+ const selector = selectorOf();
175
340
  const cap = typeof n.cap === 'number' && n.cap > 0 ? Math.floor(n.cap) : exports.DEFAULT_LOOP_CAP;
176
- return { type: 'repeat', selector: n.selector, cap, body };
341
+ const body = validateBody(n.body, `${where}.body`);
342
+ if (n.type === 'repeat')
343
+ return { type: 'repeat', selector, cap, body };
344
+ const bind = optional(n.bind, (v) => {
345
+ if (typeof v !== 'string' || !CTX_NAME_RE.test(v)) {
346
+ throw new InvalidPlanError(`${where}: while-present "bind" must be a plain identifier (got ${JSON.stringify(v)})`);
347
+ }
348
+ return v;
349
+ });
350
+ return { type: 'while-present', selector, cap, body, ...(bind ? { bind } : {}) };
177
351
  }
178
352
  default:
179
353
  throw new InvalidPlanError(`${where}: unknown node type ${JSON.stringify(n.type)}`);
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ // Compile-fidelity lint: does the plan the model produced still say what the prose said?
3
+ //
4
+ // The model is a compiler, and this is the compiler's own sanity check. It exists because
5
+ // compilation is NONDETERMINISTIC in a way that is invisible until a run fails, and two
6
+ // failure modes showed up repeatedly against a real suite:
7
+ //
8
+ // 1. An explicit directive silently vanishes. The same prose ("Launch the app WITH ITS
9
+ // DATA CLEARED so it starts logged-out") compiled to `launch <pkg> --clear` on one
10
+ // run and plain `launch <pkg>` on the next. The plan looked fine and the test failed
11
+ // several steps later, on a screen that only appears when you are already logged in.
12
+ // 2. Conditional prose compiles to an unconditional step. "IF a Continue button appears,
13
+ // tap it" becomes `wait` + `tap`, which FAILS on every run where the app skips it.
14
+ //
15
+ // Both are cheap to detect and cheap to fix: hand the finding back to the model and let it
16
+ // compile once more. That is far better than the alternative, which is a plan that is
17
+ // quietly wrong and burns a device run to say so.
18
+ //
19
+ // Deliberately CONSERVATIVE — a false positive costs a wasted recompile, so every rule
20
+ // requires a fairly unambiguous phrase and checks for a specific structural counterpart.
21
+ // It never edits the plan; the model gets the feedback and stays the author.
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.lintPlan = lintPlan;
24
+ const ir_1 = require("./ir");
25
+ /** Walk every node in the plan, including control-node bodies. */
26
+ function* walk(nodes) {
27
+ for (const n of nodes) {
28
+ yield n;
29
+ if ((0, ir_1.isControlNode)(n))
30
+ for (const body of (0, ir_1.bodiesOf)(n))
31
+ yield* walk(body);
32
+ }
33
+ }
34
+ const hasControlNode = (plan) => {
35
+ for (const n of walk(plan.steps))
36
+ if ((0, ir_1.isControlNode)(n))
37
+ return true;
38
+ return false;
39
+ };
40
+ const hasLeafWithFlag = (plan, command, flag) => {
41
+ for (const n of walk(plan.steps)) {
42
+ if (n.type === 'command' && n.command === command && n.flags.some((f) => f.name === flag))
43
+ return true;
44
+ }
45
+ return false;
46
+ };
47
+ /** Phrases that state the app must start from a clean slate. Kept tight on purpose:
48
+ * "clear" alone is far too common (clearing a text field, a clear button). */
49
+ const FRESH_START_RE = /\b(data cleared|cleared data|clear its data|with its data cleared|freshly installed|logged[- ]out|logged out|clean state|from scratch)\b/i;
50
+ /** Phrases that make a step conditional. These are the ones that produced an
51
+ * unconditional step in practice; vaguer hedges ("should", "normally") are excluded. */
52
+ const CONDITIONAL_RE = /\b(if (?:a|an|the|any)\b|may appear|might appear|sometimes|optionally|if present|if shown|if it appears|dismiss any)\b/i;
53
+ /**
54
+ * Check a compiled plan against the prose it came from.
55
+ *
56
+ * @param nl the natural-language test, verbatim
57
+ * @param plan the plan the model just produced
58
+ * @returns findings; empty means the plan is consistent with the prose
59
+ */
60
+ function lintPlan(nl, plan) {
61
+ const findings = [];
62
+ if (FRESH_START_RE.test(nl) && !hasLeafWithFlag(plan, 'launch', 'clear')) {
63
+ findings.push({
64
+ message: 'The test says the app must start from cleared data / logged out, but no step launches it with --clear. ' +
65
+ 'Emit `launch <package> --clear` so the run does not inherit the previous run\'s session.',
66
+ });
67
+ }
68
+ if (CONDITIONAL_RE.test(nl) && !hasControlNode(plan)) {
69
+ findings.push({
70
+ message: 'The test describes something that may or may not appear ("if ...", "may appear", "dismiss any ..."), ' +
71
+ 'but the plan has no if-present/when node — every step is unconditional. An unconditional step for ' +
72
+ 'optional UI fails on every run where that UI does not show. Put the optional part behind if-present ' +
73
+ '(skip when absent), or behind when (when the screen is one of several known kinds).',
74
+ });
75
+ }
76
+ return findings;
77
+ }
@@ -116,6 +116,12 @@ class OpenAiProvider {
116
116
  JSON.stringify(input.seed, null, 2));
117
117
  }
118
118
  parts.push('NATURAL-LANGUAGE TEST:\n' + input.nl);
119
+ if (input.retryFeedback) {
120
+ // Last, so it is the freshest thing in context: a previous compile of this same
121
+ // test lost something the prose stated. Naming it beats hoping the retry differs.
122
+ parts.push('YOUR PREVIOUS ATTEMPT AT THIS TEST WAS REJECTED. Fix this and emit the whole plan again:\n' +
123
+ input.retryFeedback);
124
+ }
119
125
  // Generous completion budget: on reasoning models the plan JSON shares this ceiling
120
126
  // with reasoning tokens, so leave headroom (a 'length' finish is surfaced as an error).
121
127
  const { json, usage } = await this.call(grammar_1.GRAMMAR, parts.join('\n\n'), ir_1.PLAN_JSON_SCHEMA, 16384);
package/dist/cli.js CHANGED
@@ -41,6 +41,7 @@ exports.parseDuration = parseDuration;
41
41
  exports.waitWindowMs = waitWindowMs;
42
42
  exports.waitNote = waitNote;
43
43
  exports.formatDeviceTable = formatDeviceTable;
44
+ exports.guardSettleMs = guardSettleMs;
44
45
  exports.confineToCwd = confineToCwd;
45
46
  exports.assertSafeAppId = assertSafeAppId;
46
47
  exports.chooseLogOpts = chooseLogOpts;
@@ -61,6 +62,7 @@ const output_1 = require("./output");
61
62
  const run_1 = require("./run");
62
63
  const image_1 = require("./image");
63
64
  const engine_1 = require("./agent/engine");
65
+ const lint_1 = require("./agent/lint");
64
66
  const claude_1 = require("./agent/claude");
65
67
  const openai_1 = require("./agent/openai");
66
68
  const cli_provider_1 = require("./agent/cli-provider");
@@ -93,7 +95,11 @@ function buildSelector(raw, flags) {
93
95
  if (!raw) {
94
96
  throw new errors_1.CliError('Missing selector. e.g. `@login_button`, `text:Login`, `desc:Submit`.', 2);
95
97
  }
96
- return (0, selector_1.parseSelector)(raw, { contains: (0, args_1.flagBool)(flags, 'contains'), index: (0, args_1.flagNum)(flags, 'index') });
98
+ return (0, selector_1.parseSelector)(raw, {
99
+ contains: (0, args_1.flagBool)(flags, 'contains'),
100
+ index: (0, args_1.flagNum)(flags, 'index'),
101
+ enabled: (0, args_1.flagBool)(flags, 'enabled'),
102
+ });
97
103
  }
98
104
  function parsePoint(s) {
99
105
  const m = /^(-?\d+)\s*,\s*(-?\d+)$/.exec(s.trim());
@@ -510,6 +516,19 @@ function shotMaxEdge() {
510
516
  }
511
517
  return DEFAULT_SHOT_MAX_EDGE;
512
518
  }
519
+ /** How long a `vk ai` `if-present` guard waits for its selector before deciding the
520
+ * optional UI is absent. `VERIKUN_GUARD_SETTLE_MS` overrides the engine default so the
521
+ * window can be tuned against a real app without a rebuild (0 restores the old
522
+ * single-shot probe). Exported for unit tests. */
523
+ function guardSettleMs() {
524
+ const env = process.env.VERIKUN_GUARD_SETTLE_MS;
525
+ if (env !== undefined && env !== '') {
526
+ const n = Number(env);
527
+ if (Number.isFinite(n) && n >= 0)
528
+ return n;
529
+ }
530
+ return engine_1.DEFAULT_GUARD_SETTLE_MS;
531
+ }
513
532
  /** Resolve an `--out` path and confine it to the working directory. A host-side write
514
533
  * (a screenshot PNG, captured device logs) must never land outside cwd via a `..`
515
534
  * traversal or an absolute path — including when driven by `vk ai` model output, whose
@@ -654,10 +673,20 @@ function evalAssert(els, sel, flags) {
654
673
  }
655
674
  else if (wantText !== undefined) {
656
675
  const contains = (0, args_1.flagBool)(flags, 'contains');
657
- pass = matches.some((m) => contains
658
- ? m.text.toLowerCase().includes(wantText.toLowerCase())
659
- : m.text.trim().toLowerCase() === wantText.trim().toLowerCase());
660
- reason = pass ? 'text matched' : `found, but text != ${JSON.stringify(wantText)} (got ${JSON.stringify(matches.map((m) => m.text))})`;
676
+ // Compare against text OR content-desc, mirroring what the selector layer already
677
+ // does (a `text:` selector falls back to desc when nothing matches on text). Without
678
+ // the fallback this could never pass on a Flutter app, which maps Semantics(label:)
679
+ // to contentDescription and leaves `text` empty on every node while `text:Foo` as a
680
+ // SELECTOR resolved fine, so the contradiction was silent and read as a real failure.
681
+ // Strictly widening: it only turns false negatives into passes.
682
+ const content = (m) => [m.text, m.desc].filter((v) => v !== '');
683
+ const hit = (v) => contains
684
+ ? v.toLowerCase().includes(wantText.toLowerCase())
685
+ : v.trim().toLowerCase() === wantText.trim().toLowerCase();
686
+ pass = matches.some((m) => content(m).some(hit));
687
+ reason = pass
688
+ ? 'text matched'
689
+ : `found, but text != ${JSON.stringify(wantText)} (got ${JSON.stringify(matches.flatMap(content))})`;
661
690
  }
662
691
  else {
663
692
  pass = true;
@@ -1039,8 +1068,38 @@ async function obtainPlan(key, file, opts, cost, provider) {
1039
1068
  if (seed)
1040
1069
  (0, output_1.err)(`[ai] no exact cache; seeding from a prior plan (build ${seed.build ?? 'unknown'})`);
1041
1070
  (0, output_1.err)(`[ai] compiling '${file}' with ${opts.model} (effort ${opts.effort ?? 'default'})…`);
1042
- const compiled = await provider.compile({ nl: key.nl, pkg: key.pkg, platform: key.platform, seed: seed?.plan });
1071
+ let compiled = await provider.compile({ nl: key.nl, pkg: key.pkg, platform: key.platform, seed: seed?.plan });
1043
1072
  cost.add(compiled.usage, 'compile');
1073
+ // Compilation is nondeterministic: the same prose has produced `launch --clear` on one
1074
+ // run and plain `launch` on the next, silently dropping something the test stated. One
1075
+ // guided retry is much cheaper than discovering it as a device-run failure several steps
1076
+ // later. Budget is re-checked HERE because the first attempt has already been billed.
1077
+ const findings = (0, lint_1.lintPlan)(key.nl, compiled.plan);
1078
+ if (findings.length > 0) {
1079
+ const feedback = findings.map((f) => `- ${f.message}`).join('\n');
1080
+ (0, output_1.err)(`[ai] compiled plan does not match the test — recompiling once:\n${feedback}`);
1081
+ if (cost.exceeded()) {
1082
+ (0, output_1.err)('[ai] cost ceiling reached — keeping the first plan rather than paying for a retry');
1083
+ }
1084
+ else {
1085
+ const retry = await provider.compile({
1086
+ nl: key.nl,
1087
+ pkg: key.pkg,
1088
+ platform: key.platform,
1089
+ seed: seed?.plan,
1090
+ retryFeedback: feedback,
1091
+ });
1092
+ cost.add(retry.usage, 'compile');
1093
+ const still = (0, lint_1.lintPlan)(key.nl, retry.plan);
1094
+ // Keep the retry either way: it was compiled with strictly more information. If it
1095
+ // still trips the lint, say so rather than pretending the plan is clean.
1096
+ if (still.length > 0)
1097
+ (0, output_1.err)(`[ai] the retry still does not match the test — running it anyway:\n${still.map((f) => `- ${f.message}`).join('\n')}`);
1098
+ else
1099
+ (0, output_1.err)('[ai] recompile matches the test');
1100
+ compiled = retry;
1101
+ }
1102
+ }
1044
1103
  (0, output_1.err)(`[ai] compiled ${compiled.plan.steps.length} top-level step(s) · ${cost.summaryLine()}`);
1045
1104
  try {
1046
1105
  (0, cache_1.writePlan)(key, compiled.plan);
@@ -1132,7 +1191,7 @@ async function runAiTest(file, opts, backend, platform, device) {
1132
1191
  const sealed = run_1.Recorder.archive();
1133
1192
  (0, output_1.err)(`[ai] archived the active run ('${existing.name}', ${existing.steps.length} step(s)) → ${sealed.dir}`);
1134
1193
  }
1135
- run_1.Recorder.start(`ai: ${(0, node_path_1.basename)(file)}`, platform, device, true);
1194
+ const started = run_1.Recorder.start(`ai: ${(0, node_path_1.basename)(file)}`, platform, device, true);
1136
1195
  // Suppress per-step `out()` so stdout stays the one final result; progress -> stderr.
1137
1196
  const prevQuiet = (0, output_1.setOutputQuiet)(true);
1138
1197
  let result;
@@ -1145,6 +1204,8 @@ async function runAiTest(file, opts, backend, platform, device) {
1145
1204
  log: (m) => (0, output_1.err)(m),
1146
1205
  markHealed: (m) => run_1.Recorder.markLastStepHealed(m),
1147
1206
  maxRepairs: 3,
1207
+ guardSettleMs: guardSettleMs(),
1208
+ runId: started.id,
1148
1209
  deadline,
1149
1210
  });
1150
1211
  }
@@ -1649,6 +1710,8 @@ AUTO-WAIT (selector lookups retry until they resolve)
1649
1710
  up to 5s when a lookup misses, so a settling UI needs no explicit \`wait\`.
1650
1711
  --wait <dur> override the window: 8s, 800ms, or bare ms (3000); 0 disables
1651
1712
  --no-wait fail fast on the first miss (same as --wait 0)
1713
+ A \`vk ai\` plan's if-present guard has its own smaller settle window (>=2 looks at
1714
+ the screen); VERIKUN_GUARD_SETTLE_MS tunes it, 0 = old single-shot probe.
1652
1715
  Ambiguity is never waited on (the elements are already there). The \`wait\`
1653
1716
  command stays for explicit polling, including --gone, with --timeout/--interval.
1654
1717
 
@@ -21,8 +21,20 @@ function parseSelector(raw, opts = {}) {
21
21
  }
22
22
  if (!value)
23
23
  throw new errors_1.CliError(`Empty selector value in '${raw}'`, 2);
24
- return { kind, value, contains: !!opts.contains, index: opts.index, raw };
24
+ return { kind, value, contains: !!opts.contains, index: opts.index, enabled: opts.enabled, raw };
25
25
  }
26
+ /** Is this element actionable right now?
27
+ *
28
+ * Just `enabled` — the a11y attribute, matching what Maestro's `enabled: true` means.
29
+ * An earlier version also required `clickable || longClickable`, reasoning that a
30
+ * disabled Button might report clickable=false. That was speculation and it was wrong in
31
+ * the direction that hurts: plenty of legitimate tap targets are CONTAINERS whose own
32
+ * clickable flag is false (the tappable child is inside), so the extra conjunct filtered
33
+ * out real elements and turned `--enabled` into a source of phantom "not found" misses —
34
+ * which then burned model repairs. Prefer under-filtering here: a tap on a present-but-
35
+ * odd element fails loudly, whereas a selector that silently matches nothing looks like
36
+ * app drift and sends the heal loop chasing it. */
37
+ const isActionable = (e) => e.enabled;
26
38
  const norm = (s) => s.trim().toLowerCase();
27
39
  const strip = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
28
40
  /** The ordered match tiers for a selector. First tier with a hit wins. */
@@ -77,6 +89,10 @@ function tiers(sel) {
77
89
  return sel.contains ? list.filter((t) => t.tier !== 'exact') : list;
78
90
  }
79
91
  function matchElements(elements, sel) {
92
+ // Applied BEFORE the tier ladder, not after: filtering the candidate pool keeps a
93
+ // disabled exact match from shadowing an enabled partial one.
94
+ if (sel.enabled)
95
+ elements = elements.filter(isActionable);
80
96
  for (const { tier, test } of tiers(sel)) {
81
97
  const found = elements.filter(test);
82
98
  if (found.length === 0)
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
5
5
  // (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
6
- exports.VERSION = '0.10.0';
6
+ exports.VERSION = '0.11.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
5
5
  "keywords": [
6
6
  "android",