things-api 0.17.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 (172) 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/writes.js +22 -3
  27. package/dist/cli/commands/writes.js.map +1 -1
  28. package/dist/cli/help.js +4 -0
  29. package/dist/cli/help.js.map +1 -1
  30. package/dist/cli/helpers-check.d.ts +10 -0
  31. package/dist/cli/helpers-check.js +59 -0
  32. package/dist/cli/helpers-check.js.map +1 -0
  33. package/dist/cli/main.js +22 -1
  34. package/dist/cli/main.js.map +1 -1
  35. package/dist/client.js +16 -2
  36. package/dist/client.js.map +1 -1
  37. package/dist/config.d.ts +31 -0
  38. package/dist/config.js +9 -0
  39. package/dist/config.js.map +1 -1
  40. package/dist/contracts.d.ts +1 -1
  41. package/dist/contracts.js +1 -1
  42. package/dist/db/baselines/db-v26.js +8 -4
  43. package/dist/db/baselines/db-v26.js.map +1 -1
  44. package/dist/db/baselines/db-v27.d.ts +42 -0
  45. package/dist/db/baselines/db-v27.js +47 -0
  46. package/dist/db/baselines/db-v27.js.map +1 -0
  47. package/dist/db/baselines/index.js +2 -1
  48. package/dist/db/baselines/index.js.map +1 -1
  49. package/dist/db/locate.d.ts +4 -1
  50. package/dist/db/locate.js +3 -1
  51. package/dist/db/locate.js.map +1 -1
  52. package/dist/db/schema.d.ts +1 -1
  53. package/dist/db/schema.js +9 -0
  54. package/dist/db/schema.js.map +1 -1
  55. package/dist/deputy/bridge-worker.d.ts +1 -0
  56. package/dist/deputy/bridge-worker.js +79 -0
  57. package/dist/deputy/bridge-worker.js.map +1 -0
  58. package/dist/deputy/bridge.d.ts +14 -0
  59. package/dist/deputy/bridge.js +70 -0
  60. package/dist/deputy/bridge.js.map +1 -0
  61. package/dist/deputy/client.d.ts +19 -0
  62. package/dist/deputy/client.js +104 -0
  63. package/dist/deputy/client.js.map +1 -0
  64. package/dist/deputy/db-facade.d.ts +18 -0
  65. package/dist/deputy/db-facade.js +71 -0
  66. package/dist/deputy/db-facade.js.map +1 -0
  67. package/dist/deputy/files.d.ts +1 -0
  68. package/dist/deputy/files.js +16 -0
  69. package/dist/deputy/files.js.map +1 -0
  70. package/dist/deputy/install.d.ts +105 -0
  71. package/dist/deputy/install.js +467 -0
  72. package/dist/deputy/install.js.map +1 -0
  73. package/dist/deputy/notice.d.ts +6 -0
  74. package/dist/deputy/notice.js +28 -0
  75. package/dist/deputy/notice.js.map +1 -0
  76. package/dist/deputy/notices.d.ts +31 -0
  77. package/dist/deputy/notices.js +94 -0
  78. package/dist/deputy/notices.js.map +1 -0
  79. package/dist/deputy/osa.d.ts +21 -0
  80. package/dist/deputy/osa.js +72 -0
  81. package/dist/deputy/osa.js.map +1 -0
  82. package/dist/deputy/protocol.d.ts +86 -0
  83. package/dist/deputy/protocol.js +106 -0
  84. package/dist/deputy/protocol.js.map +1 -0
  85. package/dist/deputy/routing.d.ts +63 -0
  86. package/dist/deputy/routing.js +338 -0
  87. package/dist/deputy/routing.js.map +1 -0
  88. package/dist/deputy/shortcuts-exec.d.ts +10 -0
  89. package/dist/deputy/shortcuts-exec.js +50 -0
  90. package/dist/deputy/shortcuts-exec.js.map +1 -0
  91. package/dist/diagnose.d.ts +43 -1
  92. package/dist/diagnose.js +64 -2
  93. package/dist/diagnose.js.map +1 -1
  94. package/dist/index.d.ts +13 -2
  95. package/dist/index.js +10 -1
  96. package/dist/index.js.map +1 -1
  97. package/dist/mcp/server.js +25 -7
  98. package/dist/mcp/server.js.map +1 -1
  99. package/dist/model/mappers.d.ts +12 -3
  100. package/dist/model/mappers.js +6 -1
  101. package/dist/model/mappers.js.map +1 -1
  102. package/dist/model/recurrence.d.ts +5 -1
  103. package/dist/model/recurrence.js +19 -1
  104. package/dist/model/recurrence.js.map +1 -1
  105. package/dist/model/template-projection.d.ts +26 -0
  106. package/dist/model/template-projection.js +113 -0
  107. package/dist/model/template-projection.js.map +1 -0
  108. package/dist/op-result.d.ts +46 -0
  109. package/dist/op-result.js +156 -0
  110. package/dist/op-result.js.map +1 -0
  111. package/dist/read/detail.js +7 -1
  112. package/dist/read/detail.js.map +1 -1
  113. package/dist/read/queries.js +8 -1
  114. package/dist/read/queries.js.map +1 -1
  115. package/dist/read/views.d.ts +6 -4
  116. package/dist/read/views.js +60 -18
  117. package/dist/read/views.js.map +1 -1
  118. package/dist/sync-health.js +6 -2
  119. package/dist/sync-health.js.map +1 -1
  120. package/dist/write/accessibility-probe.js +4 -2
  121. package/dist/write/accessibility-probe.js.map +1 -1
  122. package/dist/write/automation-probe.js +5 -1
  123. package/dist/write/automation-probe.js.map +1 -1
  124. package/dist/write/availability.js +7 -3
  125. package/dist/write/availability.js.map +1 -1
  126. package/dist/write/commands.js +58 -13
  127. package/dist/write/commands.js.map +1 -1
  128. package/dist/write/experimental.d.ts +19 -0
  129. package/dist/write/experimental.js +50 -0
  130. package/dist/write/experimental.js.map +1 -1
  131. package/dist/write/move.js +14 -6
  132. package/dist/write/move.js.map +1 -1
  133. package/dist/write/operations.d.ts +28 -4
  134. package/dist/write/pipeline.d.ts +2 -0
  135. package/dist/write/pipeline.js +59 -2
  136. package/dist/write/pipeline.js.map +1 -1
  137. package/dist/write/pre-state.js +33 -15
  138. package/dist/write/pre-state.js.map +1 -1
  139. package/dist/write/promote-clone.js +187 -19
  140. package/dist/write/promote-clone.js.map +1 -1
  141. package/dist/write/reorder.js +76 -27
  142. package/dist/write/reorder.js.map +1 -1
  143. package/dist/write/repeat-anchor.d.ts +30 -0
  144. package/dist/write/repeat-anchor.js +166 -1
  145. package/dist/write/repeat-anchor.js.map +1 -1
  146. package/dist/write/repeat-rule.js +10 -0
  147. package/dist/write/repeat-rule.js.map +1 -1
  148. package/dist/write/vectors/applescript.js +7 -16
  149. package/dist/write/vectors/applescript.js.map +1 -1
  150. package/dist/write/vectors/session-reachability.d.ts +55 -0
  151. package/dist/write/vectors/session-reachability.js +56 -8
  152. package/dist/write/vectors/session-reachability.js.map +1 -1
  153. package/dist/write/vectors/shortcuts.js +5 -14
  154. package/dist/write/vectors/shortcuts.js.map +1 -1
  155. package/dist/write/vectors/simulator.d.ts +16 -1
  156. package/dist/write/vectors/simulator.js +61 -10
  157. package/dist/write/vectors/simulator.js.map +1 -1
  158. package/dist/write/vectors/types.d.ts +67 -1
  159. package/dist/write/vectors/ui-recipes.js +128 -38
  160. package/dist/write/vectors/ui-recipes.js.map +1 -1
  161. package/dist/write/vectors/ui.d.ts +76 -0
  162. package/dist/write/vectors/ui.js +443 -37
  163. package/dist/write/vectors/ui.js.map +1 -1
  164. package/dist/write/verify/poller.d.ts +13 -0
  165. package/dist/write/verify/poller.js +38 -0
  166. package/dist/write/verify/poller.js.map +1 -1
  167. package/package.json +8 -1
  168. package/scripts/build-helpers.sh +104 -0
  169. package/scripts/deputy-cert-setup.sh +60 -0
  170. package/skills/things-cli/SKILL.md +8 -3
  171. package/skills/things-cli/references/errors.md +4 -0
  172. package/skills/things-cli/references/repeating.md +69 -0
