things-api 0.19.3 → 0.19.4

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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  3. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  5. package/dist/audit/schema.d.ts +14 -0
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/commands/op-result.js +13 -1
  8. package/dist/cli/commands/op-result.js.map +1 -1
  9. package/dist/cli/commands/writes.js +51 -7
  10. package/dist/cli/commands/writes.js.map +1 -1
  11. package/dist/contracts.d.ts +1 -1
  12. package/dist/contracts.js +1 -1
  13. package/dist/mcp/server.js +7 -0
  14. package/dist/mcp/server.js.map +1 -1
  15. package/dist/op-result.d.ts +9 -0
  16. package/dist/op-result.js +5 -0
  17. package/dist/op-result.js.map +1 -1
  18. package/dist/write/batch.js +4 -1
  19. package/dist/write/batch.js.map +1 -1
  20. package/dist/write/clone.js +12 -6
  21. package/dist/write/clone.js.map +1 -1
  22. package/dist/write/disclosures.d.ts +263 -0
  23. package/dist/write/disclosures.js +265 -0
  24. package/dist/write/disclosures.js.map +1 -0
  25. package/dist/write/heading.js +4 -7
  26. package/dist/write/heading.js.map +1 -1
  27. package/dist/write/make-repeating-project.js +2 -1
  28. package/dist/write/make-repeating-project.js.map +1 -1
  29. package/dist/write/opid.js +5 -3
  30. package/dist/write/opid.js.map +1 -1
  31. package/dist/write/pipeline.d.ts +39 -1
  32. package/dist/write/pipeline.js +49 -23
  33. package/dist/write/pipeline.js.map +1 -1
  34. package/dist/write/promote-clone.js +46 -36
  35. package/dist/write/promote-clone.js.map +1 -1
  36. package/dist/write/reorder.js +45 -39
  37. package/dist/write/reorder.js.map +1 -1
  38. package/dist/write/resolution-timestamps.js +4 -1
  39. package/dist/write/resolution-timestamps.js.map +1 -1
  40. package/dist/write/spawn-expectation.d.ts +135 -0
  41. package/dist/write/spawn-expectation.js +237 -0
  42. package/dist/write/spawn-expectation.js.map +1 -0
  43. package/dist/write/template-mutation.js +20 -33
  44. package/dist/write/template-mutation.js.map +1 -1
  45. package/dist/write/vectors/types.d.ts +12 -0
  46. package/dist/write/vectors/ui-recipes.js +11 -1
  47. package/dist/write/vectors/ui-recipes.js.map +1 -1
  48. package/dist/write/vectors/ui-state.d.ts +42 -0
  49. package/dist/write/vectors/ui-state.js +90 -3
  50. package/dist/write/vectors/ui-state.js.map +1 -1
  51. package/dist/write/vectors/ui.d.ts +46 -2
  52. package/dist/write/vectors/ui.js +269 -142
  53. package/dist/write/vectors/ui.js.map +1 -1
  54. package/dist/write/verify/delta.d.ts +17 -2
  55. package/dist/write/verify/delta.js +46 -16
  56. package/dist/write/verify/delta.js.map +1 -1
  57. package/dist/write/verify/poller.d.ts +3 -2
  58. package/dist/write/verify/poller.js +3 -1
  59. package/dist/write/verify/poller.js.map +1 -1
  60. package/package.json +1 -1
  61. package/skills/things-cli/SKILL.md +1 -1
@@ -35,37 +35,50 @@ import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachabi
35
35
  import { certificationOf } from "./ui-certification.js";
36
36
  import { chordCommand, driveHeadingChordReorder } from "./ui-chord.js";
37
37
  import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
