things-api 0.18.0 → 0.19.1

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 (224) hide show
  1. package/README.md +18 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -1
  4. package/deputy/helpers-Info.plist +4 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +3 -1
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +15 -2
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
  11. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  12. package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
  13. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +14 -2
  14. package/deputy/reader/Info.plist +2 -0
  15. package/deputy/reader/entitlements.plist +30 -0
  16. package/deputy/reader/main.swift +129 -40
  17. package/deputy/src/server.swift +25 -2
  18. package/deputy/src/tcc.swift +58 -0
  19. package/dist/audit/schema.d.ts +30 -1
  20. package/dist/audit/schema.js.map +1 -1
  21. package/dist/capability.d.ts +322 -0
  22. package/dist/capability.js +706 -0
  23. package/dist/capability.js.map +1 -0
  24. package/dist/cli/commands/doctor.d.ts +13 -1
  25. package/dist/cli/commands/doctor.js +141 -5
  26. package/dist/cli/commands/doctor.js.map +1 -1
  27. package/dist/cli/commands/helpers.js +189 -51
  28. package/dist/cli/commands/helpers.js.map +1 -1
  29. package/dist/cli/commands/mcp.js +8 -0
  30. package/dist/cli/commands/mcp.js.map +1 -1
  31. package/dist/cli/commands/op-result.js +6 -0
  32. package/dist/cli/commands/op-result.js.map +1 -1
  33. package/dist/cli/commands/reads.js +47 -1
  34. package/dist/cli/commands/reads.js.map +1 -1
  35. package/dist/cli/commands/repeat-flags.js +34 -2
  36. package/dist/cli/commands/repeat-flags.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +76 -83
  39. package/dist/cli/commands/setup.js.map +1 -1
  40. package/dist/cli/commands/writes.d.ts +1 -1
  41. package/dist/cli/commands/writes.js +311 -142
  42. package/dist/cli/commands/writes.js.map +1 -1
  43. package/dist/cli/dry-run.d.ts +1 -1
  44. package/dist/cli/dry-run.js +1 -1
  45. package/dist/cli/glyphs.d.ts +23 -4
  46. package/dist/cli/glyphs.js +46 -19
  47. package/dist/cli/glyphs.js.map +1 -1
  48. package/dist/cli/help.d.ts +1 -1
  49. package/dist/cli/help.js +49 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/interrupt.d.ts +54 -6
  52. package/dist/cli/interrupt.js +78 -12
  53. package/dist/cli/interrupt.js.map +1 -1
  54. package/dist/cli/main.js +8 -5
  55. package/dist/cli/main.js.map +1 -1
  56. package/dist/cli/period.d.ts +3 -1
  57. package/dist/cli/period.js +1 -1
  58. package/dist/cli/period.js.map +1 -1
  59. package/dist/cli/read-driver.js +26 -1
  60. package/dist/cli/read-driver.js.map +1 -1
  61. package/dist/cli/render.d.ts +31 -4
  62. package/dist/cli/render.js +127 -15
  63. package/dist/cli/render.js.map +1 -1
  64. package/dist/client.d.ts +23 -3
  65. package/dist/client.js +36 -13
  66. package/dist/client.js.map +1 -1
  67. package/dist/contracts.d.ts +8 -4
  68. package/dist/contracts.js +1 -1
  69. package/dist/contracts.js.map +1 -1
  70. package/dist/deputy/install.d.ts +234 -6
  71. package/dist/deputy/install.js +843 -31
  72. package/dist/deputy/install.js.map +1 -1
  73. package/dist/deputy/notices.js +2 -2
  74. package/dist/deputy/notices.js.map +1 -1
  75. package/dist/deputy/protocol.d.ts +59 -5
  76. package/dist/deputy/protocol.js +48 -11
  77. package/dist/deputy/protocol.js.map +1 -1
  78. package/dist/deputy/routing.d.ts +37 -0
  79. package/dist/deputy/routing.js +181 -27
  80. package/dist/deputy/routing.js.map +1 -1
  81. package/dist/deputy/wake.d.ts +61 -0
  82. package/dist/deputy/wake.js +149 -0
  83. package/dist/deputy/wake.js.map +1 -0
  84. package/dist/diagnose.d.ts +19 -4
  85. package/dist/diagnose.js +64 -8
  86. package/dist/diagnose.js.map +1 -1
  87. package/dist/direct-setup.d.ts +104 -0
  88. package/dist/direct-setup.js +658 -0
  89. package/dist/direct-setup.js.map +1 -0
  90. package/dist/host-access.d.ts +75 -0
  91. package/dist/host-access.js +177 -0
  92. package/dist/host-access.js.map +1 -0
  93. package/dist/index.d.ts +20 -5
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/mcp/server.d.ts +27 -1
  97. package/dist/mcp/server.js +308 -109
  98. package/dist/mcp/server.js.map +1 -1
  99. package/dist/op-result.d.ts +9 -0
  100. package/dist/op-result.js +13 -1
  101. package/dist/op-result.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +1 -1
  103. package/dist/read/filter-contract.js +4 -0
  104. package/dist/read/filter-contract.js.map +1 -1
  105. package/dist/read/shape.js +4 -0
  106. package/dist/read/shape.js.map +1 -1
  107. package/dist/read/views.d.ts +29 -0
  108. package/dist/read/views.js +60 -0
  109. package/dist/read/views.js.map +1 -1
  110. package/dist/session-grant.d.ts +48 -0
  111. package/dist/session-grant.js +185 -0
  112. package/dist/session-grant.js.map +1 -0
  113. package/dist/surface-copy.d.ts +19 -3
  114. package/dist/surface-copy.js +19 -3
  115. package/dist/surface-copy.js.map +1 -1
  116. package/dist/wizard.d.ts +58 -0
  117. package/dist/wizard.js +174 -0
  118. package/dist/wizard.js.map +1 -0
  119. package/dist/write/availability.d.ts +1 -16
  120. package/dist/write/availability.js +10 -65
  121. package/dist/write/availability.js.map +1 -1
  122. package/dist/write/batch.d.ts +20 -0
  123. package/dist/write/batch.js +111 -21
  124. package/dist/write/batch.js.map +1 -1
  125. package/dist/write/capabilities.d.ts +9 -0
  126. package/dist/write/capabilities.js +2 -0
  127. package/dist/write/capabilities.js.map +1 -1
  128. package/dist/write/clear-reminder.js +1 -1
  129. package/dist/write/clear-reminder.js.map +1 -1
  130. package/dist/write/commands.d.ts +22 -7
  131. package/dist/write/commands.js +231 -254
  132. package/dist/write/commands.js.map +1 -1
  133. package/dist/write/failure-hints.d.ts +32 -7
  134. package/dist/write/failure-hints.js +9 -14
  135. package/dist/write/failure-hints.js.map +1 -1
  136. package/dist/write/guards.js +22 -4
  137. package/dist/write/guards.js.map +1 -1
  138. package/dist/write/lock.d.ts +16 -13
  139. package/dist/write/lock.js +55 -0
  140. package/dist/write/lock.js.map +1 -1
  141. package/dist/write/make-repeating-project.js +9 -13
  142. package/dist/write/make-repeating-project.js.map +1 -1
  143. package/dist/write/move.js +9 -12
  144. package/dist/write/move.js.map +1 -1
  145. package/dist/write/operations.d.ts +51 -29
  146. package/dist/write/operations.js +16 -0
  147. package/dist/write/operations.js.map +1 -1
  148. package/dist/write/opid.d.ts +72 -14
  149. package/dist/write/opid.js +152 -8
  150. package/dist/write/opid.js.map +1 -1
  151. package/dist/write/param-schema.d.ts +80 -0
  152. package/dist/write/param-schema.js +754 -0
  153. package/dist/write/param-schema.js.map +1 -0
  154. package/dist/write/pipeline.d.ts +110 -5
  155. package/dist/write/pipeline.js +288 -14
  156. package/dist/write/pipeline.js.map +1 -1
  157. package/dist/write/pre-state.d.ts +38 -16
  158. package/dist/write/pre-state.js +117 -22
  159. package/dist/write/pre-state.js.map +1 -1
  160. package/dist/write/promote-clone.js +658 -444
  161. package/dist/write/promote-clone.js.map +1 -1
  162. package/dist/write/reorder.js +25 -8
  163. package/dist/write/reorder.js.map +1 -1
  164. package/dist/write/repeat-collateral.d.ts +31 -0
  165. package/dist/write/repeat-collateral.js +153 -0
  166. package/dist/write/repeat-collateral.js.map +1 -0
  167. package/dist/write/repeat-rule.d.ts +28 -1
  168. package/dist/write/repeat-rule.js +54 -4
  169. package/dist/write/repeat-rule.js.map +1 -1
  170. package/dist/write/resolution-timestamps.d.ts +11 -0
  171. package/dist/write/resolution-timestamps.js +108 -40
  172. package/dist/write/resolution-timestamps.js.map +1 -1
  173. package/dist/write/reversibility.js +4 -0
  174. package/dist/write/reversibility.js.map +1 -1
  175. package/dist/write/template-mutation.d.ts +137 -0
  176. package/dist/write/template-mutation.js +598 -0
  177. package/dist/write/template-mutation.js.map +1 -0
  178. package/dist/write/undo.js +10 -20
  179. package/dist/write/undo.js.map +1 -1
  180. package/dist/write/update-fields.d.ts +151 -0
  181. package/dist/write/update-fields.js +427 -0
  182. package/dist/write/update-fields.js.map +1 -0
  183. package/dist/write/vectors/applescript.js +6 -10
  184. package/dist/write/vectors/applescript.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +1 -1
  186. package/dist/write/vectors/registry.js +10 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/shortcuts.js +2 -2
  189. package/dist/write/vectors/shortcuts.js.map +1 -1
  190. package/dist/write/vectors/simulator.js +15 -4
  191. package/dist/write/vectors/simulator.js.map +1 -1
  192. package/dist/write/vectors/types.d.ts +268 -1
  193. package/dist/write/vectors/ui-certification.js +63 -13
  194. package/dist/write/vectors/ui-certification.js.map +1 -1
  195. package/dist/write/vectors/ui-chord.d.ts +144 -0
  196. package/dist/write/vectors/ui-chord.js +428 -0
  197. package/dist/write/vectors/ui-chord.js.map +1 -0
  198. package/dist/write/vectors/ui-drag.d.ts +8 -4
  199. package/dist/write/vectors/ui-drag.js +2 -0
  200. package/dist/write/vectors/ui-drag.js.map +1 -1
  201. package/dist/write/vectors/ui-recipes.d.ts +70 -3
  202. package/dist/write/vectors/ui-recipes.js +482 -44
  203. package/dist/write/vectors/ui-recipes.js.map +1 -1
  204. package/dist/write/vectors/ui.d.ts +280 -10
  205. package/dist/write/vectors/ui.js +1068 -77
  206. package/dist/write/vectors/ui.js.map +1 -1
  207. package/dist/write/vectors/url-scheme.js +3 -0
  208. package/dist/write/vectors/url-scheme.js.map +1 -1
  209. package/dist/write/verify/delta.d.ts +62 -0
  210. package/dist/write/verify/delta.js +36 -2
  211. package/dist/write/verify/delta.js.map +1 -1
  212. package/dist/write/verify/poller.d.ts +4 -1
  213. package/dist/write/verify/poller.js +14 -0
  214. package/dist/write/verify/poller.js.map +1 -1
  215. package/package.json +2 -1
  216. package/schema/envelope.schema.json +6 -4
  217. package/scripts/build-helpers.sh +5 -0
  218. package/skills/things-cli/SKILL.md +5 -3
  219. package/skills/things-cli/references/contracts.md +1 -0
  220. package/skills/things-cli/references/data-model.md +3 -0
  221. package/skills/things-cli/references/errors.md +1 -1
  222. package/skills/things-cli/references/gui.md +1 -0
  223. package/skills/things-cli/references/repeating.md +6 -0
  224. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -33,6 +33,7 @@ import { UI_DRIVE_OPS } from "../operations.js";