@@ -27,15 +27,22 @@
27
27
  */
28
28
  import { execFile } from "node:child_process";
29
29
  import { DEFAULT_UI_DRIVE_BUDGET_MS } from "../../config.js";
30
+ import { osaExec } from "../../deputy/osa.js";
30
31
  import { noteInflightStep, trace, traceActive, tracePath } from "../../trace/tracer.js";
31
32
  import { UI_DRIVE_OPS } from "../operations.js";
32
33
  import { escapeAppleScript } from "./applescript.js";
33
- import { H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
34
+ import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
34
35
  import { certificationOf } from "./ui-certification.js";
35
36
  import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
36
37
  /** GUI driving can stall on an unanswered sheet; give each step headroom. */
37
38
  const STEP_TIMEOUT_MS = 15_000;
38
- /** 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
+ */
39
46
  const WAIT_POLL_MS = 300;
40
47
  /**
41
48
  * How long `resolveStepPath` polls a candidate-addressed control before failing
@@ -48,9 +55,22 @@ const RESOLVE_CANDIDATE_TIMEOUT_MS = 5_000;
48
55
  * Settle after the reveal/activate preamble so the menu bar repopulates for the
49
56
  * newly-selected target before the canary reads it (UIC1: the Items ▸ Repeat
50
57
  * submenu appears only once a repeating item is selected, and the update is not
51
- * 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.
65
+ */
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.
52
72
  */
53
- const SETTLE_AFTER_REVEAL_MS = 1500;
73
+ const SHAPE_UNPROBED = "the Repeat dialog's shape was never measured, so this control's address is unknown (recipe bug)";
54
74
  const SE = `tell application "System Events" to tell process "Things3"`;
55
75
  /** resolve-element: does the element exist right now? Returns "true"/"false". */
56
76
  export function axResolveScript(path) {
@@ -178,6 +198,270 @@ export function axSelectPopupCandidatesScript(path, values) {
178
198
  error "none of the candidate menu items exist: " & {${list}}
179
199
  end tell`;
180
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
+ }
181
465
  /**
182
466
  * select-row: select a PROJECT row by title, purely via AX (UIC4-a). Walks the
183
467
  * content table's rows, issues the row `select` action on each (which REPLACES
@@ -400,8 +684,11 @@ async function clearDialog(run) {
400
684
  * Reachable now → proceed (disclosed); still not →
401
685
  * block with the Space remediation.
402
686
  */
403
- async function ensureWindowReachable(run) {
404
- const first = await probeSessionReachability(run, STEP_TIMEOUT_MS);
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);
405
692
  if (first.reachable)
406
693
  return { ok: true, relocated: false };
407
694
  if (first.scope === "session")
@@ -413,6 +700,9 @@ async function ensureWindowReachable(run) {
413
700
  label: "move the Things window to the current desktop",
414
701
  script: axCloseReopenActivateScript(),
415
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();
416
706
  const second = await probeSessionReachability(run, STEP_TIMEOUT_MS);
417
707
  if (second.reachable)
418
708
  return { ok: true, relocated: true };
@@ -506,6 +796,24 @@ function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(n
506
796
  function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
507
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; }
508
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
+ }
509
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; }
510
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); }
511
819
  function pick(areas,target){
@@ -528,11 +836,15 @@ function run(){
528
836
  var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
529
837
  var target=${JSON.stringify(target)};
530
838
  var spec=${JSON.stringify(spec)};
531
- // Poll for the addressed area. collect is wrapped so a stale-element ObjC
532
- // exception during traversal cannot bubble as a raw -2700; pick guards the
533
- // empty set, so dt is null (never a crash) when the target is absent.
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.
534
846
  var areas=[]; var dt=null;
535
- for(var t=0;t<20 && !dt;t++){ areas=[]; try{ collect(app,'AXDateTimeArea',16,areas); }catch(e){ areas=[]; } dt=pick(areas,target); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
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); }
536
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');
537
849
  var cal=$.NSCalendar.currentCalendar;
538
850
  var d;
@@ -566,6 +878,20 @@ function run(){
566
878
  return 'OK';
567
879
  }`;
568
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
+ }
569
895
  /** Parse a resolve-frame "x y w h" line into the frame's center point. */
570
896
  export function parseFrameCenter(stdout) {
571
897
  const nums = stdout.trim().split(/\s+/).map(Number);
@@ -577,30 +903,30 @@ export function parseFrameCenter(stdout) {
577
903
  function revealUrl(uuid) {
578
904
  return `things:///show?id=${encodeURIComponent(uuid)}`;
579
905
  }
580
- function defaultRun(command, timeoutMs) {
581
- return new Promise((resolve) => {
582
- let bin;
583
- let args;
584
- if (command.primitive === "reveal") {
585
- [bin, args] = ["open", [command.url ?? ""]];
586
- }
587
- else if (command.lang === "javascript") {
588
- // JXA (ObjC bridge) for the mouse-synthesis primitive; one stable shape.
589
- [bin, args] = ["osascript", ["-l", "JavaScript", "-e", command.script ?? ""]];
590
- }
591
- else {
592
- [bin, args] = ["osascript", ["-e", command.script ?? ""]];
593
- }
594
- execFile(bin, [...args], { timeout: timeoutMs }, (err, stdout, stderr) => {
595
- const timedOut = err !== null && err.killed === true;
596
- resolve({
597
- ok: err === null,
598
- stdout: String(stdout),
599
- stderr: String(stderr),
600
- ...(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
+ });
601
918
  });
602
919
  });
603
- });
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
+ };
604
930
  }
605
931
  /**
606
932
  * Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
@@ -730,6 +1056,26 @@ export function commandForStep(step, targetUuid) {
730
1056
  label: step.label,
731
1057
  script: axEnsureCheckboxScript(step.path ?? "", step.checkboxTarget === true),
732
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 ?? "")),
1078
+ };
733
1079
  case "wait":
734
1080
  return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
735
1081
  case "select-row":
@@ -816,7 +1162,7 @@ async function driveClickElement(step, run) {
816
1162
  }
817
1163
  return { ok: true };
818
1164
  }
819
- async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
1165
+ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
820
1166
  const done = [];
821
1167
  // The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
822
1168
  // own timeout on a slow production database (large + Things-Cloud syncing
@@ -908,7 +1254,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
908
1254
  // full-screen session REFUSES (blocked, zero mutation); a window merely on
909
1255
  // another Space is RELOCATED back and disclosed. Menu-only recipes skip this.
910
1256
  if (recipe.needsWindowReachability === true) {
911
- const reach = await ensureWindowReachable(run);
1257
+ const reach = await ensureWindowReachable(run, reachCache);
912
1258
  if (!reach.ok)
913
1259
  return blockedReachability(reach.verdict);
914
1260
  if (reach.relocated) {
@@ -943,8 +1289,39 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
943
1289
  }
944
1290
  }
945
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;
946
1299
  for (let i = idx; i < recipe.steps.length; i += 1) {
947
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
+ }
948
1325
  // Overall-drive watchdog: if the budget is spent, stop at THIS step boundary
949
1326
  // (the per-step execFile timeouts keep the boundary close), clear any open
950
1327
  // dialog, and return the honest uncertain-outcome timeout (TRACE1 #487).
@@ -991,6 +1368,28 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
991
1368
  step = { ...step, path: effective };
992
1369
  }
993
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
+ }
994
1393
  if (step.primitive === "select-row" || step.primitive === "select-heading-row") {
995
1394
  // Pure-AX row selection with readback verification (UIC4-a / HEADCERT1):
996
1395
  // "OK" only when the intended row selected (title readback for a project
@@ -1147,6 +1546,12 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
1147
1546
  // in-flight marker (for the signal handler) and, when tracing is on, records a
1148
1547
  // start/end pair with timing/outcome (TRACE1 #487).
1149
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();
1150
1555
  return {
1151
1556
  id: "ui",
1152
1557
  matrix: enabled ? enabledMatrix() : disabledMatrix(),
@@ -1157,13 +1562,14 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
1157
1562
  if (invocation.recipe === undefined) {
1158
1563
  return refusal("ui invocation carried no recipe (compile bug).");
1159
1564
  }
1160
- return drive(invocation.recipe, tracedRun, aux, budgetMs);
1565
+ return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
1161
1566
  },
1162
1567
  // Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
1163
1568
  // live session BEFORE they seed a row, so a locked/full-screen session refuses
1164
1569
  // with zero mutation. Present regardless of `enabled` (the orchestrator has
1165
- // already cleared the H-UI-DRIVE ack by the time it consults this).
1166
- probeReachability: () => probeSessionReachability(tracedRun, STEP_TIMEOUT_MS),
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),
1167
1573
  };
1168
1574
  }
1169
1575
  //# sourceMappingURL=ui.js.map