things-api 0.19.2 → 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 (66) 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/index.d.ts +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/mcp/server.js +7 -0
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/op-result.d.ts +9 -0
  18. package/dist/op-result.js +5 -0
  19. package/dist/op-result.js.map +1 -1
  20. package/dist/ui-state.d.ts +1 -1
  21. package/dist/ui-state.js +33 -8
  22. package/dist/ui-state.js.map +1 -1
  23. package/dist/write/batch.js +4 -1
  24. package/dist/write/batch.js.map +1 -1
  25. package/dist/write/clone.js +12 -6
  26. package/dist/write/clone.js.map +1 -1
  27. package/dist/write/disclosures.d.ts +263 -0
  28. package/dist/write/disclosures.js +265 -0
  29. package/dist/write/disclosures.js.map +1 -0
  30. package/dist/write/heading.js +4 -7
  31. package/dist/write/heading.js.map +1 -1
  32. package/dist/write/make-repeating-project.js +2 -1
  33. package/dist/write/make-repeating-project.js.map +1 -1
  34. package/dist/write/opid.js +5 -3
  35. package/dist/write/opid.js.map +1 -1
  36. package/dist/write/pipeline.d.ts +39 -1
  37. package/dist/write/pipeline.js +49 -23
  38. package/dist/write/pipeline.js.map +1 -1
  39. package/dist/write/promote-clone.js +46 -36
  40. package/dist/write/promote-clone.js.map +1 -1
  41. package/dist/write/reorder.js +45 -39
  42. package/dist/write/reorder.js.map +1 -1
  43. package/dist/write/resolution-timestamps.js +4 -1
  44. package/dist/write/resolution-timestamps.js.map +1 -1
  45. package/dist/write/spawn-expectation.d.ts +135 -0
  46. package/dist/write/spawn-expectation.js +237 -0
  47. package/dist/write/spawn-expectation.js.map +1 -0
  48. package/dist/write/template-mutation.js +20 -33
  49. package/dist/write/template-mutation.js.map +1 -1
  50. package/dist/write/vectors/types.d.ts +12 -0
  51. package/dist/write/vectors/ui-recipes.js +11 -1
  52. package/dist/write/vectors/ui-recipes.js.map +1 -1
  53. package/dist/write/vectors/ui-state.d.ts +136 -6
  54. package/dist/write/vectors/ui-state.js +372 -70
  55. package/dist/write/vectors/ui-state.js.map +1 -1
  56. package/dist/write/vectors/ui.d.ts +62 -2
  57. package/dist/write/vectors/ui.js +401 -151
  58. package/dist/write/vectors/ui.js.map +1 -1
  59. package/dist/write/verify/delta.d.ts +17 -2
  60. package/dist/write/verify/delta.js +46 -16
  61. package/dist/write/verify/delta.js.map +1 -1
  62. package/dist/write/verify/poller.d.ts +3 -2
  63. package/dist/write/verify/poller.js +3 -1
  64. package/dist/write/verify/poller.js.map +1 -1
  65. package/package.json +1 -1
  66. 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, describeFocusOwner, 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() {
@@ -1483,6 +1591,25 @@ ${AX_DIALOG_SHELL_SNIPPET}
1483
1591
  return "OK"
1484
1592
  end tell`;
1485
1593
  }
1594
+ /**
1595
+ * The Cancel button's on-screen FRAME, resolved through the same addressed
1596
+ * dialog-shell path the AXPress dismissal uses (issue #629). Feeds the pointer
1597
+ * fallback: if `AXPress` on the button reports success and the dialog is still
1598
+ * standing, a real click at the button's own AX-resolved centre is the next
1599
+ * thing to try before discarding the window wholesale. The frame comes from the
1600
+ * tree, never from a remembered coordinate, so a moved dialog fails closed.
1601
+ */
1602
+ export function axCancelFrameScript() {
1603
+ return `${SE}
1604
+ ${AX_DIALOG_SHELL_SNIPPET}
1605
+ if shellRef is missing value then error "no dialog is open"
1606
+ if not (exists button "Cancel" of shellRef) then error "the open dialog has no Cancel button"
1607
+ set _b to button "Cancel" of shellRef
1608
+ set _p to position of _b
1609
+ set _s to size of _b
1610
+ return ((item 1 of _p) as text) & " " & ((item 2 of _p) as text) & " " & ((item 1 of _s) as text) & " " & ((item 2 of _s) as text)
1611
+ end tell`;
1612
+ }
1486
1613
  /**
1487
1614
  * The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
1488
1615
  * recovery): close the front Things window — which takes an attached modal sheet
@@ -1570,6 +1697,67 @@ async function pressCancel(run) {
1570
1697
  }, STEP_TIMEOUT_MS);
1571
1698
  return res.ok && res.stdout.trim() === "OK";
1572
1699
  }
1700
+ /**
1701
+ * The pointer fallback for the Cancel rung (issue #629): a real click at the
1702
+ * button's own AX-resolved centre, for the case where `AXPress` reports success
1703
+ * and the dialog is demonstrably still standing. Needs Things frontmost — the
1704
+ * HID tap posts at the foreground surface (NATIVE1-e) — so it activates first,
1705
+ * and re-reads the frame AFTERWARDS, because bringing Things forward can
1706
+ * re-attach a detached editor as a sheet and move the button.
1707
+ */
1708
+ async function clickCancel(run) {
1709
+ await run({
1710
+ primitive: "activate",
1711
+ label: "bring Things forward to click its dialog's Cancel button",
1712
+ script: axActivateScript(),
1713
+ }, STEP_TIMEOUT_MS);
1714
+ const frameRes = await run({
1715
+ primitive: "resolve-frame",
1716
+ label: "locate the open dialog's Cancel button",
1717
+ script: axCancelFrameScript(),
1718
+ }, STEP_TIMEOUT_MS);
1719
+ if (!frameRes.ok)
1720
+ return false;
1721
+ const center = parseFrameCenter(frameRes.stdout);
1722
+ if (center === null)
1723
+ return false;
1724
+ const res = await run(clickPointCommand(center.x, center.y, "click the open dialog's Cancel button"), STEP_TIMEOUT_MS);
1725
+ return res.ok;
1726
+ }
1727
+ /**
1728
+ * The dismissal that needs NO working inspection (issue #629): press the
1729
+ * dialog's own Cancel button, PROVE the dialog is gone with one addressed
1730
+ * existence read, and fall through to a real click at the button's frame if the
1731
+ * press reported success while the dialog stayed up.
1732
+ *
1733
+ * This is the rung the field incident needed and did not have. Its cleanup
1734
+ * re-ran the census that had just stalled, learned nothing three times over,
1735
+ * and ended in the AX-blind close+reopen — which left the sheet standing, and
1736
+ * with it the app-wide scripting freeze that stopped the composite trashing its
1737
+ * own disposable copy (MODALX1 §2.1) and the Things Cloud sync gate.
1738
+ *
1739
+ * Everything here is addressed inside `process "Things3"`: {@link
1740
+ * axCancelDialogScript} and {@link axSheetOpenScript} are the same shape as the
1741
+ * drive steps that kept working while the census did not.
1742
+ */
1743
+ async function semanticCancel(run, expected, owner) {
1744
+ const kind = expected === null ? {} : { sheetKind: expected };
1745
+ // A stack unwinds LIFO (MODALX1 §6), so press-and-verify in a loop rather
1746
+ // than pressing once and assuming.
1747
+ for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
1748
+ if (!(await pressCancel(run)))
1749
+ break;
1750
+ if (!(await sheetStillOpen(run))) {
1751
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner, unverified: true };
1752
+ }
1753
+ }
1754
+ if (await clickCancel(run)) {
1755
+ if (!(await sheetStillOpen(run))) {
1756
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner, unverified: true };
1757
+ }
1758
+ }
1759
+ return closeReopenRung(run, expected, owner, false, true);
1760
+ }
1573
1761
  /**
1574
1762
  * Clear a half-open dialog a failed drive left behind — AUDITED at every rung
1575
1763
  * (issue #620; supersedes the unconditional Escape, which was measured firing
@@ -1594,8 +1782,15 @@ async function pressCancel(run) {
1594
1782
  * `expected` is the dialog kind this drive was observed driving; see
1595
1783
  * {@link oursToDismiss}.
1596
1784
  */
1597
- async function clearDialog(run, expected = null) {
1598
- const census = await readUiState(run, STEP_TIMEOUT_MS);
1785
+ async function clearDialog(run, expected = null, inspectionStalled = false) {
1786
+ // #629: the inspection already refused to answer once. Asking it again buys
1787
+ // nothing and costs the caller another deadline — go straight to the rung
1788
+ // that needs no inspection and proves itself with one addressed read.
1789
+ if (inspectionStalled)
1790
+ return semanticCancel(run, expected, {});
1791
+ const census = await readUiState(run, CENSUS_TIMEOUT_MS);
1792
+ if (censusUnverifiable(census))
1793
+ return semanticCancel(run, expected, {});
1599
1794
  const owner = census !== null && !census.thingsFrontmost ? { focusOwner: describeFocusOwner(census) } : {};
1600
1795
  const readable = census !== null && census.inspectable;
1601
1796
  // 0. A clean, readable "no dialog" — but only trustworthy on a session whose
@@ -1623,7 +1818,7 @@ async function clearDialog(run, expected = null) {
1623
1818
  for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
1624
1819
  if (!(await pressCancel(run)))
1625
1820
  break;
1626
- const after = await readUiState(run, STEP_TIMEOUT_MS);
1821
+ const after = await readUiState(run, CENSUS_TIMEOUT_MS);
1627
1822
  if (after === null || !after.inspectable)
1628
1823
  break;
1629
1824
  if (!after.sheetOpen)
@@ -1642,7 +1837,7 @@ async function clearDialog(run, expected = null) {
1642
1837
  label: "bring Things forward to dismiss its dialog",
1643
1838
  script: axActivateScript(),
1644
1839
  }, STEP_TIMEOUT_MS);
1645
- const reaudit = await readUiState(run, STEP_TIMEOUT_MS);
1840
+ const reaudit = await readUiState(run, CENSUS_TIMEOUT_MS);
1646
1841
  if (reaudit !== null && reaudit.inspectable) {
1647
1842
  if (!reaudit.sheetOpen)
1648
1843
  return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
@@ -1654,7 +1849,7 @@ async function clearDialog(run, expected = null) {
1654
1849
  }
1655
1850
  if (front) {
1656
1851
  await run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
1657
- const after = await readUiState(run, STEP_TIMEOUT_MS);
1852
+ const after = await readUiState(run, CENSUS_TIMEOUT_MS);
1658
1853
  if (after !== null && after.inspectable && !after.sheetOpen) {
1659
1854
  return { state: "dismissed", how: "escape", ...kind, ...owner };
1660
1855
  }
@@ -1668,7 +1863,13 @@ async function clearDialog(run, expected = null) {
1668
1863
  * `blind` says the session itself is AX-blind, in which case NOTHING the census
1669
1864
  * reports afterwards is evidence — the outcome is honestly unconfirmed.
1670
1865
  */
1671
- async function closeReopenRung(run, expected, owner, blind = false) {
1866
+ async function closeReopenRung(run, expected, owner, blind = false,
1867
+ /**
1868
+ * #629: the window-state inspection is not answering, so the outcome is
1869
+ * decided by the ADDRESSED sheet-open read instead of a fresh census. The
1870
+ * verdict is still proven — just proven by a narrower question.
1871
+ */
1872
+ inspectionStalled = false) {
1672
1873
  await run({
1673
1874
  primitive: "resolve",
1674
1875
  label: "clear a stuck dialog (close the Things window and reopen it)",
@@ -1676,7 +1877,14 @@ async function closeReopenRung(run, expected, owner, blind = false) {
1676
1877
  }, STEP_TIMEOUT_MS);
1677
1878
  if (blind)
1678
1879
  return { state: "cleared-blind", how: "window-close", ...owner };
1679
- const after = await readUiState(run, STEP_TIMEOUT_MS);
1880
+ if (inspectionStalled) {
1881
+ const kind = expected === null ? {} : { sheetKind: expected };
1882
+ if (!(await sheetStillOpen(run))) {
1883
+ return { state: "dismissed", how: "window-close", ...kind, ...owner, unverified: true };
1884
+ }
1885
+ return { state: "may-remain", ...kind, ...owner, unverified: true };
1886
+ }
1887
+ const after = await readUiState(run, CENSUS_TIMEOUT_MS);
1680
1888
  if (after === null || !after.inspectable) {
1681
1889
  return { state: "cleared-blind", how: "window-close", ...owner };
1682
1890
  }
@@ -1965,7 +2173,7 @@ async function defaultRun(command, timeoutMs) {
1965
2173
  * nothing, and changes no state; see src/write/vectors/ui-state.ts.
1966
2174
  */
1967
2175
  export function readLiveUiState(run = defaultRun) {
1968
- return readUiState(run, STEP_TIMEOUT_MS);
2176
+ return readUiState(run, CENSUS_TIMEOUT_MS);
1969
2177
  }
1970
2178
  /**
1971
2179
  * Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
@@ -2053,6 +2261,13 @@ export function judgeFocusGuard(state, expectedSheet, label) {
2053
2261
  return refuse("the window and focus state could not be read, so there is no proof the input would reach " +
2054
2262
  "Things — nothing was sent");
2055
2263
  }
2264
+ // #629: a probe that did not come back is a DIAGNOSTIC, not a state. Say so
2265
+ // in those words, name what could not be established, and route the drive
2266
+ // straight to its cleanup — the caller must not read this as "retry".
2267
+ if (censusUnverifiable(state)) {
2268
+ return refuse(`the window state inspection timed out — treating the dialog as unverifiable (${describeUnprovenProbes(state)}). Nothing was sent, and the dialog this command opened is being closed. Check that Things ` +
2269
+ "is responding, then run the same command again");
2270
+ }
2056
2271
  if (!state.inspectable) {
2057
2272
  return refuse(`${describeFocusOwner(state)}. Input sent now would go to it, not to Things — nothing was ` +
2058
2273
  "sent. Answer or dismiss the system dialog, then run the same command again");
@@ -2079,19 +2294,32 @@ export function judgeFocusGuard(state, expectedSheet, label) {
2079
2294
  * state, taken immediately before the hop, and the in-script assertions close
2080
2295
  * the remaining milliseconds (UI-automation determinism doctrine; the #595
2081
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.
2082
2311
  */
2083
2312
  function guardedRun(inner, latch) {
2084
2313
  return async (command, timeoutMs) => {
2085
- 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)) {
2086
2316
  return inner(command, timeoutMs);
2087
2317
  }
2088
- const state = await readUiState(inner, STEP_TIMEOUT_MS);
2089
2318
  // The dialog invariant applies to keystroke-class hops only: a pointer hop
2090
2319
  // is aimed at a frame it resolved a moment ago and fails closed on its own
2091
2320
  // if that frame moved.
2092
- const expected = KEYSTROKE_CLASS.has(command.primitive) ? latch.sheet : null;
2093
- const guardRefusal = judgeFocusGuard(state, expected, command.label);
2094
- if (guardRefusal !== null) {
2321
+ const expected = keystroke ? latch.sheet : null;
2322
+ const refuse = (state, why) => {
2095
2323
  trace(() => ({
2096
2324
  phase: "focus-guard",
2097
2325
  event: "refused",
@@ -2100,11 +2328,43 @@ function guardedRun(inner, latch) {
2100
2328
  frontmost: state?.frontmostApp ?? null,
2101
2329
  sheetKind: state?.sheetKind ?? null,
2102
2330
  inspectable: state?.inspectable ?? false,
2331
+ stalled: state?.stalledProbes ?? null,
2103
2332
  }));
2104
- 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 };
2105
2361
  }
2106
- if (state !== null && state.sheetOpen && latch.sheet === null)
2107
- 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);
2108
2368
  return inner(command, timeoutMs);
2109
2369
  };
2110
2370
  }
@@ -2127,6 +2387,10 @@ function dialogNoun(kind) {
2127
2387
  */
2128
2388
  export function describeCleanup(clear) {
2129
2389
  const owner = clear.focusOwner === undefined ? "" : ` (${clear.focusOwner} when cleanup started)`;
2390
+ // #629: when the window-state inspection stalled, the cleanup still ran and
2391
+ // still proved its outcome — through the narrower addressed read. Say which
2392
+ // it was, so nobody reads "confirmed closed" as more than it is.
2393
+ const how = clear.unverified === true ? " (the window state could not be inspected)" : "";
2130
2394
  switch (clear.state) {
2131
2395
  case "none":
2132
2396
  return "No dialog was left open in Things.";
@@ -2135,7 +2399,7 @@ export function describeCleanup(clear) {
2135
2399
  ? `${dialogNoun(clear.sheetKind)} was closed with its own Cancel button`
2136
2400
  : clear.how === "escape"
2137
2401
  ? `${dialogNoun(clear.sheetKind)} was dismissed with Escape`
2138
- : `${dialogNoun(clear.sheetKind)} was cleared by closing and reopening the Things window`}, confirmed closed${owner}.`;
2402
+ : `${dialogNoun(clear.sheetKind)} was cleared by closing and reopening the Things window`}, confirmed closed${how}${owner}.`;
2139
2403
  case "cleared-blind":
2140
2404
  return ("Things had no window reachable on the current screen (the Mac may be locked, or a" +
2141
2405
  " full-screen app is covering the desktop), so the open dialog could not be confirmed" +
@@ -2178,40 +2442,43 @@ function canaryPaths(recipe) {
2178
2442
  }
2179
2443
  return out;
2180
2444
  }
2181
- /**
2182
- * Resolve a step's effective element path. A `pathCandidates` step dispatches
2183
- * against the FIRST candidate that exists (the dialog-form disjunction — attached
2184
- * sheet vs detached AXUnknown window, UIC4-a). The candidates are POLLED over a
2185
- * bounded window because the full-vocabulary controls are REVEALED by the
2186
- * preceding step: switching the frequency pop-up to weekly/monthly/yearly (or
2187
- * ticking Ends=after) re-lays-out the cadence group, and the new pop-up/field
2188
- * lands ~250 ms later (UIC6). A single immediate exists-check races that render
2189
- * and would spuriously fail closed; polling matches the `dynamic` nature these
2190
- * steps already declare. Returns null when none resolve within the window.
2191
- */
2192
- async function resolveStepPath(step, run) {
2193
- if (step.pathCandidates === undefined)
2194
- return step.path ?? null;
2195
- const candidates = step.pathCandidates;
2196
- const deadline = Date.now() + (step.timeoutMs ?? RESOLVE_CANDIDATE_TIMEOUT_MS);
2197
- for (;;) {
2198
- for (const candidate of candidates) {
2199
- // candidates are tried in priority order; the first hit wins, so a race would blur which form matched
2200
- const res = await run({ primitive: "resolve", label: step.label, script: axResolveScript(candidate) }, STEP_TIMEOUT_MS);
2201
- if (res.ok && res.stdout.trim() === "true")
2202
- return candidate;
2203
- }
2204
- if (Date.now() >= deadline)
2205
- return null;
2206
- // the revealed control lands a beat after the mode switch; poll until it does
2207
- await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
2208
- }
2209
- }
2210
2445
  function refusal(detail) {
2211
2446
  return { exitCode: 1, stdout: "", stderr: detail };
2212
2447
  }
2213
- /** 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
+ */
2214
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
+ }
2215
2482
  switch (step.primitive) {
2216
2483
  case "reveal":
2217
2484
  return { primitive: "reveal", label: step.label, url: revealUrl(step.value ?? targetUuid) };
@@ -2301,8 +2568,6 @@ export function commandForStep(step, targetUuid) {
2301
2568
  // shape-selected group pop-up index of the first weekday row (RDLG2).
2302
2569
  script: axConvergeWeekdaysScript(step.path ?? "", weekdayBaseOf(step.value ?? ""), weekdayTitlesOf(step.value ?? "")),
2303
2570
  };
2304
- case "wait":
2305
- return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
2306
2571
  case "select-row":
2307
2572
  return {
2308
2573
  primitive: "select-row",
@@ -2418,12 +2683,13 @@ async function driveClickElement(step, run) {
2418
2683
  };
2419
2684
  }
2420
2685
  if (step.assertPath !== undefined) {
2421
- const ok = await waitForElement({
2686
+ // one hop, polled in-script (DRVLAT1)
2687
+ const res = await run({
2422
2688
  primitive: "wait",
2423
2689
  label: step.assertLabel ?? step.label,
2424
- script: axResolveScript(step.assertPath),
2425
- }, step.assertTimeoutMs ?? STEP_TIMEOUT_MS, run);
2426
- 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")) {
2427
2693
  return {
2428
2694
  ok: false,
2429
2695
  why: `${step.assertLabel ?? "the expected element"} did not appear after the click`,
@@ -2552,11 +2818,12 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2552
2818
  // Every step below dispatches through the PER-STEP FOCUS GUARD (issue #620);
2553
2819
  // the latch records the dialog this drive is observed driving, so the cleanup
2554
2820
  // ladder can tell our own half-open dialog from one the user opened after us.
2555
- const latch = { sheet: null };
2821
+ const latch = { sheet: null, inspectionStalled: false };
2556
2822
  const run = guardedRun(rawRun, latch);
2557
2823
  // The cleanup ladder audits for itself (it is what decides whether a keystroke
2558
- // may be sent at all), so it runs OUTSIDE the guard.
2559
- const clearNow = () => clearDialog(rawRun, latch.sheet);
2824
+ // may be sent at all), so it runs OUTSIDE the guard — and it is told when the
2825
+ // inspection has already stalled, so it never re-runs it (issue #629).
2826
+ const clearNow = () => clearDialog(rawRun, latch.sheet, latch.inspectionStalled);
2560
2827
  const done = [];
2561
2828
  // The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
2562
2829
  // own timeout on a slow production database (large + Things-Cloud syncing
@@ -2587,6 +2854,7 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2587
2854
  exitCode: 1,
2588
2855
  stdout: `ui drive watchdog stopped after ${done.length} step(s): ${done.join(" → ") || "nothing"}`,
2589
2856
  stderr: `ui drive exceeded its ${Math.round(budgetMs / 1000)}s budget at "${lastStep}"`,
2857
+ steps: [...done],
2590
2858
  timedOut: true,
2591
2859
  watchdog: {
2592
2860
  budgetMs,
@@ -2620,7 +2888,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2620
2888
  // reachable to be driven — which is not the app accepting a command and
2621
2889
  // changing nothing, and must not be reported as that.
2622
2890
  const cause = clear?.state === "cleared-blind" ? "unreachable" : stepTimedOut ? "unresponsive" : null;
2623
- 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}`] };
2624
2896
  if (cause === null)
