verikun 0.26.2 → 0.27.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.
@@ -37,15 +37,20 @@ examples below use `vk`.
37
37
  ## Before you start (once per session)
38
38
 
39
39
  Run `vk doctor` once before the first device command. Alongside the adb/device checks it
40
- reports version staleness on stderr — it never fails on that, so treat it as information:
40
+ reports version staleness and host problems on stderr — it never fails on those, so treat
41
+ them as information:
41
42
 
42
43
  - **`claude-code-plugin: … behind this CLI`** → **this skill file is out of date.** Trust
43
44
  `vk --help` over what you read here when they disagree, and tell the user to run
44
45
  `claude plugin update verikun@verikun` and restart Claude Code.
45
46
  - **`verikun: … npm has <newer>`** → tell the user `npm install -g verikun@latest`.
47
+ - **`adb server: … leaking USB handles`** → **the host's adb server has rotted.** Devices
48
+ will drop mid-flow for reasons that look like your selectors failing. Tell the user to run
49
+ `adb kill-server && adb start-server` — safe, and devices reconnect in seconds.
46
50
 
47
51
  **Tell the user, don't run it.** Upgrading changes their machine, and the plugin path needs
48
- a Claude Code restart to take effect. Mention it once and move on.
52
+ a Claude Code restart to take effect; restarting adb is host-wide and drops every device on
53
+ the machine, not just yours. Mention it once and move on.
49
54
 
50
55
  ## The loop: act → inspect → assert
51
56
 
@@ -386,7 +391,8 @@ vk ai onboarding.md --timeout 5m # tighten the run timeout (default 15m)
386
391
  including file; a fragment may include another. Name a fragment `_something.md` — `vk suite`
387
392
  skips `_`-prefixed files, so a fragment never runs as a test of its own. Each chunk compiles
388
393
  and caches separately, so editing a shared fragment recompiles the fragment, not every test
389
- that uses it.
394
+ that uses it. A title or a description states no step, so it is folded into the chunk of its
395
+ own file that does, rather than compiled as a test of its own — put it wherever reads best.
390
396
  - The plan expresses **conditions** (`if-present`, for optional interstitials like a
391
397
  permission dialog) and **bounded loops** (`repeat … until`, e.g. scroll-until) —
392
398
  control flow `vk batch` cannot, so a flaky popup or a scroll-to-find no longer breaks
