things-api 0.9.0 → 0.11.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 (261) hide show
  1. package/README.md +57 -12
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/clock.d.ts +8 -0
  8. package/dist/cli/clock.js +24 -0
  9. package/dist/cli/clock.js.map +1 -0
  10. package/dist/cli/commands/area.d.ts +26 -3
  11. package/dist/cli/commands/area.js +129 -75
  12. package/dist/cli/commands/area.js.map +1 -1
  13. package/dist/cli/commands/doctor.d.ts +1 -1
  14. package/dist/cli/commands/doctor.js +44 -5
  15. package/dist/cli/commands/doctor.js.map +1 -1
  16. package/dist/cli/commands/install-skill.d.ts +45 -0
  17. package/dist/cli/commands/install-skill.js +207 -0
  18. package/dist/cli/commands/install-skill.js.map +1 -0
  19. package/dist/cli/commands/mcp.js +23 -4
  20. package/dist/cli/commands/mcp.js.map +1 -1
  21. package/dist/cli/commands/project.d.ts +18 -1
  22. package/dist/cli/commands/project.js +74 -35
  23. package/dist/cli/commands/project.js.map +1 -1
  24. package/dist/cli/commands/reads.d.ts +15 -2
  25. package/dist/cli/commands/reads.js +459 -165
  26. package/dist/cli/commands/reads.js.map +1 -1
  27. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  28. package/dist/cli/commands/repeat-flags.js +76 -0
  29. package/dist/cli/commands/repeat-flags.js.map +1 -0
  30. package/dist/cli/commands/setup.js +8 -4
  31. package/dist/cli/commands/setup.js.map +1 -1
  32. package/dist/cli/commands/show.js +53 -30
  33. package/dist/cli/commands/show.js.map +1 -1
  34. package/dist/cli/commands/todo.d.ts +1 -1
  35. package/dist/cli/commands/todo.js +20 -13
  36. package/dist/cli/commands/todo.js.map +1 -1
  37. package/dist/cli/commands/writes.js +388 -161
  38. package/dist/cli/commands/writes.js.map +1 -1
  39. package/dist/cli/did-you-mean.d.ts +1 -1
  40. package/dist/cli/excess-args.d.ts +15 -0
  41. package/dist/cli/excess-args.js +51 -0
  42. package/dist/cli/excess-args.js.map +1 -0
  43. package/dist/cli/glyphs.d.ts +55 -2
  44. package/dist/cli/glyphs.js +114 -21
  45. package/dist/cli/glyphs.js.map +1 -1
  46. package/dist/cli/help.d.ts +54 -0
  47. package/dist/cli/help.js +405 -0
  48. package/dist/cli/help.js.map +1 -0
  49. package/dist/cli/main.js +64 -26
  50. package/dist/cli/main.js.map +1 -1
  51. package/dist/cli/move-hint.d.ts +45 -0
  52. package/dist/cli/move-hint.js +196 -0
  53. package/dist/cli/move-hint.js.map +1 -0
  54. package/dist/cli/period.d.ts +9 -8
  55. package/dist/cli/period.js +56 -30
  56. package/dist/cli/period.js.map +1 -1
  57. package/dist/cli/read-driver.d.ts +26 -19
  58. package/dist/cli/read-driver.js +77 -33
  59. package/dist/cli/read-driver.js.map +1 -1
  60. package/dist/cli/render.d.ts +92 -28
  61. package/dist/cli/render.js +333 -103
  62. package/dist/cli/render.js.map +1 -1
  63. package/dist/cli/resolve-invocation.d.ts +42 -4
  64. package/dist/cli/resolve-invocation.js +97 -14
  65. package/dist/cli/resolve-invocation.js.map +1 -1
  66. package/dist/cli/skill-check.d.ts +21 -0
  67. package/dist/cli/skill-check.js +82 -0
  68. package/dist/cli/skill-check.js.map +1 -0
  69. package/dist/cli/skill.d.ts +62 -0
  70. package/dist/cli/skill.js +142 -0
  71. package/dist/cli/skill.js.map +1 -0
  72. package/dist/cli/tag-filters.d.ts +63 -0
  73. package/dist/cli/tag-filters.js +59 -0
  74. package/dist/cli/tag-filters.js.map +1 -0
  75. package/dist/cli/verb-hint.d.ts +25 -0
  76. package/dist/cli/verb-hint.js +163 -0
  77. package/dist/cli/verb-hint.js.map +1 -0
  78. package/dist/cli/version.d.ts +4 -0
  79. package/dist/cli/version.js +18 -0
  80. package/dist/cli/version.js.map +1 -0
  81. package/dist/cli/width.d.ts +135 -0
  82. package/dist/cli/width.js +313 -0
  83. package/dist/cli/width.js.map +1 -0
  84. package/dist/client.d.ts +160 -24
  85. package/dist/client.js +132 -19
  86. package/dist/client.js.map +1 -1
  87. package/dist/config.d.ts +11 -0
  88. package/dist/config.js +3 -0
  89. package/dist/config.js.map +1 -1
  90. package/dist/contracts.d.ts +97 -17
  91. package/dist/contracts.js +44 -1
  92. package/dist/contracts.js.map +1 -1
  93. package/dist/db/fingerprint.d.ts +12 -0
  94. package/dist/db/fingerprint.js +15 -1
  95. package/dist/db/fingerprint.js.map +1 -1
  96. package/dist/db/locate.js +1 -1
  97. package/dist/db/locate.js.map +1 -1
  98. package/dist/diagnose.d.ts +63 -0
  99. package/dist/diagnose.js +38 -1
  100. package/dist/diagnose.js.map +1 -1
  101. package/dist/index.d.ts +36 -5
  102. package/dist/index.js +38 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp/server.d.ts +9 -1
  105. package/dist/mcp/server.js +1038 -436
  106. package/dist/mcp/server.js.map +1 -1
  107. package/dist/model/clock.d.ts +61 -0
  108. package/dist/model/clock.js +71 -0
  109. package/dist/model/clock.js.map +1 -0
  110. package/dist/model/dates.d.ts +26 -3
  111. package/dist/model/dates.js +84 -3
  112. package/dist/model/dates.js.map +1 -1
  113. package/dist/model/entities.d.ts +48 -10
  114. package/dist/model/entities.js.map +1 -1
  115. package/dist/model/mappers.d.ts +9 -2
  116. package/dist/model/mappers.js +32 -9
  117. package/dist/model/mappers.js.map +1 -1
  118. package/dist/model/serialize.d.ts +28 -0
  119. package/dist/model/serialize.js +80 -0
  120. package/dist/model/serialize.js.map +1 -0
  121. package/dist/model/when-sugar.d.ts +47 -0
  122. package/dist/model/when-sugar.js +45 -0
  123. package/dist/model/when-sugar.js.map +1 -0
  124. package/dist/read/area-view.d.ts +8 -1
  125. package/dist/read/area-view.js +55 -17
  126. package/dist/read/area-view.js.map +1 -1
  127. package/dist/read/detail.d.ts +1 -1
  128. package/dist/read/detail.js +11 -4
  129. package/dist/read/detail.js.map +1 -1
  130. package/dist/read/filter-contract.d.ts +117 -0
  131. package/dist/read/filter-contract.js +78 -0
  132. package/dist/read/filter-contract.js.map +1 -0
  133. package/dist/read/log-boundary.d.ts +1 -1
  134. package/dist/read/log-boundary.js +13 -3
  135. package/dist/read/log-boundary.js.map +1 -1
  136. package/dist/read/predicates.d.ts +30 -0
  137. package/dist/read/predicates.js +30 -0
  138. package/dist/read/predicates.js.map +1 -1
  139. package/dist/read/project-view.d.ts +12 -1
  140. package/dist/read/project-view.js +45 -12
  141. package/dist/read/project-view.js.map +1 -1
  142. package/dist/read/queries.d.ts +152 -7
  143. package/dist/read/queries.js +277 -36
  144. package/dist/read/queries.js.map +1 -1
  145. package/dist/read/sections.d.ts +51 -0
  146. package/dist/read/sections.js +37 -0
  147. package/dist/read/sections.js.map +1 -0
  148. package/dist/read/sidebar-order.js +2 -1
  149. package/dist/read/sidebar-order.js.map +1 -1
  150. package/dist/read/snapshot.d.ts +1 -1
  151. package/dist/read/snapshot.js +9 -2
  152. package/dist/read/snapshot.js.map +1 -1
  153. package/dist/read/tags.d.ts +27 -3
  154. package/dist/read/tags.js +83 -12
  155. package/dist/read/tags.js.map +1 -1
  156. package/dist/read/truncation.d.ts +71 -0
  157. package/dist/read/{pagination.js → truncation.js} +103 -94
  158. package/dist/read/truncation.js.map +1 -0
  159. package/dist/read/views.d.ts +147 -22
  160. package/dist/read/views.js +282 -80
  161. package/dist/read/views.js.map +1 -1
  162. package/dist/surface-copy.d.ts +19 -0
  163. package/dist/surface-copy.js +32 -0
  164. package/dist/surface-copy.js.map +1 -1
  165. package/dist/sync-health.d.ts +78 -0
  166. package/dist/sync-health.js +312 -0
  167. package/dist/sync-health.js.map +1 -0
  168. package/dist/write/accessibility-probe.d.ts +12 -0
  169. package/dist/write/accessibility-probe.js +71 -0
  170. package/dist/write/accessibility-probe.js.map +1 -0
  171. package/dist/write/automation-probe.d.ts +8 -1
  172. package/dist/write/automation-probe.js +16 -1
  173. package/dist/write/automation-probe.js.map +1 -1
  174. package/dist/write/batch.js +5 -2
  175. package/dist/write/batch.js.map +1 -1
  176. package/dist/write/capabilities.d.ts +8 -0
  177. package/dist/write/capabilities.js +13 -6
  178. package/dist/write/capabilities.js.map +1 -1
  179. package/dist/write/commands.d.ts +3 -1
  180. package/dist/write/commands.js +602 -49
  181. package/dist/write/commands.js.map +1 -1
  182. package/dist/write/edit-checklist.js +3 -2
  183. package/dist/write/edit-checklist.js.map +1 -1
  184. package/dist/write/guards.d.ts +2 -7
  185. package/dist/write/guards.js +87 -6
  186. package/dist/write/guards.js.map +1 -1
  187. package/dist/write/heading.js +2 -0
  188. package/dist/write/heading.js.map +1 -1
  189. package/dist/write/lock.d.ts +40 -2
  190. package/dist/write/lock.js +91 -14
  191. package/dist/write/lock.js.map +1 -1
  192. package/dist/write/make-repeating-project.d.ts +4 -0
  193. package/dist/write/make-repeating-project.js +253 -0
  194. package/dist/write/make-repeating-project.js.map +1 -0
  195. package/dist/write/operations.d.ts +145 -1
  196. package/dist/write/operations.js +48 -0
  197. package/dist/write/operations.js.map +1 -1
  198. package/dist/write/pipeline.d.ts +73 -2
  199. package/dist/write/pipeline.js +218 -43
  200. package/dist/write/pipeline.js.map +1 -1
  201. package/dist/write/planner.js +8 -1
  202. package/dist/write/planner.js.map +1 -1
  203. package/dist/write/pre-state.d.ts +105 -3
  204. package/dist/write/pre-state.js +144 -3
  205. package/dist/write/pre-state.js.map +1 -1
  206. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  207. package/dist/write/recurrence-rule-blob.js +150 -0
  208. package/dist/write/recurrence-rule-blob.js.map +1 -0
  209. package/dist/write/reopen.js +3 -2
  210. package/dist/write/reopen.js.map +1 -1
  211. package/dist/write/reorder.js +27 -17
  212. package/dist/write/reorder.js.map +1 -1
  213. package/dist/write/repeat-rule.d.ts +22 -0
  214. package/dist/write/repeat-rule.js +258 -0
  215. package/dist/write/repeat-rule.js.map +1 -0
  216. package/dist/write/reversibility.js +49 -0
  217. package/dist/write/reversibility.js.map +1 -1
  218. package/dist/write/tag-refs.d.ts +47 -0
  219. package/dist/write/tag-refs.js +126 -0
  220. package/dist/write/tag-refs.js.map +1 -0
  221. package/dist/write/undo.d.ts +74 -5
  222. package/dist/write/undo.js +493 -80
  223. package/dist/write/undo.js.map +1 -1
  224. package/dist/write/vectors/registry.d.ts +15 -1
  225. package/dist/write/vectors/registry.js +65 -2
  226. package/dist/write/vectors/registry.js.map +1 -1
  227. package/dist/write/vectors/simulator.d.ts +44 -0
  228. package/dist/write/vectors/simulator.js +1136 -0
  229. package/dist/write/vectors/simulator.js.map +1 -0
  230. package/dist/write/vectors/types.d.ts +154 -3
  231. package/dist/write/vectors/ui-certification.d.ts +54 -0
  232. package/dist/write/vectors/ui-certification.js +43 -0
  233. package/dist/write/vectors/ui-certification.js.map +1 -0
  234. package/dist/write/vectors/ui-drag.d.ts +188 -0
  235. package/dist/write/vectors/ui-drag.js +1095 -0
  236. package/dist/write/vectors/ui-drag.js.map +1 -0
  237. package/dist/write/vectors/ui-recipes.d.ts +83 -0
  238. package/dist/write/vectors/ui-recipes.js +610 -0
  239. package/dist/write/vectors/ui-recipes.js.map +1 -0
  240. package/dist/write/vectors/ui.d.ts +152 -0
  241. package/dist/write/vectors/ui.js +704 -0
  242. package/dist/write/vectors/ui.js.map +1 -0
  243. package/dist/write/verify/delta.d.ts +141 -4
  244. package/dist/write/verify/delta.js +199 -21
  245. package/dist/write/verify/delta.js.map +1 -1
  246. package/dist/write/verify/poller.d.ts +7 -1
  247. package/dist/write/verify/poller.js +21 -1
  248. package/dist/write/verify/poller.js.map +1 -1
  249. package/package.json +18 -3
  250. package/skills/things-cli/SKILL.md +53 -0
  251. package/skills/things-cli/references/contracts.md +41 -0
  252. package/skills/things-cli/references/gui.md +11 -0
  253. package/skills/things-cli/references/model.md +51 -0
  254. package/dist/cli/exit-codes.d.ts +0 -26
  255. package/dist/cli/exit-codes.js +0 -26
  256. package/dist/cli/exit-codes.js.map +0 -1
  257. package/dist/cli/output.d.ts +0 -42
  258. package/dist/cli/output.js +0 -16
  259. package/dist/cli/output.js.map +0 -1
  260. package/dist/read/pagination.d.ts +0 -104
  261. package/dist/read/pagination.js.map +0 -1