2625
2897
  return res;
2626
2898
  return {
@@ -2654,9 +2926,9 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2654
2926
  done.push(step.label);
2655
2927
  idx += 1;
2656
2928
  }
2657
- // Let the selection settle so the menu bar repopulates before the canary reads it.
2658
- if (idx > 0)
2659
- 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).
2660
2932
  // 0½. Session-reachability GATE for dialog-class ops (SESSGATE, #480). A recipe
2661
2933
  // that opens a sheet on the main window needs that window AX-reachable on
2662
2934
  // the current Space. Probed AFTER the preamble (which surfaces a window in a
@@ -2681,7 +2953,12 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2681
2953
  // directly, for every recipe, before anything is pressed — and a dialog
2682
2954
  // standing here also means the app is ignoring scripted changes app-wide
2683
2955
  // and holding Things Cloud sync, which is the operator's real problem.
2684
- const startState = await readUiState(rawRun, STEP_TIMEOUT_MS);
2956
+ const startState = await readUiState(rawRun, CENSUS_TIMEOUT_MS);
2957
+ // An inspection that stalls at the very first hop is remembered, so a later
2958
+ // failure's cleanup does not go asking it again (issue #629). The preflight
2959
+ // itself stays permissive — only a POSITIVE sighting refuses (MODALX1 §7).
2960
+ if (censusUnverifiable(startState))
2961
+ latch.inspectionStalled = true;
2685
2962
  if (startState !== null && startState.inspectable && startState.sheetOpen) {
2686
2963
  return refusal(`ui preflight refused: a dialog is already open in Things (${startState.sheetKind}${startState.sheetDepth > 1 ? `, on top of ${startState.sheetDepth - 1} more` : ""}), most likely left over from an earlier command or opened by hand. While one is open the ` +
2687
2964
  "app disables its menu bar, ignores keyboard input aimed at anything else, and " +
@@ -2693,7 +2970,13 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2693
2970
  // pressed. (This is also the localization check: English titles must resolve.)
2694
2971
  for (const { path, label } of canaryPaths(recipe)) {
2695
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
2696
- 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);
2697
2980
  if (!res.ok || res.stdout.trim() !== "true") {
2698
2981
  // (e) A leftover modal sheet/popover from an earlier aborted drive disables
2699
2982
  // the menu bar, so the Items ▸ Repeat path cannot resolve. Detect that
@@ -2754,9 +3037,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2754
3037
  return watchdogResult(step.label);
2755
3038
  if (step.primitive === "wait") {
2756
3039
  // A candidate-addressed wait polls for ANY of its shapes to appear (the
2757
- // 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).
2758
3042
  // steps are strictly sequential: this wait must resolve before the step that acts on the awaited element runs
2759
- 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";
2760
3045
  if (!ok) {
2761
3046
  // the abort keystroke must land (and be verified) before returning the partial-state report
2762
3047
  const clear = await clearNow();
@@ -2794,19 +3079,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2794
3079
  done.push(`${step.label} (${outcome.detail})`);
2795
3080
  continue;
2796
3081
  }
2797
- // Resolve a candidate-addressed step's effective element before dispatch
2798
- // (the sheet-vs-detached-window disjunction). A miss fails closed.
2799
- if (step.pathCandidates !== undefined) {
2800
- // the effective form must be resolved before this step can act on it
2801
- const effective = await resolveStepPath(step, run);
2802
- if (effective === null) {
2803
- // dismiss whatever opened (and verify) before reporting
2804
- const clear = await clearNow();
2805
- return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
2806
- "detached repeat editor window)", clear);
2807
- }
2808
- step = { ...step, path: effective };
2809
- }
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).
2810
3087
  const command = commandForStep(step, recipe.targetUuid);
2811
3088
  if (step.primitive === "probe-dialog-shape") {
2812
3089
  // MEASURE the dialog (RDLG2) before any shape-dependent control is touched.
@@ -2922,39 +3199,12 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2922
3199
  exitCode: 0,
2923
3200
  stdout: `${relocationNote}drove ${done.length} step(s): ${done.join(" → ")}`,
2924
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],
2925
3206
  };
2926
3207
  }