38
- import { AX_DIALOG_SHELL_SNIPPET, CENSUS_TIMEOUT_MS, censusUnverifiable, describeFocusOwner, describeUnprovenProbes, readUiState, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./ui-state.js";
38
+ import { AX_DIALOG_SHELL_SNIPPET, axFocusGuardPrelude, CENSUS_TIMEOUT_MS, censusUnverifiable, describeFocusOwner, describeUnprovenProbes, GUARD_REFUSED_TAG, parseGuardLog, readUiState, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./ui-state.js";
39
39
  /** GUI driving can stall on an unanswered sheet; give each step headroom. */
40
40
  const STEP_TIMEOUT_MS = 15_000;
41
41
  /**
42
- * Poll interval while waiting for a dynamic element (sheet/popover). KEPT at 300ms
43
- * after the PERF2 audit: the control a mode switch reveals takes ~462ms to appear
44
- * on the golden (S5b, [docs/lab/perf2-step-latency.md]), which EXCEEDS this
45
- * interval — so a 300ms poll catches it on its second round; a finer interval
46
- * would only add osascript hops for a marginal detection gain (UIC6 confirmed).
47
- */
48
- const WAIT_POLL_MS = 300;
49
- /**
50
- * How long `resolveStepPath` polls a candidate-addressed control before failing
42
+ * How long a candidate-addressed control is polled for before the step fails
51
43
  * closed. The full-vocabulary dialog reveals a pop-up/field a beat AFTER the
52
44
  * frequency/Ends switch that precedes it (UIC6: ~250 ms), so the effective-form
53
- * resolution must poll, not snap once.
45
+ * resolution must poll, not snap once. Since DRVLAT1 (issue #633) that poll runs
46
+ * IN-SCRIPT, inside the hop that acts on the control ({@link axCandidatePrelude}),
47
+ * rather than as its own osascript round-trip per candidate per round.
54
48
  */
55
49
  const RESOLVE_CANDIDATE_TIMEOUT_MS = 5_000;
56
50
  /**
57
- * Settle after the reveal/activate preamble so the menu bar repopulates for the
58
- * newly-selected target before the canary reads it (UIC1: the Items Repeat
59
- * submenu appears only once a repeating item is selected, and the update is not
60
- * instantaneous). TRIMMED 1500 1000 by the PERF2 audit (S5a,
61
- * [docs/lab/perf2-step-latency.md]): on a warm running app under DEFAULT macOS
62
- * animations the menu repopulates in ~92ms median / 116ms max (N=10) — a ~13×
63
- * margin at 1500. Menu-bar repopulation is a LOCAL UI operation (not a DB-commit /
64
- * sync-bound one), so it does not scale with DB size the way the OK commit does;
65
- * 1000ms keeps ~8.6× the golden max as host headroom. Under-margining only ever
66
- * costs a fail-closed spurious drive refusal (the canary miss), never a bad write.
51
+ * In-script poll cadence for the element waits the drive folded into their own
52
+ * hops (DRVLAT1, issue #633). The JS-side poll it replaces was kept at a coarse
53
+ * 300ms deliberately it paid a PROCESS SPAWN per round (PERF2 S5b), so a finer
54
+ * interval bought detection at the price of hops. An in-script poll pays one
55
+ * addressed `exists` per round, so it can be fine enough that a wait ends when
56
+ * the element lands rather than at the next 300ms boundary.
57
+ */
58
+ const IN_SCRIPT_POLL_S = 0.05;
59
+ /**
60
+ * How long the canary and the eligibility assertion poll for the menu bar to
61
+ * repopulate around the newly-selected target (UIC1: the Items ▸ Repeat submenu
62
+ * appears only once a matching item is selected, and the update is not
63
+ * instantaneous).
64
+ *
65
+ * This REPLACES the fixed post-preamble settle (DRVLAT1, issue #633). That settle
66
+ * was 1500ms, trimmed to 1000ms by PERF2 against a measured ~92ms median / 116ms
67
+ * max menu repopulation (S5a) — i.e. it spent ~900ms of every drive waiting out a
68
+ * margin, on every host, whether or not the menu was already there. The closed-loop
69
+ * form is strictly better on both axes: it proceeds the moment the menu answers
70
+ * (the common case, ~one poll), and it tolerates a host slower than any fixed
71
+ * settle would have covered. Under-margining still only ever costs a fail-closed
72
+ * refusal, never a bad write (determinism doctrine; BEEP1 shape-settle precedent).
73
+ */
74
+ const MENU_SETTLE_TIMEOUT_MS = 4_000;
75
+ /**
76
+ * Default window for a click's post-condition wait when the step names none.
77
+ * Every recipe that asserts one sets 5000 explicitly; this only keeps the
78
+ * in-script poll comfortably INSIDE the hop's own {@link STEP_TIMEOUT_MS}, which
79
+ * a 15s default would not (the poll would outlive its transport).
67
80
  */
68
- const SETTLE_AFTER_REVEAL_MS = 1000;
81
+ const WAIT_ASSERT_TIMEOUT_MS = 5_000;
69
82
  /**
70
83
  * A shape-dependent step reached without the dialog having been measured — a
71
84
  * recipe bug (the `probe-dialog-shape` step is missing or ran after its
@@ -299,6 +312,82 @@ end fgAssertFront`;
299
312
  export function axResolveScript(path) {
300
313
  return `${SE} to return (exists (${path}))`;
301
314
  }
315
+ /**
316
+ * The AppleScript variable a folded candidate resolution binds the live element
317
+ * to. Every addressed script takes its target as `(<path>)`, so handing it this
318
+ * name — with {@link axCandidatePrelude} in front — makes the resolution and the
319
+ * action ONE hop instead of two (DRVLAT1, issue #633).
320
+ */
321
+ export const STEP_ELEMENT_REF = "fgStepEl";
322
+ /**
323
+ * The message a folded candidate resolution raises when NONE of a step's expected
324
+ * element shapes appeared. Byte-identical to the driver's own wording for the
325
+ * separate-hop resolution it replaces, so a report reads the same either way.
326
+ */
327
+ export const CANDIDATES_MISSED = "none of its expected element shapes resolved (neither the attached sheet nor the " +
328
+ "detached repeat editor window)";
329
+ /**
330
+ * IN-SCRIPT CANDIDATE RESOLUTION (DRVLAT1, issue #633).
331
+ *
332
+ * A candidate-addressed step used to dispatch one `resolve` hop PER CANDIDATE PER
333
+ * POLL ROUND before the hop that acted on whichever answered — a process spawn
334
+ * each, on top of a 300ms JS-side poll floor. This prelude does the same work in
335
+ * the acting hop's own process: it polls the candidates in the SAME priority
336
+ * order, binds the first that exists to {@link STEP_ELEMENT_REF}, and fails closed
337
+ * with {@link CANDIDATES_MISSED} when the window elapses with none of them there.
338
+ *
339
+ * Collapsing it is also strictly better against TOCTOU: the element the script
340
+ * acts on is the one it just proved exists, with nothing dispatched in between.
341
+ */
342
+ export function axCandidatePrelude(paths, timeoutMs = RESOLVE_CANDIDATE_TIMEOUT_MS) {
343
+ const probes = paths
344
+ .map((p) => ` try
345
+ if (exists (${p})) then set ${STEP_ELEMENT_REF} to (${p})
346
+ end try
347
+ if ${STEP_ELEMENT_REF} is not missing value then exit repeat`)
348
+ .join("\n");
349
+ return `set ${STEP_ELEMENT_REF} to missing value
350
+ set fgT0 to (current date)
351
+ ${SE}
352
+ repeat
353
+ ${probes}
354
+ if ((current date) - fgT0) is greater than or equal to ${pollSeconds(timeoutMs)} then exit repeat
355
+ delay ${IN_SCRIPT_POLL_S}
356
+ end repeat
357
+ end tell
358
+ if ${STEP_ELEMENT_REF} is missing value then error "${escapeAppleScript(CANDIDATES_MISSED)}"`;
359
+ }
360
+ /**
361
+ * wait: poll for ANY of the awaited element shapes to appear, IN-SCRIPT (DRVLAT1).
362
+ * One hop for the whole wait instead of one per candidate per round — which is
363
+ * what a slow host paid most: the dialog the drive is waiting for is exactly the
364
+ * thing that is slow when the app is busy. Returns "true", or "false" when the
365
+ * window elapses with none of them present (the driver's abort path is unchanged).
366
+ */
367
+ export function axWaitAnyScript(paths, timeoutMs) {
368
+ const probes = paths
369
+ .map((p) => ` try
370
+ if (exists (${p})) then return "true"
371
+ end try`)
372
+ .join("\n");
373
+ return `set fgT0 to (current date)
374
+ ${SE}
375
+ repeat
376
+ ${probes}
377
+ if ((current date) - fgT0) is greater than or equal to ${pollSeconds(timeoutMs)} then return "false"
378
+ delay ${IN_SCRIPT_POLL_S}
379
+ end repeat
380
+ end tell`;
381
+ }
382
+ /**
383
+ * A poll window in whole seconds, as the in-script `current date` deadline reads
384
+ * it (AppleScript dates carry second granularity). Never below 1: a sub-second
385
+ * window would make a loop that checks its deadline after the FIRST probe into a
386
+ * single-shot check, which is the race these loops exist to close.
387
+ */
388
+ function pollSeconds(timeoutMs) {
389
+ return Math.max(1, Math.round(timeoutMs / 1000));
390
+ }
302
391
  /** press: AXPress the element. */
303
392
  export function axPressScript(path) {
304
393
  return `${SE} to click (${path})`;
@@ -821,12 +910,20 @@ export function axSelectPopupScript(path, value) {
821
910
  */
822
911
  export function axSelectPopupCandidatesScript(path, values) {
823
912
  const list = values.map((v) => `"${escapeAppleScript(v)}"`).join(", ");
913
+ // The menu is WAITED FOR, not slept on (DRVLAT1, issue #633): the old loop paid
914
+ // a flat 0.3s after every click before it would look again, so the common case —
915
+ // one click, menu up in well under that — spent the remainder of the settle
916
+ // doing nothing. The click cadence is unchanged (one click per round, never a
917
+ // second click into a menu that is opening — BEEP1); only the looking is finer.
824
918
  return `${SE}
825
919
  set pu to (${path})
826
920
  repeat 20 times
827
921
  if (exists menu 1 of pu) then exit repeat
828
922
  click pu
829
- delay 0.3
923
+ repeat 6 times
924
+ if (exists menu 1 of pu) then exit repeat
925
+ delay ${IN_SCRIPT_POLL_S}
926
+ end repeat
830
927
  end repeat
831
928
  repeat with candidate in {${list}}
832
929
  if (exists menu item candidate of menu 1 of pu) then
@@ -1257,26 +1354,37 @@ return "NOMATCH"`;
1257
1354
  * naming expected vs observed. Pure System Events + Things scripting, background-
1258
1355
  * capable. One stable command shape per primitive.
1259
1356
  */
1260
- export function axAssertEligibleScript(targetUuid, menuItemPath) {
1357
+ export function axAssertEligibleScript(targetUuid, menuItemPath, settleMs = MENU_SETTLE_TIMEOUT_MS) {
1261
1358
  const u = escapeAppleScript(targetUuid);
1262
- return `set selIds to {}
1263
- tell application "Things3"
1264
- try
1265
- set selIds to id of selected to dos
1266
- end try
1267
- end tell
1268
- if (count of selIds) is 0 then return "NOTSEL no to-do is selected after the reveal (expected ${u}) — the show URL navigated without selecting an eligible row"
1269
- if (count of selIds) is greater than 1 then return "NOTSEL " & (count of selIds) & " to-dos are selected, expected exactly the target ${u}"
1270
- set theId to (item 1 of selIds) as text
1271
- if theId is not "${u}" then return "WRONGSEL the selected to-do is " & theId & ", expected the target ${u}"
1272
- set repEnabled to false
1273
- tell application "System Events" to tell process "Things3"
1274
- try
1275
- set repEnabled to enabled of ${menuItemPath}
1276
- end try
1277
- end tell
1278
- if repEnabled is false then return "DISABLED the target ${u} is selected but its Repeat menu item is disabled (not an eligible row for this action)"
1279
- return "OK"`;
1359
+ return `set t0 to (current date)
1360
+ set verdict to my aeCheck()
1361
+ repeat until verdict is "OK"
1362
+ if ((current date) - t0) is greater than or equal to ${pollSeconds(settleMs)} then exit repeat
1363
+ delay ${IN_SCRIPT_POLL_S}
1364
+ set verdict to my aeCheck()
1365
+ end repeat
1366
+ return verdict
1367
+
1368
+ on aeCheck()
1369
+ set selIds to {}
1370
+ tell application "Things3"
1371
+ try
1372
+ set selIds to id of selected to dos
1373
+ end try
1374
+ end tell
1375
+ if (count of selIds) is 0 then return "NOTSEL no to-do is selected after the reveal (expected ${u}) the show URL navigated without selecting an eligible row"
1376
+ if (count of selIds) is greater than 1 then return "NOTSEL " & (count of selIds) & " to-dos are selected, expected exactly the target ${u}"
1377
+ set theId to (item 1 of selIds) as text
1378
+ if theId is not "${u}" then return "WRONGSEL the selected to-do is " & theId & ", expected the target ${u}"
1379
+ set repEnabled to false
1380
+ tell application "System Events" to tell process "Things3"
1381
+ try
1382
+ set repEnabled to enabled of ${menuItemPath}
1383
+ end try
1384
+ end tell
1385
+ if repEnabled is false then return "DISABLED the target ${u} is selected but its Repeat menu item is disabled (not an eligible row for this action)"
1386
+ return "OK"
1387
+ end aeCheck`;
1280
1388
  }
1281
1389
  /** activate: foreground Things (the fallback preamble step). */
1282
1390
  export function axActivateScript() {
@@ -2186,24 +2294,32 @@ export function judgeFocusGuard(state, expectedSheet, label) {
2186
2294
  * state, taken immediately before the hop, and the in-script assertions close
2187
2295
  * the remaining milliseconds (UI-automation determinism doctrine; the #595
2188
2296
  * pre-commit audit and BEEP1 shape-settle are the same pattern).
2297
+ *
2298
+ * FOLDED for keystroke-class hops (DRVLAT1, issue #633). Those hops are
2299
+ * AppleScript, so the census can be — and now is — the PRELUDE OF THE VERY SCRIPT
2300
+ * THAT TYPES ({@link axFocusGuardPrelude}) rather than a hop of its own. Two
2301
+ * things improve at once: the drive stops paying a process spawn per typed
2302
+ * control, and the TOCTOU window between "the census approved this" and "the
2303
+ * keystroke went out" closes to nothing — no dispatch happens in between, because
2304
+ * there is nothing in between. The judgement is still made BEFORE the keystroke
2305
+ * (in-script, fail-closed) and the sentence a caller reads is still built HERE by
2306
+ * {@link judgeFocusGuard}, from the census that same hop logged, so there remains
2307
+ * exactly one wording of every refusal.
2308
+ *
2309
+ * POINTER-class hops keep the separate census: they dispatch JXA, which cannot
2310
+ * carry an AppleScript prelude.
2189
2311
  */
2190
2312
  function guardedRun(inner, latch) {
2191
2313
  return async (command, timeoutMs) => {
2192
- if (!KEYSTROKE_CLASS.has(command.primitive) && !POINTER_CLASS.has(command.primitive)) {
2314
+ const keystroke = KEYSTROKE_CLASS.has(command.primitive);
2315
+ if (!keystroke && !POINTER_CLASS.has(command.primitive)) {
2193
2316
  return inner(command, timeoutMs);
2194
2317
  }
2195
- const state = await readUiState(inner, CENSUS_TIMEOUT_MS);
2196
- // An inspection that would not answer poisons every later inspection's
2197
- // credibility, so the cleanup ladder is told once and never asks again
2198
- // (issue #629).
2199
- if (state === null || censusUnverifiable(state))
2200
- latch.inspectionStalled = true;
2201
2318
  // The dialog invariant applies to keystroke-class hops only: a pointer hop
2202
2319
  // is aimed at a frame it resolved a moment ago and fails closed on its own
2203
2320
  // if that frame moved.
2204
- const expected = KEYSTROKE_CLASS.has(command.primitive) ? latch.sheet : null;
2205
- const guardRefusal = judgeFocusGuard(state, expected, command.label);
2206
- if (guardRefusal !== null) {
2321
+ const expected = keystroke ? latch.sheet : null;
2322
+ const refuse = (state, why) => {
2207
2323
  trace(() => ({
2208
2324
  phase: "focus-guard",
2209
2325
  event: "refused",
@@ -2214,10 +2330,41 @@ function guardedRun(inner, latch) {
2214
2330
  inspectable: state?.inspectable ?? false,
2215
2331
  stalled: state?.stalledProbes ?? null,
2216
2332
  }));
2217
- return { ok: false, stdout: "", stderr: guardRefusal };
2333
+ return { ok: false, stdout: "", stderr: why };
2334
+ };
2335
+ // An inspection that would not answer poisons every later inspection's
2336
+ // credibility, so the cleanup ladder is told once and never asks again
2337
+ // (issue #629).
2338
+ const noteStall = (state) => {
2339
+ if (state === null || censusUnverifiable(state))
2340
+ latch.inspectionStalled = true;
2341
+ };
2342
+ // Latched only on a census that APPROVED the hop — a dialog seen while
2343
+ // refusing is, by construction, not the one this drive is driving.
2344
+ const latchSheet = (state) => {
2345
+ if (state !== null && state.sheetOpen && latch.sheet === null)
2346
+ latch.sheet = state.sheetKind;
2347
+ };
2348
+ if (keystroke && command.lang !== "javascript" && typeof command.script === "string") {
2349
+ const res = await inner({ ...command, script: `${axFocusGuardPrelude(expected)}\n${command.script}` }, timeoutMs);
2350
+ const { state, stderr } = parseGuardLog(res.stderr);
2351
+ noteStall(state);
2352
+ if (!res.ok && stderr.includes(GUARD_REFUSED_TAG)) {
2353
+ // The script refused. Re-judge the census it logged for the sentence; a
2354
+ // census too damaged to re-judge still refuses — the hop already did.
2355
+ return refuse(state, judgeFocusGuard(state, expected, command.label) ??
2356
+ `refused to run "${command.label}": the window and focus state could not be read, so ` +
2357
+ "there is no proof the input would reach Things — nothing was sent");
2358
+ }
2359
+ latchSheet(state);
2360
+ return { ...res, stderr };
2218
2361
  }
2219
- if (state !== null && state.sheetOpen && latch.sheet === null)
2220
- latch.sheet = state.sheetKind;
2362
+ const state = await readUiState(inner, CENSUS_TIMEOUT_MS);
2363
+ noteStall(state);
2364
+ const guardRefusal = judgeFocusGuard(state, expected, command.label);
2365
+ if (guardRefusal !== null)
2366
+ return refuse(state, guardRefusal);
2367
+ latchSheet(state);
2221
2368
  return inner(command, timeoutMs);
2222
2369
  };
2223
2370
  }
@@ -2295,40 +2442,43 @@ function canaryPaths(recipe) {
2295
2442
  }
2296
2443
  return out;
2297
2444
  }
2298
- /**
2299
- * Resolve a step's effective element path. A `pathCandidates` step dispatches
2300
- * against the FIRST candidate that exists (the dialog-form disjunction — attached
2301
- * sheet vs detached AXUnknown window, UIC4-a). The candidates are POLLED over a
2302
- * bounded window because the full-vocabulary controls are REVEALED by the
2303
- * preceding step: switching the frequency pop-up to weekly/monthly/yearly (or
2304
- * ticking Ends=after) re-lays-out the cadence group, and the new pop-up/field
2305
- * lands ~250 ms later (UIC6). A single immediate exists-check races that render
2306
- * and would spuriously fail closed; polling matches the `dynamic` nature these
2307
- * steps already declare. Returns null when none resolve within the window.
2308
- */
2309
- async function resolveStepPath(step, run) {
2310
- if (step.pathCandidates === undefined)
2311
- return step.path ?? null;
2312
- const candidates = step.pathCandidates;
2313
- const deadline = Date.now() + (step.timeoutMs ?? RESOLVE_CANDIDATE_TIMEOUT_MS);
2314
- for (;;) {
2315
- for (const candidate of candidates) {
2316
- // candidates are tried in priority order; the first hit wins, so a race would blur which form matched
2317
- const res = await run({ primitive: "resolve", label: step.label, script: axResolveScript(candidate) }, STEP_TIMEOUT_MS);
2318
- if (res.ok && res.stdout.trim() === "true")
2319
- return candidate;
2320
- }
2321
- if (Date.now() >= deadline)
2322
- return null;
2323
- // the revealed control lands a beat after the mode switch; poll until it does
2324
- await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
2325
- }
2326
- }
2327
2445
  function refusal(detail) {
2328
2446
  return { exitCode: 1, stdout: "", stderr: detail };
2329
2447
  }
2330
- /** Compile one recipe step into its primitive command (no dispatch). */
2448
+ /**
2449
+ * Compile one recipe step into its primitive command (no dispatch).
2450
+ *
2451
+ * A CANDIDATE-ADDRESSED step compiles to ONE script that resolves its own element
2452
+ * and then acts on it (DRVLAT1, issue #633): the addressed body is generated
2453
+ * against {@link STEP_ELEMENT_REF} and {@link axCandidatePrelude} is prepended, so
2454
+ * the resolution that used to be a separate `resolve` hop (or several) now rides
2455
+ * the acting hop. Steps whose script is JXA, or that resolve their own target,
2456
+ * are unaffected.
2457
+ */
2331
2458
  export function commandForStep(step, targetUuid) {
2459
+ if (step.primitive === "wait") {
2460
+ // The whole wait is ONE hop: the candidates are polled in-script until one of
2461
+ // them exists or the step's own window elapses (DRVLAT1).
2462
+ const paths = step.pathCandidates ?? [step.path ?? ""];
2463
+ return {
2464
+ primitive: "wait",
2465
+ label: step.label,
2466
+ script: axWaitAnyScript(paths, step.timeoutMs ?? STEP_TIMEOUT_MS),
2467
+ };
2468
+ }
2469
+ if (step.pathCandidates !== undefined && step.path === undefined) {
2470
+ const candidates = step.pathCandidates;
2471
+ const inner = commandForStep({ ...step, path: STEP_ELEMENT_REF }, targetUuid);
2472
+ // Only an AppleScript body can take the AppleScript prelude; a JXA step
2473
+ // (set-datetime, the pointer primitives) resolves its own target anyway.
2474
+ if (inner.lang === "javascript" || typeof inner.script !== "string" || inner.script === "") {
2475
+ return inner;
2476
+ }
2477
+ return {
2478
+ ...inner,
2479
+ script: `${axCandidatePrelude(candidates, RESOLVE_CANDIDATE_TIMEOUT_MS)}\n${inner.script}`,
2480
+ };
2481
+ }
2332
2482
  switch (step.primitive) {
2333
2483
  case "reveal":
2334
2484
  return { primitive: "reveal", label: step.label, url: revealUrl(step.value ?? targetUuid) };
@@ -2418,8 +2568,6 @@ export function commandForStep(step, targetUuid) {
2418
2568
  // shape-selected group pop-up index of the first weekday row (RDLG2).
2419
2569
  script: axConvergeWeekdaysScript(step.path ?? "", weekdayBaseOf(step.value ?? ""), weekdayTitlesOf(step.value ?? "")),
2420
2570
  };
2421
- case "wait":
2422
- return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
2423
2571
  case "select-row":
2424
2572
  return {
2425
2573
  primitive: "select-row",
@@ -2535,12 +2683,13 @@ async function driveClickElement(step, run) {
2535
2683
  };
2536
2684
  }
2537
2685
  if (step.assertPath !== undefined) {
2538
- const ok = await waitForElement({
2686
+ // one hop, polled in-script (DRVLAT1)
2687
+ const res = await run({
2539
2688
  primitive: "wait",
2540
2689
  label: step.assertLabel ?? step.label,
2541
- script: axResolveScript(step.assertPath),
2542
- }, step.assertTimeoutMs ?? STEP_TIMEOUT_MS, run);
2543
- if (!ok) {
2690
+ script: axWaitAnyScript([step.assertPath], step.assertTimeoutMs ?? WAIT_ASSERT_TIMEOUT_MS),
2691
+ }, STEP_TIMEOUT_MS);
2692
+ if (!(res.ok && res.stdout.trim() === "true")) {
2544
2693
  return {
2545
2694
  ok: false,
2546
2695
  why: `${step.assertLabel ?? "the expected element"} did not appear after the click`,
@@ -2705,6 +2854,7 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2705
2854
  exitCode: 1,
2706
2855
  stdout: `ui drive watchdog stopped after ${done.length} step(s): ${done.join(" → ") || "nothing"}`,
2707
2856
  stderr: `ui drive exceeded its ${Math.round(budgetMs / 1000)}s budget at "${lastStep}"`,
2857
+ steps: [...done],
2708
2858
  timedOut: true,
2709
2859
  watchdog: {
2710
2860
  budgetMs,
@@ -2738,7 +2888,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2738
2888
  // reachable to be driven — which is not the app accepting a command and
2739
2889
  // changing nothing, and must not be reported as that.
2740
2890
  const cause = clear?.state === "cleared-blind" ? "unreachable" : stepTimedOut ? "unresponsive" : null;
2741
- const res = refusal(base + cleanup);
2891
+ // The step list rides EVERY partial exit: a failure always carries the
2892
+ // play-by-play, which is what made the field bug reports rich (#632). The
2893
+ // step that stopped the drive is named as the last entry so the list reads
2894
+ // as the whole attempt, not only the part that worked.
2895
+ const res = { ...refusal(base + cleanup), steps: [...done, `${failed} — FAILED: ${why}`] };
2742
2896
  if (cause === null)
2743
2897
  return res;
2744
2898
  return {
@@ -2772,9 +2926,9 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2772
2926
  done.push(step.label);
2773
2927
  idx += 1;
2774
2928
  }
2775
- // Let the selection settle so the menu bar repopulates before the canary reads it.
2776
- if (idx > 0)
2777
- await new Promise((r) => setTimeout(r, SETTLE_AFTER_REVEAL_MS));
2929
+ // The menu bar repopulates around the new selection a beat after the preamble
2930
+ // (UIC1). That beat is WAITED OUT IN THE CANARY below, which polls each element
2931
+ // it must resolve — no fixed settle stands here any more (DRVLAT1, issue #633).
2778
2932
  // 0½. Session-reachability GATE for dialog-class ops (SESSGATE, #480). A recipe
2779
2933
  // that opens a sheet on the main window needs that window AX-reachable on
2780
2934
  // the current Space. Probed AFTER the preamble (which surfaces a window in a
@@ -2816,7 +2970,13 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2816
2970
  // pressed. (This is also the localization check: English titles must resolve.)
2817
2971
  for (const { path, label } of canaryPaths(recipe)) {
2818
2972
  // the canary resolves elements one at a time; a single miss aborts before anything is pressed, so parallelizing would waste work and blur which element failed
2819
- const res = await run({ primitive: "resolve", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
2973
+ // POLLED, not snapped: this is where the drive waits out the menu-bar
2974
+ // repopulation the preamble triggered (DRVLAT1 — it replaces the fixed settle).
2975
+ const res = await run({
2976
+ primitive: "resolve",
2977
+ label,
2978
+ script: axWaitAnyScript([path], MENU_SETTLE_TIMEOUT_MS),
2979
+ }, STEP_TIMEOUT_MS);
2820
2980
  if (!res.ok || res.stdout.trim() !== "true") {
2821
2981
  // (e) A leftover modal sheet/popover from an earlier aborted drive disables
2822
2982
  // the menu bar, so the Items ▸ Repeat path cannot resolve. Detect that
@@ -2877,9 +3037,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2877
3037
  return watchdogResult(step.label);
2878
3038
  if (step.primitive === "wait") {
2879
3039
  // A candidate-addressed wait polls for ANY of its shapes to appear (the
2880
- // dialog opening as an attached sheet OR a detached AXUnknown window).
3040
+ // dialog opening as an attached sheet OR a detached AXUnknown window) — the
3041
+ // whole poll inside ONE hop (DRVLAT1).
2881
3042
  // steps are strictly sequential: this wait must resolve before the step that acts on the awaited element runs
2882
- const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
3043
+ const res = await run(commandForStep(step, recipe.targetUuid), STEP_TIMEOUT_MS);
3044
+ const ok = res.ok && res.stdout.trim() === "true";
2883
3045
  if (!ok) {
2884
3046
  // the abort keystroke must land (and be verified) before returning the partial-state report
2885
3047
  const clear = await clearNow();
@@ -2917,19 +3079,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2917
3079
  done.push(`${step.label} (${outcome.detail})`);
2918
3080
  continue;
2919
3081
  }
2920
- // Resolve a candidate-addressed step's effective element before dispatch
2921
- // (the sheet-vs-detached-window disjunction). A miss fails closed.
2922
- if (step.pathCandidates !== undefined) {
2923
- // the effective form must be resolved before this step can act on it
2924
- const effective = await resolveStepPath(step, run);
2925
- if (effective === null) {
2926
- // dismiss whatever opened (and verify) before reporting
2927
- const clear = await clearNow();
2928
- return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
2929
- "detached repeat editor window)", clear);
2930
- }
2931
- step = { ...step, path: effective };
2932
- }
3082
+ // A candidate-addressed step resolves its effective element (the
3083
+ // sheet-vs-detached-window disjunction) INSIDE its own script now — see
3084
+ // commandForStep / axCandidatePrelude. A miss raises CANDIDATES_MISSED there
3085
+ // and lands on this step's ordinary failure path, with the same wording and
3086
+ // the same clean abort it had when the resolution was its own hop (DRVLAT1).
2933
3087
  const command = commandForStep(step, recipe.targetUuid);
2934
3088
  if (step.primitive === "probe-dialog-shape") {
2935
3089
  // MEASURE the dialog (RDLG2) before any shape-dependent control is touched.
@@ -3045,39 +3199,12 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
3045
3199
  exitCode: 0,
3046
3200
  stdout: `${relocationNote}drove ${done.length} step(s): ${done.join(" → ")}`,
3047
3201
  stderr: "",
3202
+ // The same play-by-play as a LIST (#632). `stdout` keeps the prose form the
3203
+ // trace and the transport-failure paths already read; `steps` is what the
3204
+ // change-history record stores and `--verbose` renders.
3205
+ steps: relocationNote === "" ? [...done] : [relocationNote.trim(), ...done],
3048
3206
  };
3049
3207
  }
3050
- async function waitForElement(command, timeoutMs, run) {
3051
- const deadline = Date.now() + timeoutMs;
3052
- for (;;) {
3053
- // polling the same element until it appears is inherently sequential
3054
- const res = await run(command, STEP_TIMEOUT_MS);
3055
- if (res.ok && res.stdout.trim() === "true")
3056
- return true;
3057
- if (Date.now() >= deadline)
3058
- return false;
3059
- // inter-poll delay between sequential existence checks
3060
- await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
3061
- }
3062
- }
3063
- /** Poll until ANY of the candidate element shapes exists (the sheet-vs-detached-window disjunction). */
3064
- async function waitForAnyElement(paths, label, timeoutMs, run) {
3065
- const deadline = Date.now() + timeoutMs;
3066
- for (;;) {
3067
- for (const path of paths) {
3068
- // Emitted as the `wait` primitive (not `resolve`) so the command stream a
3069
- // caller observes is unchanged from the single-path waitForElement.
3070
- // candidates checked in priority order; the first present shape ends the wait
3071
- const res = await run({ primitive: "wait", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
3072
- if (res.ok && res.stdout.trim() === "true")
3073
- return true;
3074
- }
3075
- if (Date.now() >= deadline)
3076
- return false;
3077
- // inter-poll delay between sequential existence checks
3078
- await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
3079
- }
3080
- }
3081
3208
  function enabledMatrix() {
3082
3209
  const matrix = {};
3083
3210
  for (const op of UI_DRIVE_OPS) {