33
33
  import { escapeAppleScript } from "./applescript.js";
34
34
  import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
35
35
  import { certificationOf } from "./ui-certification.js";
36
+ import { chordCommand, driveHeadingChordReorder } from "./ui-chord.js";
36
37
  import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
37
38
  /** GUI driving can stall on an unanswered sheet; give each step headroom. */
38
39
  const STEP_TIMEOUT_MS = 15_000;
@@ -72,6 +73,197 @@ const SETTLE_AFTER_REVEAL_MS = 1000;
72
73
  */
73
74
  const SHAPE_UNPROBED = "the Repeat dialog's shape was never measured, so this control's address is unknown (recipe bug)";
74
75
  const SE = `tell application "System Events" to tell process "Things3"`;
76
+ /**
77
+ * The cadence group's re-layout settle budget (BEEP1) — how many times
78
+ * `set-group-number` re-reads the group's shape looking for two consecutive
79
+ * identical reads, and how long it waits between reads. ~4s of headroom against
80
+ * a re-layout measured to finish well inside 1.5s; the gate exits on the FIRST
81
+ * agreeing pair, so the common (already-settled) case costs one extra read.
82
+ */
83
+ const SETTLE_READS = 40;
84
+ const SETTLE_POLL_S = 0.1;
85
+ /**
86
+ * Row tolerance (points) for every LABEL-ANCHORED field address here — how far a
87
+ * control's y may sit from its label's y and still count as the same row.
88
+ * Measured on Things 3.23 (CGRD1 §A census): `Every`@286 / interval@283,
89
+ * `Ends:`@375 / count@372, `days earlier`@413 / start-offset@409 — a 3–4pt
90
+ * baseline offset, so 8 is ~2× the observed worst case and well under the
91
+ * ~45pt row pitch of the cadence group.
92
+ */
93
+ const ROW_TOLERANCE = 8;
94
+ /**
95
+ * How many of the Next: menu's own item titles a MISS reports back (NEXTPOP1).
96
+ * A refusal that names only the date the dialog lacked cannot distinguish "the
97
+ * rule genuinely does not produce that date" from "the menu was not the rule's"
98
+ * — the two failures read identically, and the second one cost a whole campaign
99
+ * to tell apart. Naming the pop-up's current value plus the first few options it
100
+ * offered makes the refusal self-diagnosing. Bounded so a 100-item level cannot
101
+ * flood the message.
102
+ */
103
+ const SAMPLE_ITEMS = 5;
104
+ /**
105
+ * The `Next:` occurrence pop-up's asynchronous-recompute budget (NEXTPOP1) — how
106
+ * long {@link axSettleOccurrencesScript} waits for the dialog to absorb a rule
107
+ * change before the drive touches it again, and how often it looks. MEASURED at
108
+ * **0.4s** on golden-v4 / Things 3.23 (DIAG4: the control flipped from the seed's
109
+ * first occurrence to the anchor's between t+0.3s and t+0.4s), so the budget is
110
+ * ~3× the observed latency; the poll exits early the moment the control moves,
111
+ * which is the only case that costs anything.
112
+ */
113
+ const OCCURRENCE_SETTLE_MS = 1200;
114
+ const OCCURRENCE_POLL_MS = 100;
115
+ /**
116
+ * The shared AppleScript handler prelude for every LABEL-ANCHORED field address
117
+ * in the Repeat dialog — the HXPC1 discrimination law in ONE place, so the
118
+ * pre-commit audit re-reads each field through the SAME address the drive wrote
119
+ * it through (a self-referential read-back is exactly what let #589's wrong-field
120
+ * write report OK).
121
+ *
122
+ * The laws, all measured on Things 3.23 / build 32300036 (CGRD1 §A census, and
123
+ * HXPC1 §A before it — docs/lab/cgrd1-precommit-audit.md, hxpc1-picker-assert.md):
124
+ *
125
+ * - The cadence group's numeric fields are identified by the LABEL ROW they sit
126
+ * on, never by index among the group's text fields. Selecting an "Ends: after"
127
+ * bound INSERTS the count AHEAD of the interval, so index 1 is a different
128
+ * control at different moments.
129
+ * - The interval is matched POSITIVELY, on the `Every` label's row. Every fixed
130
+ * frequency (daily/weekly/monthly/yearly) carries that label at y=286 with the
131
+ * interval at y=283. An AFTER-COMPLETION cadence group carries NEITHER an
132
+ * `Every` nor an `Ends:` label (census: its only static text is "after previous
133
+ * item is checked off.") and offers exactly ONE text field — so that shape falls
134
+ * to the sole-field rule, which is itself a uniqueness check, not an index.
135
+ * - Anything else FAILS CLOSED reporting the whole numeric-field inventory rather
136
+ * than typing a number into a field it cannot vouch for. An AX tree is an
137
+ * undocumented private surface: an unrecognized shape is a refusal, never a
138
+ * best guess.
139
+ */
140
+ const AX_CADENCE_HANDLERS = `on cgLabelY(g, want)
141
+ tell application "System Events"
142
+ set outY to missing value
143
+ repeat with i from 1 to (count of static texts of g)
144
+ set sv to ""
145
+ try
146
+ set sv to (value of static text i of g) as text
147
+ end try
148
+ if sv is want then
149
+ -- bind the position, THEN index it: System Events refuses indexing a
150
+ -- position read inline from a specifier (-1700).
151
+ set labelPos to position of static text i of g
152
+ set outY to item 2 of labelPos
153
+ end if
154
+ end repeat
155
+ return outY
156
+ end tell
157
+ end cgLabelY
158
+
159
+ on cgInventory(g)
160
+ tell application "System Events"
161
+ set inv to ""
162
+ repeat with i from 1 to (count of text fields of g)
163
+ set fp to position of text field i of g
164
+ set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of g) as text) & ")"
165
+ end repeat
166
+ if inv is "" then set inv to " (none)"
167
+ return inv
168
+ end tell
169
+ end cgInventory
170
+
171
+ on cgOnRow(g, y, tol, want)
172
+ tell application "System Events"
173
+ set hits to {}
174
+ repeat with i from 1 to (count of text fields of g)
175
+ set fp to position of text field i of g
176
+ set dy to (item 2 of fp) - y
177
+ if dy < 0 then set dy to -dy
178
+ set onRow to (dy <= tol)
179
+ if onRow is want then set end of hits to text field i of g
180
+ end repeat
181
+ return hits
182
+ end tell
183
+ end cgOnRow
184
+
185
+ on cgSettle(g)
186
+ -- SETTLE ON THE GROUP'S OWN SHAPE, never on a clock (determinism doctrine).
187
+ -- A frequency switch REBUILDS the cadence group. Two things go wrong when a
188
+ -- read starts too early: the row discrimination reads positions from controls
189
+ -- that are still moving, and keystrokes land on a field being torn down —
190
+ -- unhandled, so macOS beeps (BEEP1). Poll until two consecutive reads of the
191
+ -- group's label + field-position signature agree, then proceed.
192
+ tell application "System Events"
193
+ set sig to ""
194
+ set prevSig to "<none>"
195
+ repeat ${SETTLE_READS} times
196
+ set prevSig to sig
197
+ set sig to ""
198
+ repeat with i from 1 to (count of static texts of g)
199
+ set sv to ""
200
+ try
201
+ set sv to (value of static text i of g) as text
202
+ end try
203
+ set sig to sig & "|s:" & sv
204
+ end repeat
205
+ repeat with i from 1 to (count of text fields of g)
206
+ set fp to position of text field i of g
207
+ set sig to sig & "|f:" & (item 2 of fp)
208
+ end repeat
209
+ if sig is prevSig then return true
210
+ delay ${SETTLE_POLL_S}
211
+ end repeat
212
+ error "the Repeat dialog's cadence group is still re-laying out — its shape changed on every read; last seen" & sig
213
+ end tell
214
+ end cgSettle
215
+
216
+ on cgField(g, target, tol)
217
+ tell application "System Events"
218
+ set endsY to my cgLabelY(g, "Ends:")
219
+ set everyY to my cgLabelY(g, "Every")
220
+ set nf to (count of text fields of g)
221
+ if target is "ends-count" then
222
+ if endsY is missing value then error "the Repeat dialog's cadence group carries no \\"Ends:\\" label, so the ends-after count field cannot be identified — numeric fields:" & my cgInventory(g)
223
+ set hits to my cgOnRow(g, endsY, tol, true)
224
+ if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"Ends:\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
225
+ return item 1 of hits
226
+ end if
227
+ if everyY is not missing value then
228
+ set hits to my cgOnRow(g, everyY, tol, true)
229
+ if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"Every\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
230
+ return item 1 of hits
231
+ end if
232
+ if endsY is not missing value then
233
+ set hits to my cgOnRow(g, endsY, tol, false)
234
+ if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) off the \\"Ends:\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
235
+ return item 1 of hits
236
+ end if
237
+ if nf is not 1 then error "the Repeat dialog's cadence group carries neither an \\"Every\\" nor an \\"Ends:\\" label and offers " & nf & " numeric field(s), so the interval cannot be identified — numeric fields:" & my cgInventory(g)
238
+ -- positional-ok: reached ONLY after the line above proved the group holds
239
+ -- exactly one text field, so this is a uniqueness statement, not an index.
240
+ -- The after-completion cadence group is that shape (MEASURED, CGRD1 §A: its
241
+ -- only static text is "after previous item is checked off.", one field).
242
+ return text field 1 of g
243
+ end tell
244
+ end cgField
245
+
246
+ on rfInventory(c)
247
+ tell application "System Events"
248
+ set inv to ""
249
+ repeat with i from 1 to (count of text fields of c)
250
+ set fp to position of text field i of c
251
+ set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of c) as text) & ")"
252
+ end repeat
253
+ if inv is "" then set inv to " (none)"
254
+ return inv
255
+ end tell
256
+ end rfInventory
257
+
258
+ on rfField(c, rowLabel, tol)
259
+ tell application "System Events"
260
+ set labelY to my cgLabelY(c, rowLabel)
261
+ if labelY is missing value then error "the Repeat dialog shows no \\"" & rowLabel & "\\" label, so the field beside it cannot be identified — text fields:" & my rfInventory(c)
262
+ set hits to my cgOnRow(c, labelY, tol, true)
263
+ if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"" & rowLabel & "\\" row, expected exactly 1 — text fields:" & my rfInventory(c)
264
+ return item 1 of hits
265
+ end tell
266
+ end rfField`;
75
267
  /** resolve-element: does the element exist right now? Returns "true"/"false". */