2927
- async function waitForElement(command, timeoutMs, run) {
2928
- const deadline = Date.now() + timeoutMs;
2929
- for (;;) {
2930
- // polling the same element until it appears is inherently sequential
2931
- const res = await run(command, STEP_TIMEOUT_MS);
2932
- if (res.ok && res.stdout.trim() === "true")
2933
- return true;
2934
- if (Date.now() >= deadline)
2935
- return false;
2936
- // inter-poll delay between sequential existence checks
2937
- await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
2938
- }
2939
- }
2940
- /** Poll until ANY of the candidate element shapes exists (the sheet-vs-detached-window disjunction). */
2941
- async function waitForAnyElement(paths, label, timeoutMs, run) {
2942
- const deadline = Date.now() + timeoutMs;
2943
- for (;;) {
2944
- for (const path of paths) {
2945
- // Emitted as the `wait` primitive (not `resolve`) so the command stream a
2946
- // caller observes is unchanged from the single-path waitForElement.
2947
- // candidates checked in priority order; the first present shape ends the wait
2948
- const res = await run({ primitive: "wait", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
2949
- if (res.ok && res.stdout.trim() === "true")
2950
- return true;
2951
- }
2952
- if (Date.now() >= deadline)
2953
- return false;
2954
- // inter-poll delay between sequential existence checks
2955
- await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
2956
- }
2957
- }
2958
3208
  function enabledMatrix() {
2959
3209
  const matrix = {};
2960
3210
  for (const op of UI_DRIVE_OPS) {
@@ -3031,7 +3281,7 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
3031
3281
  // composite's FIRST leg mints a row through the URL scheme, which sails
3032
3282
  // straight past an open dialog — and every AppleScript leg after it then
3033
3283
  // fails, leaving a copy behind. The orchestrator asks this BEFORE it seeds.
3034
- probeUiState: () => readUiState(tracedRun, STEP_TIMEOUT_MS),
3284
+ probeUiState: () => readUiState(tracedRun, CENSUS_TIMEOUT_MS),
3035
3285
  // Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
3036
3286
  // live session BEFORE they seed a row, so a locked/full-screen session refuses
3037
3287
  // with zero mutation. Present regardless of `enabled` (the orchestrator has