@@ -0,0 +1,704 @@
1
+ /**
2
+ * The Accessibility GUI ("ui") vector — the FOURTH write vector, for the
3
+ * GUI-only transforms that have no headless spelling (make/reschedule/pause/
4
+ * resume/stop a repeat, convert a to-do or heading to a project). It drives
5
+ * the LOCAL Things app through the Accessibility API (the AXUIElement tree via
6
+ * `osascript` + System Events), addressing SEMANTIC elements (`menu item
7
+ * "Pause" …`, `button "Convert" of sheet 1 …`) — NEVER coordinates, never
8
+ * screenshots. That semantic addressing is what makes it fail-closed:
9
+ *
10
+ * - Recipe canary preflight: before ANY press, every statically-reachable
11
+ * element the recipe will touch is resolved; a single miss refuses the
12
+ * whole drive, naming the element (a Things update moved/renamed the menu,
13
+ * Accessibility is not granted, the app is not running, or the app is not
14
+ * in English). Nothing is pressed on a partial resolution.
15
+ * - Wait-for-element with timeout for async UI (sheets/popovers): the driver
16
+ * polls for the expected element and, on timeout, aborts (Escape) and
17
+ * reports partial state honestly — which steps ran, which did not.
18
+ *
19
+ * Two-key gated: the `ui.enabled` config (below — an unset config makes the
20
+ * matrix report the op unsupported) AND a per-call `dangerouslyDriveGui`
21
+ * acknowledgement (H-UI-DRIVE, enforced by the pipeline's guards). Every op
22
+ * ships UNCERTIFIED (ui-certification.ts): the element paths are derived from
23
+ * the known menu structure but not yet exercised on real hardware.
24
+ *
25
+ * A vendored native AXUIElement client is an explicitly-deferred follow-up;
26
+ * v1 shells out to `osascript` with ONE stable command shape per primitive.
27
+ */
28
+ import { execFile } from "node:child_process";
29
+ import { UI_DRIVE_OPS } from "../operations.js";
30
+ import { escapeAppleScript } from "./applescript.js";
31
+ import { certificationOf } from "./ui-certification.js";
32
+ import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
33
+ /** GUI driving can stall on an unanswered sheet; give each step headroom. */
34
+ const STEP_TIMEOUT_MS = 15_000;
35
+ /** Poll interval while waiting for a dynamic element (sheet/popover). */
36
+ const WAIT_POLL_MS = 300;
37
+ /**
38
+ * How long `resolveStepPath` polls a candidate-addressed control before failing
39
+ * closed. The full-vocabulary dialog reveals a pop-up/field a beat AFTER the
40
+ * frequency/Ends switch that precedes it (UIC6: ~250 ms), so the effective-form
41
+ * resolution must poll, not snap once.
42
+ */
43
+ const RESOLVE_CANDIDATE_TIMEOUT_MS = 5_000;
44
+ /**
45
+ * Settle after the reveal/activate preamble so the menu bar repopulates for the
46
+ * newly-selected target before the canary reads it (UIC1: the Items ▸ Repeat
47
+ * submenu appears only once a repeating item is selected, and the update is not
48
+ * instantaneous).
49
+ */
50
+ const SETTLE_AFTER_REVEAL_MS = 1500;
51
+ const SE = `tell application "System Events" to tell process "Things3"`;
52
+ /** resolve-element: does the element exist right now? Returns "true"/"false". */
53
+ export function axResolveScript(path) {
54
+ return `${SE} to return (exists (${path}))`;
55
+ }
56
+ /** press: AXPress the element. */
57
+ export function axPressScript(path) {
58
+ return `${SE} to click (${path})`;
59
+ }
60
+ /**
61
+ * set-field-value: enter a value into the dialog's numeric text field (interval,
62
+ * ends-count, start-days-earlier). It FOCUSES the field, selects all, TYPES the
63
+ * value, and Tabs to commit — because `set value of <field>` writes the field's
64
+ * displayed text WITHOUT firing the edit, so the app's binding keeps the old
65
+ * number (the field shows "5" but the rule stays interval 1 — a silent no-op
66
+ * exactly like `set value` on a pop-up, UIC6; it went unnoticed while every base
67
+ * case used the default interval 1). Real keystrokes fire the change the binding
68
+ * needs; Tab (not Return, which would fire the default OK button) commits and
69
+ * moves focus. Foreground-bound (keystrokes reach the frontmost app) — the
70
+ * reveal/activate preamble puts Things there. One stable command shape.
71
+ */
72
+ export function axSetValueScript(path, value) {
73
+ return `${SE}
74
+ set tf to (${path})
75
+ set focused of tf to true
76
+ delay 0.15
77
+ keystroke "a" using command down
78
+ delay 0.1
79
+ keystroke "${escapeAppleScript(value)}"
80
+ delay 0.1
81
+ key code 48
82
+ delay 0.2
83
+ end tell`;
84
+ }
85
+ /**
86
+ * select-popup: choose an item in a pop-up button by NAME. Setting `value` on a
87
+ * Things pop-up button is a silent no-op (UIC1 / UI2-i) — the control must be
88
+ * opened and the menu item clicked. The open-click is POLLED until the menu
89
+ * actually renders: in the full-vocabulary dialog a preceding pop-up's menu is
90
+ * still animating closed when the next select fires, and that first open-click
91
+ * is ABSORBED (the pop-up stays closed, so `menu 1` is an invalid index and the
92
+ * item click errors -1719, UIC6). Re-clicking only while the menu is absent
93
+ * (never once it is open) opens it reliably without toggling it back shut. One
94
+ * stable command shape per primitive.
95
+ */
96
+ export function axSelectPopupScript(path, value) {
97
+ return `${SE}
98
+ set pu to (${path})
99
+ repeat 20 times
100
+ if (exists menu 1 of pu) then exit repeat
101
+ click pu
102
+ delay 0.3
103
+ end repeat
104
+ click menu item "${escapeAppleScript(value)}" of menu 1 of pu
105
+ end tell`;
106
+ }
107
+ /**
108
+ * select-row: select a PROJECT row by title, purely via AX (UIC4-a). Walks the
109
+ * content table's rows, issues the row `select` action on each (which REPLACES
110
+ * the table selection — single-select, UIC5), and reads back Things' `name of
111
+ * selected to dos`; the first row whose readback equals the target title is LEFT
112
+ * selected and the script returns "OK". Non-selectable rows (the area/Someday
113
+ * header, the blank spacer) select nothing (readback count 0) and are skipped.
114
+ * Returns "NOMATCH" if no row selects to the title — the readback is the
115
+ * selection-landed verification, so a match guarantees the intended row is
116
+ * selected. One stable command shape per primitive.
117
+ *
118
+ * UIC5 correction: the shipped form set the TABLE's `AXSelectedRows` attribute
119
+ * to a one-row list, which is a SILENT NO-OP on Things' content table via System
120
+ * Events (no error, selection never lands). The row `select` action is the
121
+ * working pure-System-Events route and stays background-capable with no focus
122
+ * steal (UIC5-e). (UIC4-a proved settability with the ObjC-bridge NSArray set —
123
+ * a different API than the System Events attribute set the driver shells out to.)
124
+ */
125
+ export function axSelectRowScript(tablePath, title) {
126
+ const t = escapeAppleScript(title);
127
+ return `tell application "System Events" to tell process "Things3"
128
+ set theTable to (${tablePath})
129
+ set n to (count rows of theTable)
130
+ repeat with i from 1 to n
131
+ try
132
+ select (row i of theTable)
133
+ tell application "Things3" to set selNames to (name of selected to dos)
134
+ if (count of selNames) is 1 and ((item 1 of selNames) as text) is "${t}" then
135
+ return "OK"
136
+ end if
137
+ end try
138
+ end repeat
139
+ end tell
140
+ return "NOMATCH"`;
141
+ }
142
+ /**
143
+ * select-heading-row: select a HEADING as a content-table row by POSITION,
144
+ * purely via AX (HEADCERT1). A heading is not `things:///show`-selectable and
145
+ * its row carries no stable AX title handle, so identity is positional. Walks
146
+ * the revealed project view's content table; for each row it issues the row
147
+ * `select` action, then checks two things: the row genuinely took the selection
148
+ * (`selected of row` — header/spacer rows do not) AND `Things3 → name of
149
+ * selected to dos` is EMPTY (a heading is not a to-do; a to-do row's readback is
150
+ * its title). The Nth such heading row (0-based `ordinal`, in top-to-bottom =
151
+ * `index` order) is LEFT selected and the script returns "OK"; "NOMATCH" if the
152
+ * project has fewer headings. With the heading selected, `Items ▸ Convert to
153
+ * Project…` enables. Pure System Events, background-capable, no focus steal.
154
+ * One stable command shape per primitive.
155
+ */
156
+ export function axSelectHeadingRowScript(tablePath, ordinal) {
157
+ const n = Math.max(0, Math.trunc(ordinal));
158
+ return `tell application "System Events" to tell process "Things3"
159
+ set theTable to (${tablePath})
160
+ set rowCount to (count rows of theTable)
161
+ set headingSeen to 0
162
+ repeat with i from 1 to rowCount
163
+ try
164
+ select (row i of theTable)
165
+ delay 0.25
166
+ if (selected of (row i of theTable)) then
167
+ tell application "Things3" to set selNames to (name of selected to dos)
168
+ if (count of selNames) is 0 then
169
+ if headingSeen is ${n} then return "OK"
170
+ set headingSeen to headingSeen + 1
171
+ end if
172
+ end if
173
+ end try
174
+ end repeat
175
+ end tell
176
+ return "NOMATCH"`;
177
+ }
178
+ /** activate: foreground Things (the fallback preamble step). */
179
+ export function axActivateScript() {
180
+ return `tell application "Things3" to activate`;
181
+ }
182
+ /** key: a space-separated keystroke spec (e.g. "down down return"). */
183
+ export function axKeyScript(keys) {
184
+ const KEY_CODES = { return: 36, escape: 53, down: 125, up: 126, tab: 48 };
185
+ const lines = keys
186
+ .split(/\s+/)
187
+ .filter((k) => k !== "")
188
+ .map((k) => KEY_CODES[k] !== undefined
189
+ ? `key code ${KEY_CODES[k]}`
190
+ : `keystroke "${escapeAppleScript(k)}"`);
191
+ return `tell application "System Events" to tell process "Things3"\n ${lines.join("\n ")}\nend tell`;
192
+ }
193
+ /** The abort keystroke sent to dismiss a half-open sheet/popover on failure. */
194
+ export function axAbortScript() {
195
+ return `tell application "System Events" to key code 53`; // Escape
196
+ }
197
+ /**
198
+ * resolve-frame: read the element's on-screen frame (top-left origin, points)
199
+ * from the live AX tree and print "x y w h". Used by `click-element` to target
200
+ * the frame CENTER — the position comes from AX (`position`/`size`), never a
201
+ * guessed pixel, so a missing element errors (fail-closed) instead of clicking
202
+ * a stale coordinate. Points map 1:1 to CGEvent coordinates (NATIVE1-b).
203
+ */
204
+ export function axFrameScript(path) {
205
+ return `${SE}
206
+ set _p to position of (${path})
207
+ set _s to size of (${path})
208
+ return ((item 1 of _p) as text) & " " & ((item 2 of _p) as text) & " " & ((item 1 of _s) as text) & " " & ((item 2 of _s) as text)
209
+ end tell`;
210
+ }
211
+ /**
212
+ * click-point: synthesize a single left mouse click at (x, y) via the global
213
+ * HID event tap (the NATIVE1 JXA ObjC-bridge path — `CGEventPostToPid` is inert
214
+ * for Things' hit-testing; only `CGEventPost(kCGHIDEventTap)` lands). The HID
215
+ * tap posts to the FOREGROUND surface, so the recipe must have activated Things
216
+ * first. Event types are the stable CGEventType values (5 = mouse-moved,
217
+ * 1 = left-down, 2 = left-up).
218
+ */
219
+ export function jxaClickScript(x, y) {
220
+ const xi = Math.round(x);
221
+ const yi = Math.round(y);
222
+ return `ObjC.import('Foundation');
223
+ ObjC.import('CoreGraphics');
224
+ function sleep(ms){ $.NSThread.sleepForTimeInterval(ms/1000); }
225
+ function mev(t){ return $.CGEventCreateMouseEvent($(), t, $.CGPointMake(${xi}, ${yi}), 0); }
226
+ $.CGEventPost($.kCGHIDEventTap, mev(5)); sleep(20);
227
+ $.CGEventPost($.kCGHIDEventTap, mev(1)); sleep(15);
228
+ $.CGEventPost($.kCGHIDEventTap, mev(2));`;
229
+ }
230
+ /** The command that posts an AX-resolved mouse click (one stable JXA shape). */
231
+ function clickPointCommand(x, y, label) {
232
+ return { primitive: "click-point", label, lang: "javascript", script: jxaClickScript(x, y) };
233
+ }
234
+ /**
235
+ * set-datetime: set the Repeat dialog's `AXDateTimeArea` (reminder time / "ends
236
+ * on date" bound) via the ObjC AX bridge. Things' date/time control holds an
237
+ * NSDate, and System Events cannot write it (`set value … to <date>` → -10000,
238
+ * UIC6), so — like the mouse-synthesis primitive — this runs in JXA and calls
239
+ * `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly. The control is
240
+ * found by ROLE within Things' front dialog (there is exactly one during a
241
+ * reminder/end-date step; the matrix never sets both at once), polled briefly
242
+ * so it is caught right after the checkbox/pop-up that reveals it, and the
243
+ * script THROWS when absent so the driver fails closed. `spec` is
244
+ * `time:HH:mm` (keep the control's date, overwrite the time-of-day) or
245
+ * `date:YYYY-MM-DD` (overwrite the date at midnight). One stable JXA shape.
246
+ */
247
+ export function axSetDateTimeScript(spec) {
248
+ return `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
249
+ function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
250
+ function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
251
+ 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; }
252
+ 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; }
253
+ function run(){
254
+ var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
255
+ if(!apps || apps.count===0) throw new Error('Things not running');
256
+ var pid=apps.objectAtIndex(0).processIdentifier;
257
+ var app=$.AXUIElementCreateApplication(pid);
258
+ var dt=null;
259
+ for(var t=0;t<20 && !dt;t++){ dt=find(app,'AXDateTimeArea',16); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
260
+ if(!dt) throw new Error('no AXDateTimeArea in the Repeat dialog');
261
+ var spec=${JSON.stringify(spec)};
262
+ var cal=$.NSCalendar.currentCalendar;
263
+ var d;
264
+ if(spec.indexOf('time:')===0){
265
+ // Set the time-of-day on the control's own (today's) date via the purpose-
266
+ // built calendar API — component-bag mutation via JXA silently drops the
267
+ // hour, leaking the current wall-clock hour into the reminder (UIC6).
268
+ var cur=attr(dt,'AXValue'); if(!cur) throw new Error('date/time control has no value');
269
+ var hm=spec.slice(5).split(':');
270
+ d=cal.dateBySettingHourMinuteSecondOfDateOptions(+hm[0], +hm[1], 0, cur, 0);
271
+ } else if(spec.indexOf('date:')===0){
272
+ var ymd=spec.slice(5).split('-');
273
+ var comps=$.NSDateComponents.alloc.init;
274
+ comps.year=+ymd[0]; comps.month=+ymd[1]; comps.day=+ymd[2]; comps.hour=0; comps.minute=0; comps.second=0;
275
+ d=cal.dateFromComponents(comps);
276
+ } else { throw new Error('bad datetime spec: '+spec); }
277
+ if(!d) throw new Error('could not build date from '+spec);
278
+ var err=$.AXUIElementSetAttributeValue(dt,$('AXValue'),d);
279
+ if(err!==0) throw new Error('AXValue set failed err='+err);
280
+ $.NSThread.sleepForTimeInterval(0.2);
281
+ return 'OK';
282
+ }`;
283
+ }
284
+ /** Parse a resolve-frame "x y w h" line into the frame's center point. */
285
+ export function parseFrameCenter(stdout) {
286
+ const nums = stdout.trim().split(/\s+/).map(Number);
287
+ if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n)))
288
+ return null;
289
+ const [x, y, w, h] = nums;
290
+ return { x: x + w / 2, y: y + h / 2 };
291
+ }
292
+ function revealUrl(uuid) {
293
+ return `things:///show?id=${encodeURIComponent(uuid)}`;
294
+ }
295
+ function defaultRun(command, timeoutMs) {
296
+ return new Promise((resolve) => {
297
+ let bin;
298
+ let args;
299
+ if (command.primitive === "reveal") {
300
+ [bin, args] = ["open", [command.url ?? ""]];
301
+ }
302
+ else if (command.lang === "javascript") {
303
+ // JXA (ObjC bridge) for the mouse-synthesis primitive; one stable shape.
304
+ [bin, args] = ["osascript", ["-l", "JavaScript", "-e", command.script ?? ""]];
305
+ }
306
+ else {
307
+ [bin, args] = ["osascript", ["-e", command.script ?? ""]];
308
+ }
309
+ execFile(bin, [...args], { timeout: timeoutMs }, (err, stdout, stderr) => {
310
+ const timedOut = err !== null && err.killed === true;
311
+ resolve({
312
+ ok: err === null,
313
+ stdout: String(stdout),
314
+ stderr: String(stderr),
315
+ ...(timedOut && { timedOut: true }),
316
+ });
317
+ });
318
+ });
319
+ }
320
+ /** The element paths the preflight canary resolves (static steps only). */
321
+ function canaryPaths(recipe) {
322
+ const out = [];
323
+ for (const step of recipe.steps) {
324
+ if (step.dynamic === true)
325
+ continue;
326
+ if (step.primitive !== "press" &&
327
+ step.primitive !== "set-value" &&
328
+ step.primitive !== "resolve" &&
329
+ step.primitive !== "click-element" &&
330
+ step.primitive !== "select-row" &&
331
+ step.primitive !== "select-heading-row") {
332
+ continue;
333
+ }
334
+ // A candidate-addressed step is resolved at run time (its element is
335
+ // dynamic by construction), so it is never canaried here.
336
+ if (step.pathCandidates !== undefined)
337
+ continue;
338
+ const path = step.canaryPath ?? step.path;
339
+ if (path !== undefined)
340
+ out.push({ path, label: step.label });
341
+ }
342
+ return out;
343
+ }
344
+ /**
345
+ * Resolve a step's effective element path. A `pathCandidates` step dispatches
346
+ * against the FIRST candidate that exists (the dialog-form disjunction — attached
347
+ * sheet vs detached AXUnknown window, UIC4-a). The candidates are POLLED over a
348
+ * bounded window because the full-vocabulary controls are REVEALED by the
349
+ * preceding step: switching the frequency pop-up to weekly/monthly/yearly (or
350
+ * ticking Ends=after) re-lays-out the cadence group, and the new pop-up/field
351
+ * lands ~250 ms later (UIC6). A single immediate exists-check races that render
352
+ * and would spuriously fail closed; polling matches the `dynamic` nature these
353
+ * steps already declare. Returns null when none resolve within the window.
354
+ */
355
+ async function resolveStepPath(step, run) {
356
+ if (step.pathCandidates === undefined)
357
+ return step.path ?? null;
358
+ const candidates = step.pathCandidates;
359
+ const deadline = Date.now() + (step.timeoutMs ?? RESOLVE_CANDIDATE_TIMEOUT_MS);
360
+ for (;;) {
361
+ for (const candidate of candidates) {
362
+ // candidates are tried in priority order; the first hit wins, so a race would blur which form matched
363
+ const res = await run({ primitive: "resolve", label: step.label, script: axResolveScript(candidate) }, STEP_TIMEOUT_MS);
364
+ if (res.ok && res.stdout.trim() === "true")
365
+ return candidate;
366
+ }
367
+ if (Date.now() >= deadline)
368
+ return null;
369
+ // the revealed control lands a beat after the mode switch; poll until it does
370
+ await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
371
+ }
372
+ }
373
+ function refusal(detail) {
374
+ return { exitCode: 1, stdout: "", stderr: detail };
375
+ }
376
+ /** Compile one recipe step into its primitive command (no dispatch). */
377
+ export function commandForStep(step, targetUuid) {
378
+ switch (step.primitive) {
379
+ case "reveal":
380
+ return { primitive: "reveal", label: step.label, url: revealUrl(step.value ?? targetUuid) };
381
+ case "activate":
382
+ return { primitive: "activate", label: step.label, script: axActivateScript() };
383
+ case "press":
384
+ return { primitive: "press", label: step.label, script: axPressScript(step.path ?? "") };
385
+ case "resolve":
386
+ return { primitive: "resolve", label: step.label, script: axResolveScript(step.path ?? "") };
387
+ case "set-value":
388
+ return {
389
+ primitive: "set-value",
390
+ label: step.label,
391
+ script: axSetValueScript(step.path ?? "", step.value ?? ""),
392
+ };
393
+ case "select-popup":
394
+ return {
395
+ primitive: "select-popup",
396
+ label: step.label,
397
+ script: axSelectPopupScript(step.path ?? "", step.value ?? ""),
398
+ };
399
+ case "set-datetime":
400
+ return {
401
+ primitive: "set-datetime",
402
+ label: step.label,
403
+ lang: "javascript",
404
+ script: axSetDateTimeScript(step.value ?? ""),
405
+ };
406
+ case "wait":
407
+ return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
408
+ case "select-row":
409
+ return {
410
+ primitive: "select-row",
411
+ label: step.label,
412
+ script: axSelectRowScript(step.path ?? "", step.value ?? ""),
413
+ };
414
+ case "select-heading-row":
415
+ return {
416
+ primitive: "select-heading-row",
417
+ label: step.label,
418
+ script: axSelectHeadingRowScript(step.path ?? "", Number(step.value ?? "0")),
419
+ };
420
+ case "key":
421
+ return { primitive: "key", label: step.label, script: axKeyScript(step.keys ?? "") };
422
+ case "click-element":
423
+ // Phase 1 of the click: read the target's frame. driveClickElement runs
424
+ // this, then posts the click at the resolved center and asserts the outcome.
425
+ return {
426
+ primitive: "resolve-frame",
427
+ label: step.label,
428
+ lang: "applescript",
429
+ script: axFrameScript(step.path ?? ""),
430
+ };
431
+ case "drag-reorder":
432
+ // Composite step: drive() hands it to the sidebar drag driver, which
433
+ // dispatches its own snapshot/scroll/drag commands through `run`. This
434
+ // shape only exists so the step renders/compiles uniformly.
435
+ return {
436
+ primitive: "sidebar-snapshot",
437
+ label: step.label,
438
+ lang: "javascript",
439
+ script: jxaSidebarSnapshotScript(),
440
+ };
441
+ }
442
+ }
443
+ /**
444
+ * Execute a `click-element` step: resolve the target's AX frame, synthesize a
445
+ * mouse click at its center, then verify the declared post-click outcome. Fails
446
+ * closed at every stage — a missing frame aborts BEFORE any click (no guessed
447
+ * pixel is ever clicked); a missing post-click element dismisses whatever opened
448
+ * (Escape) and aborts.
449
+ */
450
+ async function driveClickElement(step, run) {
451
+ const frameRes = await run(commandForStep(step, ""), STEP_TIMEOUT_MS);
452
+ const center = frameRes.ok ? parseFrameCenter(frameRes.stdout) : null;
453
+ if (center === null) {
454
+ return {
455
+ ok: false,
456
+ why: "its on-screen position did not resolve — a Things update may have moved the control, " +
457
+ "or the app is not in the expected state; no click was sent",
458
+ };
459
+ }
460
+ const clickRes = await run(clickPointCommand(center.x, center.y, step.label), STEP_TIMEOUT_MS);
461
+ if (!clickRes.ok) {
462
+ return {
463
+ ok: false,
464
+ why: clickRes.timedOut === true
465
+ ? "the click timed out"
466
+ : clickRes.stderr.trim() || "the click failed",
467
+ needsAbort: true,
468
+ };
469
+ }
470
+ if (step.assertPath !== undefined) {
471
+ const ok = await waitForElement({
472
+ primitive: "wait",
473
+ label: step.assertLabel ?? step.label,
474
+ script: axResolveScript(step.assertPath),
475
+ }, step.assertTimeoutMs ?? STEP_TIMEOUT_MS, run);
476
+ if (!ok) {
477
+ return {
478
+ ok: false,
479
+ why: `${step.assertLabel ?? "the expected element"} did not appear after the click`,
480
+ needsAbort: true,
481
+ };
482
+ }
483
+ }
484
+ return { ok: true };
485
+ }
486
+ async function drive(recipe, run, aux) {
487
+ const done = [];
488
+ const abort = () => run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
489
+ const partial = (failed, why, dismissed = true) => refusal(`ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.` +
490
+ (dismissed ? " The open sheet/popover was dismissed (Escape)." : ""));
491
+ // 0. Run the leading reveal/activate preamble BEFORE the canary. The Items
492
+ // menu is context-dependent — its Repeat submenu (and the plain "Repeat…"
493
+ // item) only materialize once a matching item is SELECTED (UIC1). Resolving
494
+ // those menu paths in the canary is only meaningful after the reveal has
495
+ // selected the target, so the preamble must run first.
496
+ let idx = 0;
497
+ while (idx < recipe.steps.length &&
498
+ (recipe.steps[idx]?.primitive === "reveal" || recipe.steps[idx]?.primitive === "activate")) {
499
+ const step = recipe.steps[idx];
500
+ // the preamble steps are strictly sequential (select, then foreground) and each must land before the next
501
+ const res = await run(commandForStep(step, recipe.targetUuid), STEP_TIMEOUT_MS);
502
+ if (!res.ok) {
503
+ return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed");
504
+ }
505
+ done.push(step.label);
506
+ idx += 1;
507
+ }
508
+ // Let the selection settle so the menu bar repopulates before the canary reads it.
509
+ if (idx > 0)
510
+ await new Promise((r) => setTimeout(r, SETTLE_AFTER_REVEAL_MS));
511
+ // 1. Recipe canary: resolve every statically-reachable element (now that the
512
+ // target is selected). A miss refuses the whole drive before anything is
513
+ // pressed. (This is also the localization check: English titles must resolve.)
514
+ for (const { path, label } of canaryPaths(recipe)) {
515
+ // the canary resolves elements one at a time; a single miss aborts before anything is pressed, so parallelizing would waste work and blur which element failed
516
+ const res = await run({ primitive: "resolve", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
517
+ if (!res.ok || res.stdout.trim() !== "true") {
518
+ return refusal(`ui preflight refused: element for "${label}" did not resolve (${path}) — a Things ` +
519
+ "update may have changed the menu, Accessibility may not be granted, Things may not " +
520
+ "be running, or the app may not be in English. Nothing was pressed.");
521
+ }
522
+ }
523
+ // 2. Execute the remaining steps in order; a dynamic element is waited-for.
524
+ for (let i = idx; i < recipe.steps.length; i += 1) {
525
+ let step = recipe.steps[i];
526
+ if (step.primitive === "wait") {
527
+ // A candidate-addressed wait polls for ANY of its shapes to appear (the
528
+ // dialog opening as an attached sheet OR a detached AXUnknown window).
529
+ // steps are strictly sequential: this wait must resolve before the step that acts on the awaited element runs
530
+ const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
531
+ if (!ok) {
532
+ // the abort keystroke must land before returning the partial-state report
533
+ await abort();
534
+ return partial(step.label, "the expected element never appeared within the timeout");
535
+ }
536
+ done.push(step.label);
537
+ continue;
538
+ }
539
+ if (step.primitive === "drag-reorder") {
540
+ // The sidebar drag driver runs its own snapshot → scroll → drag →
541
+ // DB-assert ladder (ui-drag.ts); every gesture anchors on frames it
542
+ // resolves live, and a failed assert triggers a verified recovery drag.
543
+ if (step.drag === undefined)
544
+ return partial(step.label, "no drag spec compiled", false);
545
+ // the drag ladder depends on the UI state the preamble produced
546
+ const outcome = await driveSidebarAreaReorder(step.drag, run, aux);
547
+ if (!outcome.ok)
548
+ return partial(step.label, outcome.detail, false);
549
+ done.push(`${step.label} (${outcome.detail})`);
550
+ continue;
551
+ }
552
+ // Resolve a candidate-addressed step's effective element before dispatch
553
+ // (the sheet-vs-detached-window disjunction). A miss fails closed.
554
+ if (step.pathCandidates !== undefined) {
555
+ // the effective form must be resolved before this step can act on it
556
+ const effective = await resolveStepPath(step, run);
557
+ if (effective === null) {
558
+ // dismiss whatever opened before reporting
559
+ await abort();
560
+ return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
561
+ "detached repeat editor window)");
562
+ }
563
+ step = { ...step, path: effective };
564
+ }
565
+ const command = commandForStep(step, recipe.targetUuid);
566
+ if (step.primitive === "select-row" || step.primitive === "select-heading-row") {
567
+ // Pure-AX row selection with readback verification (UIC4-a / HEADCERT1):
568
+ // "OK" only when the intended row selected (title readback for a project
569
+ // row; the Nth empty-readback heading row for a heading).
570
+ // the selection must land before the menu that acts on it is pressed
571
+ const res = await run(command, STEP_TIMEOUT_MS);
572
+ if (!res.ok || res.stdout.trim() !== "OK") {
573
+ // clear any transient state before reporting
574
+ await abort();
575
+ const noMatch = step.primitive === "select-heading-row"
576
+ ? "the project view exposed no selectable heading row at the target position — the " +
577
+ "heading may have been converted/deleted already, or the project's headings changed"
578
+ : "no content-table row selected to the target project's title — it may not be a " +
579
+ "selectable row in this view, or its title changed";
580
+ return partial(step.label, res.ok
581
+ ? noMatch
582
+ : res.timedOut === true
583
+ ? "the row-selection step timed out"
584
+ : res.stderr.trim() || "the row-selection step failed");
585
+ }
586
+ done.push(step.label);
587
+ continue;
588
+ }
589
+ if (step.primitive === "click-element") {
590
+ // A mouse click at an AX-resolved frame center (the NATIVE1 primitive),
591
+ // used only where AXPress is inert (Things' custom `…`/repeat-bar popover).
592
+ // the click depends on the UI state the previous step produced
593
+ const outcome = await driveClickElement(step, run);
594
+ if (!outcome.ok) {
595
+ // dismiss whatever the click opened before reporting
596
+ if (outcome.needsAbort === true)
597
+ await abort();
598
+ return partial(step.label, outcome.why ?? "the click failed");
599
+ }
600
+ done.push(step.label);
601
+ continue;
602
+ }
603
+ // each recipe step depends on the UI state the previous step produced; they cannot be parallelized
604
+ const res = await run(command, STEP_TIMEOUT_MS);
605
+ if (!res.ok) {
606
+ // dismiss the half-open sheet/popover before reporting partial state
607
+ if (step.primitive !== "reveal" && step.primitive !== "activate")
608
+ await abort();
609
+ return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed");
610
+ }
611
+ done.push(step.label);
612
+ }
613
+ return { exitCode: 0, stdout: `drove ${done.length} step(s): ${done.join(" → ")}`, stderr: "" };
614
+ }
615
+ async function waitForElement(command, timeoutMs, run) {
616
+ const deadline = Date.now() + timeoutMs;
617
+ for (;;) {
618
+ // polling the same element until it appears is inherently sequential
619
+ const res = await run(command, STEP_TIMEOUT_MS);
620
+ if (res.ok && res.stdout.trim() === "true")
621
+ return true;
622
+ if (Date.now() >= deadline)
623
+ return false;
624
+ // inter-poll delay between sequential existence checks
625
+ await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
626
+ }
627
+ }
628
+ /** Poll until ANY of the candidate element shapes exists (the sheet-vs-detached-window disjunction). */
629
+ async function waitForAnyElement(paths, label, timeoutMs, run) {
630
+ const deadline = Date.now() + timeoutMs;
631
+ for (;;) {
632
+ for (const path of paths) {
633
+ // Emitted as the `wait` primitive (not `resolve`) so the command stream a
634
+ // caller observes is unchanged from the single-path waitForElement.
635
+ // candidates checked in priority order; the first present shape ends the wait
636
+ const res = await run({ primitive: "wait", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
637
+ if (res.ok && res.stdout.trim() === "true")
638
+ return true;
639
+ }
640
+ if (Date.now() >= deadline)
641
+ return false;
642
+ // inter-poll delay between sequential existence checks
643
+ await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
644
+ }
645
+ }
646
+ function enabledMatrix() {
647
+ const matrix = {};
648
+ for (const op of UI_DRIVE_OPS) {
649
+ const cert = certificationOf(op);
650
+ matrix[op] = {
651
+ support: "yes",
652
+ // The most-disruptive tier: the drive foregrounds Things and takes over
653
+ // UI focus. The `dangerouslyDriveGui` ack lifts the disruption ceiling.
654
+ disruption: 3,
655
+ // The RECIPE is wired and lab-derived (validated for planning); on-device
656
+ // CERTIFICATION is a separate axis surfaced by `things capabilities`.
657
+ validation: "validated",
658
+ ...(cert !== undefined && { evidence: cert.evidence }),
659
+ notes: `drives the Things app through the Accessibility API (${cert?.status ?? "uncertified"}` +
660
+ " — recipe element paths pending on-device confirmation); menu-path element presses do not " +
661
+ "steal focus and work under a locked session (AXVM1), while ops that open Things' custom " +
662
+ "repeat menus additionally move the pointer, bring the app to the foreground, and need an " +
663
+ "unlocked session with the display awake (NATIVE1)",
664
+ };
665
+ }
666
+ return matrix;
667
+ }
668
+ function disabledMatrix() {
669
+ const matrix = {};
670
+ for (const op of UI_DRIVE_OPS) {
671
+ matrix[op] = {
672
+ support: "no",
673
+ disruption: 3,
674
+ validation: "validated",
675
+ notes: "the Accessibility GUI vector is off on this machine — enable it with `things config " +
676
+ "set ui-enabled true`, then grant Accessibility to this process (see docs/setup.md). " +
677
+ "It drives the local Things GUI and is intended for a dedicated always-on Mac.",
678
+ };
679
+ }
680
+ return matrix;
681
+ }
682
+ /**
683
+ * The ui vector. Config-gated: when `ui.enabled` is false the matrix reports
684
+ * every op unsupported (with a remediation naming the config key + setup doc),
685
+ * so the operation is never dispatched. When enabled, `execute` runs the
686
+ * compiled recipe fail-closed.
687
+ */
688
+ export function createUiVector(config, run = defaultRun, aux = {}) {
689
+ const enabled = config.ui.enabled;
690
+ return {
691
+ id: "ui",
692
+ matrix: enabled ? enabledMatrix() : disabledMatrix(),
693
+ async execute(invocation) {
694
+ if (!enabled) {
695
+ return refusal("the ui vector is disabled (`things config set ui-enabled true` to enable it).");
696
+ }
697
+ if (invocation.recipe === undefined) {
698
+ return refusal("ui invocation carried no recipe (compile bug).");
699
+ }
700
+ return drive(invocation.recipe, run, aux);
701
+ },
702
+ };
703
+ }
704
+ //# sourceMappingURL=ui.js.map