package/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.27.0] - 2026-09-13
10
+
11
+ ### Added
12
+ - **`vk doctor`** warns when the host's adb server has leaked USB handles, naming the restart that fixes it.
13
+ - **`vk server`** restarts a rotted adb server while idle; `VERIKUN_NO_ADB_RECYCLE=1` opts out.
14
+
15
+ ### Changed
16
+ - **Docs**: every page trimmed to what a reader needs; history and design rationale removed, gotchas kept, open issues linked.
17
+
18
+ ## [0.26.3] - 2026-09-11
19
+
20
+ A `vk ai` test's description is no longer compiled into steps of its own.
21
+
22
+ ### Fixed
23
+ - **`vk ai`** no longer compiles a title or description above an `@include` as a test of its
24
+ own, inventing steps that then ran first. ([#133])
25
+
26
+ [#133]: https://github.com/ddikman/verikun/issues/133
27
+
9
28
  ## [0.26.2] - 2026-09-10
10
29
 
11
30
  Android hierarchy reads no longer mistake a sheet's or dialog's barrier for an empty screen.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  - **Puppeteer for native mobile** — a thin wrapper over native Android and iOS automation runners with zero runtime dependencies.
9
9
  - **Natural-language tests** — `vk ai <file>`: runs plain-English tests, compiled once and replayed model-free (~$0), calling a model only to self-heal a drifted step. Tests share a preamble with `@include`, written once instead of pasted into each. A compile that does not cover its test is rejected rather than cached as a pass. [What that costs](https://ddikman.github.io/verikun/reference/cost/), and how the `--max-cost-usd` ceiling bounds it.
10
10
  - **Self-improving** — the agent runner will provide prescriptive improvements to existing scripts to help stabilise flakiness for future runs.
11
- - **CI-ready** — `vk suite` runs a folder of tests as one gated pass/fail run, across one device or a whole pool of them; `vk server` exposes real devices over an authenticated tunnel so a disposable CI runner (no phone attached) can still drive them, and routes around any that goes bad.
11
+ - **CI-ready** — `vk suite` runs a folder of tests as one gated pass/fail run, across one device or a whole pool of them; `vk server` exposes real devices over an authenticated tunnel so a disposable CI runner (no phone attached) can still drive them, and routes around any that goes bad — including restarting a host adb server that has started dropping them.
12
12
 
13
13
  ```
14
14
  $ vk ui
@@ -0,0 +1,208 @@
1
+ "use strict";
2
+ /**
3
+ * Is the HOST's adb server healthy, or has it rotted?
4
+ *
5
+ * A long-lived adb server leaks IOKit Mach ports and never recovers. Measured on macOS
6
+ * 2026-09-12: a 9-day-old server logging EXC_GUARD `GUARD_TYPE_MACH_PORT` / `INVALID_NAME`
7
+ * at 2/sec — 27,983 in 7h, every one of them from the adb pid — having burned 92 minutes
8
+ * of CPU on the error loop. `adb kill-server && adb start-server` took it to zero and every
9
+ * device came back in ~5s.
10
+ *
11
+ * WHY VERIKUN CARES, rather than leaving this to the operator: adb rot DEFEATS DEVICE
12
+ * FAILOVER. `device/failover.ts` moves the server off a device that fails, but every
13
+ * candidate sits behind the same host adb — so when the transport is what broke, failover
14
+ * walks the pool retiring healthy phones for a host-side fault. That is the same polarity
15
+ * error `ARTIFACT_RULES` exists to prevent on the install path: enumerate the thing you can
16
+ * actually attribute, and never blame the open-ended side.
17
+ *
18
+ * THE RATE IS A LEAKED-HANDLE COUNTER, which is what makes this measurable rather than
19
+ * guessed. adb scans USB at ~1Hz and each STALE device handle throws one violation per
20
+ * pass, so the per-minute count reads directly as "how many handles adb has leaked". It
21
+ * stepped 59 -> 118/min at the exact second a device re-enumerated, and never came back
22
+ * down. A healthy server sits at exactly 0.
23
+ *
24
+ * EVIDENCE, NEVER AGE, is the trigger — see `adbServerRotting`. Age alone would restart a
25
+ * perfectly good server, which for a default-on behaviour is a new way to fail; the whole
26
+ * point of a measured signal is that a healthy host is never disturbed.
27
+ *
28
+ * macOS-only, and that is honest rather than lazy: EXC_GUARD and Mach ports do not exist on
29
+ * Linux, and the rot has not been measured there. Everywhere else this reports `undefined`
30
+ * and every caller degrades to doing nothing — never to a blind restart.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.VIOLATION_WINDOW_MS = void 0;
34
+ exports.parseEtime = parseEtime;
35
+ exports.countViolations = countViolations;
36
+ exports.adbServerRotting = adbServerRotting;
37
+ exports.describeRot = describeRot;
38
+ exports.adbRecycleEnabled = adbRecycleEnabled;
39
+ exports.adbServerHealth = adbServerHealth;
40
+ exports.adbHealthProbe = adbHealthProbe;
41
+ exports.recycleAdbServer = recycleAdbServer;
42
+ const node_os_1 = require("node:os");
43
+ const exec_1 = require("./exec");
44
+ /** The window we count violations over. Long enough that a ~1Hz loop is unmissable, short
45
+ * enough that `log show` stays ~1s (measured: 1.2s for 2m). */
46
+ exports.VIOLATION_WINDOW_MS = 2 * 60 * 1000;
47
+ /**
48
+ * `log`'s own absolute path, not a bare `log`. Belt-and-braces against a shadowing shell
49
+ * function (the author hit exactly that while investigating: `log` was a zsh function and
50
+ * every query failed with "too many arguments"). We spawn without a shell so this cannot
51
+ * bite us today, but the absolute path costs nothing and documents the hazard.
52
+ */
53
+ const LOG_BIN = '/usr/bin/log';
54
+ /**
55
+ * Match the kernel's report of adb's guard violations, and NOTHING else.
56
+ *
57
+ * All three clauses are load-bearing. Without `process == "kernel"` this matches unrelated
58
+ * processes; without the `[adb:` clause it matches any process's guard violations, and a
59
+ * host with an unrelated misbehaving binary would be told its adb had rotted. The line the
60
+ * kernel actually emits:
61
+ *
62
+ * ERROR: [adb:96399] EXC_GUARD AST: type=0x1 flavor=0x200 target=0x24d7 ...
63
+ */
64
+ const VIOLATION_PREDICATE = 'process == "kernel" AND eventMessage CONTAINS "EXC_GUARD" AND eventMessage CONTAINS "[adb:"';
65
+ /**
66
+ * `ps -o etime=` elapsed time -> ms. Formats, narrowest first: `MM:SS`, `HH:MM:SS`,
67
+ * `DD-HH:MM:SS`. Returns undefined rather than throwing on anything unrecognised — this
68
+ * feeds an advisory, and a parse failure must degrade to silence, never to a wrong number.
69
+ */
70
+ function parseEtime(raw) {
71
+ const s = raw.trim();
72
+ if (!s)
73
+ return undefined;
74
+ const [days, clock] = s.includes('-') ? s.split('-', 2) : ['0', s];
75
+ const parts = clock.split(':');
76
+ if (parts.length < 2 || parts.length > 3)
77
+ return undefined;
78
+ // Test the DIGITS, not Number(): `Number('')` is 0, not NaN, so a leading '-' ("-5:00")
79
+ // would otherwise parse as a happy five minutes instead of being rejected.
80
+ const fields = [days, ...parts];
81
+ if (!fields.every((f) => /^\d+$/.test(f)))
82
+ return undefined;
83
+ const nums = fields.map((f) => Number(f));
84
+ const [d, ...rest] = nums;
85
+ const [h, m, sec] = rest.length === 3 ? rest : [0, ...rest];
86
+ return ((d * 24 + h) * 60 * 60 + m * 60 + sec) * 1000;
87
+ }
88
+ /**
89
+ * Count real events in `log show --style compact` output.
90
+ *
91
+ * It ALWAYS prints a `Timestamp Ty Process[PID:TID]` header, so a naive line count reports
92
+ * 1 on a perfectly healthy host — which would make every caller cry rot forever. Counting
93
+ * only lines that name adb is both the fix and a second guard on the predicate.
94
+ */
95
+ function countViolations(stdout) {
96
+ return stdout.split('\n').filter((l) => l.includes('[adb:')).length;
97
+ }
98
+ /**
99
+ * Has this adb server rotted? Evidence only.
100
+ *
101
+ * A healthy server produces EXACTLY zero over the window (measured repeatedly on a freshly
102
+ * restarted one), and a rotted one produces 60-120 per minute — there is no middle ground
103
+ * to tune a threshold against, so any nonzero count is the signal. `undefined` violations
104
+ * (not macOS, or the query failed) is NEVER rot: we do not restart on a hunch.
105
+ */
106
+ function adbServerRotting(health) {
107
+ return (health.violations ?? 0) > 0;
108
+ }
109
+ /** One line a human can act on, or undefined when there is nothing worth saying. */
110
+ function describeRot(health) {
111
+ if (!adbServerRotting(health))
112
+ return undefined;
113
+ const perMin = Math.round((health.violations / (health.windowMs ?? exports.VIOLATION_WINDOW_MS)) * 60_000);
114
+ const age = health.ageMs !== undefined ? `, up ${Math.floor(health.ageMs / 3_600_000)}h` : '';
115
+ return `adb server is leaking USB handles (~${perMin} kernel guard violations/min${age}) — devices will drop`;
116
+ }
117
+ /**
118
+ * Is the idle adb-server recycle active? ONE definition, because two callers ask — the
119
+ * server's timer and its startup banner — and a banner that disagrees with the behaviour is
120
+ * worse than no banner at all.
121
+ *
122
+ * Android only: `adb` is the only transport that rots this way, so an iOS server never pays
123
+ * for the check. On by default (`vk server` is built to sit on a CI host for days, which is
124
+ * the condition that rots it); `VERIKUN_NO_ADB_RECYCLE=1` restores the previous behaviour
125
+ * exactly, for the rare host running other adb work alongside the server.
126
+ */
127
+ function adbRecycleEnabled(platform) {
128
+ return platform === 'android' && process.env.VERIKUN_NO_ADB_RECYCLE !== '1';
129
+ }
130
+ /** The running adb server's pid. Undefined when there is none, or when `pgrep` is absent. */
131
+ function adbServerPid() {
132
+ try {
133
+ // `adb -L tcp:5037 fork-server server` is the canonical argv; match loosely so a
134
+ // non-default port or an ADB_SERVER_SOCKET still resolves.
135
+ const r = (0, exec_1.runText)('pgrep', ['-f', 'adb.*fork-server'], { timeout: 5000 });
136
+ const pid = Number(r.stdout.split('\n')[0]?.trim());
137
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ /** How long pid has been running, via `ps -o etime=`. */
144
+ function processAgeMs(pid) {
145
+ try {
146
+ const r = (0, exec_1.runText)('ps', ['-p', String(pid), '-o', 'etime='], { timeout: 5000 });
147
+ return parseEtime(r.stdout);
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
153
+ /**
154
+ * Survey the host's adb server. Never throws: every probe is wrapped, and an unavailable
155
+ * signal comes back `undefined` so callers do nothing rather than something wrong.
156
+ */
157
+ function adbServerHealth(windowMs = exports.VIOLATION_WINDOW_MS) {
158
+ const pid = adbServerPid();
159
+ const health = { pid, ageMs: pid ? processAgeMs(pid) : undefined };
160
+ if ((0, node_os_1.platform)() !== 'darwin' || !pid)
161
+ return health;
162
+ try {
163
+ const secs = Math.max(1, Math.round(windowMs / 1000));
164
+ const r = (0, exec_1.runText)(LOG_BIN, ['show', '--last', `${secs}s`, '--style', 'compact', '--predicate', VIOLATION_PREDICATE], {
165
+ timeout: 20000,
166
+ });
167
+ if (r.code !== 0)
168
+ return health;
169
+ return { ...health, violations: countViolations(r.stdout), windowMs };
170
+ }
171
+ catch {
172
+ return health; // log(1) missing, sandboxed, or slow — no opinion, not a failure
173
+ }
174
+ }
175
+ /**
176
+ * The `vk doctor` line. ADVISORY, always: a rotted adb server is a thing to fix, not a
177
+ * machine that cannot drive a device, and exit 3 is reserved for the latter. Returns null
178
+ * when there is nothing to say, so doctor stays quiet on a healthy host.
179
+ */
180
+ function adbHealthProbe(health = adbServerHealth()) {
181
+ const detail = describeRot(health);
182
+ if (!detail)
183
+ return null;
184
+ return {
185
+ name: 'adb server',
186
+ ok: true,
187
+ advisory: true,
188
+ detail,
189
+ hint: 'restart it: `adb kill-server && adb start-server` (safe; devices reconnect in a few seconds)',
190
+ };
191
+ }
192
+ /**
193
+ * Restart the host's adb server. Returns whether it came back.
194
+ *
195
+ * HOST-GLOBAL and therefore never called speculatively — `kill-server` drops every
196
+ * transport on the machine, including devices this process does not own. Callers must have
197
+ * evidence (`adbServerRotting`) and, on the server, an idle pool.
198
+ */
199
+ function recycleAdbServer(adb) {
200
+ try {
201
+ (0, exec_1.runText)(adb, ['kill-server'], { timeout: 20000 });
202
+ const r = (0, exec_1.runText)(adb, ['start-server'], { timeout: 30000 });
203
+ return r.code === 0;
204
+ }
205
+ catch {
206
+ return false; // never a new way to fail: the caller logs and carries on
207
+ }
208
+ }
@@ -9,25 +9,30 @@
9
9
  // because the preamble is conditional ("dismiss whichever of these six screens is present"),
10
10
  // which is exactly the part only `vk ai` can express.
11
11
  //
12
- // Two properties make the mechanism honest:
12
+ // Three properties make the mechanism honest:
13
13
  //
14
14
  // - **The resolved text is the cache key.** Callers hash `nl` (fragments already inlined),
15
15
  // so editing a fragment invalidates every test that includes it. Keying on the top-level
16
16
  // file would silently replay stale plans for all of them.
17
17
  // - **A fragment is not a test.** `vk suite` skips `_`-prefixed files and never recurses
18
18
  // into subdirectories, so a fragment gets no report row and no `--app` data reset.
19
+ // - **A chunk is a whole prompt, so a chunk that is not a test must not become one.** Prose
20
+ // that only describes the test is folded into the chunk of its own file that states the
21
+ // steps it describes, never compiled alone (groupDescriptions, issue #133).
19
22
  //
20
- // `segments` is what makes the compile CHEAP as well as short: each contiguous chunk of
21
- // prose is compiled on its own and cached under its own key, so a preamble shared by nine
22
- // tests is compiled ONCE (see compileFromSegments in cli.ts). Splicing happens at the plan
23
- // level, which the shallow IR allows — a plan is a flat list of steps.
23
+ // `segments` is what makes the compile CHEAP as well as short: each chunk of prose is
24
+ // compiled on its own and cached under its own key, so a preamble shared by nine tests is
25
+ // compiled ONCE (see compileFromSegments in cli.ts). Splicing happens at the plan level,
26
+ // which the shallow IR allows — a plan is a flat list of steps.
24
27
  Object.defineProperty(exports, "__esModule", { value: true });
25
28
  exports.hasInstruction = hasInstruction;
29
+ exports.statesInstruction = statesInstruction;
26
30
  exports.resolveIncludes = resolveIncludes;
27
31
  exports.segmentLabel = segmentLabel;
28
32
  const node_fs_1 = require("node:fs");
29
33
  const node_path_1 = require("node:path");
30
34
  const errors_1 = require("../errors");
35
+ const lint_1 = require("./lint");
31
36
  /** An include directive: `@include <path>` alone on a line (leading space allowed).
32
37
  * Deliberately a whole-line form — a path is everything after the keyword, so it needs
33
38
  * no quoting and can contain spaces. */
@@ -47,6 +52,47 @@ function hasInstruction(text) {
47
52
  const bare = text.replace(/<!--[\s\S]*?-->/g, '');
48
53
  return bare.split('\n').some((line) => line.trim() !== '' && !DECORATION_RE.test(line));
49
54
  }
55
+ /** A list item of ANY kind — ordered or bulleted. `lint.ts` counts only ORDERED items, because
56
+ * there an unordered bullet carrying explanation would inflate the expected plan size and
57
+ * manufacture a rejection. Here the polarity is reversed: a bulleted preamble
58
+ * ("- Launch the app with its data cleared.") is a list of steps, and missing it is what would
59
+ * do damage. */
60
+ const LIST_ITEM_RE = /^\s*(?:\d+[.)]|[-*+])\s+/;
61
+ /**
62
+ * Does this chunk STATE a step, or only DESCRIBE the test?
63
+ *
64
+ * Deliberately NOT `lint.ts`'s `instructionUnits(text) > 0`, even though that counter answers a
65
+ * neighbouring question. It is documented as biased to UNDERCOUNT, which is safe where it is
66
+ * used ("an undercount only weakens detection, while an overcount would reject a correct plan")
67
+ * and exactly backwards here, where an undercount means calling a real step a description.
68
+ * Measured, all scoring zero units: a bulleted preamble, "First, launch the app.", and this
69
+ * repo's own `_launch-to-home.md` reworded to open with its subject instead of its verb.
70
+ *
71
+ * So this asks for POSITIVE EVIDENCE and errs the other way — a list item of any kind, or one
72
+ * of the same verbs `lint.ts` knows appearing ANYWHERE in a line rather than only at its start.
73
+ * Every misreading it can still make is the safe one: a description that happens to contain
74
+ * "check" or "wait" reads as a step and is compiled alone, which is what happened before this
75
+ * existed. Nothing regresses; some things stop being fabricated.
76
+ *
77
+ * Exported solely so the unit suite can reach it.
78
+ */
79
+ function statesInstruction(text) {
80
+ let fenced = false;
81
+ for (const line of text.replace(/<!--[\s\S]*?-->/g, '').split('\n')) {
82
+ if (FENCE_RE.test(line)) {
83
+ fenced = !fenced;
84
+ continue;
85
+ }
86
+ if (fenced)
87
+ continue;
88
+ if (LIST_ITEM_RE.test(line))
89
+ return true;
90
+ for (const word of line.toLowerCase().match(/[a-z]+/g) ?? [])
91
+ if (lint_1.IMPERATIVES.has(word))
92
+ return true;
93
+ }
94
+ return false;
95
+ }
50
96
  const endWithNewline = (s) => (s.endsWith('\n') ? s : `${s}\n`);
51
97
  const readFile = (path) => (0, node_fs_1.readFileSync)(path, 'utf8');
52
98
  /**
@@ -58,17 +104,21 @@ const readFile = (path) => (0, node_fs_1.readFileSync)(path, 'utf8');
58
104
  * otherwise expand until it ran out of memory.
59
105
  */
60
106
  function resolveIncludes(file, read = readFile) {
61
- const segments = [];
62
- const nl = expand((0, node_path_1.resolve)(process.cwd(), file), [], segments, read, null);
63
- return { nl, segments };
107
+ const ex = { out: [], read, units: 0 };
108
+ // `nl` is assembled from `parts`, independently of `ex.out`, so regrouping the chunks below
109
+ // cannot change the resolved text — and therefore cannot change the whole-test cache key, nor
110
+ // what the assembled-plan lint is asked about.
111
+ const nl = expand((0, node_path_1.resolve)(process.cwd(), file), [], ex, null);
112
+ return { nl, segments: groupDescriptions(ex.out) };
64
113
  }
65
- function expand(path, stack, out, read, from) {
114
+ function expand(path, stack, ex, from) {
115
+ const unit = ex.units++;
66
116
  if (stack.includes(path)) {
67
117
  throw new errors_1.CliError(`ai: include cycle — ${[...stack, path].map((p) => shortName(p)).join(' → ')}`, 2);
68
118
  }
69
119
  let text;
70
120
  try {
71
- text = read(path);
121
+ text = ex.read(path);
72
122
  }
73
123
  catch (e) {
74
124
  const where = from ? ` (included from ${from})` : '';
@@ -91,8 +141,9 @@ function expand(path, stack, out, read, from) {
91
141
  // The label points at the chunk's first real line, not at the blank line that
92
142
  // separated it from the include above it.
93
143
  const lead = buf.findIndex((l) => l.trim() !== '');
94
- if (lead >= 0)
95
- out.push({ text: chunk, source: path, startLine: bufStart + lead, compilable: hasInstruction(chunk) });
144
+ if (lead >= 0) {
145
+ ex.out.push({ text: chunk, source: path, startLine: bufStart + lead, compilable: hasInstruction(chunk), unit });
146
+ }
96
147
  buf = [];
97
148
  };
98
149
  lines.forEach((line, i) => {
@@ -106,11 +157,87 @@ function expand(path, stack, out, read, from) {
106
157
  return;
107
158
  }
108
159
  flush();
109
- parts.push(endWithNewline(expand((0, node_path_1.resolve)((0, node_path_1.dirname)(path), m[1]), [...stack, path], out, read, `${shortName(path)}:${i + 1}`)));
160
+ parts.push(endWithNewline(expand((0, node_path_1.resolve)((0, node_path_1.dirname)(path), m[1]), [...stack, path], ex, `${shortName(path)}:${i + 1}`)));
110
161
  });
111
162
  flush();
112
163
  return parts.join('');
113
164
  }
165
+ /**
166
+ * The chunk a description folds into: the next chunk of its OWN expansion that states a step,
167
+ * else the previous one. `-1` when that expansion states no step anywhere.
168
+ *
169
+ * Scanning by EXPANSION rather than by path is what keeps a fragment included twice identical in
170
+ * both places. Scanning by FILE rather than simply "the next chunk" is the load-bearing half:
171
+ * the next chunk is normally the fragment the `@include` pulled in, and folding a per-test
172
+ * description into it would change the FRAGMENT's cache key per test, destroying the
173
+ * compiled-once property `@include` exists for. Another expansion's chunks are skipped OVER, not
174
+ * stopped at — reaching across the include to the file's own next chunk is the entire point.
175
+ */
176
+ function hostFor(raw, describes, i) {
177
+ const usable = (j) => raw[j].unit === raw[i].unit && raw[j].compilable && !describes[j];
178
+ for (let j = i + 1; j < raw.length; j++)
179
+ if (usable(j))
180
+ return j;
181
+ for (let j = i - 1; j >= 0; j--)
182
+ if (usable(j))
183
+ return j;
184
+ return -1;
185
+ }
186
+ const plain = ({ text, source, startLine, compilable }) => ({ text, source, startLine, compilable });
187
+ /**
188
+ * Fold a chunk that only DESCRIBES the test into the chunk of the same file that states the
189
+ * steps it describes (issue #133).
190
+ *
191
+ * A title and a summary paragraph written above the first `@include` used to become a chunk of
192
+ * its own — and a chunk is a whole prompt. Compiled alone, the summary IS the test, so the model
193
+ * invents a plan for it and those steps are spliced AHEAD of the launch the include was there to
194
+ * perform; the reported case was 34 fabricated steps whose first act was to tap a login button on
195
+ * the Android launcher. `SECTION_NOTE` has told the model since `@include` shipped that a summary
196
+ * is not an instruction, and it still went the wrong way on 5 of 14 tests in one run: whether
197
+ * prose reads as a spec is a model judgement, so the fix has to be deterministic.
198
+ *
199
+ * A STRICT IMPROVEMENT, deliberately: every chunk either folds into a neighbour or is left
200
+ * exactly as it was. There is no third disposition, and in particular nothing is DROPPED — prose
201
+ * this pass discarded would be prose the model never sees, and a fragment wrongly discarded is a
202
+ * suite that silently stops launching its app. No coverage rule could catch that: the floor and
203
+ * the tail anchors both measure `nl`, which this pass does not touch.
204
+ */
205
+ function groupDescriptions(raw) {
206
+ const describes = raw.map((s) => s.compilable && !statesInstruction(s.text));
207
+ const host = raw.map((_, i) => (describes[i] ? hostFor(raw, describes, i) : -1));
208
+ // The overwhelmingly common case — no description, or one with nowhere to go — allocates
209
+ // nothing and is byte-identical to the behaviour before this pass existed.
210
+ if (host.every((h) => h < 0))
211
+ return raw.map(plain);
212
+ const out = [];
213
+ const slot = new Map(); // index in `raw` -> index in `out`
214
+ raw.forEach((s, i) => {
215
+ if (host[i] >= 0)
216
+ return; // folded into its host below
217
+ slot.set(i, out.length);
218
+ out.push(plain(s));
219
+ });
220
+ // Prepends run BACKWARDS and appends run FORWARDS. Not a style choice: each fold lands at the
221
+ // host's edge, so whichever is written LAST ends up nearest the host. A file with two
222
+ // descriptions separated by two includes has to come out in the order its author wrote them,
223
+ // and a single pass in either direction reverses one of those two chains.
224
+ for (let i = raw.length - 1; i >= 0; i--) {
225
+ if (host[i] <= i)
226
+ continue;
227
+ const at = slot.get(host[i]);
228
+ // The earlier line, so `segmentLabel` names where the folded text now begins. That label is
229
+ // the accounting for the fold: a chunk that printed as `t.md:8` prints as `t.md:1`, and the
230
+ // "assembled from N chunk(s)" line drops by one. No prose moves unannounced.
231
+ out[at] = { ...out[at], text: raw[i].text + out[at].text, startLine: Math.min(out[at].startLine, raw[i].startLine) };
232
+ }
233
+ for (let i = 0; i < raw.length; i++) {
234
+ if (host[i] < 0 || host[i] > i)
235
+ continue;
236
+ const at = slot.get(host[i]);
237
+ out[at] = { ...out[at], text: out[at].text + raw[i].text };
238
+ }
239
+ return out;
240
+ }
114
241
  /** A path as a human reads it: relative to the cwd when it is below it. */
115
242
  function shortName(path) {
116
243
  const cwd = `${process.cwd()}/`;
@@ -28,6 +28,7 @@
28
28
  // rule requires a fairly unambiguous phrase and checks for a specific structural counterpart.
29
29
  // It never edits the plan; the model gets the feedback and stays the author.
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.IMPERATIVES = void 0;
31
32
  exports.coverageChecksEnabled = coverageChecksEnabled;
32
33
  exports.instructionLines = instructionLines;
33
34
  exports.instructionUnits = instructionUnits;
@@ -87,8 +88,15 @@ const FENCE_RE = /^\s*(```|~~~)/;
87
88
  * Verbs that open an instruction. A CLOSED set, and a tight one — every entry here raises
88
89
  * the expected plan size, so a loose entry costs a false rejection while a missing one only
89
90
  * costs detection. The floor below is generous enough to absorb the resulting undercount.
91
+ *
92
+ * Exported for ONE other consumer, `agent/include.ts`, which asks a different question of the
93
+ * same vocabulary: not "how many steps does this prose state" but "does it state one at all".
94
+ * Shared rather than copied for the reason `pidAlive` is shared with `agent/plan-lock.ts` — a
95
+ * second list of verbs drifts from this one invisibly. Note that it is NOT part of
96
+ * `COMPILER_FINGERPRINT`, so an edit here moves a chunk boundary as well as a lint threshold;
97
+ * the version bump such an edit already requires is what rotates the fingerprint.
90
98
  */
91
- const IMPERATIVES = new Set([
99
+ exports.IMPERATIVES = new Set([
92
100
  'tap', 'press', 'click', 'type', 'enter', 'fill',
93
101
  'launch', 'open', 'start', 'restart', 'relaunch', 'stop', 'close',
94
102
  'confirm', 'verify', 'assert', 'check', 'ensure',
@@ -121,7 +129,7 @@ function instructionLines(nl) {
121
129
  const body = (ordered ? raw.replace(ORDERED_ITEM_RE, '') : raw).trim();
122
130
  if (!body)
123
131
  continue;
124
- if (ordered || IMPERATIVES.has((/^([A-Za-z]+)/.exec(body)?.[1] ?? '').toLowerCase()))
132
+ if (ordered || exports.IMPERATIVES.has((/^([A-Za-z]+)/.exec(body)?.[1] ?? '').toLowerCase()))
125
133
  out.push(body);
126
134
  }
127
135
  return out;
package/dist/cli.js CHANGED
@@ -93,6 +93,7 @@ const suite_1 = require("./suite");
93
93
  const failover_1 = require("./device/failover");
94
94
  const wait_1 = require("./wait");
95
95
  const version_1 = require("./version");
96
+ const adb_health_1 = require("./adb-health");
96
97
  const update_check_1 = require("./update-check");
97
98
  const auto_wait_1 = require("./commands/auto-wait");
98
99
  const batch_1 = require("./commands/batch");
@@ -475,6 +476,12 @@ async function cmdDoctor(ctx) {
475
476
  const adb = process.env.ADB || 'adb';
476
477
  if (!reportProbe((0, drivers_1.probeAdb)()))
477
478
  return 3;
479
+ // adb being PRESENT is not the same as adb being WELL: a long-lived server leaks USB
480
+ // handles and starts dropping devices mid-run, and no device-level remedy reaches it
481
+ // (see adb-health.ts). Advisory and evidence-only, so a healthy host stays silent.
482
+ const adbRot = (0, adb_health_1.adbHealthProbe)();
483
+ if (adbRot)
484
+ reportProbe(adbRot);
478
485
  const devices = ctx.driver.listDevices();
479
486
  const usable = devices.filter((d) => d.state === 'device');
480
487
  const claims = (0, claims_1.claimsEnabled)();
package/dist/server.js CHANGED
@@ -58,6 +58,7 @@ const server_pool_1 = require("./server-pool");
58
58
  const ir_1 = require("./agent/ir");
59
59
  const rpc_1 = require("./rpc");
60
60
  const cli_1 = require("./cli");
61
+ const adb_health_1 = require("./adb-health");
61
62
  const wait_1 = require("./wait");
62
63
  const version_1 = require("./version");
63
64
  /**
@@ -89,6 +90,10 @@ const LOCK_IDLE_MS = 5 * 60 * 1000;
89
90
  // comfortably inside the client's 15-minute install ceiling at ~1 minute an install,
90
91
  // while a farm of ten wedged emulators cannot burn ten installs inside one request.
91
92
  const MAX_FAILOVER_HOPS = 2;
93
+ // How often to ask whether the host's adb server has rotted. Generous on purpose: the
94
+ // check shells out to `log show` (~1s) and the condition it looks for accumulates over
95
+ // DAYS, so a tight interval would buy nothing and spend host time on every idle server.
96
+ const ADB_RECYCLE_CHECK_MS = 10 * 60 * 1000;
92
97
  // Gap between the two probes that separate a momentary blip from a dead device. Mirrors
93
98
  // suite.ts's stillBroken, and for the same reason: a flaky dump also surfaces as exit 3,
94
99
  // so acting on one probe would rotate the pool on ordinary flake.
@@ -448,6 +453,72 @@ function buildServer(config) {
448
453
  // The first timer this server has ever had, so this is the first thing that could hold the
449
454
  // process open after Ctrl-C. It must not.
450
455
  reconcileTimer?.unref?.();
456
+ // --- adb server recycle ----------------------------------------------------
457
+ //
458
+ // A long-lived adb server leaks USB handles until it drops devices mid-run, and only a
459
+ // restart cures it (the measurements: `adb-health.ts`). `vk server` is BUILT to sit on a
460
+ // CI host for days — precisely the condition that rots it — so this is ON by default:
461
+ // on a dedicated box, recycling a broken transport is the expected behaviour, not a
462
+ // surprise. `VERIKUN_NO_ADB_RECYCLE=1` opts out, for the rare host running other adb
463
+ // work beside the server; that restores today's behaviour exactly, the equivalence
464
+ // `VERIKUN_NO_CLAIM` and `VERIKUN_NO_FAILOVER` are held to.
465
+ //
466
+ // Three properties are what make a DEFAULT-ON, HOST-GLOBAL restart safe:
467
+ //
468
+ // * EVIDENCE, NEVER AGE. A healthy server measures exactly zero violations, so a
469
+ // healthy host is never touched. Age alone would restart a perfectly good server on
470
+ // a timer — a new way to fail, which is the one thing this may not add.
471
+ // * FULLY IDLE ONLY. `kill-server` drops every transport on the machine, so anything
472
+ // mid-run vetoes. `othersActive` is the existing predicate for that and already
473
+ // steps over an idle lease, so a crashed client cannot wedge this forever.
474
+ // * ANDROID ONLY — and in practice macOS only, since `adbServerHealth` reports no
475
+ // evidence elsewhere and no-evidence is never rot. An iOS server never pays for it.
476
+ //
477
+ // The residual race is a client arriving during the ~2s restart and getting a busy
478
+ // error. Accepted deliberately: we only ever get here when adb is ALREADY broken, so
479
+ // that client's alternative was a server that drops its device mid-suite. An honest
480
+ // refusal beats a half-dead transport. `exclusive` is held across the restart so the
481
+ // refusal is the clean one the lease layer already knows how to give.
482
+ const adbRecycleOn = (0, adb_health_1.adbRecycleEnabled)(config.platform);
483
+ const RECYCLE_TOKEN = '__adb-recycle__';
484
+ const recycleAdbIfRotten = async () => {
485
+ // Cheap gate first: never shell out to `log show` while the server is working.
486
+ if (othersActive(RECYCLE_TOKEN) || inFlight.size > 0)
487
+ return;
488
+ const health = (0, adb_health_1.adbServerHealth)();
489
+ if (!(0, adb_health_1.adbServerRotting)(health))
490
+ return;
491
+ await serializeFailover(async () => {
492
+ // Re-check inside the queue: the health probe shells out for ~1s, which is ample
493
+ // time for a run to start, and by here we are about to cut every transport.
494
+ if (othersActive(RECYCLE_TOKEN) || inFlight.size > 0)
495
+ return;
496
+ (0, output_1.err)(`[server] ${(0, adb_health_1.describeRot)(health)}`);
497
+ exclusive = RECYCLE_TOKEN;
498
+ try {
499
+ const ok = (0, adb_health_1.recycleAdbServer)(process.env.ADB || 'adb');
500
+ (0, output_1.err)(ok ? '[server] adb server restarted — devices reconnecting' : '[server] adb server restart failed — continuing');
501
+ }
502
+ finally {
503
+ exclusive = null;
504
+ }
505
+ });
506
+ };
507
+ let recycling = false;
508
+ const recycleTimer = adbRecycleOn
509
+ ? setInterval(() => {
510
+ if (recycling)
511
+ return;
512
+ recycling = true;
513
+ void recycleAdbIfRotten()
514
+ .catch((e) => (0, output_1.err)(`[server] adb recycle check failed — ${(0, server_http_1.firstLine)(e.message)}`))
515
+ .finally(() => {
516
+ recycling = false;
517
+ });
518
+ }, ADB_RECYCLE_CHECK_MS)
519
+ : null;
520
+ // Like the reconcile timer: must never hold the process open at Ctrl-C.
521
+ recycleTimer?.unref?.();
451
522
  const pickFailoverDevice = (failed, reason) => serializeFailover(() => pickFailoverDeviceLocked(failed, reason));
452
523
  /**
453
524
  * Bring in a healthy replacement for `failed`. Returns the serial moved to, or null
@@ -1679,6 +1750,8 @@ function buildServer(config) {
1679
1750
  server.on('close', () => {
1680
1751
  if (reconcileTimer)
1681
1752
  clearInterval(reconcileTimer);
1753
+ if (recycleTimer)
1754
+ clearInterval(recycleTimer);
1682
1755
  dropRetainedInstall();
1683
1756
  });
1684
1757
  return server;
@@ -1906,6 +1979,14 @@ async function cmdServer(positionals, flags) {
1906
1979
  (0, output_1.err)('[server] NOTE: an authenticated client can now power-cycle AND erase this device.');
1907
1980
  }
1908
1981
  (0, output_1.err)(`[server] failover: ${failover.why}`);
1982
+ // Announced in both states, like the failover kill switch. ON is worth saying because
1983
+ // `adb kill-server` is host-global and an operator should not meet it as a surprise;
1984
+ // OFF is worth saying because a rotted adb server is otherwise a baffling flake.
1985
+ if (platform === 'android') {
1986
+ (0, output_1.err)(`[server] adb recycle: ${(0, adb_health_1.adbRecycleEnabled)(platform)
1987
+ ? 'on — a rotted adb server is restarted while idle (VERIKUN_NO_ADB_RECYCLE=1 to disable)'
1988
+ : 'disabled (VERIKUN_NO_ADB_RECYCLE)'}`);
1989
+ }
1909
1990
  (0, output_1.err)(serverLog
1910
1991
  ? `[server] log: ${serverLog.path} (--log-file ${server_log_1.LOG_OFF} to disable)`
1911
1992
  : `[server] log: stderr only (--log-file ${server_log_1.LOG_OFF})`);
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.26.2';
6
+ exports.VERSION = '0.27.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.26.2",
3
+ "version": "0.27.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",