things-api 0.16.0 → 0.18.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.
Files changed (208) hide show
  1. package/README.md +1 -1
  2. package/deputy/VERSION +1 -0
  3. package/deputy/helpers-Info.plist +23 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +20 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +115 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +23 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  10. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +125 -0
  11. package/deputy/reader/Info.plist +20 -0
  12. package/deputy/reader/entitlements.plist +15 -0
  13. package/deputy/reader/main.swift +491 -0
  14. package/deputy/src/main.swift +133 -0
  15. package/deputy/src/osascript.swift +102 -0
  16. package/deputy/src/server.swift +350 -0
  17. package/deputy/src/sqlite.swift +121 -0
  18. package/dist/cli/commands/doctor.js +55 -2
  19. package/dist/cli/commands/doctor.js.map +1 -1
  20. package/dist/cli/commands/helpers.d.ts +17 -0
  21. package/dist/cli/commands/helpers.js +148 -0
  22. package/dist/cli/commands/helpers.js.map +1 -0
  23. package/dist/cli/commands/op-result.d.ts +15 -0
  24. package/dist/cli/commands/op-result.js +39 -0
  25. package/dist/cli/commands/op-result.js.map +1 -0
  26. package/dist/cli/commands/repeat-flags.d.ts +3 -3
  27. package/dist/cli/commands/repeat-flags.js +90 -33
  28. package/dist/cli/commands/repeat-flags.js.map +1 -1
  29. package/dist/cli/commands/writes.js +79 -6
  30. package/dist/cli/commands/writes.js.map +1 -1
  31. package/dist/cli/help.js +13 -2
  32. package/dist/cli/help.js.map +1 -1
  33. package/dist/cli/helpers-check.d.ts +10 -0
  34. package/dist/cli/helpers-check.js +59 -0
  35. package/dist/cli/helpers-check.js.map +1 -0
  36. package/dist/cli/interrupt.d.ts +42 -0
  37. package/dist/cli/interrupt.js +105 -0
  38. package/dist/cli/interrupt.js.map +1 -0
  39. package/dist/cli/main.js +27 -1
  40. package/dist/cli/main.js.map +1 -1
  41. package/dist/client.js +19 -2
  42. package/dist/client.js.map +1 -1
  43. package/dist/config.d.ts +70 -0
  44. package/dist/config.js +25 -1
  45. package/dist/config.js.map +1 -1
  46. package/dist/contracts.d.ts +9 -2
  47. package/dist/contracts.js +1 -1
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/db/baselines/db-v26.js +8 -4
  50. package/dist/db/baselines/db-v26.js.map +1 -1
  51. package/dist/db/baselines/db-v27.d.ts +42 -0
  52. package/dist/db/baselines/db-v27.js +47 -0
  53. package/dist/db/baselines/db-v27.js.map +1 -0
  54. package/dist/db/baselines/index.js +2 -1
  55. package/dist/db/baselines/index.js.map +1 -1
  56. package/dist/db/locate.d.ts +4 -1
  57. package/dist/db/locate.js +3 -1
  58. package/dist/db/locate.js.map +1 -1
  59. package/dist/db/schema.d.ts +1 -1
  60. package/dist/db/schema.js +9 -0
  61. package/dist/db/schema.js.map +1 -1
  62. package/dist/deputy/bridge-worker.d.ts +1 -0
  63. package/dist/deputy/bridge-worker.js +79 -0
  64. package/dist/deputy/bridge-worker.js.map +1 -0
  65. package/dist/deputy/bridge.d.ts +14 -0
  66. package/dist/deputy/bridge.js +70 -0
  67. package/dist/deputy/bridge.js.map +1 -0
  68. package/dist/deputy/client.d.ts +19 -0
  69. package/dist/deputy/client.js +104 -0
  70. package/dist/deputy/client.js.map +1 -0
  71. package/dist/deputy/db-facade.d.ts +18 -0
  72. package/dist/deputy/db-facade.js +71 -0
  73. package/dist/deputy/db-facade.js.map +1 -0
  74. package/dist/deputy/files.d.ts +1 -0
  75. package/dist/deputy/files.js +16 -0
  76. package/dist/deputy/files.js.map +1 -0
  77. package/dist/deputy/install.d.ts +105 -0
  78. package/dist/deputy/install.js +467 -0
  79. package/dist/deputy/install.js.map +1 -0
  80. package/dist/deputy/notice.d.ts +6 -0
  81. package/dist/deputy/notice.js +28 -0
  82. package/dist/deputy/notice.js.map +1 -0
  83. package/dist/deputy/notices.d.ts +31 -0
  84. package/dist/deputy/notices.js +94 -0
  85. package/dist/deputy/notices.js.map +1 -0
  86. package/dist/deputy/osa.d.ts +21 -0
  87. package/dist/deputy/osa.js +72 -0
  88. package/dist/deputy/osa.js.map +1 -0
  89. package/dist/deputy/protocol.d.ts +86 -0
  90. package/dist/deputy/protocol.js +106 -0
  91. package/dist/deputy/protocol.js.map +1 -0
  92. package/dist/deputy/routing.d.ts +63 -0
  93. package/dist/deputy/routing.js +338 -0
  94. package/dist/deputy/routing.js.map +1 -0
  95. package/dist/deputy/shortcuts-exec.d.ts +10 -0
  96. package/dist/deputy/shortcuts-exec.js +50 -0
  97. package/dist/deputy/shortcuts-exec.js.map +1 -0
  98. package/dist/diagnose.d.ts +43 -1
  99. package/dist/diagnose.js +64 -2
  100. package/dist/diagnose.js.map +1 -1
  101. package/dist/index.d.ts +16 -2
  102. package/dist/index.js +17 -1
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp/server.js +25 -7
  105. package/dist/mcp/server.js.map +1 -1
  106. package/dist/model/mappers.d.ts +12 -3
  107. package/dist/model/mappers.js +6 -1
  108. package/dist/model/mappers.js.map +1 -1
  109. package/dist/model/occurrences.d.ts +14 -1
  110. package/dist/model/occurrences.js +20 -4
  111. package/dist/model/occurrences.js.map +1 -1
  112. package/dist/model/recurrence.d.ts +64 -9
  113. package/dist/model/recurrence.js +94 -19
  114. package/dist/model/recurrence.js.map +1 -1
  115. package/dist/model/template-projection.d.ts +26 -0
  116. package/dist/model/template-projection.js +113 -0
  117. package/dist/model/template-projection.js.map +1 -0
  118. package/dist/op-result.d.ts +46 -0
  119. package/dist/op-result.js +156 -0
  120. package/dist/op-result.js.map +1 -0
  121. package/dist/paths.d.ts +8 -0
  122. package/dist/paths.js +10 -0
  123. package/dist/paths.js.map +1 -1
  124. package/dist/read/detail.js +7 -1
  125. package/dist/read/detail.js.map +1 -1
  126. package/dist/read/queries.js +8 -1
  127. package/dist/read/queries.js.map +1 -1
  128. package/dist/read/views.d.ts +6 -4
  129. package/dist/read/views.js +60 -18
  130. package/dist/read/views.js.map +1 -1
  131. package/dist/sync-health.js +8 -3
  132. package/dist/sync-health.js.map +1 -1
  133. package/dist/trace/tracer.d.ts +87 -0
  134. package/dist/trace/tracer.js +167 -0
  135. package/dist/trace/tracer.js.map +1 -0
  136. package/dist/write/accessibility-probe.js +4 -2
  137. package/dist/write/accessibility-probe.js.map +1 -1
  138. package/dist/write/automation-probe.js +5 -1
  139. package/dist/write/automation-probe.js.map +1 -1
  140. package/dist/write/availability.js +7 -3
  141. package/dist/write/availability.js.map +1 -1
  142. package/dist/write/clear-reminder.js +24 -21
  143. package/dist/write/clear-reminder.js.map +1 -1
  144. package/dist/write/commands.js +95 -45
  145. package/dist/write/commands.js.map +1 -1
  146. package/dist/write/completion-context.d.ts +57 -0
  147. package/dist/write/completion-context.js +49 -0
  148. package/dist/write/completion-context.js.map +1 -0
  149. package/dist/write/experimental.d.ts +19 -0
  150. package/dist/write/experimental.js +50 -0
  151. package/dist/write/experimental.js.map +1 -1
  152. package/dist/write/failure-hints.d.ts +10 -1
  153. package/dist/write/failure-hints.js +8 -0
  154. package/dist/write/failure-hints.js.map +1 -1
  155. package/dist/write/guards.d.ts +1 -1
  156. package/dist/write/guards.js +10 -0
  157. package/dist/write/guards.js.map +1 -1
  158. package/dist/write/move.js +14 -6
  159. package/dist/write/move.js.map +1 -1
  160. package/dist/write/operations.d.ts +40 -7
  161. package/dist/write/pipeline.d.ts +41 -0
  162. package/dist/write/pipeline.js +289 -19
  163. package/dist/write/pipeline.js.map +1 -1
  164. package/dist/write/pre-state.d.ts +1 -1
  165. package/dist/write/pre-state.js +35 -17
  166. package/dist/write/pre-state.js.map +1 -1
  167. package/dist/write/promote-clone.js +434 -19
  168. package/dist/write/promote-clone.js.map +1 -1
  169. package/dist/write/reorder.js +76 -27
  170. package/dist/write/reorder.js.map +1 -1
  171. package/dist/write/repeat-anchor.d.ts +140 -0
  172. package/dist/write/repeat-anchor.js +403 -0
  173. package/dist/write/repeat-anchor.js.map +1 -0
  174. package/dist/write/repeat-asserts.d.ts +28 -0
  175. package/dist/write/repeat-asserts.js +169 -0
  176. package/dist/write/repeat-asserts.js.map +1 -0
  177. package/dist/write/repeat-rule.js +37 -12
  178. package/dist/write/repeat-rule.js.map +1 -1
  179. package/dist/write/vectors/applescript.js +7 -16
  180. package/dist/write/vectors/applescript.js.map +1 -1
  181. package/dist/write/vectors/session-reachability.d.ts +133 -0
  182. package/dist/write/vectors/session-reachability.js +151 -0
  183. package/dist/write/vectors/session-reachability.js.map +1 -0
  184. package/dist/write/vectors/shortcuts.js +5 -14
  185. package/dist/write/vectors/shortcuts.js.map +1 -1
  186. package/dist/write/vectors/simulator.d.ts +16 -1
  187. package/dist/write/vectors/simulator.js +122 -32
  188. package/dist/write/vectors/simulator.js.map +1 -1
  189. package/dist/write/vectors/types.d.ts +183 -10
  190. package/dist/write/vectors/ui-recipes.d.ts +8 -0
  191. package/dist/write/vectors/ui-recipes.js +246 -58
  192. package/dist/write/vectors/ui-recipes.js.map +1 -1
  193. package/dist/write/vectors/ui.d.ts +156 -14
  194. package/dist/write/vectors/ui.js +846 -84
  195. package/dist/write/vectors/ui.js.map +1 -1
  196. package/dist/write/verify/delta.d.ts +12 -14
  197. package/dist/write/verify/delta.js +35 -34
  198. package/dist/write/verify/delta.js.map +1 -1
  199. package/dist/write/verify/poller.d.ts +13 -0
  200. package/dist/write/verify/poller.js +38 -0
  201. package/dist/write/verify/poller.js.map +1 -1
  202. package/package.json +8 -1
  203. package/schema/envelope.schema.json +22 -0
  204. package/scripts/build-helpers.sh +104 -0
  205. package/scripts/deputy-cert-setup.sh +60 -0
  206. package/skills/things-cli/SKILL.md +11 -3
  207. package/skills/things-cli/references/errors.md +4 -0
  208. package/skills/things-cli/references/repeating.md +69 -0