76
268
  export function axResolveScript(path) {
77
269
  return `${SE} to return (exists (${path}))`;
@@ -82,33 +274,49 @@ export function axPressScript(path) {
82
274
  }
83
275
  /**
84
276
  * set-field-value: enter a value into the dialog's numeric text field (interval,
85
- * ends-count, start-days-earlier). It FOCUSES the field, selects all, TYPES the
86
- * value, and Tabs to commit — because `set value of <field>` writes the field's
87
- * displayed text WITHOUT firing the edit, so the app's binding keeps the old
88
- * number (the field shows "5" but the rule stays interval 1 — a silent no-op
89
- * exactly like `set value` on a pop-up, UIC6; it went unnoticed while every base
90
- * case used the default interval 1). Real keystrokes fire the change the binding
91
- * needs; Tab (not Return, which would fire the default OK button) commits and
92
- * moves focus. Foreground-bound (keystrokes reach the frontmost app) the
93
- * reveal/activate preamble puts Things there. One stable command shape.
277
+ * ends-count, start-days-earlier) and into the Move… picker's filter field. It
278
+ * FOCUSES the field, TYPES the value, and Tabs to commit — because
279
+ * `set value of <field>` writes the field's displayed text WITHOUT firing the
280
+ * edit, so the app's binding keeps the old number (the field shows "5" but the
281
+ * rule stays interval 1 — a silent no-op exactly like `set value` on a pop-up,
282
+ * UIC6; it went unnoticed while every base case used the default interval 1).
283
+ * Real keystrokes fire the change the binding needs; Tab (not Return, which would
284
+ * fire the default OK button) commits and moves focus. Foreground-bound
285
+ * (keystrokes reach the frontmost app) — the reveal/activate preamble puts Things
286
+ * there. One stable command shape.
287
+ *
288
+ * NO SELECT-ALL KEYSTROKE (BEEP1, 2026-08-25, docs/lab/beep1-numeric-field-beep.md).
289
+ * The primitive used to send ⌘A before typing, and that ONE keystroke was the
290
+ * audible macOS alert beep every numeric-field drive fired on the live host:
291
+ * Things' `Edit ▸ Select All` menu item exists and is DISABLED while the Repeat
292
+ * sheet is up, AppKit dispatches ⌘A as a menu key equivalent FIRST, the disabled
293
+ * item swallows it, nothing handles it → NSBeep. It is a menu-dispatch fact, not
294
+ * a focus race: the beep survives a verified first responder and a 1.5 s settle,
295
+ * while Tab and the digits themselves are silent. ⌘A was also REDUNDANT —
296
+ * `set focused of tf to true` installs the field editor with the ENTIRE content
297
+ * selected (measured: `AXSelectedTextRange` length goes 0 → the full value
298
+ * length, on both a 1- and a 2-character value), so typing replaces the old value
299
+ * outright, including the shrinking case (12 → 3) that a stale caret would have
300
+ * corrupted into "123". Dropping the keystroke is therefore silent AND correct on
301
+ * all three fields.
302
+ *
303
+ * CLOSED-LOOP (determinism doctrine): type, Tab-commit, then READ THE FIELD BACK
304
+ * and retry if it did not hold — the interval field, when it is the first numeric
305
+ * field after a frequency/type switch, races the dialog's group re-layout and
306
+ * reverts to 1 (UIC7, oddities §8l). Re-focus + re-type after a settle lands it
307
+ * once the re-layout has finished, and the re-focus re-selects the whole value,
308
+ * so a retry starts from a clean field without ⌘A. Fail-closed (an `error`, i.e.
309
+ * a transport failure the pipeline re-verifies) if it never holds — the
310
+ * create/reschedule delta's rule assertion is the final DB-level authority.
94
311
  */
95
312
  export function axSetValueScript(path, value, attempts = 3) {
96
313
  const v = escapeAppleScript(value);
97
314
  const n = Math.max(1, Math.trunc(attempts));
98
- // CLOSED-LOOP (determinism doctrine): type, Tab-commit, then READ THE FIELD
99
- // BACK and retry if it did not hold — the interval field, when it is the first
100
- // numeric field after a frequency/type switch, races the dialog's group
101
- // re-layout and reverts to 1 (UIC7, oddities §8l). Re-focus + re-type after a
102
- // settle lands it once the re-layout has finished. Fail-closed (an `error`,
103
- // i.e. a transport failure the pipeline re-verifies) if it never holds — the
104
- // create/reschedule delta's rule assertion is the final DB-level authority.
105
315
  return `${SE}
106
316
  set tf to (${path})
107
317
  repeat ${n} times
108
318
  set focused of tf to true
109
319
  delay 0.15
110
- keystroke "a" using command down
111
- delay 0.1
112
320
  keystroke "${v}"
113
321
  delay 0.1
114
322
  key code 48
@@ -121,6 +329,309 @@ export function axSetValueScript(path, value, attempts = 3) {
121
329
  error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
122
330
  end tell`;
123
331
  }
332
+ /**
333
+ * set-group-number: drive ONE of the Repeat dialog's two numeric fields —
334
+ * the cadence INTERVAL or the ENDS-AFTER COUNT — addressed by the LABEL ROW it
335
+ * sits on (HXPC1, docs/lab/hxpc1-picker-assert.md §A; hardened by CGRD1,
336
+ * docs/lab/cgrd1-precommit-audit.md §A).
337
+ *
338
+ * Both fields used to be spelled `text field 1 of group 1`, which is the same
339
+ * control at different moments. Measured on Things 3.23 (build 32300036):
340
+ *
341
+ * Ends: never → group text fields = 1 · tf1 = interval @[311,283]
342
+ * Ends: after N → group text fields = 2 · tf1 = COUNT @[402,372]
343
+ * tf2 = interval @[311,283]
344
+ *
345
+ * i.e. selecting the "after" bound INSERTS the count ahead of the interval. The
346
+ * create path got away with it by driving the interval while it was still the
347
+ * sole field, but a RESCHEDULE opens the dialog pre-populated: a rule that
348
+ * already ends after N presents both fields from the first step, so the interval
349
+ * drive wrote the requested interval into the count field, the count drive then
350
+ * overwrote it, and the interval silently never changed.
351
+ *
352
+ * The addresses are the {@link AX_CADENCE_HANDLERS} laws — POSITIVE label-row
353
+ * matches wherever the app offers a label to match, a uniqueness check where it
354
+ * does not, and a fail-closed refusal naming the whole numeric-field inventory
355
+ * otherwise. The write itself is the {@link axSetValueScript} closed loop —
356
+ * focus, type, Tab-commit, read back, bounded retries — and, like it, sends NO
357
+ * select-all keystroke: the ⌘A that used to open it is the macOS alert beep
358
+ * (BEEP1, docs/lab/beep1-numeric-field-beep.md), and focusing the field already
359
+ * selects its whole content.
360
+ *
361
+ * The read-back this loop performs is SELF-REFERENTIAL by construction — it
362
+ * re-reads the field it addressed, so it can only prove the keystrokes landed
363
+ * where they were aimed, never that they were aimed at the right control. The
364
+ * PRE-COMMIT AUDIT ({@link axAuditDialogScript}) is what closes that: it re-reads
365
+ * every control through these same handlers just before the OK press.
366
+ */
367
+ export function axSetGroupNumberScript(groupPath, target, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
368
+ const v = escapeAppleScript(value);
369
+ const n = Math.max(1, Math.trunc(attempts));
370
+ const tol = Math.max(1, Math.trunc(rowTolerance));
371
+ return `${AX_CADENCE_HANDLERS}
372
+
373
+ ${SE}
374
+ set g to (${groupPath})
375
+ my cgSettle(g)
376
+ set tf to my cgField(g, "${target}", ${tol})
377
+ repeat ${n} times
378
+ set focused of tf to true
379
+ delay 0.15
380
+ keystroke "${v}"
381
+ delay 0.1
382
+ key code 48
383
+ delay 0.2
384
+ try
385
+ if ((value of tf) as text) is "${v}" then return "OK"
386
+ end try
387
+ delay 0.3
388
+ end repeat
389
+ error "the ${target} field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
390
+ end tell`;
391
+ }
392
+ /**
393
+ * set-row-field: drive a Repeat-dialog text field addressed by the pinned English
394
+ * LABEL sharing its row — the same discrimination law as
395
+ * {@link axSetGroupNumberScript}, applied to a field that lives on the dialog
396
+ * SHELL rather than in the cadence group.
397
+ *
398
+ * Its one caller is the "and start [N] days earlier" offset the "Add deadlines"
399
+ * checkbox reveals, which shipped as `text field 1` of the shell. That address
400
+ * was the HXPC1 error class exactly: a value-bearing text field picked by index
401
+ * out of a STATE-DEPENDENT tree (the field does not exist at all until the
402
+ * checkbox is ticked), verified only by re-reading the same index it wrote. It
403
+ * happened to be right on 3.23 — measured, the shell carries 0 direct text fields
404
+ * with deadlines off and exactly 1 with them on, whether or not reminders are also
405
+ * on (CGRD1 §B census) — but nothing in the address said so, and an AX tree is an
406
+ * undocumented private surface that may add a second field in any release.
407
+ *
408
+ * The label anchor is `days earlier` (y=413 against the field's y=409, CGRD1 §B).
409
+ * A missing label, or anything other than exactly one field on its row, FAILS
410
+ * CLOSED naming the shell's whole text-field inventory. The write is the
411
+ * {@link axSetValueScript} closed loop.
412
+ */
413
+ export function axSetRowFieldScript(containerPath, rowLabel, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
414
+ const v = escapeAppleScript(value);
415
+ const label = escapeAppleScript(rowLabel);
416
+ const n = Math.max(1, Math.trunc(attempts));
417
+ const tol = Math.max(1, Math.trunc(rowTolerance));
418
+ return `${AX_CADENCE_HANDLERS}
419
+
420
+ ${SE}
421
+ set c to (${containerPath})
422
+ set tf to my rfField(c, "${label}", ${tol})
423
+ repeat ${n} times
424
+ set focused of tf to true
425
+ delay 0.15
426
+ keystroke "${v}"
427
+ delay 0.1
428
+ key code 48
429
+ delay 0.2
430
+ try
431
+ if ((value of tf) as text) is "${v}" then return "OK"
432
+ end try
433
+ delay 0.3
434
+ end repeat
435
+ error "the \\"${label}\\" field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
436
+ end tell`;
437
+ }
438
+ /** AppleScript list literal of quoted strings. */
439
+ function asList(values) {
440
+ return `{${values.map((v) => `"${escapeAppleScript(v)}"`).join(", ")}}`;
441
+ }
442
+ /** The intended value(s) as the mismatch report should read them. */
443
+ function intendedText(control) {
444
+ if (control.expectedLabel !== undefined)
445
+ return control.expectedLabel;
446
+ return control.expected.map((v) => `\\"${escapeAppleScript(v)}\\"`).join(" or ");
447
+ }
448
+ /**
449
+ * audit-dialog: RE-READ EVERY CONTROL THIS DRIVE SET, through each control's own
450
+ * discriminated address, and refuse to commit if any one of them does not hold
451
+ * the value the drive intended.
452
+ *
453
+ * This exists because a per-step read-back is SELF-REFERENTIAL. Every setter here
454
+ * confirms its write by re-reading the element it addressed, so it proves the
455
+ * keystrokes landed where they were aimed — and nothing else. The #589 wrong-field
456
+ * write reported OK for exactly that reason: the interval drive typed into the
457
+ * ends-count field, then read the ends-count field back and found its own number
458
+ * sitting there. The address was wrong; a read-back through the same address
459
+ * cannot see that.
460
+ *
461
+ * The audit is the outside view. It is assembled from the recipe's OWN step list
462
+ * (so no control the recipe drives can be left out of the audit by omission) and
463
+ * runs as the last step before the OK press, comparing the dialog's complete
464
+ * intended state against what the dialog actually shows: frequency, the
465
+ * after-completion cadence unit, interval, ends bound and its count, the
466
+ * deadline/reminder checkboxes, the start-days-earlier offset, the weekday set,
467
+ * the monthly/yearly anchor pop-ups and the 3.23 first-occurrence pop-up. A
468
+ * mismatch is an `error` naming EVERY differing control with both values, which
469
+ * aborts the drive fail-closed BEFORE the commit and runs the standard clean-abort
470
+ * path — nothing reaches the database.
471
+ *
472
+ * Deterministic throughout: the cadence group is settled on its own shape
473
+ * signature (the BEEP1 two-agreeing-reads gate), never on a sleep, and every field
474
+ * is found by its label row rather than by index ({@link AX_CADENCE_HANDLERS}).
475
+ *
476
+ * The dialog's three `AXDateTimeArea` controls are audited separately — their
477
+ * values are NSDates no System Events read can reach, so they ride
478
+ * {@link axAuditDateAreasScript} through the same ObjC bridge that writes them.
479
+ */
480
+ export function axAuditDialogScript(spec, rowTolerance = ROW_TOLERANCE) {
481
+ const tol = Math.max(1, Math.trunc(rowTolerance));
482
+ const body = spec.controls
483
+ .map((c, i) => {
484
+ const name = escapeAppleScript(c.label);
485
+ const want = asList(c.expected);
486
+ const intended = intendedText(c);
487
+ const miss = `set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\")"`;
488
+ switch (c.kind) {
489
+ case "popup":
490
+ return ` set v${i} to "(unreadable)"
491
+ try
492
+ set v${i} to (value of (${c.path ?? ""})) as text
493
+ end try
494
+ if not (my aqAny(v${i}, ${want})) then ${miss}`;
495
+ case "occurrence-popup":
496
+ return ` set v${i} to "(unreadable)"
497
+ try
498
+ set v${i} to (value of (${c.path ?? ""})) as text
499
+ end try
500
+ set d${i} to my aqYMD(v${i})
501
+ if d${i} is missing value then
502
+ ${miss}
503
+ else if not (my aqAny(d${i}, ${want})) then
504
+ set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\" = " & d${i} & ")"
505
+ end if`;
506
+ case "checkbox":
507
+ return ` set v${i} to "(unreadable)"
508
+ try
509
+ set v${i} to ((value of (${c.path ?? ""})) as integer) as text
510
+ end try
511
+ if not (my aqAny(v${i}, ${want})) then set end of bad to "${name} (intended ${intended}, dialog shows " & (my aqTick(v${i})) & ")"`;
512
+ case "group-number":
513
+ return ` set v${i} to "(unreadable)"
514
+ try
515
+ set v${i} to ((value of (my cgField(g, "${c.numberTarget ?? "interval"}", ${tol}))) as text)
516
+ end try
517
+ if not (my aqAny(v${i}, ${want})) then ${miss}`;
518
+ case "row-field":
519
+ return ` set v${i} to "(unreadable)"
520
+ try
521
+ set v${i} to ((value of (my rfField(sh, "${escapeAppleScript(c.rowLabel ?? "")}", ${tol}))) as text)
522
+ end try
523
+ if not (my aqAny(v${i}, ${want})) then ${miss}`;
524
+ case "weekdays":
525
+ return ` set got${i} to {}
526
+ repeat with k from ${Math.max(1, Math.trunc(c.weekdayBase ?? 2))} to (count of pop up buttons of g)
527
+ set end of got${i} to ((value of pop up button k of g) as text)
528
+ end repeat
529
+ set v${i} to my aqJoin(got${i}, ",")
530
+ set off${i} to false
531
+ repeat with w in ${want}
532
+ if not (my aqAny(w as text, got${i})) then set off${i} to true
533
+ end repeat
534
+ repeat with w in got${i}
535
+ if not (my aqAny(w as text, ${want})) then set off${i} to true
536
+ end repeat
537
+ if off${i} then ${miss}`;
538
+ }
539
+ })
540
+ .join("\n");
541
+ return `${AX_CADENCE_HANDLERS}
542
+
543
+ on aqAny(v, lst)
544
+ repeat with c in lst
545
+ if (v as text) is (c as text) then return true
546
+ end repeat
547
+ return false
548
+ end aqAny
549
+
550
+ on aqJoin(lst, sep)
551
+ set out to ""
552
+ repeat with x in lst
553
+ if out is not "" then set out to out & sep
554
+ set out to out & (x as text)
555
+ end repeat
556
+ return out
557
+ end aqJoin
558
+
559
+ on aqTick(v)
560
+ if (v as text) is "1" then return "checked"
561
+ if (v as text) is "0" then return "unchecked"
562
+ return "\\"" & (v as text) & "\\""
563
+ end aqTick
564
+
565
+ on aqPad2(n)
566
+ set s to (n as integer) as text
567
+ if (length of s) < 2 then set s to "0" & s
568
+ return s
569
+ end aqPad2
570
+
571
+ on aqYMD(t)
572
+ -- Occurrence-pop-up titles are LOCALIZED ("Sun, Jul 12, 2026"), so the match is
573
+ -- made by PARSING the title to a date and comparing calendar components — never
574
+ -- by rebuilding the app's display string (the axSelectNextOccurrenceScript law).
575
+ set s to t as text
576
+ try
577
+ set d to date s
578
+ return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
579
+ end try
580
+ try
581
+ set ofs to offset of ", " in s
582
+ if ofs > 0 then
583
+ set d to date (text (ofs + 2) thru -1 of s)
584
+ return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
585
+ end if
586
+ end try
587
+ return missing value
588
+ end aqYMD
589
+
590
+ ${SE}
591
+ set sh to (${spec.shell})
592
+ set g to (${spec.group})
593
+ my cgSettle(g)
594
+ set bad to {}
595
+ ${body}
596
+ if (count of bad) is 0 then return "OK"
597
+ error "the Repeat dialog does not hold what this drive entered — " & (count of bad) & " control(s) differ: " & my aqJoin(bad, "; ")
598
+ end tell`;
599
+ }
600
+ /**
601
+ * The pre-commit audit's DATE-AREA leg. The dialog's first-occurrence, ends-on and
602
+ * reminder controls are `AXDateTimeArea`s whose value is an NSDate — unreachable
603
+ * from System Events — so they are re-read through the SAME ObjC bridge, the same
604
+ * shell-scoped walk and the SAME deterministic `pick` discriminator that
605
+ * {@link axSetDateTimeScript} writes them through. A control the audit cannot find,
606
+ * or one holding a different date/time, throws naming every area the dialog does
607
+ * present (y-position + time-of-day), so the drive aborts before the OK press.
608
+ */
609
+ export function axAuditDateAreasScript(areas) {
610
+ return `${AX_DATE_AREA_PRELUDE}
611
+ function run(){
612
+ var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
613
+ if(!apps || apps.count===0) throw new Error('Things not running');
614
+ var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
615
+ var wanted=${JSON.stringify(areas)};
616
+ var cal=$.NSCalendar.currentCalendar;
617
+ var found=[]; try{ var shell=findShell(app); if(shell) collect(shell,'AXDateTimeArea',16,found); }catch(e){ found=[]; }
618
+ var bad=[];
619
+ for(var i=0;i<wanted.length;i++){
620
+ var w=wanted[i];
621
+ var dt=pick(found,w.target);
622
+ if(!dt){ bad.push(w.label+' (intended '+w.spec+', but this dialog state presents no '+w.target+' control among ['+inv(found)+'])'); continue; }
623
+ if(w.spec.indexOf('date:')===0){
624
+ var got=ymdStr(dt,cal), want=w.spec.slice(5);
625
+ if(got!==want) bad.push(w.label+' (intended '+want+', dialog shows '+(got||'(no value)')+')');
626
+ } else {
627
+ var gott=hmStr(dt,cal), p=w.spec.slice(5).split(':'), wantt=(+p[0])+':'+('0'+(+p[1])).slice(-2);
628
+ if(gott!==wantt) bad.push(w.label+' (intended '+wantt+', dialog shows '+(gott||'(no value)')+')');
629
+ }
630
+ }
631
+ if(bad.length) throw new Error('the Repeat dialog does not hold what this drive entered — '+bad.length+' control(s) differ: '+bad.join('; '));
632
+ return 'OK';
633
+ }`;
634
+ }
124
635
  /**
125
636
  * ensure-checkbox: converge a dialog checkbox to a target state through a
126
637
  * DETERMINISTIC CLOSED LOOP (RRD1, determinism doctrine) — never a blind toggle.
@@ -310,6 +821,9 @@ ${SE}
310
821
  set theMenu to menu 1 of pu
311
822
  set clickedTitle to ""
312
823
  set levelsSeen to 0
824
+ set opener to (value of pu) as text
825
+ set sample to ""
826
+ set sampled to 0
313
827
  if isToday then
314
828
  set nms to name of every menu item of theMenu
315
829
  if (count of nms) > 0 then
@@ -330,6 +844,13 @@ ${SE}
330
844
  repeat with i from 1 to (count of nms)
331
845
  set nm to item i of nms
332
846
  if nm is not missing value then
847
+ -- Keep a short sample of what THIS menu actually offered, so a miss can
848
+ -- report the dates the dialog had rather than only the one it lacked.
849
+ if sampled < ${SAMPLE_ITEMS} then
850
+ if sample is not "" then set sample to sample & ", "
851
+ set sample to sample & (nm as text)
852
+ set sampled to sampled + 1
853
+ end if
333
854
  set ymd to my parsedYMD(nm)
334
855
  if ymd is not missing value then
335
856
  if (item 1 of ymd) is wantY and (item 2 of ymd) is wantM and (item 3 of ymd) is wantD then
@@ -363,7 +884,7 @@ ${SE}
363
884
  end repeat
364
885
  if clickedTitle is "" then
365
886
  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."
887
+ 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, which opened on \\"" & opener & "\\" and led with: " & sample & ". Ask for a date the rule actually produces, or change the rule."
367
888
  end if
368
889
  delay 0.4
369
890
  set shown to (value of pu) as text
@@ -373,6 +894,48 @@ ${SE}
373
894
  return "OK"
374
895
  end tell`;
375
896
  }
897
+ /**
898
+ * settle-occurrences: let the 3.23 `Next:` pop-up ABSORB the rule change the
899
+ * preceding steps made, before the drive touches the dialog again (NEXTPOP1).
900
+ *
901
+ * MEASURED (golden-v4 / Things 3.23, `research-nextpop1.sh` DIAG3/DIAG4): the
902
+ * dialog recomputes the first-occurrence pop-up — its displayed value AND the
903
+ * menu of occurrences behind it — ASYNCHRONOUSLY. After the yearly anchor was
904
+ * moved from Aug 6 to Aug 20 the control flipped at **t+0.4s** with nothing else
905
+ * driven; when the very next step (the "Add deadlines" checkbox) was pressed
906
+ * inside that window instead, the control NEVER caught up — it still read
907
+ * `Thu, Aug 6, 2026`, and its menu still enumerated the Aug-6 series, six
908
+ * seconds later. A cancelled recompute does not retry.
909
+ *
910
+ * That is what made every deadlined monthly/yearly promote fail closed on 3.23:
911
+ * the anchor drive is followed immediately by the deadline controls, so by the
912
+ * time `select-next-occurrence` opened the menu it was the SEED's series, and
913
+ * the requested date — the rule's own first due date — was genuinely not in it
914
+ * (VMRES1 §4.3, reproduced and explained in NEXTPOP1).
915
+ *
916
+ * The wait is closed-loop in the direction that matters: it exits the moment the
917
+ * control MOVES, which is the case that needs waiting for. When the rule change
918
+ * did not move the first occurrence there is nothing to observe, so the budget
919
+ * bounds it — deliberately over-cautious, since the cost is a fraction of a
920
+ * second and the alternative is a series that starts on the wrong date.
921
+ */
922
+ export function axSettleOccurrencesScript(popupPath, budgetMs = OCCURRENCE_SETTLE_MS, pollMs = OCCURRENCE_POLL_MS) {
923
+ const poll = Math.max(50, Math.trunc(pollMs)) / 1000;
924
+ const reads = Math.max(1, Math.ceil(Math.max(1, Math.trunc(budgetMs)) / Math.max(50, pollMs)));
925
+ // `before` and `after` are AppleScript's own positional keywords and `now` is
926
+ // taken too — `set before to …` does not even COMPILE (osacompile: "Expected
927
+ // expression but found “to”"), and osascript reports that as a drive failure at
928
+ // run time, mid-dialog. Hence the deliberately dull variable names.
929
+ return `${SE}
930
+ set wasValue to (value of ${popupPath}) as text
931
+ repeat ${reads} times
932
+ delay ${poll}
933
+ set curValue to (value of ${popupPath}) as text
934
+ if curValue is not wasValue then return "moved: " & wasValue & " -> " & curValue
935
+ end repeat
936
+ return "unchanged: " & wasValue
937
+ end tell`;
938
+ }
376
939
  /**
377
940
  * converge-weekdays: drive the weekly dialog's weekday ROWS onto an exact target
378
941
  * set through a deterministic closed loop (RDLG2 — the RRD1 fix).
@@ -473,6 +1036,19 @@ end tell`;
473
1036
  * selection-landed verification, so a match guarantees the intended row is
474
1037
  * selected. One stable command shape per primitive.
475
1038
  *
1039
+ * VMRES1 correction (2026-08-23, golden-v4 / Things 3.23): the readback LAGS the
1040
+ * `select` action, so reading `name of selected to dos` immediately after it can
1041
+ * return the PREVIOUS iteration's selection. A row whose `select` lands nothing
1042
+ * (the blank spacer that follows the project rows) then matched the prior row's
1043
+ * title, the loop returned "OK" one row LATE, and the table was left with NOTHING
1044
+ * selected — `Items ▸ Repeat…` never materialized and the drive died at its wait
1045
+ * (`verify-failed:silent-noop`). It reproduced 3/3 on the second project-repeat
1046
+ * drive of a Things session and 0/2 on the first, which is the signature of a
1047
+ * race, not of app state. Fixed the way the heading sibling below already does
1048
+ * it: settle after `select`, then require `selected of (row i)` — the row THIS
1049
+ * iteration targeted must itself hold the selection — before trusting the title
1050
+ * readback. Evidence: [docs/lab/vmres1-residuals.md](../../../docs/lab/vmres1-residuals.md) §2.
1051
+ *
476
1052
  * UIC5 correction: the shipped form set the TABLE's `AXSelectedRows` attribute
477
1053
  * to a one-row list, which is a SILENT NO-OP on Things' content table via System
478
1054
  * Events (no error, selection never lands). The row `select` action is the
@@ -488,9 +1064,12 @@ export function axSelectRowScript(tablePath, title) {
488
1064
  repeat with i from 1 to n
489
1065
  try
490
1066
  select (row i of theTable)
491
- tell application "Things3" to set selNames to (name of selected to dos)
492
- if (count of selNames) is 1 and ((item 1 of selNames) as text) is "${t}" then
493
- return "OK"
1067
+ delay 0.25
1068
+ if (selected of (row i of theTable)) then
1069
+ tell application "Things3" to set selNames to (name of selected to dos)
1070
+ if (count of selNames) is 1 and ((item 1 of selNames) as text) is "${t}" then
1071
+ return "OK"
1072
+ end if
494
1073
  end if
495
1074
  end try
496
1075
  end repeat
@@ -583,6 +1162,141 @@ export function axKeyScript(keys) {
583
1162
  : `keystroke "${escapeAppleScript(k)}"`);
584
1163
  return `tell application "System Events" to tell process "Things3"\n ${lines.join("\n ")}\nend tell`;
585
1164
  }
1165
+ /**
1166
+ * type-text: send literal text to whatever control holds focus (HXPC1). The
1167
+ * Move… picker focuses its own filter field the instant it opens, and that field
1168
+ * is NOT addressable as a direct child of the picker window — so there is no
1169
+ * element to hand `set-value`, whose select-all + Tab commit would be wrong for a
1170
+ * search field regardless (a popover filter has no next key view for Tab to move
1171
+ * to). Unlike {@link axKeyScript}, which splits its spec on whitespace and would
1172
+ * drop the spaces out of a multi-word project title, this sends the string as
1173
+ * ONE keystroke. It is deliberately not self-verifying: the `click-picker-row`
1174
+ * step that follows resolves the destination row by name and fails closed when
1175
+ * the filter did not produce it, so a keystroke that landed elsewhere can never
1176
+ * be committed. One stable command shape.
1177
+ */
1178
+ export function axTypeTextScript(text) {
1179
+ return `${SE}
1180
+ keystroke "${escapeAppleScript(text)}"
1181
+ end tell`;
1182
+ }
1183
+ /**
1184
+ * resolve-frame for a control nested inside a CONTENT-TABLE ROW: walk the
1185
+ * table's rows → cells → cell children and return the frame of the one whose
1186
+ * `AXDescription` equals `description` (HXPC1, docs/lab/hxpc1-picker-assert.md
1187
+ * §B0). Same "x y w h" contract as {@link axFrameScript}.
1188
+ *
1189
+ * This exists because the heading row's `…` button — the only content-row
1190
+ * control that carries its own title (`"More. <heading title>"`, the HEADXPROJ
1191
+ * enabler) — sits at `UI element N of cell 1 of row M of the table`, and
1192
+ * `first UI element of <table> whose description is …` searches the table's
1193
+ * DIRECT children only. Those are the rows, which carry no description, so the
1194
+ * shipped one-level spelling matched nothing and the ellipsis drives
1195
+ * (`project.move-heading-to-project`, `project.dissolve-heading`) died at their
1196
+ * own frame resolution before any click — measured on Things 3.23 against a
1197
+ * heading whose button the raw Accessibility API resolves at the same instant.
1198
+ * The row/cell indices are never guessed: every row is walked and the match is
1199
+ * exact, so a heading whose title changed under us fails closed by name.
1200
+ */
1201
+ export function axRowCellFrameScript(tablePath, description) {
1202
+ const d = escapeAppleScript(description);
1203
+ return `${SE}
1204
+ set t to (${tablePath})
1205
+ repeat with r in rows of t
1206
+ repeat with c in UI elements of r
1207
+ repeat with e in UI elements of c
1208
+ try
1209
+ if ((description of e) as text) is "${d}" then
1210
+ set _p to position of e
1211
+ set _s to size of e
1212
+ 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)
1213
+ end if
1214
+ end try
1215
+ end repeat
1216
+ end repeat
1217
+ end repeat
1218
+ error "no row of this project's list exposes \\"${d}\\" — the heading may have been renamed, moved or deleted since it was read"
1219
+ end tell`;
1220
+ }
1221
+ /**
1222
+ * resolve-frame for the Move… picker ROW carrying an exact project title — the
1223
+ * step that replaced the recipe's blind Return (HXPC1,
1224
+ * docs/lab/hxpc1-picker-assert.md §B).
1225
+ *
1226
+ * The picker exposes no `AXSelected` / `AXFocused` / `AXHighlighted` on any row
1227
+ * (measured — only its filter field is focused), so there is nothing to read
1228
+ * back from a keyboard commit and no way to assert what Return would take. What
1229
+ * it does expose is one `AXUnknown` per row whose `AXDescription` IS the project
1230
+ * title, and — whenever the filter holds text — a trailing
1231
+ * `New Project "<typed text>"` row that CREATES a project when committed. That
1232
+ * row is what the blind Return took whenever the destination was missing from
1233
+ * the picker, which an ordinary database-resolved destination reaches: a
1234
+ * COMPLETED or CANCELED project appears nowhere in the picker, so the drive
1235
+ * minted a second project of the same title and moved the heading into it
1236
+ * (measured 3.23: projects 14 → 15, heading re-parented to the new row).
1237
+ *
1238
+ * So the commit is addressed instead of guessed. The script requires:
1239
+ * - the picker to be the window it claims (its `AXIdentifier` begins
1240
+ * `MovePopUpDialog-`) — a positive identity check, so a different detached
1241
+ * window can never be clicked into;
1242
+ * - EXACTLY ONE row whose description equals the destination title (the
1243
+ * New-Project row's description is the quoted form, so an exact match cannot
1244
+ * hit it);
1245
+ * - that row's centre to lie inside the picker's own scroll area — the CNCAC1
1246
+ * off-screen hazard, where a row scrolled past the fold still resolves a
1247
+ * frame and a click at it lands on the desktop.
1248
+ * Any miss FAILS CLOSED naming the destination and listing every row the picker
1249
+ * actually offered, so the caller learns what the app was willing to move to.
1250
+ */
1251
+ export function axPickerRowFrameScript(pickerPath, title) {
1252
+ const t = escapeAppleScript(title);
1253
+ return `${SE}
1254
+ set w to (${pickerPath})
1255
+ set pickerId to ""
1256
+ try
1257
+ set pickerId to (value of attribute "AXIdentifier" of w) as text
1258
+ end try
1259
+ if pickerId does not start with "MovePopUpDialog-" then
1260
+ error "the front dialog is not the Move… project picker (window id \\"" & pickerId & "\\") — nothing was committed"
1261
+ end if
1262
+ -- positional-ok: the picker window holds exactly one scroll area (MEASURED,
1263
+ -- HXPC1 §B2: "direct text fields=0 scroll areas=1"), and the window's own
1264
+ -- AXIdentifier was checked above, so this is a container handle inside an
1265
+ -- already-identified window — the ROW is addressed by exact title below.
1266
+ set sa to scroll area 1 of w
1267
+ set saPos to position of sa
1268
+ set saSize to size of sa
1269
+ set saTop to item 2 of saPos
1270
+ set saBottom to saTop + (item 2 of saSize)
1271
+ set hits to {}
1272
+ set offered to ""
1273
+ repeat with i from 1 to (count of UI elements of sa)
1274
+ set e to UI element i of sa
1275
+ set d to ""
1276
+ try
1277
+ set d to (description of e) as text
1278
+ end try
1279
+ if d is not "" and (role of e) is "AXUnknown" then
1280
+ set offered to offered & " [" & d & "]"
1281
+ if d is "${t}" then set end of hits to e
1282
+ end if
1283
+ end repeat
1284
+ if (count of hits) is 0 then
1285
+ error "the Move… picker offers no project named \\"${t}\\" — it offered:" & offered & ". Committing here would have created a new project with that name instead of moving into the existing one. A completed or canceled project is not offered by this picker."
1286
+ end if
1287
+ if (count of hits) > 1 then
1288
+ error "the Move… picker offers " & (count of hits) & " rows named \\"${t}\\" — it offered:" & offered
1289
+ end if
1290
+ set row1 to item 1 of hits
1291
+ set rp to position of row1
1292
+ set rs to size of row1
1293
+ set cy to (item 2 of rp) + ((item 2 of rs) / 2)
1294
+ if cy < saTop or cy > saBottom then
1295
+ error "the \\"${t}\\" row is scrolled out of the Move… picker's visible list, so clicking it would land outside the picker — narrow the destination or scroll it into view"
1296
+ end if
1297
+ return ((item 1 of rp) as text) & " " & ((item 2 of rp) as text) & " " & ((item 1 of rs) as text) & " " & ((item 2 of rs) as text)
1298
+ end tell`;
1299
+ }
586
1300
  /** The abort keystroke sent to dismiss a half-open sheet/popover on failure. */
587
1301
  export function axAbortScript() {
588
1302
  return `tell application "System Events" to key code 53`; // Escape
@@ -603,6 +1317,9 @@ export function axAbortScript() {
603
1317
  export function axCloseReopenActivateScript() {
604
1318
  return `tell application "Things3"
605
1319
  try
1320
+ -- positional-ok: an APP-LEVEL command to the Things scripting dictionary, not
1321
+ -- an Accessibility element path — "close the front window", whichever it is,
1322
+ -- which is the whole intent of the maneuver (a stuck sheet goes with it).
606
1323
  close window 1
607
1324
  end try
608
1325
  reopen
@@ -624,6 +1341,8 @@ export function axSheetOpenScript() {
624
1341
  return `${SE}
625
1342
  set sheetOpen to false
626
1343
  try
1344
+ -- positional-ok: an EXISTENCE probe over the one attached sheet a window can
1345
+ -- present; no element is read or written through this path.
627
1346
  if (exists sheet 1 of (first window whose subrole is "AXStandardWindow")) then set sheetOpen to true
628
1347
  end try
629
1348
  try
@@ -791,45 +1510,7 @@ function clickPointCommand(x, y, label) {
791
1510
  * calendar date at midnight). One stable JXA shape.
792
1511
  */
793
1512
  export function axSetDateTimeScript(spec, target) {
794
- return `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
795
- function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
796
- function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
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; }
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); }
1513
+ return `${AX_DATE_AREA_PRELUDE}
833
1514
  function run(){
834
1515
  var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
835
1516
  if(!apps || apps.count===0) throw new Error('Things not running');
@@ -867,7 +1548,9 @@ function run(){
867
1548
  $.NSThread.sleepForTimeInterval(0.2);
868
1549
  // READ-BACK: a control can accept the AX write (err 0) yet reject the value —
869
1550
  // 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).
1551
+ // rather than let a garbled commit verify as ok (YANCH1 #493). Like every
1552
+ // per-step read-back here it is SELF-REFERENTIAL (it re-reads the area it just
1553
+ // picked), so the pre-commit audit re-checks it from the outside.
871
1554
  if(spec.indexOf('date:')===0){
872
1555
  var got=ymdStr(dt,cal); var want=spec.slice(5);
873
1556
  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');
@@ -878,6 +1561,55 @@ function run(){
878
1561
  return 'OK';
879
1562
  }`;
880
1563
  }
1564
+ /**
1565
+ * The shared ObjC-bridge prelude for every `AXDateTimeArea` read or write: the
1566
+ * attribute helpers, the DIALOG-SHELL resolver (so the walk stays inside the
1567
+ * dialog's small subtree — the app-root descent PERF2 removed cost ~4.4s on a busy
1568
+ * host), and the deterministic {@link pick} target discriminator. The write
1569
+ * ({@link axSetDateTimeScript}) and the pre-commit read
1570
+ * ({@link axAuditDateAreasScript}) MUST agree on which area is which, so they
1571
+ * share one definition rather than two that can drift apart.
1572
+ */
1573
+ const AX_DATE_AREA_PRELUDE = `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
1574
+ function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
1575
+ function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
1576
+ 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; }
1577
+ 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); }
1578
+ function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
1579
+ 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; }
1580
+ 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; }
1581
+ // Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
1582
+ // small subtree — never the app-wide tree, whose main-window list content is the
1583
+ // 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
1584
+ // presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
1585
+ // priority order the System-Events pathCandidates use: an attached AXSheet on the
1586
+ // standard window (Things frontmost), then a detached top-level AXUnknown window
1587
+ // that is not the 40x40 utility window (Things backgrounded). null when neither is
1588
+ // present — the caller then falls through to the same named "presents 0 date
1589
+ // area(s)" error the app-root walk threw when the dialog was absent.
1590
+ function findShell(app){
1591
+ var wins=windowsOf(app);
1592
+ 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]; } }
1593
+ 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]; } }
1594
+ return null;
1595
+ }
1596
+ 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; }
1597
+ 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); }
1598
+ function pick(areas,target){
1599
+ if(areas.length===0) return null;
1600
+ var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
1601
+ if(target==='reminder'){
1602
+ var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
1603
+ return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
1604
+ }
1605
+ var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
1606
+ if(midnight.length===0) midnight=sorted;
1607
+ return target==='ends' ? midnight[midnight.length-1] : midnight[0];
1608
+ }
1609
+ 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)'; }
1610
+ 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); }
1611
+ 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); }
1612
+ `;
881
1613
  /**
882
1614
  * The converge-weekdays step encodes both of its inputs in `value` as
883
1615
  * `"<base>|<Weekday>,<Weekday>…"`: the base is the group pop-up index of the
@@ -1035,6 +1767,33 @@ export function commandForStep(step, targetUuid) {
1035
1767
  label: step.label,
1036
1768
  script: axSetValueScript(step.path ?? "", step.value ?? ""),
1037
1769
  };
1770
+ case "set-group-number":
1771
+ return {
1772
+ primitive: "set-group-number",
1773
+ label: step.label,
1774
+ script: axSetGroupNumberScript(step.path ?? "", step.numberTarget ?? "interval", step.value ?? ""),
1775
+ };
1776
+ case "set-row-field":
1777
+ return {
1778
+ primitive: "set-row-field",
1779
+ label: step.label,
1780
+ script: axSetRowFieldScript(step.path ?? "", step.rowLabel ?? "", step.value ?? ""),
1781
+ };
1782
+ case "audit-dialog":
1783
+ // Compiled by driveDialogAudit, which resolves the live dialog shell and the
1784
+ // measured shape first (a control's path and its weekday base both depend on
1785
+ // them). This shape exists only so the step renders/compiles uniformly.
1786
+ return {
1787
+ primitive: "audit-dialog",
1788
+ label: step.label,
1789
+ script: "",
1790
+ };
1791
+ case "type-text":
1792
+ return {
1793
+ primitive: "type-text",
1794
+ label: step.label,
1795
+ script: axTypeTextScript(step.value ?? ""),
1796
+ };
1038
1797
  case "select-popup":
1039
1798
  return {
1040
1799
  primitive: "select-popup",
@@ -1068,6 +1827,12 @@ export function commandForStep(step, targetUuid) {
1068
1827
  label: step.label,
1069
1828
  script: axSelectNextOccurrenceScript(step.path ?? "", step.value ?? ""),
1070
1829
  };
1830
+ case "settle-occurrences":
1831
+ return {
1832
+ primitive: "settle-occurrences",
1833
+ label: step.label,
1834
+ script: axSettleOccurrencesScript(step.path ?? ""),
1835
+ };
1071
1836
  case "converge-weekdays":
1072
1837
  return {
1073
1838
  primitive: "converge-weekdays",
@@ -1101,11 +1866,26 @@ export function commandForStep(step, targetUuid) {
1101
1866
  case "click-element":
1102
1867
  // Phase 1 of the click: read the target's frame. driveClickElement runs
1103
1868
  // this, then posts the click at the resolved center and asserts the outcome.
1869
+ // A `rowCellDescription` step resolves its target by walking the addressed
1870
+ // content table's rows/cells instead (the heading `…` button, which sits
1871
+ // three levels below the table a `whose` clause can reach).
1104
1872
  return {
1105
1873
  primitive: "resolve-frame",
1106
1874
  label: step.label,
1107
1875
  lang: "applescript",
1108
- script: axFrameScript(step.path ?? ""),
1876
+ script: step.rowCellDescription !== undefined
1877
+ ? axRowCellFrameScript(step.path ?? "", step.rowCellDescription)
1878
+ : axFrameScript(step.path ?? ""),
1879
+ };
1880
+ case "click-picker-row":
1881
+ // Phase 1 of the picker commit: resolve the row carrying the destination's
1882
+ // exact title (identity-checked, uniqueness-checked, on-screen-checked).
1883
+ // driveClickElement then clicks it — the recipe never presses Return.
1884
+ return {
1885
+ primitive: "resolve-frame",
1886
+ label: step.label,
1887
+ lang: "applescript",
1888
+ script: axPickerRowFrameScript(step.path ?? "", step.value ?? ""),
1109
1889
  };
1110
1890
  case "drag-reorder":
1111
1891
  // Composite step: drive() hands it to the sidebar drag driver, which
@@ -1117,8 +1897,32 @@ export function commandForStep(step, targetUuid) {
1117
1897
  lang: "javascript",
1118
1898
  script: jxaSidebarSnapshotScript(),
1119
1899
  };
1900
+ case "chord-reorder":
1901
+ // Composite step: drive() hands it to the heading-chord driver, which
1902
+ // dispatches its own select/chord commands through `run` and asserts the
1903
+ // database between them. This shape only exists so the step renders and
1904
+ // compiles uniformly; the chord it names is the FIRST hop's, and the
1905
+ // driver recomputes every subsequent one from the live order.
1906
+ return chordCommand("up-one");
1120
1907
  }
1121
1908
  }
1909
+ /**
1910
+ * Recover the message an AppleScript `error "…"` raised from osascript's stderr,
1911
+ * dropping the wrapper osascript adds around it (`<line>:<col>: execution error:
1912
+ * <message> (-1728)`). Returns null when stderr carries no such message, so a
1913
+ * caller can fall back to its own wording. This is what lets a resolver script
1914
+ * REFUSE with a sentence the operator can act on — "the Move… picker offers no
1915
+ * project named X — it offered […]" — instead of the driver's generic guess.
1916
+ */
1917
+ function scriptErrorText(stderr) {
1918
+ const raw = stderr.trim();
1919
+ if (raw === "")
1920
+ return null;
1921
+ const marker = raw.lastIndexOf("execution error:");
1922
+ const body = marker >= 0 ? raw.slice(marker + "execution error:".length) : raw;
1923
+ const trimmed = body.replace(/\s*\(-?\d+\)\s*$/, "").trim();
1924
+ return trimmed === "" ? null : trimmed;
1925
+ }
1122
1926
  /**
1123
1927
  * Execute a `click-element` step: resolve the target's AX frame, synthesize a
1124
1928
  * mouse click at its center, then verify the declared post-click outcome. Fails
@@ -1130,10 +1934,17 @@ async function driveClickElement(step, run) {
1130
1934
  const frameRes = await run(commandForStep(step, ""), STEP_TIMEOUT_MS);
1131
1935
  const center = frameRes.ok ? parseFrameCenter(frameRes.stdout) : null;
1132
1936
  if (center === null) {
1937
+ // A resolver that REFUSED (rather than merely failing to find an element)
1938
+ // carries the diagnosis — which row it wanted, and what the surface offered
1939
+ // instead. Prefer it over the generic guess: that text is the whole point of
1940
+ // the picker-row and heading-button resolvers (HXPC1).
1941
+ const named = scriptErrorText(frameRes.stderr);
1133
1942
  return {
1134
1943
  ok: false,
1135
- why: "its on-screen position did not resolve — a Things update may have moved the control, " +
1136
- "or the app is not in the expected state; no click was sent",
1944
+ why: named !== null
1945
+ ? `${named} no click was sent`
1946
+ : "its on-screen position did not resolve — a Things update may have moved the control, " +
1947
+ "or the app is not in the expected state; no click was sent",
1137
1948
  };
1138
1949
  }
1139
1950
  const clickRes = await run(clickPointCommand(center.x, center.y, step.label), STEP_TIMEOUT_MS);
@@ -1162,6 +1973,121 @@ async function driveClickElement(step, run) {
1162
1973
  }
1163
1974
  return { ok: true };
1164
1975
  }
1976
+ /**
1977
+ * Execute the PRE-COMMIT FULL-DIALOG AUDIT step (CGRD1): re-read every control the
1978
+ * drive set — through each control's own discriminated address — and refuse the
1979
+ * commit if any of them does not hold the intended value.
1980
+ *
1981
+ * Three resolutions happen here rather than in the recipe, because only the driver
1982
+ * knows them: WHICH dialog shell is live (attached sheet vs detached editor), which
1983
+ * SHAPE the dialog measured (the +1 group-index fork, and which first-occurrence
1984
+ * control class exists), and therefore which candidate path each control has. A
1985
+ * shell that does not resolve, or a shape-dependent control with no shape probed,
1986
+ * fails closed exactly like every other address here — an unaudited commit is not
1987
+ * an option, because the audit is the only non-self-referential check the drive has.
1988
+ *
1989
+ * The System Events sweep and the ObjC date-area sweep are separate commands
1990
+ * because the dialog's date/time controls hold NSDates that System Events cannot
1991
+ * read; both must pass.
1992
+ */
1993
+ async function driveDialogAudit(step, run, dialogShape) {
1994
+ const plan = step.audit;
1995
+ if (plan === undefined)
1996
+ return { ok: false, why: "no audit plan compiled (recipe bug)" };
1997
+ // Which of the two dialog shells is live, in the SAME priority order the drive's
1998
+ // own candidate resolution used — so the audit reads the dialog the drive wrote.
1999
+ let shellIndex = -1;
2000
+ for (let i = 0; i < plan.shells.length; i += 1) {
2001
+ const res = await run({
2002
+ primitive: "resolve",
2003
+ label: step.label,
2004
+ script: axResolveScript(plan.shells[i]),
2005
+ }, STEP_TIMEOUT_MS);
2006
+ if (res.ok && res.stdout.trim() === "true") {
2007
+ shellIndex = i;
2008
+ break;
2009
+ }
2010
+ }
2011
+ if (shellIndex < 0) {
2012
+ return {
2013
+ ok: false,
2014
+ why: "the Repeat dialog could not be re-read before committing (neither the attached sheet " +
2015
+ "nor the detached repeat editor window resolved), so what it holds could not be checked",
2016
+ };
2017
+ }
2018
+ const shell = plan.shells[shellIndex];
2019
+ const group = (plan.groups[shellIndex] ?? plan.groups[0]);
2020
+ const scriptControls = [];
2021
+ const dateAreas = [];
2022
+ for (const raw of plan.controls) {
2023
+ if (raw.onlyShape !== undefined || raw.shaped !== undefined) {
2024
+ if (dialogShape === null)
2025
+ return { ok: false, why: SHAPE_UNPROBED };
2026
+ if (raw.onlyShape !== undefined && raw.onlyShape !== dialogShape)
2027
+ continue;
2028
+ }
2029
+ const override = raw.shaped === undefined ? undefined : raw.shaped[dialogShape ?? "next-popup"];
2030
+ if (raw.shaped !== undefined && override === undefined) {
2031
+ return {
2032
+ ok: false,
2033
+ why: `the audit has no check for "${raw.label}" under the "${dialogShape ?? "unmeasured"}" Repeat dialog (recipe bug)`,
2034
+ };
2035
+ }
2036
+ const control = { ...raw, ...override };
2037
+ if (control.kind === "date-area") {
2038
+ dateAreas.push({
2039
+ label: control.label,
2040
+ target: control.dtTarget ?? "next",
2041
+ spec: control.dtSpec ?? "",
2042
+ });
2043
+ continue;
2044
+ }
2045
+ const candidates = control.pathCandidates;
2046
+ scriptControls.push({
2047
+ label: control.label,
2048
+ kind: control.kind,
2049
+ ...(candidates !== undefined && {
2050
+ path: (candidates[shellIndex] ?? candidates[0]),
2051
+ }),
2052
+ ...(control.numberTarget !== undefined && { numberTarget: control.numberTarget }),
2053
+ ...(control.rowLabel !== undefined && { rowLabel: control.rowLabel }),
2054
+ ...(control.weekdayBase !== undefined && { weekdayBase: control.weekdayBase }),
2055
+ expected: control.expected ?? [],
2056
+ ...(control.expectedLabel !== undefined && { expectedLabel: control.expectedLabel }),
2057
+ });
2058
+ }
2059
+ if (scriptControls.length > 0) {
2060
+ const res = await run({
2061
+ primitive: "audit-dialog",
2062
+ label: step.label,
2063
+ script: axAuditDialogScript({ shell, group, controls: scriptControls }),
2064
+ }, STEP_TIMEOUT_MS);
2065
+ if (!res.ok || res.stdout.trim() !== "OK") {
2066
+ return { ok: false, why: auditFailureText(res) };
2067
+ }
2068
+ }
2069
+ if (dateAreas.length > 0) {
2070
+ const res = await run({
2071
+ primitive: "audit-dialog",
2072
+ label: step.label,
2073
+ lang: "javascript",
2074
+ script: axAuditDateAreasScript(dateAreas),
2075
+ }, STEP_TIMEOUT_MS);
2076
+ if (!res.ok || res.stdout.trim() !== "OK") {
2077
+ return { ok: false, why: auditFailureText(res) };
2078
+ }
2079
+ }
2080
+ return { ok: true };
2081
+ }
2082
+ /** The audit's own refusal text, preferred over the driver's generic guess. */
2083
+ function auditFailureText(res) {
2084
+ const named = scriptErrorText(res.stderr);
2085
+ if (named !== null)
2086
+ return `${named} — nothing was committed`;
2087
+ if (res.timedOut === true)
2088
+ return "the pre-commit dialog audit timed out; nothing was committed";
2089
+ return "the Repeat dialog could not be re-read before committing; nothing was committed";
2090
+ }
1165
2091
  async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
1166
2092
  const done = [];
1167
2093
  // The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
@@ -1210,7 +2136,14 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1210
2136
  // `clear`: how a half-open sheet was cleaned up after a failure (honest — never
1211
2137
  // claim a dismissal we could not see, SESSGATE #480); undefined = no sheet was
1212
2138
  // opened / no cleanup ran (a benign preamble/canary failure).
1213
- const partial = (failed, why, clear) => {
2139
+ const partial = (failed, why, clear,
2140
+ /**
2141
+ * The failing step's own transport outcome: `true` when its osascript was
2142
+ * killed by its deadline rather than answering. Together with a blind cleanup
2143
+ * this is what separates "the Things window stopped answering" from "the app
2144
+ * answered and refused/did nothing" (#512) — see {@link ExecuteResult.uiUnreachable}.
2145
+ */
2146
+ stepTimedOut = false) => {
1214
2147
  const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
1215
2148
  const cleanup = clear === undefined
1216
2149
  ? ""
@@ -1225,7 +2158,28 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1225
2158
  : " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
1226
2159
  " Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
1227
2160
  " make the next drive's preflight fail).";
1228
- return refusal(base + cleanup);
2161
+ // #512: name an environment failure as one. A cleanup that had to run BLIND
2162
+ // is direct evidence the session went AX-blind mid-drive; a step killed by
2163
+ // its own deadline is the window not answering. Either way the app was not
2164
+ // reachable to be driven — which is not the app accepting a command and
2165
+ // changing nothing, and must not be reported as that.
2166
+ const cause = clear?.state === "cleared-blind" ? "unreachable" : stepTimedOut ? "unresponsive" : null;
2167
+ const res = refusal(base + cleanup);
2168
+ if (cause === null)
2169
+ return res;
2170
+ return {
2171
+ ...res,
2172
+ uiUnreachable: {
2173
+ step: failed,
2174
+ cause,
2175
+ ...(clear !== undefined && { clear: clear.state }),
2176
+ remediation: cause === "unreachable"
2177
+ ? "unlock the Mac, or leave the full-screen app so a Things window is visible on the " +
2178
+ "desktop you're viewing, then run the same command again"
2179
+ : "bring Things to the front and check that it is responding, then run the same " +
2180
+ "command again",
2181
+ },
2182
+ };
1229
2183
  };
1230
2184
  // 0. Run the leading reveal/activate preamble BEFORE the canary. The Items
1231
2185
  // menu is context-dependent — its Repeat submenu (and the plain "Repeat…"
@@ -1239,7 +2193,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1239
2193
  // the preamble steps are strictly sequential (select, then foreground) and each must land before the next
1240
2194
  const res = await run(commandForStep(step, recipe.targetUuid), STEP_TIMEOUT_MS);
1241
2195
  if (!res.ok) {
1242
- return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed");
2196
+ return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", undefined, res.timedOut === true);
1243
2197
  }
1244
2198
  done.push(step.label);
1245
2199
  idx += 1;
@@ -1354,6 +2308,21 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1354
2308
  done.push(`${step.label} (${outcome.detail})`);
1355
2309
  continue;
1356
2310
  }
2311
+ if (step.primitive === "chord-reorder") {
2312
+ // The heading-chord driver runs its own select → chord → DB-assert loop
2313
+ // (ui-chord.ts): every chord is computed from the order it just read, and
2314
+ // a chord that moves nothing (or moves the wrong row) stops the drive
2315
+ // rather than being re-sent. No sheet is involved, so no dismissal clause.
2316
+ if (step.chord === undefined)
2317
+ return partial(step.label, "no chord spec compiled");
2318
+ const spec = step.chord;
2319
+ // the chord ladder depends on the UI state the reveal produced
2320
+ const outcome = await driveHeadingChordReorder(spec, run, aux.headingOrder, (ordinal) => axSelectHeadingRowScript(spec.tablePath, ordinal));
2321
+ if (!outcome.ok)
2322
+ return partial(step.label, outcome.detail);
2323
+ done.push(`${step.label} (${outcome.detail})`);
2324
+ continue;
2325
+ }
1357
2326
  // Resolve a candidate-addressed step's effective element before dispatch
1358
2327
  // (the sheet-vs-detached-window disjunction). A miss fails closed.
1359
2328
  if (step.pathCandidates !== undefined) {
@@ -1384,7 +2353,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1384
2353
  "it again; nothing was entered into the rule"
1385
2354
  : res.timedOut === true
1386
2355
  ? "the dialog-shape probe timed out"
1387
- : res.stderr.trim() || "the dialog-shape probe failed", clear);
2356
+ : res.stderr.trim() || "the dialog-shape probe failed", clear, res.timedOut === true);
1388
2357
  }
1389
2358
  dialogShape = verdict;
1390
2359
  done.push(`${step.label} (${verdict})`);
@@ -1408,7 +2377,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1408
2377
  ? noMatch
1409
2378
  : res.timedOut === true
1410
2379
  ? "the row-selection step timed out"
1411
- : res.stderr.trim() || "the row-selection step failed", clear);
2380
+ : res.stderr.trim() || "the row-selection step failed", clear, res.timedOut === true);
1412
2381
  }
1413
2382
  done.push(step.label);
1414
2383
  continue;
@@ -1430,14 +2399,31 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1430
2399
  : "the target to-do was not confirmed selected/eligible after the reveal"
1431
2400
  : res.timedOut === true
1432
2401
  ? "the eligibility check timed out"
1433
- : res.stderr.trim() || "the eligibility check failed", clear);
2402
+ : res.stderr.trim() || "the eligibility check failed", clear, res.timedOut === true);
2403
+ }
2404
+ done.push(step.label);
2405
+ continue;
2406
+ }
2407
+ if (step.primitive === "audit-dialog") {
2408
+ // The last thing before the commit: re-read EVERY control this drive set,
2409
+ // through each control's own discriminated address, and abort fail-closed if
2410
+ // any of them disagrees with what was requested (CGRD1). Every per-step
2411
+ // read-back before this point is self-referential — it re-reads the element it
2412
+ // addressed — so a wrong ADDRESS is invisible until here. A mismatch clears
2413
+ // the dialog through the standard clean-abort path, so nothing is committed.
2414
+ const outcome = await driveDialogAudit(step, run, dialogShape);
2415
+ if (!outcome.ok) {
2416
+ const clear = await clearDialog(run);
2417
+ return partial(step.label, outcome.why ?? "the pre-commit dialog audit failed", clear);
1434
2418
  }
1435
2419
  done.push(step.label);
1436
2420
  continue;
1437
2421
  }
1438
- if (step.primitive === "click-element") {
2422
+ if (step.primitive === "click-element" || step.primitive === "click-picker-row") {
1439
2423
  // A mouse click at an AX-resolved frame center (the NATIVE1 primitive),
1440
- // used only where AXPress is inert (Things' custom `…`/repeat-bar popover).
2424
+ // used only where AXPress is inert (Things' custom `…`/repeat-bar popover)
2425
+ // — and, for `click-picker-row`, where committing by keyboard would take
2426
+ // whatever the app highlighted, including the row that CREATES a project.
1441
2427
  // the click depends on the UI state the previous step produced
1442
2428
  const outcome = await driveClickElement(step, run);
1443
2429
  if (!outcome.ok) {
@@ -1456,7 +2442,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1456
2442
  const clear = step.primitive !== "reveal" && step.primitive !== "activate"
1457
2443
  ? await clearDialog(run)
1458
2444
  : undefined;
1459
- return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear);
2445
+ return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear, res.timedOut === true);
1460
2446
  }
1461
2447
  done.push(step.label);
1462
2448
  }
@@ -1527,8 +2513,9 @@ function disabledMatrix() {
1527
2513
  disruption: 3,
1528
2514
  validation: "validated",
1529
2515
  notes: "the Accessibility GUI vector is off on this machine — enable it with `things config " +
