verikun 0.4.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Dikman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,435 @@
1
+ # verikun
2
+
3
+ Drive a connected Android device/emulator (and, later, iOS simulators) the way
4
+ Puppeteer drives a browser — **tap, type, swipe, screenshot**, and most
5
+ importantly **inspect the UI hierarchy by semantic identifiers** so an AI agent
6
+ can act and then *verify* what happened.
7
+
8
+ It is a thin, deterministic, zero-runtime-dependency wrapper over `adb` (and
9
+ `xcrun simctl` for iOS) that turns the raw `uiautomator` dump into a compact,
10
+ token-efficient list of meaningful elements addressable by `resource-id`,
11
+ visible text, accessibility label, or class.
12
+
13
+ ```
14
+ $ vk ui
15
+ [0] TextView "Welcome back" (540,360)
16
+ [1] EditText @email_input (540,720) focused
17
+ [2] EditText @password_input (540,860) pwd
18
+ [3] Button "Sign in" @sign_in_btn (540,1020) tap
19
+ [4] TextView "Forgot password?" @forgot (540,1140) tap
20
+
21
+ $ vk tap @sign_in_btn
22
+ tapped [3] Button "Sign in" @sign_in_btn (540,1020) tap
23
+ ```
24
+
25
+ ## Install
26
+
27
+ Requires Node ≥ 18 and the Android platform-tools (`adb`) on your `PATH`.
28
+
29
+ ```sh
30
+ npm install -g verikun # installs the `verikun` and `vk` commands globally
31
+ ```
32
+
33
+ Then run `vk doctor` to check your setup. Re-run the same command to upgrade later.
34
+
35
+ ### Install as a Claude Code plugin
36
+
37
+ This repo doubles as a Claude Code [plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces). Installing the plugin gives Claude the `verikun` skill — the agent-facing usage guide — so it knows how to drive devices.
38
+
39
+ ```sh
40
+ /plugin marketplace add ddikman/verikun # add this repo as a marketplace
41
+ /plugin install verikun@verikun # install the plugin (ships the skill)
42
+ ```
43
+
44
+ The plugin ships the **skill**; the `vk` **CLI** is a separate Node package — install it with `npm install -g verikun` (see [Install](#install) above) so `vk` lands on your `PATH`. The compiled `dist/` is gitignored, so it isn't bundled into the installed plugin.
45
+
46
+ ## Quick start
47
+
48
+ ```sh
49
+ vk doctor --fix # check adb/device; disable animations for stable dumps
50
+ vk devices # list attached devices
51
+ vk ui # semantic snapshot of the current screen
52
+ vk tap @login_button # tap by resource-id
53
+ vk text @email "me@example.com" # focus a field and type
54
+ vk wait text:"Welcome" --timeout 8000
55
+ vk screenshot # -> ./.verikun/screen.png
56
+ ```
57
+
58
+ ## Commands
59
+
60
+ ### Inspect
61
+ | Command | Description |
62
+ |---|---|
63
+ | `ui [--all] [--tree] [--json]` | Compact list of interactive/labeled elements. `--all` keeps layout nodes; `--tree` indents by nesting; `--json` is structured. |
64
+ | `find <selector> [--json] [--wait <dur>\|--no-wait]` | Print elements matching a selector. [Auto-waits](#auto-wait) up to 5s; exit 1 if still none. |
65
+ | `assert <selector> [--text S] [--gone] [--contains] [--wait <dur>\|--no-wait]` | Assertion for tests. [Auto-waits](#auto-wait) until it passes. Exit 0 pass / 1 fail. |
66
+ | `wait <selector> [--timeout ms] [--interval ms] [--gone]` | Poll the hierarchy until match (or absence). Exit 1 on timeout. (Explicit polling — distinct from the `--wait` flag.) |
67
+ | `current` | Best-effort foreground app/activity. |
68
+ | `log [package] [-n lines] [--since t] [--out path] [--full] [--json]` | Recent device logs (Android `logcat` snapshot — Android only). Prints to stdout; `--out` saves to a file, `--json` is structured. **Inside a run, defaults to logs since the run started** (so pre-session logs are excluded); `-n` caps to the last N lines instead, `--since <MM-DD HH:MM:SS.mmm>` sets an explicit start, `--full` dumps everything. A `package` scopes logs to that app's process, **falling back to system-wide when the app isn't running** (e.g. it crashed) so the crash trace is still captured. Unlike other inspection commands it **is recorded**, so its output lands in the archived report. ⚠️ logs are raw device output and may contain anything the app logged, including secrets. |
69
+
70
+ ### Act
71
+ | Command | Description |
72
+ |---|---|
73
+ | `tap <selector\|index>` / `tap --at x,y` | Tap an element (or raw coordinates). Selector taps [auto-wait](#auto-wait); a bare integer taps `[index]` from the latest `ui` (never waits). |
74
+ | `text <selector> <text…> [--clear] [--enter]` | Focus a field and type. `--clear` deletes existing text first. The field lookup [auto-waits](#auto-wait). Punctuation/symbols (e.g. emails like `bob@mail.com`) are escaped for the device shell and type verbatim — quote the value in your shell, or use [`batch`](#batch)/stdin (no host shell), so the caller's shell can't drop the `@`. |
75
+ | `type <text…> [--enter]` | Type into the currently focused field. |
76
+ | `key <name\|code>` / `back` / `home` / `enter` | Send a key event (named keys or a raw Android keycode). |
77
+ | `swipe <up\|down\|left\|right> [--on <selector>] [--distance f] [--duration ms]` | Directional swipe over the screen (or within an element via `--on`, whose lookup [auto-waits](#auto-wait)). `--distance` is a fraction of the region (default 0.6). |
78
+ | `swipe --from x,y --to x,y [--duration ms]` | Explicit swipe between two points. |
79
+ | `screenshot [--out path] [--more] [--max px] [--full] [--json]` | Save a PNG (default `./.verikun/screen.png`); prints the path. [Downscaled](#screenshots) to a 700px longest edge by default to save tokens; `--more` bumps detail, `--max px` sets an exact cap, `--full` keeps the original. |
80
+ | `launch <app> [--clear] [--no-restart]` / `stop <app>` | App lifecycle by package id (Android) / bundle id (iOS). `launch` **restarts by default** — it force-stops the app first (a no-op if it isn't running) so a rerun starts fresh instead of resurfacing a still-running instance's current screen; `--no-restart` skips that. `--clear` also wipes the app's local data (login/session, prefs, cache) for a fresh-install start. |
81
+ | `clear <app>` | Wipe the app's locally stored data — login/session, preferences, caches — resetting it to a just-installed state (Android `pm clear`, which also force-stops the app). iOS not supported yet. |
82
+
83
+ ### Batch
84
+ | Command | Description |
85
+ |---|---|
86
+ | `batch [--file <path>] [--quiet]` | Run newline-separated commands — from `--file`, else piped **stdin** — each exactly as its own command (same auto-wait, recording, exit codes). Streams each result to stdout and **stops on the first non-zero exit**, propagating that code. Blank lines and `#` comments are skipped; `--quiet` hides per-line progress. See [Batch](#batch). |
87
+
88
+ ### AI
89
+ | Command | Description |
90
+ |---|---|
91
+ | `ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--cost-override in/out] [--effort e] [--package pkg] [--app-build id] [--show-plan] [--recompile] [--json]` | Run a plain-English test: compile it to a deterministic plan once, replay it model-free, and self-heal failures via the model. Needs `ANTHROPIC_API_KEY`. See [AI](#ai--natural-language-tests). |
92
+
93
+ ### Environment
94
+ | Command | Description |
95
+ |---|---|
96
+ | `devices [--json]` | List attached devices/simulators. |
97
+ | `doctor [--fix]` | Diagnose adb + device; `--fix` sets the three animation scales to 0 for deterministic UI. |
98
+
99
+ ### Test runs
100
+ | Command | Description |
101
+ |---|---|
102
+ | `run start [name] [--force]` | Begin a named run. One auto-starts on the first action if you don't. |
103
+ | `run status` | Show the active run and its recorded steps. |
104
+ | `run archive [name]` | Write JUnit + HTML report to `./.verikun/runs/<id>/`; exits non-zero if any step failed. |
105
+ | `run clear` | Discard the active run without a report. |
106
+
107
+ ## Test runs & reports
108
+
109
+ Actions are recorded into a **test run** — one auto-starts on the first action
110
+ (set `VERIKUN_NO_RUN=1` to disable). Every command becomes a step with its
111
+ timing, the selector + identifier it resolved through, and pass/fail; a failing
112
+ step also captures a screenshot **and** the UI hierarchy of the page. When a
113
+ step fails you can additionally run `vk log <package>` to pull the device logs —
114
+ that step records the logs **into the same run**, so the crash trace shows up in
115
+ the report alongside the failure.
116
+
117
+ `vk run archive` finalizes the run into `./.verikun/runs/<id>/`:
118
+
119
+ - **`report.xml`** — JUnit: one `<testcase>` per step with timings, `<failure>`
120
+ for failed assertions, `<error>` for environment errors, and the resolved
121
+ identifier in `<system-out>`. Drops straight into CI.
122
+ - **`report.html`** — a self-contained report: every step, the identifiers used,
123
+ any screenshots taken, the screenshot + hierarchy of any failed page, and any
124
+ device logs captured via `vk log`.
125
+ - **`run.json`** — the raw recording.
126
+
127
+ `vk run archive` exits non-zero when the run contained failures, so the same
128
+ command both produces the report and gates CI.
129
+
130
+ ### Automatic rollover
131
+
132
+ So an implicit run never silently merges unrelated activity, the active run
133
+ **auto-closes (archives) and a fresh one starts** when the context changes:
134
+
135
+ | Trigger | Applies to | Tune with |
136
+ |---|---|---|
137
+ | Idle too long (default 30 min) | implicit runs only | `VERIKUN_RUN_IDLE_MIN` (minutes; `0` disables) |
138
+ | Different device serial | any run | — |
139
+ | Different session | any run | `VERIKUN_SESSION` (falls back to `TERM_SESSION_ID`) |
140
+
141
+ A run you named with `vk run start` is **sticky to idle** — only a hard context
142
+ change (device or session) rolls it over. Rollover always *archives* the old run
143
+ (never discards it) and prints the reason + destination to stderr. Set
144
+ `VERIKUN_NO_RUN=1` to disable recording entirely.
145
+
146
+ ## Batch
147
+
148
+ Drive a whole flow from a single process instead of one `vk` call per step.
149
+ `vk batch` reads newline-separated commands — from `--file <path>`, or piped on
150
+ **stdin** — and runs each **exactly as if you'd typed it as its own `vk` command**:
151
+ the same [selector auto-wait](#auto-wait), the same
152
+ [test-run recording](#test-runs--reports) (every line is its own step), and the
153
+ same stdout/stderr split and [exit codes](#exit-codes).
154
+
155
+ ```sh
156
+ vk batch --file login.flow # from a file
157
+
158
+ vk batch <<'EOF' # …or piped on stdin
159
+ launch com.example.app
160
+ text @email_input "user@example.com"
161
+ text @password_input "hunter2" --enter
162
+ assert text:"Welcome back" --wait 8s
163
+ EOF
164
+ ```
165
+
166
+ - **Each result streams to stdout** as the command finishes — the same bytes you'd
167
+ get running the line on its own.
168
+ - **It stops at the first command that exits non-zero**, noting where it halted (on
169
+ stderr) and **exiting with that command's code**. A failed `tap`/`assert` means
170
+ the rest of the flow can no longer be trusted, so it breaks rather than press on.
171
+ - **Blank lines and `#` comments** are skipped, so a flow file can be annotated.
172
+ - **Globals on the `batch` call carry into every line** unless the line overrides
173
+ them — `--device`, `--platform` / `--ios` / `--android`, and `--json`. So
174
+ `vk batch --ios --file f` runs the whole flow against the simulator.
175
+ - `--quiet` silences the per-line progress notes on stderr; stdout data is untouched.
176
+
177
+ Because each line records like an individual action, ending a batch with
178
+ `run archive` turns the flow into a JUnit + HTML report in one shot:
179
+
180
+ ```sh
181
+ printf 'launch com.example.app\nassert @home_tab\nrun archive smoke\n' | vk batch
182
+ ```
183
+
184
+ ## AI — natural-language tests
185
+
186
+ `vk ai <file>` runs a test written in plain English. It treats the model as a
187
+ **compiler, not a runtime**: it compiles the prose into a deterministic plan once
188
+ (paying tokens), caches that plan by the test text + app build, then **replays it
189
+ with no model calls on the happy path**. The model is woken only to *repair* a step
190
+ whose selector stops resolving; a green run persists the repaired plan, so the next
191
+ run is free again. That is what keeps a CI suite's steady-state token cost near zero.
192
+ Needs `ANTHROPIC_API_KEY`.
193
+
194
+ ```sh
195
+ # onboarding.md (plain English):
196
+ # Launch com.example.app fresh.
197
+ # If a notifications permission dialog appears, allow it.
198
+ # Tap "Get started", then assert the home tab is visible.
199
+
200
+ vk ai onboarding.md # first run: compile, then run
201
+ vk ai onboarding.md # cached: replays with no model call
202
+ vk ai onboarding.md --show-plan # print the compiled plan, don't run
203
+ vk ai onboarding.md --max-cost-usd 0.50 # tighten the spend cap (default $3)
204
+ vk ai onboarding.md --timeout 5m # tighten the run timeout (default 15m)
205
+ ```
206
+
207
+ The compiled plan supports **conditions** (`if-present`, for optional interstitials
208
+ like permission dialogs) and **bounded loops** (`repeat … until`, e.g. scroll until a
209
+ row appears) — control flow a flat [`batch`](#batch) script can't express. Loops carry
210
+ a hard iteration cap and stop early if the screen stops changing.
211
+
212
+ - **Progress streams to stderr** (so a CI job never goes silent); **stdout is the
213
+ report path** (or a JSON summary with `--json`). The compiled plan is logged to the
214
+ run before it executes, for troubleshooting.
215
+ - **Cost and time are bounded by default.** Each run reports `compile / repairs /
216
+ replay=$0 / est $…` and aborts if the estimate crosses **`--max-cost-usd` (default
217
+ $3)** or the wall-clock passes **`--timeout` (default 15m)** — so a runaway loop or
218
+ repair can't spend or hang without limit. `--cost-override <input/output>` overrides
219
+ the bundled per-1M price table if it drifts.
220
+ - **`--model`** picks the model (`claude-haiku-4-5` · `claude-sonnet-4-6` (default) ·
221
+ `claude-opus-4-8` · `claude-fable-5`); **`--recompile`** ignores the cache.
222
+ - An `ai` run records like any other flow, so it produces the same JUnit + HTML report —
223
+ with the cost line and any **suggested test improvements** (workarounds the model
224
+ applied, which you can fold back into the prose to stabilize the test and cut tokens).
225
+
226
+ ## Selectors
227
+
228
+ ```
229
+ @login shorthand for id:login
230
+ id:login resource-id — matches full id, idShort, or a "/login" suffix
231
+ text:Sign in visible text (exact, case-insensitive, trimmed)
232
+ desc:Submit content-desc / accessibility label
233
+ class:Button simplified type ("Button") or full class ("android.widget.Button")
234
+ "Sign in" a bare string is treated as text: (exact)
235
+ ```
236
+
237
+ Modifiers: `--contains` makes text/desc matches substring-based; `--index N`
238
+ selects the Nth match (0-based) when a selector intentionally matches several.
239
+ If a selector for an action matches more than one element and no `--index` is
240
+ given, the command fails with exit code 2 and lists the candidates — it never
241
+ taps a guess.
242
+
243
+ ## Auto-wait
244
+
245
+ A UI rarely settles the instant the previous action returns. So selector
246
+ commands — `tap`, `text`, `find`, `assert`, and `swipe --on` — **don't fail the
247
+ moment a lookup misses**: they re-capture the hierarchy and retry until it
248
+ resolves or a **5-second** window elapses. A straightforward flow can then skip
249
+ explicit `wait` calls (fewer round-trips, fewer tokens):
250
+
251
+ ```sh
252
+ vk tap @next # waits up to 5s for @next to appear, then taps
253
+ vk assert text:"Done" # waits up to 5s for "Done" to show, then asserts
254
+ vk find @spinner --no-wait # existence probe: answer now, don't wait
255
+ ```
256
+
257
+ | Flag | Effect |
258
+ |---|---|
259
+ | *(none)* | Wait up to **5s** (the default) for the lookup to resolve. |
260
+ | `--wait <dur>` | Override the window: `8s`, `800ms`, or a bare number of ms (`3000`). `0` disables. |
261
+ | `--no-wait` | Fail immediately on the first miss (identical to `--wait 0`). |
262
+ | `--interval <ms>` | Poll cadence while waiting (default 300 ms). |
263
+
264
+ Two deliberate boundaries:
265
+
266
+ - **Ambiguity is never waited on.** If the lookup matches more than one element,
267
+ they're already on screen — the command reports the candidates and exits 2 at
268
+ once (waiting can't disambiguate). Add `--index N` or refine the selector.
269
+ - **`assert --gone` waits for *disappearance*** — it polls until the element is
270
+ absent, so it subsumes "`wait --gone` then assert" in one call.
271
+
272
+ This is distinct from the `wait` **command**, which stays for explicit polling
273
+ (with its own `--timeout`/`--interval` and `--gone`) when you want to block on a
274
+ condition as a step in its own right.
275
+
276
+ ## Global flags
277
+
278
+ | Flag | Meaning |
279
+ |---|---|
280
+ | `-d, --device <serial>` | Target a specific device (or `VERIKUN_DEVICE` / `ANDROID_SERIAL`). |
281
+ | `-p, --platform <android\|ios>` | Platform (default `android`). `--ios` / `--android` are shortcuts. |
282
+ | `-j, --json` | Machine-readable output (also serializes errors). |
283
+ | `--` | End flag parsing, so text/arguments may start with `-`. |
284
+
285
+ ## Exit codes
286
+
287
+ | Code | Meaning |
288
+ |---|---|
289
+ | `0` | success / found / assertion passed |
290
+ | `1` | not found / assertion failed / wait timeout |
291
+ | `2` | usage error or ambiguous selector (caller must refine) |
292
+ | `3` | environment error (adb/simctl missing, no/multiple devices, dump failed) |
293
+
294
+ Data goes to stdout; diagnostics/errors go to stderr.
295
+
296
+ ## Screenshots
297
+
298
+ A device screenshot is large (~1080×2400), and an agent that reads it back as an
299
+ image pays for that pixel area in tokens — yet you seldom need much detail to see
300
+ what's on screen. So `vk screenshot` **downscales by default** to a **700px
301
+ longest edge**: UI text stays legible while the image shrinks ~12× in area (and
302
+ proportionally in tokens).
303
+
304
+ | Flag | Effect |
305
+ |---|---|
306
+ | *(none)* | Cap the longest edge at **700px** (never upscales). |
307
+ | `--more` | Bump to a higher-detail **1400px** cap when 700 reads too coarse. |
308
+ | `--max <px>` | Use an exact cap — e.g. `--max 500` to save even more. |
309
+ | `--full` | Write the original, full-resolution capture. |
310
+ | `VERIKUN_SHOT_MAX_EDGE` | Env var to change the default cap globally. |
311
+
312
+ Precedence: `--full` > `--max <px>` > `--more` > the default.
313
+
314
+ Resizing is a dependency-free, pure-Node PNG resample (box filter). PNGs it can't
315
+ safely resample (palette, 16-bit, interlaced) are written through untouched, so a
316
+ screenshot is never corrupted — only sometimes left full-size (noted on stderr).
317
+ Failure-evidence captures in test-run reports stay full-resolution for debugging.
318
+
319
+ ## How it works
320
+
321
+ ```
322
+ cli.ts ──> drivers/ ──> adb / xcrun (platform I/O)
323
+ │ └─ produces normalized Element[]
324
+ ├─ ui/android-parse.ts uiautomator XML -> Element[]
325
+ ├─ ui/selector.ts @id / text: / desc: / class: matching
326
+ └─ ui/format.ts compact / tree / json rendering
327
+ ```
328
+
329
+ The `Driver` interface (`src/types.ts`) is the seam between platforms. The
330
+ selector, formatting, and command layers operate only on the normalized
331
+ `Element[]`, so they are entirely platform-agnostic.
332
+
333
+ - **Android** (`drivers/adb.ts`): `uiautomator dump` for the hierarchy,
334
+ `screencap -p` for screenshots, `input tap/text/swipe/keyevent`, `wm size`.
335
+ - **iOS** (`drivers/simctl.ts`): `screenshot`, `launch`, `stop` work today via
336
+ `xcrun simctl`. Full interaction and hierarchy inspection are planned via
337
+ WebDriverAgent — see [iOS roadmap](#ios-roadmap) below.
338
+
339
+ Run artifacts (screenshots, dumps) are written under `./.verikun/` (gitignored).
340
+
341
+ ## iOS roadmap
342
+
343
+ Today `vk --ios` supports **screenshots, launch, and stop** via `xcrun simctl`.
344
+ Tapping, swiping, typing, and `vk ui` hierarchy inspection are not yet wired up.
345
+
346
+ The planned backend is **[WebDriverAgent](https://github.com/appium/WebDriverAgent)**
347
+ (WDA) — an open-source XCTest HTTP server maintained by the Appium team. It
348
+ requires no Python and works on both simulators and physical devices. Once WDA
349
+ is running, `vk` will drive it over HTTP and the command layer stays unchanged.
350
+
351
+ **One-time setup (when this lands):**
352
+ 1. Clone WebDriverAgent and open it in Xcode
353
+ 2. Set your Apple developer signing team
354
+ 3. Build & run on the target device or simulator
355
+ 4. `vk --ios tap`, `vk --ios ui`, etc. will work automatically
356
+
357
+ Until then, running any unsupported iOS command prints an explanation and exits
358
+ with code 3.
359
+
360
+ ## Using it from an AI agent
361
+
362
+ See [`.claude/skills/verikun/SKILL.md`](.claude/skills/verikun/SKILL.md) — the
363
+ companion skill that teaches the act → inspect → assert loop, selector grammar,
364
+ exit-code semantics, and gotchas.
365
+
366
+ ### Example: full onboarding walkthrough
367
+
368
+ A Claude agent drove a multi-step Android onboarding flow end-to-end using only
369
+ `vk` commands — no coordinates, no hardcoded waits beyond `sleep 1` on
370
+ transitions.
371
+
372
+ ```sh
373
+ # 1. See where we are
374
+ vk screenshot # read PNG to confirm current screen
375
+
376
+ # 2. Welcome splash
377
+ vk tap @get_started_button_id
378
+
379
+ # 3. Intro/explainer screens — same button each time
380
+ vk tap @tap_to_continue_label_id
381
+ vk tap @tap_to_continue_label_id
382
+
383
+ # 4. Scrollable list — scroll until the item is visible, then tap
384
+ vk swipe up
385
+ vk tap @target_item_id
386
+
387
+ # 5. Transition screen after selection
388
+ vk tap @tap_to_continue_label_id
389
+
390
+ # 6. Option grid — inspect to find the right index, tap it
391
+ vk ui # [4] ImageView desc="My preferred option"
392
+ vk tap 4
393
+
394
+ # 7. Final screen before sign-up
395
+ vk tap @tap_to_continue_label_id
396
+ # → sign-up screen reached; onboarding complete
397
+ ```
398
+
399
+ **Cost:** $0.45 · **Wall time:** ~4 min · **Model:** Claude Sonnet 4.6 with
400
+ prompt-cache hits (1 M cache-read tokens kept cost low on a long conversation).
401
+
402
+ ## Build from source
403
+
404
+ For local development, or to run an unreleased version, build from a clone:
405
+
406
+ ```sh
407
+ git clone https://github.com/ddikman/verikun && cd verikun
408
+ npm install # installs dev deps (typescript, @types/node) and builds dist/ via the prepare hook
409
+ npm link # optional: put `verikun` and `vk` on your PATH
410
+ ```
411
+
412
+ Without `npm link`, run it as `node dist/bin/verikun.js <command>`. See [Development](#development) below for the watch/test loop.
413
+
414
+ ## Development
415
+
416
+ ```sh
417
+ npm run dev # tsc --watch
418
+ npm run build # one-off compile
419
+ npm test # type-check + run the unit suite
420
+ npm run test:watch # re-run the suite on change
421
+ ```
422
+
423
+ Zero runtime dependencies; the only dev dependencies are `typescript` and
424
+ `@types/node`.
425
+
426
+ ### Tests
427
+
428
+ Unit tests cover the platform-agnostic core (selector matching, the
429
+ `uiautomator` XML parser, formatting, the PNG downscaler, report rendering,
430
+ argument/duration parsing, and the device-shell escaper) and run on **Node's
431
+ built-in test runner** (`node:test`) — no test framework is added, in keeping
432
+ with the zero-runtime-dependency rule. They live in `tests/*.test.ts`, compile
433
+ via `tsconfig.test.json` into the gitignored `.test-build/`, and need no device.
434
+ Driver code that talks to `adb`/`xcrun` is verified end-to-end instead, by
435
+ running the built CLI against a real device (`vk doctor`, `vk ui`).
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMPILER_FINGERPRINT = void 0;
4
+ exports.nlHash = nlHash;
5
+ exports.planKey = planKey;
6
+ exports.readPlan = readPlan;
7
+ exports.writePlan = writePlan;
8
+ exports.findSeed = findSeed;
9
+ const node_crypto_1 = require("node:crypto");
10
+ const node_fs_1 = require("node:fs");
11
+ const node_path_1 = require("node:path");
12
+ const output_1 = require("../output");
13
+ const version_1 = require("../version");
14
+ const ir_1 = require("./ir");
15
+ const grammar_1 = require("./grammar");
16
+ const sha256 = (s) => (0, node_crypto_1.createHash)('sha256').update(s).digest('hex');
17
+ /**
18
+ * Fingerprint of the COMPILER that produced a plan: the verikun version PLUS the exact
19
+ * grammar/repair prompts handed to the model. A cached plan is replayed ONLY when this
20
+ * matches the running build — so updating verikun (a version bump, OR any change to the
21
+ * grammar/repair instructions) invalidates stale plans and forces a recompile against
22
+ * the current compiler, instead of silently replaying a plan the old one produced. The
23
+ * version alone wouldn't catch unreleased grammar edits (same `0.3.0`); folding the
24
+ * grammar text in does.
25
+ */
26
+ exports.COMPILER_FINGERPRINT = sha256([version_1.VERSION, grammar_1.GRAMMAR, grammar_1.REPAIR_GRAMMAR].join(String.fromCharCode(0))).slice(0, 16);
27
+ function plansDir() {
28
+ return (0, node_path_1.join)((0, output_1.artifactDir)(), 'plans');
29
+ }
30
+ /** Hash of the NL text alone — the seed-matching identity, build-independent. */
31
+ function nlHash(nl) {
32
+ return sha256(nl);
33
+ }
34
+ /** The full cache key (filename stem): NL + package + build + platform. A new build
35
+ * changes this, so its plan is a distinct entry (no blind stale replay). */
36
+ function planKey(input) {
37
+ // NUL-joined: a separator that can't occur in any component, so distinct inputs never
38
+ // collide. We build the NUL with fromCharCode(0), not a backslash-zero literal, which
39
+ // some tools write as a real NUL byte (which makes the whole file read as "binary").
40
+ return sha256([input.nl, input.pkg ?? '', input.build ?? '', input.platform].join(String.fromCharCode(0))).slice(0, 32);
41
+ }
42
+ function entryPath(key) {
43
+ return (0, node_path_1.join)(plansDir(), `${key}.json`);
44
+ }
45
+ /** Read the cached plan for this exact key. Returns null on miss OR a corrupt /
46
+ * unparseable file (treated as a miss so a poisoned file forces one recompile,
47
+ * not a permanent failure). */
48
+ function readPlan(input) {
49
+ const p = entryPath(planKey(input));
50
+ if (!(0, node_fs_1.existsSync)(p))
51
+ return null;
52
+ try {
53
+ const entry = JSON.parse((0, node_fs_1.readFileSync)(p, 'utf8'));
54
+ // A plan compiled by a DIFFERENT verikun/grammar must not be replayed — treat it as
55
+ // a miss so it recompiles against the current compiler. (findSeed deliberately does
56
+ // NOT apply this gate: an older plan is still a fine starting point to adapt from.)
57
+ if (entry.compilerFingerprint !== exports.COMPILER_FINGERPRINT)
58
+ return null;
59
+ // Re-validate the plan shape — a hand-edited or partially-written file that
60
+ // parses as JSON but isn't a valid Plan is still a miss.
61
+ entry.plan = (0, ir_1.parsePlan)(entry.plan);
62
+ return entry;
63
+ }
64
+ catch (e) {
65
+ // A cache file that exists but won't parse/validate is corrupt — surface it (then
66
+ // recompile) rather than swallow it silently.
67
+ (0, output_1.err)(`[ai] ignoring unreadable plan cache ${p} (${e.message}) — recompiling`);
68
+ return null;
69
+ }
70
+ }
71
+ /** Write a plan to the cache atomically (temp + rename). Called right after a compile
72
+ * (caching the clean plan, so an unchanged test never recompiles) and again after a
73
+ * fully-green run (caching the healed plan). A half-healed plan from a failed run is
74
+ * never persisted — the clean compile stays cached. */
75
+ function writePlan(input, plan) {
76
+ const dir = plansDir();
77
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
78
+ const entry = {
79
+ nlHash: nlHash(input.nl),
80
+ pkg: input.pkg,
81
+ build: input.build,
82
+ platform: input.platform,
83
+ verikunVersion: version_1.VERSION,
84
+ compilerFingerprint: exports.COMPILER_FINGERPRINT,
85
+ savedAt: new Date().toISOString(),
86
+ plan,
87
+ };
88
+ const target = entryPath(planKey(input));
89
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
90
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(entry, null, 2));
91
+ (0, node_fs_1.renameSync)(tmp, target);
92
+ return entry;
93
+ }
94
+ /**
95
+ * Find a plan to seed a new build from: the most recently saved cached plan with
96
+ * the same NL text and package but a DIFFERENT (or unknown) build. Returns null if
97
+ * there is no prior plan to adapt. Tolerant — unreadable entries are skipped.
98
+ */
99
+ function findSeed(input) {
100
+ const dir = plansDir();
101
+ if (!(0, node_fs_1.existsSync)(dir))
102
+ return null;
103
+ const wantNl = nlHash(input.nl);
104
+ const exactKey = planKey(input);
105
+ let best = null;
106
+ for (const file of (0, node_fs_1.readdirSync)(dir)) {
107
+ if (!file.endsWith('.json') || file.startsWith('.') || `${file}` === `${exactKey}.json`)
108
+ continue;
109
+ try {
110
+ const entry = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, file), 'utf8'));
111
+ if (entry.nlHash !== wantNl)
112
+ continue;
113
+ if ((entry.pkg ?? '') !== (input.pkg ?? ''))
114
+ continue;
115
+ if (entry.platform !== input.platform)
116
+ continue; // never seed across platforms
117
+ entry.plan = (0, ir_1.parsePlan)(entry.plan); // skip if it doesn't validate
118
+ if (!best || entry.savedAt > best.savedAt)
119
+ best = entry;
120
+ }
121
+ catch (e) {
122
+ // A seed candidate that won't parse/validate is corrupt — warn and skip it, rather
123
+ // than let one bad file silently vanish from seeding.
124
+ (0, output_1.err)(`[ai] skipping unreadable cache entry ${file} (${e.message})`);
125
+ }
126
+ }
127
+ return best;
128
+ }