@@ -26,13 +26,23 @@
26
26
  * v1 shells out to `osascript` with ONE stable command shape per primitive.
27
27
  */
28
28
  import { execFile } from "node:child_process";
29
+ import { DEFAULT_UI_DRIVE_BUDGET_MS } from "../../config.js";
30
+ import { osaExec } from "../../deputy/osa.js";
31
+ import { noteInflightStep, trace, traceActive, tracePath } from "../../trace/tracer.js";
29
32
  import { UI_DRIVE_OPS } from "../operations.js";
30
33
  import { escapeAppleScript } from "./applescript.js";
34
+ import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
31
35
  import { certificationOf } from "./ui-certification.js";
32
36
  import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
33
37
  /** GUI driving can stall on an unanswered sheet; give each step headroom. */
34
38
  const STEP_TIMEOUT_MS = 15_000;
35
- /** Poll interval while waiting for a dynamic element (sheet/popover). */
39
+ /**
40
+ * Poll interval while waiting for a dynamic element (sheet/popover). KEPT at 300ms
41
+ * after the PERF2 audit: the control a mode switch reveals takes ~462ms to appear
42
+ * on the golden (S5b, [docs/lab/perf2-step-latency.md]), which EXCEEDS this
43
+ * interval — so a 300ms poll catches it on its second round; a finer interval
44
+ * would only add osascript hops for a marginal detection gain (UIC6 confirmed).
45
+ */
36
46
  const WAIT_POLL_MS = 300;
37
47
  /**
38
48
  * How long `resolveStepPath` polls a candidate-addressed control before failing
@@ -45,9 +55,22 @@ const RESOLVE_CANDIDATE_TIMEOUT_MS = 5_000;
45
55
  * Settle after the reveal/activate preamble so the menu bar repopulates for the
46
56
  * newly-selected target before the canary reads it (UIC1: the Items ▸ Repeat
47
57
  * submenu appears only once a repeating item is selected, and the update is not
48
- * instantaneous).
58
+ * instantaneous). TRIMMED 1500 → 1000 by the PERF2 audit (S5a,
59
+ * [docs/lab/perf2-step-latency.md]): on a warm running app under DEFAULT macOS
60
+ * animations the menu repopulates in ~92ms median / 116ms max (N=10) — a ~13×
61
+ * margin at 1500. Menu-bar repopulation is a LOCAL UI operation (not a DB-commit /
62
+ * sync-bound one), so it does not scale with DB size the way the OK commit does;
63
+ * 1000ms keeps ~8.6× the golden max as host headroom. Under-margining only ever
64
+ * costs a fail-closed spurious drive refusal (the canary miss), never a bad write.
49
65
  */
50
- const SETTLE_AFTER_REVEAL_MS = 1500;
66
+ const SETTLE_AFTER_REVEAL_MS = 1000;
67
+ /**
68
+ * A shape-dependent step reached without the dialog having been measured — a
69
+ * recipe bug (the `probe-dialog-shape` step is missing or ran after its
70
+ * dependants). Refused, never guessed: the two shapes address DIFFERENT controls
71
+ * at the same index.
72
+ */
73
+ const SHAPE_UNPROBED = "the Repeat dialog's shape was never measured, so this control's address is unknown (recipe bug)";
51
74
  const SE = `tell application "System Events" to tell process "Things3"`;
52
75
  /** resolve-element: does the element exist right now? Returns "true"/"false". */