1530
- "set ui-enabled true`, then grant Accessibility to this process (see docs/setup.md). " +
1531
- "It drives the local Things GUI and is intended for a dedicated always-on Mac.",
2516
+ "set ui-enabled true`, then run `things helpers setup --gui`, which grants GUI-driving " +
2517
+ "to the helper pair (the only identity it is granted to). It drives the local Things " +
2518
+ "GUI and is intended for a dedicated always-on Mac.",
1532
2519
  };
1533
2520
  }
1534
2521
  return matrix;
@@ -1554,10 +2541,14 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
1554
2541
  const reachCache = createReachabilityCache();
1555
2542
  return {
1556
2543
  id: "ui",
2544
+ // Article IV: the pipeline's GUI gate keys on this declaration, never on
2545
+ // the id — see WriteVector.drivesGui.
2546
+ drivesGui: true,
1557
2547
  matrix: enabled ? enabledMatrix() : disabledMatrix(),
1558
2548
  async execute(invocation) {
1559
2549
  if (!enabled) {
1560
- return refusal("the ui vector is disabled (`things config set ui-enabled true` to enable it).");
2550
+ return refusal("the ui vector is disabled (`things config set ui-enabled true` to enable it, then " +
2551
+ "`things helpers setup --gui`).");
1561
2552
  }
1562
2553
  if (invocation.recipe === undefined) {
1563
2554
  return refusal("ui invocation carried no recipe (compile bug).");