53
76
  export function axResolveScript(path) {
@@ -98,6 +121,39 @@ export function axSetValueScript(path, value, attempts = 3) {
98
121
  error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
99
122
  end tell`;
100
123
  }
124
+ /**
125
+ * ensure-checkbox: converge a dialog checkbox to a target state through a
126
+ * DETERMINISTIC CLOSED LOOP (RRD1, determinism doctrine) — never a blind toggle.
127
+ * It reads the checkbox's `AXValue` (0 = unchecked, 1 = checked), presses it ONLY
128
+ * when the observed value differs from `target`, then RE-READS to confirm the new
129
+ * value equals `target`. A press that did not register (or a value that has not
130
+ * settled) is retried a bounded number of times; if it never converges the script
131
+ * FAILS CLOSED (an `error`, i.e. a transport failure the pipeline re-verifies).
132
+ *
133
+ * This is what makes the "Add deadlines" / "Add reminders" checkboxes safe on a
134
+ * PRE-POPULATED reschedule dialog: the dialog opens with the item's CURRENT
135
+ * deadline/reminder state already ticked, so the old unconditional `click`
136
+ * FLIPPED an already-correct box the wrong way — the live #493-adjacent bug where
137
+ * a blind "Add deadlines" press UNCHECKED an already-deadlined rule and hid the
138
+ * "start N days earlier" field, collapsing the drive. Reading before pressing
139
+ * makes an already-correct box a no-op. One stable command shape per primitive.
140
+ */
141
+ export function axEnsureCheckboxScript(path, target, attempts = 3) {
142
+ const want = target ? 1 : 0;
143
+ const n = Math.max(1, Math.trunc(attempts));
144
+ return `${SE}
145
+ set cb to (${path})
146
+ repeat ${n} times
147
+ set cur to (value of cb) as integer
148
+ if cur is ${want} then return "OK"
149
+ click cb
150
+ delay 0.2
151
+ end repeat
152
+ set cur to (value of cb) as integer
153
+ if cur is ${want} then return "OK"
154
+ error "checkbox did not converge to ${want} after ${n} attempt(s); still " & cur
155
+ end tell`;
156
+ }
101
157
  /**
102
158
  * select-popup: choose an item in a pop-up button by NAME. Setting `value` on a
103
159
  * Things pop-up button is a silent no-op (UIC1 / UI2-i) — the control must be
@@ -142,6 +198,270 @@ export function axSelectPopupCandidatesScript(path, values) {
142
198
  error "none of the candidate menu items exist: " & {${list}}
143
199
  end tell`;
144
200
  }
201
+ /**
202
+ * probe-dialog-shape: MEASURE which Repeat dialog is open (RDLG2) — the whole
203
+ * version fork, decided by STRUCTURE rather than by the app version, so the
204
+ * recipe self-selects on any host and a future redesign refuses instead of
205
+ * silently pressing the wrong control:
206
+ *
207
+ * - `next-popup` (Things 3.23+) — the first occurrence is an `AXPopUpButton`
208
+ * listing the rule's own upcoming occurrences. It sits between Ends and every
209
+ * per-frequency control, so weekday / monthly / yearly pop-ups are one index
210
+ * further along.
211
+ * - `legacy` (Things ≤ 3.22) — the first occurrence is a free-form
212
+ * `AXDateTimeArea`, and the per-frequency controls follow Ends directly.
213
+ *
214
+ * The discriminator is the CONTROL CLASS on the `Next:` row, not the presence of
215
+ * the label: RDLG2d measured Things 3.22.14 and found it carries the same
216
+ * `Next:` static text (and the same occurrence-preview line) as 3.23 — only the
217
+ * control beside it changed. So the probe reads the label's row position and asks
218
+ * what kind of control shares that row, which also keeps it independent of the
219
+ * dialog's Ends state (an `Ends: on date` bound adds a SECOND date area, on a
220
+ * different row, in both shapes). Each branch is a POSITIVE match, so an
221
+ * unrecognized third dialog returns "unknown" and the driver refuses. Labels are
222
+ * pinned English, exactly like every other selector here.
223
+ */
224
+ export function axProbeDialogShapeScript(groupPath, rowTolerance = 8) {
225
+ const tol = Math.max(1, Math.trunc(rowTolerance));
226
+ return `${SE}
227
+ set g to (${groupPath})
228
+ set nextY to missing value
229
+ set nStatic to (count of static texts of g)
230
+ repeat with i from 1 to nStatic
231
+ set v to ""
232
+ try
233
+ set v to (value of static text i of g) as text
234
+ end try
235
+ if v is "Next:" then
236
+ set p to position of static text i of g
237
+ set nextY to item 2 of p
238
+ end if
239
+ end repeat
240
+ if nextY is missing value then return "unknown"
241
+ set nPop to (count of pop up buttons of g)
242
+ repeat with i from 1 to nPop
243
+ set p to position of pop up button i of g
244
+ set dy to (item 2 of p) - nextY
245
+ if dy < 0 then set dy to -dy
246
+ if dy <= ${tol} then return "next-popup"
247
+ end repeat
248
+ try
249
+ set areas to (every UI element of g whose role is "AXDateTimeArea")
250
+ repeat with i from 1 to (count of areas)
251
+ set p to position of (item i of areas)
252
+ set dy to (item 2 of p) - nextY
253
+ if dy < 0 then set dy to -dy
254
+ if dy <= ${tol} then return "legacy"
255
+ end repeat
256
+ end try
257
+ return "unknown"
258
+ end tell`;
259
+ }
260
+ /**
261
+ * select-next-occurrence: set the first occurrence through the Things 3.23
262
+ * `Next:` POP-UP (RDLG2). 3.23 replaced the free-form first-occurrence date area
263
+ * with a bounded MENU — `Today`, then the rule's own upcoming occurrences, then a
264
+ * `More…` item whose submenu carries the next hundred, cascading further the same
265
+ * way. Two consequences this script encodes:
266
+ *
267
+ * - a requested date is reachable ONLY if the rule itself produces it (or it is
268
+ * today): the menu offers nothing else, so an OFF-RULE first occurrence — free
269
+ * to set on ≤3.22 — is UNEXPRESSIBLE in this dialog and must fail closed with
270
+ * a named reason rather than land some neighbouring date;
271
+ * - item titles are localized (`Sun, Jul 12, 2026`), so the match is made by
272
+ * PARSING each title to a date (with a leading-weekday retry) and comparing
273
+ * calendar components — never by rebuilding the app's display string.
274
+ *
275
+ * The cascade is walked to a bounded depth; the click is verified by reading the
276
+ * pop-up's value back and requiring it to equal the clicked item's own title
277
+ * (the fail-closed read-back the ANCH2/YANCH1 date drives established).
278
+ */
279
+ export function axSelectNextOccurrenceScript(popupPath, isoDate, maxLevels = 6) {
280
+ const [y, m, d] = isoDate.split("-").map((part) => Number(part));
281
+ const levels = Math.max(1, Math.trunc(maxLevels));
282
+ return `on parsedYMD(t)
283
+ set s to t as text
284
+ try
285
+ set theDate to date s
286
+ return {year of theDate, (month of theDate) as integer, day of theDate}
287
+ end try
288
+ try
289
+ set ofs to offset of ", " in s
290
+ if ofs > 0 then
291
+ set theDate to date (text (ofs + 2) thru -1 of s)
292
+ return {year of theDate, (month of theDate) as integer, day of theDate}
293
+ end if
294
+ end try
295
+ return missing value
296
+ end parsedYMD
297
+
298
+ set wantY to ${y}
299
+ set wantM to ${m}
300
+ set wantD to ${d}
301
+ set rightNow to current date
302
+ set isToday to ((year of rightNow) is wantY and ((month of rightNow) as integer) is wantM and (day of rightNow) is wantD)
303
+ ${SE}
304
+ set pu to (${popupPath})
305
+ repeat 20 times
306
+ if (exists menu 1 of pu) then exit repeat
307
+ click pu
308
+ delay 0.3
309
+ end repeat
310
+ set theMenu to menu 1 of pu
311
+ set clickedTitle to ""
312
+ set levelsSeen to 0
313
+ if isToday then
314
+ set nms to name of every menu item of theMenu
315
+ if (count of nms) > 0 then
316
+ set t1 to item 1 of nms
317
+ if t1 is not missing value then
318
+ if (my parsedYMD(t1)) is missing value then
319
+ set clickedTitle to t1 as text
320
+ click menu item 1 of theMenu
321
+ end if
322
+ end if
323
+ end if
324
+ end if
325
+ repeat ${levels} times
326
+ if clickedTitle is not "" then exit repeat
327
+ set levelsSeen to levelsSeen + 1
328
+ set nms to name of every menu item of theMenu
329
+ set hit to 0
330
+ repeat with i from 1 to (count of nms)
331
+ set nm to item i of nms
332
+ if nm is not missing value then
333
+ set ymd to my parsedYMD(nm)
334
+ if ymd is not missing value then
335
+ if (item 1 of ymd) is wantY and (item 2 of ymd) is wantM and (item 3 of ymd) is wantD then
336
+ set hit to i
337
+ exit repeat
338
+ end if
339
+ end if
340
+ end if
341
+ end repeat
342
+ if hit > 0 then
343
+ set clickedTitle to (item hit of nms) as text
344
+ click menu item hit of theMenu
345
+ exit repeat
346
+ end if
347
+ set lastI to (count of nms)
348
+ if lastI is 0 then exit repeat
349
+ if (item lastI of nms) is missing value then exit repeat
350
+ set deeper to missing value
351
+ try
352
+ set deeper to menu 1 of menu item lastI of theMenu
353
+ end try
354
+ if deeper is missing value then
355
+ try
356
+ click menu item lastI of theMenu
357
+ delay 0.5
358
+ set deeper to menu 1 of menu item lastI of theMenu
359
+ end try
360
+ end if
361
+ if deeper is missing value then exit repeat
362
+ set theMenu to deeper
363
+ end repeat
364
+ if clickedTitle is "" then
365
+ key code 53
366
+ error "select-next-occurrence: this Repeat dialog offers only the rule's own upcoming occurrences (and today) as the first occurrence, and ${isoDate} is not one of them — searched " & levelsSeen & " level(s) of the Next: menu. Ask for a date the rule actually produces, or change the rule."
367
+ end if
368
+ delay 0.4
369
+ set shown to (value of pu) as text
370
+ if shown is not clickedTitle then
371
+ error "select-next-occurrence: the Next: pop-up committed \\"" & shown & "\\", not the requested \\"" & clickedTitle & "\\" — the selection did not take"
372
+ end if
373
+ return "OK"
374
+ end tell`;
375
+ }
376
+ /**
377
+ * converge-weekdays: drive the weekly dialog's weekday ROWS onto an exact target
378
+ * set through a deterministic closed loop (RDLG2 — the RRD1 fix).
379
+ *
380
+ * The shipped drive set the FIRST weekday row and then pressed "+" and re-drove
381
+ * THE SAME row index per extra weekday, which on a PRE-POPULATED reschedule
382
+ * dialog left the rule's existing weekdays untouched: `{mon,wed}` retargeted to
383
+ * `{tue,thu,sat}` committed `{mon,tue,thu,sat}` (VMQ1 cell 2Tb — caught only by
384
+ * the write pipeline's verify). It also had no way to SHRINK a set.
385
+ *
386
+ * The loop instead: (1) read the live row count; (2) press the row-add button —
387
+ * the smaller-x button of a weekday row, resolved from live geometry rather than
388
+ * a pinned index, because the row buttons enumerate in an unstable order — until
389
+ * there are at least as many rows as target weekdays; (3) assign EVERY row from
390
+ * the target set, cycling, so a surplus row duplicates a target weekday instead
391
+ * of keeping a stale one (the app stores the weekdays as a SET, so duplicates
392
+ * collapse on commit — this is what makes shrinking possible without the
393
+ * remove button); (4) read every row back and require the set to match exactly.
394
+ * Anything else errors — the pipeline re-verifies against the DB regardless.
395
+ *
396
+ * `base` is the 1-based group pop-up index of the first weekday row (2 on the
397
+ * legacy dialog, 3 once the 3.23 `Next:` pop-up sits in front of them).
398
+ */
399
+ export function axConvergeWeekdaysScript(groupPath, base, titles) {
400
+ const list = titles.map((t) => `"${escapeAppleScript(t)}"`).join(", ");
401
+ const b = Math.max(1, Math.trunc(base));
402
+ return `set wantList to {${list}}
403
+ set baseIx to ${b}
404
+ ${SE}
405
+ set g to (${groupPath})
406
+ set k to (count of wantList)
407
+ repeat 14 times
408
+ set n to (count of pop up buttons of g) - baseIx + 1
409
+ if n >= k then exit repeat
410
+ set nb to (count of buttons of g)
411
+ if nb is 0 then error "converge-weekdays: the dialog exposes no weekday row button, so a second weekday cannot be added"
412
+ set bestI to 0
413
+ set bestX to 1000000
414
+ repeat with i from 1 to nb
415
+ set p to position of button i of g
416
+ set px to item 1 of p
417
+ if px < bestX then
418
+ set bestX to px
419
+ set bestI to i
420
+ end if
421
+ end repeat
422
+ click button bestI of g
423
+ delay 0.5
424
+ end repeat
425
+ set n to (count of pop up buttons of g) - baseIx + 1
426
+ if n < k then error "converge-weekdays: the dialog would not grow to " & k & " weekday row(s) — it stopped at " & n
427
+ repeat with i from 1 to n
428
+ set wi to ((i - 1) mod k) + 1
429
+ set wantVal to item wi of wantList
430
+ set pu to pop up button (baseIx + i - 1) of g
431
+ if ((value of pu) as text) is not wantVal then
432
+ repeat 20 times
433
+ if (exists menu 1 of pu) then exit repeat
434
+ click pu
435
+ delay 0.3
436
+ end repeat
437
+ if not (exists menu item wantVal of menu 1 of pu) then
438
+ key code 53
439
+ error "converge-weekdays: the weekday pop-up offers no item \\"" & wantVal & "\\" (the app may not be in English)"
440
+ end if
441
+ click menu item wantVal of menu 1 of pu
442
+ delay 0.4
443
+ end if
444
+ end repeat
445
+ set absent to ""
446
+ repeat with wi from 1 to k
447
+ set wantVal to item wi of wantList
448
+ set seen to false
449
+ repeat with i from 1 to n
450
+ if ((value of pop up button (baseIx + i - 1) of g) as text) is wantVal then set seen to true
451
+ end repeat
452
+ if not seen then set absent to absent & wantVal & " "
453
+ end repeat
454
+ set strays to ""
455
+ repeat with i from 1 to n
456
+ set v to (value of pop up button (baseIx + i - 1) of g) as text
457
+ if wantList does not contain v then set strays to strays & v & " "
458
+ end repeat
459
+ if absent is not "" or strays is not "" then
460
+ error "converge-weekdays: the weekday rows did not converge — missing: " & absent & "| unexpected: " & strays
461
+ end if
462
+ return "OK"
463
+ end tell`;
464
+ }
145
465
  /**
146
466
  * select-row: select a PROJECT row by title, purely via AX (UIC4-a). Walks the
147
467
  * content table's rows, issues the row `select` action on each (which REPLACES
@@ -213,6 +533,41 @@ export function axSelectHeadingRowScript(tablePath, ordinal) {
213
533
  end tell
214
534
  return "NOMATCH"`;
215
535
  }
536
+ /**
537
+ * assert-eligible: after a `things:///show?id=` reveal, VERIFY the target to-do
538
+ * is genuinely the sole selection AND that the menu item that acts on it is
539
+ * enabled — before the menu is pressed (ADR1, issue #480). The reveal is assumed
540
+ * to select the row, but on some surfaces it can navigate without selecting; an
541
+ * AXPress on the resulting DISABLED `Items ▸ Repeat…` is a silent no-op, so the
542
+ * dialog never opens and the drive dies far downstream at the dialog-wait timeout
543
+ * with no hint of the real cause. Reading `Things3 → id of selected to dos` is
544
+ * uuid-precise (never a fuzzy title match), so a match GUARANTEES the intended row
545
+ * is selected. Returns "OK" only when exactly the target is selected and the menu
546
+ * item is enabled; otherwise a diagnostic (`NOTSEL…`/`WRONGSEL…`/`DISABLED…`)
547
+ * naming expected vs observed. Pure System Events + Things scripting, background-
548
+ * capable. One stable command shape per primitive.
549
+ */
550
+ export function axAssertEligibleScript(targetUuid, menuItemPath) {
551
+ const u = escapeAppleScript(targetUuid);
552
+ return `set selIds to {}
553
+ tell application "Things3"
554
+ try
555
+ set selIds to id of selected to dos
556
+ end try
557
+ end tell
558
+ 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"
559
+ if (count of selIds) is greater than 1 then return "NOTSEL " & (count of selIds) & " to-dos are selected, expected exactly the target ${u}"
560
+ set theId to (item 1 of selIds) as text
561
+ if theId is not "${u}" then return "WRONGSEL the selected to-do is " & theId & ", expected the target ${u}"
562
+ set repEnabled to false
563
+ tell application "System Events" to tell process "Things3"
564
+ try
565
+ set repEnabled to enabled of ${menuItemPath}
566
+ end try
567
+ end tell
568
+ 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)"
569
+ return "OK"`;
570
+ }
216
571
  /** activate: foreground Things (the fallback preamble step). */
217
572
  export function axActivateScript() {
218
573
  return `tell application "Things3" to activate`;
@@ -232,6 +587,29 @@ export function axKeyScript(keys) {
232
587
  export function axAbortScript() {
233
588
  return `tell application "System Events" to key code 53`; // Escape
234
589
  }
590
+ /**
591
+ * The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
592
+ * recovery): close the front Things window — which takes an attached modal sheet
593
+ * with it — then reopen and activate. Runs entirely through Things' own
594
+ * AppleScript dictionary, so it works WITHOUT the Accessibility tree (the exact
595
+ * property needed when the session is AX-blind). Two uses:
596
+ * - CLEANUP: clear a stuck modal sheet a failed drive left open (unblocks the
597
+ * app-wide AppleScript-mutation freeze that sheet imposes);
598
+ * - RELOCATION: pull a window that was on another Space back to the current one
599
+ * so its dialog can open AX-reachably (the wrong-Space recovery branch).
600
+ * `reopen` restores the default window on the CURRENT Space; `activate` foregrounds
601
+ * it. Returns "OK".
602
+ */
603
+ export function axCloseReopenActivateScript() {
604
+ return `tell application "Things3"
605
+ try
606
+ close window 1
607
+ end try
608
+ reopen
609
+ activate
610
+ end tell
611
+ return "OK"`;
612
+ }
235
613
  /**
236
614
  * sheet-open probe: is a modal SHEET attached to the Things standard window, OR
237
615
  * a detached repeat-editor / popover window (an `AXUnknown` that is not the
@@ -262,19 +640,86 @@ async function sheetStillOpen(run) {
262
640
  return !(res.ok && res.stdout.trim() === "false");
263
641
  }
264
642
  /**
265
- * Send Escape and VERIFY the sheet/popover is gone (0½ defect (d): the abort
266
- * must never claim dismissal it did not confirm). Retries Escape ONCE if the
267
- * first is not honored. Returns whether dismissal was verified the caller
268
- * words its partial-state report accordingly (fail-closed: on an unverifiable
269
- * dismissal it must warn the sheet may remain open).
643
+ * Clear a half-open sheet/popover a failed drive left behind HONESTLY (SESSGATE
644
+ * #480 fix; supersedes the old verifiedAbort, whose AX-blind sheet probe returned
645
+ * "gone" it could not actually see, letting the still-open modal freeze the
646
+ * app-wide AppleScript mutations the caller then attempted the auto-trash
647
+ * silent-noop). Escape first; then:
648
+ * - AX-BLIND (a not-reachable probe): Escape may never have reached the sheet
649
+ * and the sheet probe cannot see it, so run the app-level close+reopen that
650
+ * works blind (it takes the stuck sheet with the window). Reported as
651
+ * "cleared-blind" — never falsely "confirmed gone".
652
+ * - REACHABLE: the sheet probe is trustworthy — confirm the dismissal (retry
653
+ * Escape once), and if it will not go, warn "may remain".
270
654
  */
271
- async function verifiedAbort(run) {
655
+ async function clearDialog(run) {
272
656
  const escape = () => run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
273
657
  await escape();
658
+ const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
659
+ if (!reach.reachable) {
660
+ // Cannot trust Escape or the sheet probe while AX-blind — use the proven
661
+ // app-level maneuver, which clears a stuck sheet without the Accessibility tree.
662
+ await run({
663
+ primitive: "resolve",
664
+ label: "clear a stuck dialog (close the Things window and reopen it)",
665
+ script: axCloseReopenActivateScript(),
666
+ }, STEP_TIMEOUT_MS);
667
+ return { state: "cleared-blind" };
668
+ }
274
669
  if (!(await sheetStillOpen(run)))
275
- return { dismissed: true };
670
+ return { state: "dismissed" };
276
671
  await escape(); // one retry
277
- return { dismissed: !(await sheetStillOpen(run)) };
672
+ return { state: (await sheetStillOpen(run)) ? "may-remain" : "dismissed" };
673
+ }
674
+ /**
675
+ * The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
676
+ * activate preamble (which surfaces a window in a healthy session) and BEFORE any
677
+ * menu press. Three outcomes matched to the live session state:
678
+ * - reachable → proceed;
679
+ * - not reachable, "session" → REFUSE (locked screen / full-screen Space —
680
+ * the certain-failure case): block, zero mutation;
681
+ * - not reachable, "window" → RELOCATE: only Things' window is off the
682
+ * current Space, so run the app-level close+reopen
683
+ * that pulls it back, then RE-PROBE closed-loop.
684
+ * Reachable now → proceed (disclosed); still not →
685
+ * block with the Space remediation.
686
+ */
687
+ async function ensureWindowReachable(run, reachCache) {
688
+ // First probe MAY be served from the pre-seed gate's memo (PERF1) — but only a
689
+ // reachable verdict is ever memoized, so every refusal/relocation below is still
690
+ // decided on a fresh probe (see ReachabilityProbeCache).
691
+ const first = await reachCache.probe(run, STEP_TIMEOUT_MS);
692
+ if (first.reachable)
693
+ return { ok: true, relocated: false };
694
+ if (first.scope === "session")
695
+ return { ok: false, verdict: first };
696
+ // scope "window": Things' window is on another Space (or absent) while the
697
+ // session is otherwise fine — try to bring it to the current Space, then re-probe.
698
+ await run({
699
+ primitive: "resolve",
700
+ label: "move the Things window to the current desktop",
701
+ script: axCloseReopenActivateScript(),
702
+ }, STEP_TIMEOUT_MS);
703
+ // The relocation just changed window state — drop any memo and re-probe LIVE
704
+ // (a closed-loop verify of the maneuver, never a cached verdict).
705
+ reachCache.invalidate();
706
+ const second = await probeSessionReachability(run, STEP_TIMEOUT_MS);
707
+ if (second.reachable)
708
+ return { ok: true, relocated: true };
709
+ return { ok: false, verdict: second.reachable ? first : second };
710
+ }
711
+ /** The blocked ExecuteResult a dialog-class op returns when the session is unreachable. */
712
+ function blockedReachability(verdict) {
713
+ return {
714
+ exitCode: 4,
715
+ stdout: "",
716
+ stderr: `${verdict.detail} ${verdict.remediation}`,
717
+ blocked: {
718
+ hazard: H_UI_SESSION_UNREACHABLE,
719
+ detail: verdict.detail,
720
+ remediation: verdict.remediation,
721
+ },
722
+ };
278
723
  }
279
724
  /**
280
725
  * resolve-frame: read the element's on-screen frame (top-left origin, points)
@@ -314,40 +759,100 @@ function clickPointCommand(x, y, label) {
314
759
  return { primitive: "click-point", label, lang: "javascript", script: jxaClickScript(x, y) };
315
760
  }
316
761
  /**
317
- * set-datetime: set the Repeat dialog's `AXDateTimeArea` (reminder time / "ends
318
- * on date" bound) via the ObjC AX bridge. Things' date/time control holds an
319
- * NSDate, and System Events cannot write it (`set value … to <date>` → -10000,
320
- * UIC6), so — like the mouse-synthesis primitive — this runs in JXA and calls
321
- * `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly. The control is
322
- * found by ROLE within Things' front dialog (there is exactly one during a
323
- * reminder/end-date step; the matrix never sets both at once), polled briefly
324
- * so it is caught right after the checkbox/pop-up that reveals it, and the
325
- * script THROWS when absent so the driver fails closed. `spec` is
326
- * `time:HH:mm` (keep the control's date, overwrite the time-of-day) or
327
- * `date:YYYY-MM-DD` (overwrite the date at midnight). One stable JXA shape.
762
+ * set-datetime: set ONE of the Repeat dialog's `AXDateTimeArea` controls via the
763
+ * ObjC AX bridge. Things' date/time controls hold an NSDate, and System Events
764
+ * cannot write them (`set value … to <date>` → -10000, UIC6), so — like the
765
+ * mouse-synthesis primitive — this runs in JXA and calls
766
+ * `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly.
767
+ *
768
+ * A fixed rule can expose up to THREE date areas at once "Next:" (first
769
+ * occurrence), "Ends: on date", and the reminder time (ANCH2 census). Targeting
770
+ * "the first AXDateTimeArea by role" is therefore AMBIGUOUS that ambiguity
771
+ * collapsed the series when `--ends-on` added a second area (oddities §8v, now
772
+ * retracted) and made the reminder look undrivable (UIC6-g, now retracted). This
773
+ * driver selects DETERMINISTICALLY by `target` (ANCH2, docs/lab/anch2-next-field.md):
774
+ * - `reminder` — the only area carrying a time-of-day (the date pickers sit at
775
+ * midnight); falls back to the bottom-most area if none carry a time.
776
+ * - `next` — the TOP (smallest-y) midnight date picker.
777
+ * - `ends` — the BOTTOM (largest-y) midnight date picker (present only once
778
+ * "Ends: on date" is selected).
779
+ * The areas are polled briefly (revealed a beat after the checkbox/pop-up), and
780
+ * the script THROWS a NAMED, structured error when the addressed control is
781
+ * absent — reporting which target was sought and the FULL date-area inventory of
782
+ * the current dialog state (count + per-area y / time-of-day) — so a dialog shape
783
+ * that does not present the target (e.g. the deadline-mode variant, YANCH1 #493)
784
+ * fails closed with an actionable message, never an uncaught `-[__NSArray0
785
+ * objectAtIndex:]` (-2700) from indexing an empty collection. It then READS THE
786
+ * CONTROL BACK after the write and throws if the committed value differs from the
787
+ * request: a control that silently rejects the write (the macOS error beep the
788
+ * user hears) must fail the step loudly, never leave a garbled/default value to be
789
+ * verified as ok (YANCH1; UIC6-g refuse-rather-than-commit precedent). `spec` is
790
+ * `time:HH:mm` (keep the date, set the time-of-day) or `date:YYYY-MM-DD` (set the
791
+ * calendar date at midnight). One stable JXA shape.
328
792
  */
329
- export function axSetDateTimeScript(spec) {
793
+ export function axSetDateTimeScript(spec, target) {
330
794
  return `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
331
795
  function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
332
796
  function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
333
797
  function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
334
- function find(el,role,depth){ if(depth<0) return null; if(rolestr(el)===role) return el; var ks=kids(el); for(var i=0;i<ks.length;i++){ var r=find(ks[i],role,depth-1); if(r) return r;} return null; }
798
+ function collect(el,role,depth,out){ if(depth<0) return; if(rolestr(el)===role) out.push(el); var ks=kids(el); for(var i=0;i<ks.length;i++) collect(ks[i],role,depth-1,out); }
799
+ function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
800
+ function windowsOf(el){ var c=attr(el,'AXWindows'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
801
+ function sizeWH(el){ var s=attr(el,'AXSize'); if(!s) return null; var d=ObjC.castRefToObject($.CFCopyDescription(s)).js; var mw=String(d).match(/w:([-0-9.]+)/); var mh=String(d).match(/h:([-0-9.]+)/); return (mw&&mh)? {w:+mw[1], h:+mh[1]} : null; }
802
+ // Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
803
+ // small subtree — never the app-wide tree, whose main-window list content is the
804
+ // 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
805
+ // presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
806
+ // priority order the System-Events pathCandidates use: an attached AXSheet on the
807
+ // standard window (Things frontmost), then a detached top-level AXUnknown window
808
+ // that is not the 40x40 utility window (Things backgrounded). null when neither is
809
+ // present — the caller then falls through to the same named "presents 0 date
810
+ // area(s)" error the app-root walk threw when the dialog was absent.
811
+ function findShell(app){
812
+ var wins=windowsOf(app);
813
+ for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXStandardWindow'){ var sh=[]; collect(wins[i],'AXSheet',3,sh); if(sh.length) return sh[0]; } }
814
+ for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXUnknown'){ var wh=sizeWH(wins[i]); if(!wh || !(wh.w===40 && wh.h===40)) return wins[i]; } }
815
+ return null;
816
+ }
817
+ function posY(el){ var p=attr(el,'AXPosition'); if(!p) return 0; var d=ObjC.castRefToObject($.CFCopyDescription(p)).js; var m=String(d).match(/y:([-0-9.]+)/); return m? +m[1] : 0; }
818
+ function timeOfDay(el){ var v=attr(el,'AXValue'); if(!v) return -1; var cal=$.NSCalendar.currentCalendar; return cal.componentFromDate($.NSCalendarUnitHour,v)*60 + cal.componentFromDate($.NSCalendarUnitMinute,v); }
819
+ function pick(areas,target){
820
+ if(areas.length===0) return null;
821
+ var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
822
+ if(target==='reminder'){
823
+ var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
824
+ return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
825
+ }
826
+ var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
827
+ if(midnight.length===0) midnight=sorted;
828
+ return target==='ends' ? midnight[midnight.length-1] : midnight[0];
829
+ }
830
+ function inv(areas){ var s=[]; for(var i=0;i<areas.length;i++){ s.push('#'+i+'(y='+Math.round(posY(areas[i]))+',tod='+timeOfDay(areas[i])+')'); } return areas.length? s.join(' ') : '(none)'; }
831
+ function ymdStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var y=cal.componentFromDate($.NSCalendarUnitYear,v), m=cal.componentFromDate($.NSCalendarUnitMonth,v), dd=cal.componentFromDate($.NSCalendarUnitDay,v); return y+'-'+('0'+m).slice(-2)+'-'+('0'+dd).slice(-2); }
832
+ function hmStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var h=cal.componentFromDate($.NSCalendarUnitHour,v), mi=cal.componentFromDate($.NSCalendarUnitMinute,v); return h+':'+('0'+mi).slice(-2); }
335
833
  function run(){
336
834
  var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
337
835
  if(!apps || apps.count===0) throw new Error('Things not running');
338
- var pid=apps.objectAtIndex(0).processIdentifier;
339
- var app=$.AXUIElementCreateApplication(pid);
340
- var dt=null;
341
- for(var t=0;t<20 && !dt;t++){ dt=find(app,'AXDateTimeArea',16); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
342
- if(!dt) throw new Error('no AXDateTimeArea in the Repeat dialog');
836
+ var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
837
+ var target=${JSON.stringify(target)};
343
838
  var spec=${JSON.stringify(spec)};
839
+ // Poll for the addressed area WITHIN THE DIALOG SHELL (PERF2): resolve the sheet
840
+ // / detached editor first, then collect only its subtree — the app-root descent
841
+ // this replaced cost ~4.4s on the busy host by walking the main window's list
842
+ // content. collect is wrapped so a stale-element ObjC exception during traversal
843
+ // cannot bubble as a raw -2700; pick guards the empty set, so dt is null (never a
844
+ // crash) when the target is absent. When no shell resolves (dialog absent), areas
845
+ // stays empty and the loop falls through to the SAME named error below.
846
+ var areas=[]; var dt=null;
847
+ for(var t=0;t<20 && !dt;t++){ areas=[]; try{ var shell=findShell(app); if(shell) collect(shell,'AXDateTimeArea',16,areas); }catch(e){ areas=[]; } dt=pick(areas,target); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
848
+ if(!dt) throw new Error('set-datetime '+target+': this Repeat-dialog state presents '+areas.length+' date area(s) ['+inv(areas)+'] but none is the '+target+' control — the requested first occurrence / bound cannot be set in this dialog shape');
344
849
  var cal=$.NSCalendar.currentCalendar;
345
850
  var d;
346
851
  if(spec.indexOf('time:')===0){
347
- // Set the time-of-day on the control's own (today's) date via the purpose-
348
- // built calendar API — component-bag mutation via JXA silently drops the
349
- // hour, leaking the current wall-clock hour into the reminder (UIC6).
350
- var cur=attr(dt,'AXValue'); if(!cur) throw new Error('date/time control has no value');
852
+ // Set the time-of-day on the control's own date via the purpose-built
853
+ // calendar API — component-bag mutation via JXA silently drops the hour,
854
+ // leaking the current wall-clock hour into the reminder (UIC6).
855
+ var cur=attr(dt,'AXValue'); if(!cur) throw new Error('set-datetime '+target+': the date/time control has no value to anchor the time on');
351
856
  var hm=spec.slice(5).split(':');
352
857
  d=cal.dateBySettingHourMinuteSecondOfDateOptions(+hm[0], +hm[1], 0, cur, 0);
353
858
  } else if(spec.indexOf('date:')===0){
@@ -358,11 +863,35 @@ function run(){
358
863
  } else { throw new Error('bad datetime spec: '+spec); }
359
864
  if(!d) throw new Error('could not build date from '+spec);
360
865
  var err=$.AXUIElementSetAttributeValue(dt,$('AXValue'),d);
361
- if(err!==0) throw new Error('AXValue set failed err='+err);
866
+ if(err!==0) throw new Error('set-datetime '+target+': the control refused the write (AX err='+err+')');
362
867
  $.NSThread.sleepForTimeInterval(0.2);
868
+ // READ-BACK: a control can accept the AX write (err 0) yet reject the value —
869
+ // the macOS error beep — leaving its prior/default value. Fail the step loudly
870
+ // rather than let a garbled commit verify as ok (YANCH1 #493).
871
+ if(spec.indexOf('date:')===0){
872
+ var got=ymdStr(dt,cal); var want=spec.slice(5);
873
+ if(got!==want) throw new Error('set-datetime '+target+' rejected: the control committed '+(got||'(no value)')+', not the requested '+want+' — the write did not take');
874
+ } else {
875
+ var gott=hmStr(dt,cal); var wanth=spec.slice(5).split(':'); var wantt=(+wanth[0])+':'+('0'+(+wanth[1])).slice(-2);
876
+ if(gott!==wantt) throw new Error('set-datetime '+target+' rejected: the control committed '+(gott||'(no value)')+', not the requested '+wantt+' — the write did not take');
877
+ }
363
878
  return 'OK';
364
879
  }`;
365
880
  }
881
+ /**
882
+ * The converge-weekdays step encodes both of its inputs in `value` as
883
+ * `"<base>|<Weekday>,<Weekday>…"`: the base is the group pop-up index of the
884
+ * FIRST weekday row, which the dialog SHAPE decides (2 legacy / 3 next-popup),
885
+ * so it rides the same shape-selected `value` the driver merges in.
886
+ */
887
+ export function weekdayBaseOf(value) {
888
+ const base = Number(value.split("|", 1)[0]);
889
+ return Number.isFinite(base) && base > 0 ? Math.trunc(base) : 2;
890
+ }
891
+ export function weekdayTitlesOf(value) {
892
+ const rest = value.slice(value.indexOf("|") + 1);
893
+ return rest.split(",").filter((t) => t !== "");
894
+ }
366
895
  /** Parse a resolve-frame "x y w h" line into the frame's center point. */
367
896
  export function parseFrameCenter(stdout) {
368
897
  const nums = stdout.trim().split(/\s+/).map(Number);
@@ -374,30 +903,64 @@ export function parseFrameCenter(stdout) {
374
903
  function revealUrl(uuid) {
375
904
  return `things:///show?id=${encodeURIComponent(uuid)}`;
376
905
  }
377
- function defaultRun(command, timeoutMs) {
378
- return new Promise((resolve) => {
379
- let bin;
380
- let args;
381
- if (command.primitive === "reveal") {
382
- [bin, args] = ["open", [command.url ?? ""]];
383
- }
384
- else if (command.lang === "javascript") {
385
- // JXA (ObjC bridge) for the mouse-synthesis primitive; one stable shape.
386
- [bin, args] = ["osascript", ["-l", "JavaScript", "-e", command.script ?? ""]];
387
- }
388
- else {
389
- [bin, args] = ["osascript", ["-e", command.script ?? ""]];
390
- }
391
- execFile(bin, [...args], { timeout: timeoutMs }, (err, stdout, stderr) => {
392
- const timedOut = err !== null && err.killed === true;
393
- resolve({
394
- ok: err === null,
395
- stdout: String(stdout),
396
- stderr: String(stderr),
397
- ...(timedOut && { timedOut: true }),
906
+ async function defaultRun(command, timeoutMs) {
907
+ if (command.primitive === "reveal") {
908
+ // `open` is consent-free (LaunchServices, no AppleEvent) — never routed.
909
+ return new Promise((resolve) => {
910
+ execFile("open", [command.url ?? ""], { timeout: timeoutMs }, (err, stdout, stderr) => {
911
+ const timedOut = err !== null && err.killed === true;
912
+ resolve({
913
+ ok: err === null,
914
+ stdout: String(stdout),
915
+ stderr: String(stderr),
916
+ ...(timedOut && { timedOut: true }),
917
+ });
398
918
  });
399
919
  });
400
- });
920
+ }
921
+ // JXA (ObjC bridge) for the mouse-synthesis primitive; one stable shape.
922
+ const lang = command.lang === "javascript" ? "javascript" : "applescript";
923
+ const res = await osaExec(command.script ?? "", { lang, timeoutMs });
924
+ return {
925
+ ok: res.exitCode === 0 && res.timedOut !== true,
926
+ stdout: res.stdout,
927
+ stderr: res.stderr,
928
+ ...(res.timedOut === true && { timedOut: true }),
929
+ };
930
+ }
931
+ /**
932
+ * Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
933
+ * step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
934
+ * even with tracing off), and — when tracing is on — a `ui-dispatch` start/end
935
+ * pair lands in the trace carrying the hop's duration and outcome. This
936
+ * per-osascript granularity is exactly what reconstructs a hang: the timeline
937
+ * shows which step's osascript was in flight, and for how long, when it stopped
938
+ * (TRACE1 #487). Overhead when tracing is off is one boolean check + a field write.
939
+ */
940
+ function tracingRun(inner) {
941
+ return async (command, timeoutMs) => {
942
+ noteInflightStep(command.label);
943
+ if (!traceActive())
944
+ return inner(command, timeoutMs);
945
+ const started = Date.now();
946
+ trace(() => ({
947
+ phase: "ui-dispatch",
948
+ event: "start",
949
+ primitive: command.primitive,
950
+ label: command.label,
951
+ }));
952
+ const res = await inner(command, timeoutMs);
953
+ trace(() => ({
954
+ phase: "ui-dispatch",
955
+ event: "end",
956
+ primitive: command.primitive,
957
+ label: command.label,
958
+ durationMs: Date.now() - started,
959
+ ok: res.ok,
960
+ timedOut: res.timedOut === true,
961
+ }));
962
+ return res;
963
+ };
401
964
  }
402
965
  /** The element paths the preflight canary resolves (static steps only). */
403
966
  function canaryPaths(recipe) {
@@ -485,7 +1048,33 @@ export function commandForStep(step, targetUuid) {
485
1048
  primitive: "set-datetime",
486
1049
  label: step.label,
487
1050
  lang: "javascript",
488
- script: axSetDateTimeScript(step.value ?? ""),
1051
+ script: axSetDateTimeScript(step.value ?? "", step.dtTarget ?? "next"),
1052
+ };
1053
+ case "ensure-checkbox":
1054
+ return {
1055
+ primitive: "ensure-checkbox",
1056
+ label: step.label,
1057
+ script: axEnsureCheckboxScript(step.path ?? "", step.checkboxTarget === true),
1058
+ };
1059
+ case "probe-dialog-shape":
1060
+ return {
1061
+ primitive: "probe-dialog-shape",
1062
+ label: step.label,
1063
+ script: axProbeDialogShapeScript(step.path ?? ""),
1064
+ };
1065
+ case "select-next-occurrence":
1066
+ return {
1067
+ primitive: "select-next-occurrence",
1068
+ label: step.label,
1069
+ script: axSelectNextOccurrenceScript(step.path ?? "", step.value ?? ""),
1070
+ };
1071
+ case "converge-weekdays":
1072
+ return {
1073
+ primitive: "converge-weekdays",
1074
+ label: step.label,
1075
+ // `value` is "<base index>|<Weekday>,<Weekday>…" — the base index is the
1076
+ // shape-selected group pop-up index of the first weekday row (RDLG2).
1077
+ script: axConvergeWeekdaysScript(step.path ?? "", weekdayBaseOf(step.value ?? ""), weekdayTitlesOf(step.value ?? "")),
489
1078
  };
490
1079
  case "wait":
491
1080
  return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
@@ -501,6 +1090,12 @@ export function commandForStep(step, targetUuid) {
501
1090
  label: step.label,
502
1091
  script: axSelectHeadingRowScript(step.path ?? "", Number(step.value ?? "0")),
503
1092
  };
1093
+ case "assert-eligible":
1094
+ return {
1095
+ primitive: "assert-eligible",
1096
+ label: step.label,
1097
+ script: axAssertEligibleScript(step.value ?? targetUuid, step.path ?? ""),
1098
+ };
504
1099
  case "key":
505
1100
  return { primitive: "key", label: step.label, script: axKeyScript(step.keys ?? "") };
506
1101
  case "click-element":
@@ -567,20 +1162,69 @@ async function driveClickElement(step, run) {
567
1162
  }
568
1163
  return { ok: true };
569
1164
  }
570
- async function drive(recipe, run, aux) {
1165
+ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
571
1166
  const done = [];
572
- // `dismissed`: true = abort verified the sheet gone; false = abort could NOT
573
- // confirm dismissal (warn it may remain open — 0½ defect (d): never claim a
574
- // dismissal we did not see); undefined = no sheet was opened / no abort ran.
575
- const partial = (failed, why, dismissed) => {
1167
+ // The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
1168
+ // own timeout on a slow production database (large + Things-Cloud syncing
1169
+ // commits the Repeat dialog several times slower than the lab golden), which
1170
+ // is how #487 fired: the caller's 30s kill left empty stdout and no retained
1171
+ // exit code. This budget lets the CLI give up FIRST — clearing any open dialog
1172
+ // and returning an honest, uncertain-outcome timeout — so the caller always
1173
+ // receives structured output. Checked between steps (per-step execFile
1174
+ // timeouts bound each osascript, so a step boundary is never far off).
1175
+ const driveStart = Date.now();
1176
+ const driveDeadline = driveStart + budgetMs;
1177
+ const overBudget = () => Date.now() >= driveDeadline;
1178
+ const watchdogResult = async (lastStep) => {
1179
+ // Attempt the SESSGATE dialog clearance so the watchdog never leaves a stuck
1180
+ // modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
1181
+ // whose OK press was mid-commit could still land — the pipeline re-verifies
1182
+ // and shapes the final result accordingly.
1183
+ const clear = await clearDialog(run);
1184
+ trace(() => ({
1185
+ phase: "watchdog",
1186
+ budgetMs,
1187
+ elapsedMs: Date.now() - driveStart,
1188
+ lastStep,
1189
+ clear: clear.state,
1190
+ completed: done,
1191
+ }));
1192
+ return {
1193
+ exitCode: 1,
1194
+ stdout: `ui drive watchdog stopped after ${done.length} step(s): ${done.join(" → ") || "nothing"}`,
1195
+ stderr: `ui drive exceeded its ${Math.round(budgetMs / 1000)}s budget at "${lastStep}"`,
1196
+ timedOut: true,
1197
+ watchdog: {
1198
+ budgetMs,
1199
+ elapsedMs: Date.now() - driveStart,
1200
+ lastStep,
1201
+ clear: clear.state,
1202
+ tracePath: tracePath(),
1203
+ },
1204
+ };
1205
+ };
1206
+ // A note prepended to the success summary when the drive had to RELOCATE the
1207
+ // Things window to the current Space to open its dialog (SESSGATE wrong-Space
1208
+ // recovery) — surfaced to the caller as a disclosure warning.
1209
+ let relocationNote = "";
1210
+ // `clear`: how a half-open sheet was cleaned up after a failure (honest — never
1211
+ // claim a dismissal we could not see, SESSGATE #480); undefined = no sheet was
1212
+ // opened / no cleanup ran (a benign preamble/canary failure).
1213
+ const partial = (failed, why, clear) => {
576
1214
  const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
577
- const cleanup = dismissed === true
578
- ? " The open sheet/popover was dismissed (Escape, confirmed gone)."
579
- : dismissed === false
580
- ? " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
581
- " Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
582
- " make the next drive's preflight fail)."
583
- : "";
1215
+ const cleanup = clear === undefined
1216
+ ? ""
1217
+ : clear.state === "dismissed"
1218
+ ? " The open sheet/popover was dismissed (Escape, confirmed gone)."
1219
+ : clear.state === "cleared-blind"
1220
+ ? " Things had no window reachable on the current screen (the Mac may be locked, or a" +
1221
+ " full-screen app is covering the desktop), so the open dialog could not be confirmed" +
1222
+ " through the on-screen layer — the Things window was closed and reopened to clear it," +
1223
+ " discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
1224
+ " before retrying."
1225
+ : " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
1226
+ " Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
1227
+ " make the next drive's preflight fail).";
584
1228
  return refusal(base + cleanup);
585
1229
  };
586
1230
  // 0. Run the leading reveal/activate preamble BEFORE the canary. The Items
@@ -603,6 +1247,22 @@ async function drive(recipe, run, aux) {
603
1247
  // Let the selection settle so the menu bar repopulates before the canary reads it.
604
1248
  if (idx > 0)
605
1249
  await new Promise((r) => setTimeout(r, SETTLE_AFTER_REVEAL_MS));
1250
+ // 0½. Session-reachability GATE for dialog-class ops (SESSGATE, #480). A recipe
1251
+ // that opens a sheet on the main window needs that window AX-reachable on
1252
+ // the current Space. Probed AFTER the preamble (which surfaces a window in a
1253
+ // healthy session) and BEFORE the canary/press (no mutation yet): a locked /
1254
+ // full-screen session REFUSES (blocked, zero mutation); a window merely on
1255
+ // another Space is RELOCATED back and disclosed. Menu-only recipes skip this.
1256
+ if (recipe.needsWindowReachability === true) {
1257
+ const reach = await ensureWindowReachable(run, reachCache);
1258
+ if (!reach.ok)
1259
+ return blockedReachability(reach.verdict);
1260
+ if (reach.relocated) {
1261
+ relocationNote =
1262
+ "the Things window was on another desktop, so it was moved to the desktop you're viewing " +
1263
+ "to open the dialog. ";
1264
+ }
1265
+ }
606
1266
  // 1. Recipe canary: resolve every statically-reachable element (now that the
607
1267
  // target is selected). A miss refuses the whole drive before anything is
608
1268
  // pressed. (This is also the localization check: English titles must resolve.)
@@ -629,8 +1289,44 @@ async function drive(recipe, run, aux) {
629
1289
  }
630
1290
  }
631
1291
  // 2. Execute the remaining steps in order; a dynamic element is waited-for.
1292
+ //
1293
+ // `dialogShape` is the Repeat dialog's MEASURED structure (RDLG2), set by the
1294
+ // recipe's `probe-dialog-shape` step and consumed by the steps that address a
1295
+ // control the 3.23 redesign moved or replaced. It stays null on every recipe
1296
+ // that never probes (no shape-dependent step), and any step that needs it while
1297
+ // it is null fails closed rather than guessing an index.
1298
+ let dialogShape = null;
632
1299
  for (let i = idx; i < recipe.steps.length; i += 1) {
633
1300
  let step = recipe.steps[i];
1301
+ // Shape-gated step: the recipe emits BOTH the legacy and the 3.23 drive for a
1302
+ // control whose CLASS changed, and only the matching one runs.
1303
+ if (step.onlyShape !== undefined) {
1304
+ if (dialogShape === null) {
1305
+ const clear = await clearDialog(run);
1306
+ return partial(step.label, SHAPE_UNPROBED, clear);
1307
+ }
1308
+ if (step.onlyShape !== dialogShape)
1309
+ continue;
1310
+ }
1311
+ // Shape-selected paths/values (the +1 index shift the 3.23 "Next:" pop-up
1312
+ // introduced, and the weekday-row base index).
1313
+ if (step.shaped !== undefined) {
1314
+ if (dialogShape === null) {
1315
+ const clear = await clearDialog(run);
1316
+ return partial(step.label, SHAPE_UNPROBED, clear);
1317
+ }
1318
+ const override = step.shaped[dialogShape];
1319
+ if (override === undefined) {
1320
+ const clear = await clearDialog(run);
1321
+ return partial(step.label, `this step has no drive for the "${dialogShape}" Repeat dialog (recipe bug)`, clear);
1322
+ }
1323
+ step = { ...step, ...override };
1324
+ }
1325
+ // Overall-drive watchdog: if the budget is spent, stop at THIS step boundary
1326
+ // (the per-step execFile timeouts keep the boundary close), clear any open
1327
+ // dialog, and return the honest uncertain-outcome timeout (TRACE1 #487).
1328
+ if (overBudget())
1329
+ return watchdogResult(step.label);
634
1330
  if (step.primitive === "wait") {
635
1331
  // A candidate-addressed wait polls for ANY of its shapes to appear (the
636
1332
  // dialog opening as an attached sheet OR a detached AXUnknown window).
@@ -638,8 +1334,8 @@ async function drive(recipe, run, aux) {
638
1334
  const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
639
1335
  if (!ok) {
640
1336
  // the abort keystroke must land (and be verified) before returning the partial-state report
641
- const { dismissed } = await verifiedAbort(run);
642
- return partial(step.label, "the expected element never appeared within the timeout", dismissed);
1337
+ const clear = await clearDialog(run);
1338
+ return partial(step.label, "the expected element never appeared within the timeout", clear);
643
1339
  }
644
1340
  done.push(step.label);
645
1341
  continue;
@@ -665,13 +1361,35 @@ async function drive(recipe, run, aux) {
665
1361
  const effective = await resolveStepPath(step, run);
666
1362
  if (effective === null) {
667
1363
  // dismiss whatever opened (and verify) before reporting
668
- const { dismissed } = await verifiedAbort(run);
1364
+ const clear = await clearDialog(run);
669
1365
  return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
670
- "detached repeat editor window)", dismissed);
1366
+ "detached repeat editor window)", clear);
671
1367
  }
672
1368
  step = { ...step, path: effective };
673
1369
  }
674
1370
  const command = commandForStep(step, recipe.targetUuid);
1371
+ if (step.primitive === "probe-dialog-shape") {
1372
+ // MEASURE the dialog (RDLG2) before any shape-dependent control is touched.
1373
+ // A shape we do not recognize refuses the drive with the dialog cleared —
1374
+ // the same fail-closed posture as a canary miss, and for the same reason:
1375
+ // pressing a structural index into an unknown tree is how a GUI driver
1376
+ // writes the wrong rule.
1377
+ const res = await run(command, STEP_TIMEOUT_MS);
1378
+ const verdict = res.stdout.trim();
1379
+ if (!res.ok || (verdict !== "next-popup" && verdict !== "legacy")) {
1380
+ const clear = await clearDialog(run);
1381
+ return partial(step.label, res.ok
1382
+ ? 'its first-occurrence row ("Next:") holds neither an occurrence pop-up nor a date ' +
1383
+ "field, so the dialog matched neither known shape — a Things update has redesigned " +
1384
+ "it again; nothing was entered into the rule"
1385
+ : res.timedOut === true
1386
+ ? "the dialog-shape probe timed out"
1387
+ : res.stderr.trim() || "the dialog-shape probe failed", clear);
1388
+ }
1389
+ dialogShape = verdict;
1390
+ done.push(`${step.label} (${verdict})`);
1391
+ continue;
1392
+ }
675
1393
  if (step.primitive === "select-row" || step.primitive === "select-heading-row") {
676
1394
  // Pure-AX row selection with readback verification (UIC4-a / HEADCERT1):
677
1395
  // "OK" only when the intended row selected (title readback for a project
@@ -680,7 +1398,7 @@ async function drive(recipe, run, aux) {
680
1398
  const res = await run(command, STEP_TIMEOUT_MS);
681
1399
  if (!res.ok || res.stdout.trim() !== "OK") {
682
1400
  // clear any transient state (and verify) before reporting
683
- const { dismissed } = await verifiedAbort(run);
1401
+ const clear = await clearDialog(run);
684
1402
  const noMatch = step.primitive === "select-heading-row"
685
1403
  ? "the project view exposed no selectable heading row at the target position — the " +
686
1404
  "heading may have been converted/deleted already, or the project's headings changed"
@@ -690,7 +1408,29 @@ async function drive(recipe, run, aux) {
690
1408
  ? noMatch
691
1409
  : res.timedOut === true
692
1410
  ? "the row-selection step timed out"
693
- : res.stderr.trim() || "the row-selection step failed", dismissed);
1411
+ : res.stderr.trim() || "the row-selection step failed", clear);
1412
+ }
1413
+ done.push(step.label);
1414
+ continue;
1415
+ }
1416
+ if (step.primitive === "assert-eligible") {
1417
+ // ADR1 (#480): fail EARLY + NAMED when the reveal did not land an eligible
1418
+ // selection, rather than letting a disabled-menu no-op surface downstream
1419
+ // as an opaque dialog-wait timeout. The script returns "OK" or a diagnostic
1420
+ // (NOTSEL…/WRONGSEL…/DISABLED…) that IS the human-readable failure reason.
1421
+ // the selection/enabled state must be confirmed before the menu is pressed
1422
+ const res = await run(command, STEP_TIMEOUT_MS);
1423
+ const verdict = res.stdout.trim();
1424
+ if (!res.ok || verdict !== "OK") {
1425
+ // clear any transient state (and verify) before reporting
1426
+ const clear = await clearDialog(run);
1427
+ return partial(step.label, res.ok
1428
+ ? verdict !== ""
1429
+ ? verdict
1430
+ : "the target to-do was not confirmed selected/eligible after the reveal"
1431
+ : res.timedOut === true
1432
+ ? "the eligibility check timed out"
1433
+ : res.stderr.trim() || "the eligibility check failed", clear);
694
1434
  }
695
1435
  done.push(step.label);
696
1436
  continue;
@@ -701,9 +1441,9 @@ async function drive(recipe, run, aux) {
701
1441
  // the click depends on the UI state the previous step produced
702
1442
  const outcome = await driveClickElement(step, run);
703
1443
  if (!outcome.ok) {
704
- // dismiss whatever the click opened (and verify) before reporting
705
- const dismissed = outcome.needsAbort === true ? (await verifiedAbort(run)).dismissed : undefined;
706
- return partial(step.label, outcome.why ?? "the click failed", dismissed);
1444
+ // clear whatever the click opened (honest cleanup) before reporting
1445
+ const clear = outcome.needsAbort === true ? await clearDialog(run) : undefined;
1446
+ return partial(step.label, outcome.why ?? "the click failed", clear);
707
1447
  }
708
1448
  done.push(step.label);
709
1449
  continue;
@@ -711,15 +1451,20 @@ async function drive(recipe, run, aux) {
711
1451
  // each recipe step depends on the UI state the previous step produced; they cannot be parallelized
712
1452
  const res = await run(command, STEP_TIMEOUT_MS);
713
1453
  if (!res.ok) {
714
- // dismiss the half-open sheet/popover (and verify) before reporting partial state
715
- const dismissed = step.primitive !== "reveal" && step.primitive !== "activate"
716
- ? (await verifiedAbort(run)).dismissed
1454
+ // clear the half-open sheet/popover (honest never claim an unconfirmed
1455
+ // dismissal) before reporting partial state
1456
+ const clear = step.primitive !== "reveal" && step.primitive !== "activate"
1457
+ ? await clearDialog(run)
717
1458
  : undefined;
718
- return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", dismissed);
1459
+ return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear);
719
1460
  }
720
1461
  done.push(step.label);
721
1462
  }
722
- return { exitCode: 0, stdout: `drove ${done.length} step(s): ${done.join(" → ")}`, stderr: "" };
1463
+ return {
1464
+ exitCode: 0,
1465
+ stdout: `${relocationNote}drove ${done.length} step(s): ${done.join(" → ")}`,
1466
+ stderr: "",
1467
+ };
723
1468
  }
724
1469
  async function waitForElement(command, timeoutMs, run) {
725
1470
  const deadline = Date.now() + timeoutMs;
@@ -796,6 +1541,17 @@ function disabledMatrix() {
796
1541
  */
797
1542
  export function createUiVector(config, run = defaultRun, aux = {}) {
798
1543
  const enabled = config.ui.enabled;
1544
+ const budgetMs = config.ui.driveBudgetMs ?? DEFAULT_UI_DRIVE_BUDGET_MS;
1545
+ // Every osascript hop runs through the tracing seam: it notes the step on the
1546
+ // in-flight marker (for the signal handler) and, when tracing is on, records a
1547
+ // start/end pair with timing/outcome (TRACE1 #487).
1548
+ const tracedRun = tracingRun(run);
1549
+ // Intra-invocation reachability memo (PERF1), shared between the pre-seed gate
1550
+ // (probeReachability) and the in-drive gate (ensureWindowReachable) so a promote
1551
+ // composite does not probe the session — seconds-long on a busy desktop — twice.
1552
+ // The vector is rebuilt per client-open, so this is naturally scoped to one CLI
1553
+ // invocation; the memo's own TTL bounds reuse for a long-lived programmatic client.
1554
+ const reachCache = createReachabilityCache();
799
1555
  return {
800
1556
  id: "ui",
801
1557
  matrix: enabled ? enabledMatrix() : disabledMatrix(),
@@ -806,8 +1562,14 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
806
1562
  if (invocation.recipe === undefined) {
807
1563
  return refusal("ui invocation carried no recipe (compile bug).");
808
1564
  }
809
- return drive(invocation.recipe, run, aux);
1565
+ return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
810
1566
  },
1567
+ // Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
1568
+ // live session BEFORE they seed a row, so a locked/full-screen session refuses
1569
+ // with zero mutation. Present regardless of `enabled` (the orchestrator has
1570
+ // already cleared the H-UI-DRIVE ack by the time it consults this). Populates
1571
+ // the memo the in-drive gate reuses (PERF1).
1572
+ probeReachability: () => reachCache.probe(tracedRun, STEP_TIMEOUT_MS),
811
1573
  };
812
1574
  }
813
1575
  //# sourceMappingURL=ui.js.map