things-api 0.19.0 → 0.19.2

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 (154) hide show
  1. package/README.md +3 -1
  2. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  3. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  5. package/dist/audit/schema.d.ts +19 -2
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/capability.d.ts +166 -4
  8. package/dist/capability.js +303 -13
  9. package/dist/capability.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +13 -1
  11. package/dist/cli/commands/doctor.js +52 -22
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/helpers.js +14 -3
  14. package/dist/cli/commands/helpers.js.map +1 -1
  15. package/dist/cli/commands/mcp.js +8 -0
  16. package/dist/cli/commands/mcp.js.map +1 -1
  17. package/dist/cli/commands/repeat-flags.js +34 -2
  18. package/dist/cli/commands/repeat-flags.js.map +1 -1
  19. package/dist/cli/commands/setup.js +16 -3
  20. package/dist/cli/commands/setup.js.map +1 -1
  21. package/dist/cli/commands/ui-state.d.ts +11 -0
  22. package/dist/cli/commands/ui-state.js +27 -0
  23. package/dist/cli/commands/ui-state.js.map +1 -0
  24. package/dist/cli/commands/writes.js +102 -67
  25. package/dist/cli/commands/writes.js.map +1 -1
  26. package/dist/cli/help.js +2 -0
  27. package/dist/cli/help.js.map +1 -1
  28. package/dist/cli/interrupt.d.ts +54 -6
  29. package/dist/cli/interrupt.js +78 -12
  30. package/dist/cli/interrupt.js.map +1 -1
  31. package/dist/cli/main.js +10 -5
  32. package/dist/cli/main.js.map +1 -1
  33. package/dist/client.d.ts +1 -1
  34. package/dist/contracts.d.ts +4 -4
  35. package/dist/contracts.js +1 -1
  36. package/dist/contracts.js.map +1 -1
  37. package/dist/deputy/install.d.ts +15 -1
  38. package/dist/deputy/install.js +46 -6
  39. package/dist/deputy/install.js.map +1 -1
  40. package/dist/deputy/osa.d.ts +42 -2
  41. package/dist/deputy/osa.js +79 -5
  42. package/dist/deputy/osa.js.map +1 -1
  43. package/dist/deputy/routing.d.ts +34 -0
  44. package/dist/deputy/routing.js +95 -0
  45. package/dist/deputy/routing.js.map +1 -1
  46. package/dist/deputy/wake.d.ts +61 -0
  47. package/dist/deputy/wake.js +149 -0
  48. package/dist/deputy/wake.js.map +1 -0
  49. package/dist/diagnose.d.ts +6 -4
  50. package/dist/diagnose.js +20 -4
  51. package/dist/diagnose.js.map +1 -1
  52. package/dist/direct-setup.d.ts +31 -5
  53. package/dist/direct-setup.js +200 -26
  54. package/dist/direct-setup.js.map +1 -1
  55. package/dist/host-access.d.ts +12 -0
  56. package/dist/host-access.js +12 -0
  57. package/dist/host-access.js.map +1 -1
  58. package/dist/index.d.ts +5 -3
  59. package/dist/index.js +3 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/mcp/server.js +23 -9
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/op-result.js +8 -1
  64. package/dist/op-result.js.map +1 -1
  65. package/dist/session-grant.js +16 -1
  66. package/dist/session-grant.js.map +1 -1
  67. package/dist/surface-copy.d.ts +11 -3
  68. package/dist/surface-copy.js +11 -3
  69. package/dist/surface-copy.js.map +1 -1
  70. package/dist/ui-state.d.ts +46 -0
  71. package/dist/ui-state.js +82 -0
  72. package/dist/ui-state.js.map +1 -0
  73. package/dist/wizard.js +8 -1
  74. package/dist/wizard.js.map +1 -1
  75. package/dist/write/accessibility-probe.js +4 -1
  76. package/dist/write/accessibility-probe.js.map +1 -1
  77. package/dist/write/automation-probe.js +7 -1
  78. package/dist/write/automation-probe.js.map +1 -1
  79. package/dist/write/availability.d.ts +0 -15
  80. package/dist/write/availability.js +7 -62
  81. package/dist/write/availability.js.map +1 -1
  82. package/dist/write/batch.js +17 -6
  83. package/dist/write/batch.js.map +1 -1
  84. package/dist/write/commands.d.ts +10 -6
  85. package/dist/write/commands.js +120 -31
  86. package/dist/write/commands.js.map +1 -1
  87. package/dist/write/failure-hints.d.ts +36 -7
  88. package/dist/write/failure-hints.js +33 -14
  89. package/dist/write/failure-hints.js.map +1 -1
  90. package/dist/write/field-limits.d.ts +100 -0
  91. package/dist/write/field-limits.js +144 -0
  92. package/dist/write/field-limits.js.map +1 -0
  93. package/dist/write/guards.js +3 -2
  94. package/dist/write/guards.js.map +1 -1
  95. package/dist/write/move.js +9 -12
  96. package/dist/write/move.js.map +1 -1
  97. package/dist/write/operations.d.ts +15 -8
  98. package/dist/write/operations.js +9 -0
  99. package/dist/write/operations.js.map +1 -1
  100. package/dist/write/opid.d.ts +67 -23
  101. package/dist/write/opid.js +141 -19
  102. package/dist/write/opid.js.map +1 -1
  103. package/dist/write/param-schema.d.ts +17 -44
  104. package/dist/write/param-schema.js +74 -7
  105. package/dist/write/param-schema.js.map +1 -1
  106. package/dist/write/pipeline.d.ts +38 -7
  107. package/dist/write/pipeline.js +134 -18
  108. package/dist/write/pipeline.js.map +1 -1
  109. package/dist/write/pre-state.d.ts +30 -16
  110. package/dist/write/pre-state.js +88 -20
  111. package/dist/write/pre-state.js.map +1 -1
  112. package/dist/write/promote-clone.d.ts +26 -0
  113. package/dist/write/promote-clone.js +333 -41
  114. package/dist/write/promote-clone.js.map +1 -1
  115. package/dist/write/repeat-collateral.d.ts +31 -0
  116. package/dist/write/repeat-collateral.js +153 -0
  117. package/dist/write/repeat-collateral.js.map +1 -0
  118. package/dist/write/resolution-timestamps.js +25 -2
  119. package/dist/write/resolution-timestamps.js.map +1 -1
  120. package/dist/write/template-mutation.js +163 -16
  121. package/dist/write/template-mutation.js.map +1 -1
  122. package/dist/write/vectors/applescript.js +3 -10
  123. package/dist/write/vectors/applescript.js.map +1 -1
  124. package/dist/write/vectors/simulator.js +8 -2
  125. package/dist/write/vectors/simulator.js.map +1 -1
  126. package/dist/write/vectors/types.d.ts +236 -4
  127. package/dist/write/vectors/ui-certification.js +63 -13
  128. package/dist/write/vectors/ui-certification.js.map +1 -1
  129. package/dist/write/vectors/ui-chord.d.ts +144 -0
  130. package/dist/write/vectors/ui-chord.js +428 -0
  131. package/dist/write/vectors/ui-chord.js.map +1 -0
  132. package/dist/write/vectors/ui-drag.d.ts +8 -4
  133. package/dist/write/vectors/ui-drag.js +2 -0
  134. package/dist/write/vectors/ui-drag.js.map +1 -1
  135. package/dist/write/vectors/ui-recipes.d.ts +51 -3
  136. package/dist/write/vectors/ui-recipes.js +452 -44
  137. package/dist/write/vectors/ui-recipes.js.map +1 -1
  138. package/dist/write/vectors/ui-state.d.ts +157 -0
  139. package/dist/write/vectors/ui-state.js +237 -0
  140. package/dist/write/vectors/ui-state.js.map +1 -0
  141. package/dist/write/vectors/ui.d.ts +386 -13
  142. package/dist/write/vectors/ui.js +1543 -124
  143. package/dist/write/vectors/ui.js.map +1 -1
  144. package/dist/write/vectors/url-scheme.js +3 -0
  145. package/dist/write/vectors/url-scheme.js.map +1 -1
  146. package/dist/write/verify/delta.d.ts +41 -0
  147. package/dist/write/verify/delta.js +36 -2
  148. package/dist/write/verify/delta.js.map +1 -1
  149. package/dist/write/verify/poller.d.ts +4 -1
  150. package/dist/write/verify/poller.js +14 -0
  151. package/dist/write/verify/poller.js.map +1 -1
  152. package/package.json +1 -1
  153. package/schema/envelope.schema.json +2 -1
  154. package/skills/things-cli/SKILL.md +2 -2
@@ -33,7 +33,9 @@ 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";
38
+ import { AX_DIALOG_SHELL_SNIPPET, describeFocusOwner, readUiState, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./ui-state.js";
37
39
  /** GUI driving can stall on an unanswered sheet; give each step headroom. */
38
40
  const STEP_TIMEOUT_MS = 15_000;
39
41
  /**
@@ -72,6 +74,227 @@ const SETTLE_AFTER_REVEAL_MS = 1000;
72
74
  */
73
75
  const SHAPE_UNPROBED = "the Repeat dialog's shape was never measured, so this control's address is unknown (recipe bug)";
74
76
  const SE = `tell application "System Events" to tell process "Things3"`;
77
+ /**
78
+ * The cadence group's re-layout settle budget (BEEP1) — how many times
79
+ * `set-group-number` re-reads the group's shape looking for two consecutive
80
+ * identical reads, and how long it waits between reads. ~4s of headroom against
81
+ * a re-layout measured to finish well inside 1.5s; the gate exits on the FIRST
82
+ * agreeing pair, so the common (already-settled) case costs one extra read.
83
+ */
84
+ const SETTLE_READS = 40;
85
+ const SETTLE_POLL_S = 0.1;
86
+ /**
87
+ * Row tolerance (points) for every LABEL-ANCHORED field address here — how far a
88
+ * control's y may sit from its label's y and still count as the same row.
89
+ * Measured on Things 3.23 (CGRD1 §A census): `Every`@286 / interval@283,
90
+ * `Ends:`@375 / count@372, `days earlier`@413 / start-offset@409 — a 3–4pt
91
+ * baseline offset, so 8 is ~2× the observed worst case and well under the
92
+ * ~45pt row pitch of the cadence group.
93
+ */
94
+ const ROW_TOLERANCE = 8;
95
+ /**
96
+ * How many of the Next: menu's own item titles a MISS reports back (NEXTPOP1).
97
+ * A refusal that names only the date the dialog lacked cannot distinguish "the
98
+ * rule genuinely does not produce that date" from "the menu was not the rule's"
99
+ * — the two failures read identically, and the second one cost a whole campaign
100
+ * to tell apart. Naming the pop-up's current value plus the first few options it
101
+ * offered makes the refusal self-diagnosing. Bounded so a 100-item level cannot
102
+ * flood the message.
103
+ */
104
+ const SAMPLE_ITEMS = 5;
105
+ /**
106
+ * The `Next:` occurrence pop-up's asynchronous-recompute budget (NEXTPOP1) — how
107
+ * long {@link axSettleOccurrencesScript} waits for the dialog to absorb a rule
108
+ * change before the drive touches it again, and how often it looks. MEASURED at
109
+ * **0.4s** on golden-v4 / Things 3.23 (DIAG4: the control flipped from the seed's
110
+ * first occurrence to the anchor's between t+0.3s and t+0.4s), so the budget is
111
+ * ~3× the observed latency; the poll exits early the moment the control moves,
112
+ * which is the only case that costs anything.
113
+ */
114
+ const OCCURRENCE_SETTLE_MS = 1200;
115
+ const OCCURRENCE_POLL_MS = 100;
116
+ /**
117
+ * The shared AppleScript handler prelude for every LABEL-ANCHORED field address
118
+ * in the Repeat dialog — the HXPC1 discrimination law in ONE place, so the
119
+ * pre-commit audit re-reads each field through the SAME address the drive wrote
120
+ * it through (a self-referential read-back is exactly what let #589's wrong-field
121
+ * write report OK).
122
+ *
123
+ * The laws, all measured on Things 3.23 / build 32300036 (CGRD1 §A census, and
124
+ * HXPC1 §A before it — docs/lab/cgrd1-precommit-audit.md, hxpc1-picker-assert.md):
125
+ *
126
+ * - The cadence group's numeric fields are identified by the LABEL ROW they sit
127
+ * on, never by index among the group's text fields. Selecting an "Ends: after"
128
+ * bound INSERTS the count AHEAD of the interval, so index 1 is a different
129
+ * control at different moments.
130
+ * - The interval is matched POSITIVELY, on the `Every` label's row. Every fixed
131
+ * frequency (daily/weekly/monthly/yearly) carries that label at y=286 with the
132
+ * interval at y=283. An AFTER-COMPLETION cadence group carries NEITHER an
133
+ * `Every` nor an `Ends:` label (census: its only static text is "after previous
134
+ * item is checked off.") and offers exactly ONE text field — so that shape falls
135
+ * to the sole-field rule, which is itself a uniqueness check, not an index.
136
+ * - Anything else FAILS CLOSED reporting the whole numeric-field inventory rather
137
+ * than typing a number into a field it cannot vouch for. An AX tree is an
138
+ * undocumented private surface: an unrecognized shape is a refusal, never a
139
+ * best guess.
140
+ */
141
+ const AX_CADENCE_HANDLERS = `on cgLabelY(g, want)
142
+ tell application "System Events"
143
+ set outY to missing value
144
+ repeat with i from 1 to (count of static texts of g)
145
+ set sv to ""
146
+ try
147
+ set sv to (value of static text i of g) as text
148
+ end try
149
+ if sv is want then
150
+ -- bind the position, THEN index it: System Events refuses indexing a
151
+ -- position read inline from a specifier (-1700).
152
+ set labelPos to position of static text i of g
153
+ set outY to item 2 of labelPos
154
+ end if
155
+ end repeat
156
+ return outY
157
+ end tell
158
+ end cgLabelY
159
+
160
+ on cgInventory(g)
161
+ tell application "System Events"
162
+ set inv to ""
163
+ repeat with i from 1 to (count of text fields of g)
164
+ set fp to position of text field i of g
165
+ set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of g) as text) & ")"
166
+ end repeat
167
+ if inv is "" then set inv to " (none)"
168
+ return inv
169
+ end tell
170
+ end cgInventory
171
+
172
+ on cgOnRow(g, y, tol, want)
173
+ tell application "System Events"
174
+ set hits to {}
175
+ repeat with i from 1 to (count of text fields of g)
176
+ set fp to position of text field i of g
177
+ set dy to (item 2 of fp) - y
178
+ if dy < 0 then set dy to -dy
179
+ set onRow to (dy <= tol)
180
+ if onRow is want then set end of hits to text field i of g
181
+ end repeat
182
+ return hits
183
+ end tell
184
+ end cgOnRow
185
+
186
+ on cgSettle(g)
187
+ -- SETTLE ON THE GROUP'S OWN SHAPE, never on a clock (determinism doctrine).
188
+ -- A frequency switch REBUILDS the cadence group. Two things go wrong when a
189
+ -- read starts too early: the row discrimination reads positions from controls
190
+ -- that are still moving, and keystrokes land on a field being torn down —
191
+ -- unhandled, so macOS beeps (BEEP1). Poll until two consecutive reads of the
192
+ -- group's label + field-position signature agree, then proceed.
193
+ tell application "System Events"
194
+ set sig to ""
195
+ set prevSig to "<none>"
196
+ repeat ${SETTLE_READS} times
197
+ set prevSig to sig
198
+ set sig to ""
199
+ repeat with i from 1 to (count of static texts of g)
200
+ set sv to ""
201
+ try
202
+ set sv to (value of static text i of g) as text
203
+ end try
204
+ set sig to sig & "|s:" & sv
205
+ end repeat
206
+ repeat with i from 1 to (count of text fields of g)
207
+ set fp to position of text field i of g
208
+ set sig to sig & "|f:" & (item 2 of fp)
209
+ end repeat
210
+ if sig is prevSig then return true
211
+ delay ${SETTLE_POLL_S}
212
+ end repeat
213
+ error "the Repeat dialog's cadence group is still re-laying out — its shape changed on every read; last seen" & sig
214
+ end tell
215
+ end cgSettle
216
+
217
+ on cgField(g, target, tol)
218
+ tell application "System Events"
219
+ set endsY to my cgLabelY(g, "Ends:")
220
+ set everyY to my cgLabelY(g, "Every")
221
+ set nf to (count of text fields of g)
222
+ if target is "ends-count" then
223
+ 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)
224
+ set hits to my cgOnRow(g, endsY, tol, true)
225
+ 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)
226
+ return item 1 of hits
227
+ end if
228
+ if everyY is not missing value then
229
+ set hits to my cgOnRow(g, everyY, tol, true)
230
+ 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)
231
+ return item 1 of hits
232
+ end if
233
+ if endsY is not missing value then
234
+ set hits to my cgOnRow(g, endsY, tol, false)
235
+ 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)
236
+ return item 1 of hits
237
+ end if
238
+ 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)
239
+ -- positional-ok: reached ONLY after the line above proved the group holds
240
+ -- exactly one text field, so this is a uniqueness statement, not an index.
241
+ -- The after-completion cadence group is that shape (MEASURED, CGRD1 §A: its
242
+ -- only static text is "after previous item is checked off.", one field).
243
+ return text field 1 of g
244
+ end tell
245
+ end cgField
246
+
247
+ on rfInventory(c)
248
+ tell application "System Events"
249
+ set inv to ""
250
+ repeat with i from 1 to (count of text fields of c)
251
+ set fp to position of text field i of c
252
+ set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of c) as text) & ")"
253
+ end repeat
254
+ if inv is "" then set inv to " (none)"
255
+ return inv
256
+ end tell
257
+ end rfInventory
258
+
259
+ on rfField(c, rowLabel, tol)
260
+ tell application "System Events"
261
+ set labelY to my cgLabelY(c, rowLabel)
262
+ 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)
263
+ set hits to my cgOnRow(c, labelY, tol, true)
264
+ 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)
265
+ return item 1 of hits
266
+ end tell
267
+ end rfField`;
268
+ /**
269
+ * The IN-SCRIPT half of the per-step focus guard (issue #620).
270
+ *
271
+ * A synthetic keystroke is not addressed at an element — System Events hands it
272
+ * to whatever application owns the screen at that instant. So every script that
273
+ * types re-asserts, in the same osascript hop that will do the typing, that
274
+ * Things is still frontmost; the drive-level census (see {@link guardedRun})
275
+ * runs a moment earlier and cannot close the last few milliseconds. The
276
+ * assertion FAILS CLOSED and names the application that owns the screen
277
+ * instead — never the contents of its window.
278
+ *
279
+ * This is the cheapest possible check: one System Events property read, no
280
+ * sleeps, no polling (UI-automation determinism doctrine).
281
+ */
282
+ export const AX_FOCUS_GUARD_HANDLERS = `on fgFrontApp()
283
+ set frontName to ""
284
+ try
285
+ tell application "System Events" to set frontName to (name of first application process whose frontmost is true) as text
286
+ end try
287
+ return frontName
288
+ end fgFrontApp
289
+
290
+ on fgAssertFront(what)
291
+ set f to my fgFrontApp()
292
+ if f is "${THINGS_PROCESS}" then return true
293
+ if f is "" then
294
+ error "refused to " & what & ": the frontmost application could not be read, so there is no proof the keystrokes would reach Things — nothing was typed"
295
+ end if
296
+ error "refused to " & what & ": " & f & " is frontmost, not Things — a keystroke goes to whatever owns the screen, so nothing was typed"
297
+ end fgAssertFront`;
75
298
  /** resolve-element: does the element exist right now? Returns "true"/"false". */
76
299
  export function axResolveScript(path) {
77
300
  return `${SE} to return (exists (${path}))`;
@@ -82,33 +305,64 @@ export function axPressScript(path) {
82
305
  }
83
306
  /**
84
307
  * 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.
308
+ * ends-count, start-days-earlier) and into the Move… picker's filter field. It
309
+ * FOCUSES the field, TYPES the value, and Tabs to commit — because
310
+ * `set value of <field>` writes the field's displayed text WITHOUT firing the
311
+ * edit, so the app's binding keeps the old number (the field shows "5" but the
312
+ * rule stays interval 1 — a silent no-op exactly like `set value` on a pop-up,
313
+ * UIC6; it went unnoticed while every base case used the default interval 1).
314
+ * Real keystrokes fire the change the binding needs; Tab (not Return, which would
315
+ * fire the default OK button) commits and moves focus. Foreground-bound
316
+ * (keystrokes reach the frontmost app) — the reveal/activate preamble puts Things
317
+ * there. One stable command shape.
318
+ *
319
+ * NO SELECT-ALL KEYSTROKE (BEEP1, 2026-08-25, docs/lab/beep1-numeric-field-beep.md).
320
+ * The primitive used to send ⌘A before typing, and that ONE keystroke was the
321
+ * audible macOS alert beep every numeric-field drive fired on the live host:
322
+ * Things' `Edit ▸ Select All` menu item exists and is DISABLED while the Repeat
323
+ * sheet is up, AppKit dispatches ⌘A as a menu key equivalent FIRST, the disabled
324
+ * item swallows it, nothing handles it → NSBeep. It is a menu-dispatch fact, not
325
+ * a focus race: the beep survives a verified first responder and a 1.5 s settle,
326
+ * while Tab and the digits themselves are silent. ⌘A was also REDUNDANT —
327
+ * `set focused of tf to true` installs the field editor with the ENTIRE content
328
+ * selected (measured: `AXSelectedTextRange` length goes 0 → the full value
329
+ * length, on both a 1- and a 2-character value), so typing replaces the old value
330
+ * outright, including the shrinking case (12 → 3) that a stale caret would have
331
+ * corrupted into "123". Dropping the keystroke is therefore silent AND correct on
332
+ * all three fields.
333
+ *
334
+ * CLOSED-LOOP (determinism doctrine): type, Tab-commit, then READ THE FIELD BACK
335
+ * and retry if it did not hold — the interval field, when it is the first numeric
336
+ * field after a frequency/type switch, races the dialog's group re-layout and
337
+ * reverts to 1 (UIC7, oddities §8l). Re-focus + re-type after a settle lands it
338
+ * once the re-layout has finished, and the re-focus re-selects the whole value,
339
+ * so a retry starts from a clean field without ⌘A. Fail-closed (an `error`, i.e.
340
+ * a transport failure the pipeline re-verifies) if it never holds — the
341
+ * create/reschedule delta's rule assertion is the final DB-level authority.
342
+ *
343
+ * READ-BACK FIRST (issue #620 item 7): a field that ALREADY holds the requested
344
+ * value is left alone and the script returns {@link OK_ALREADY} — the whole
345
+ * keystroke class disappears for the defaults, which is most drives (the field
346
+ * incident died typing interval `1` into a field already showing `1`). The skip
347
+ * is proven by TWO reads a settle apart, because the one way a matching value
348
+ * can go stale is the UIC7 re-layout revert, which lands within that window; and
349
+ * whatever this decides, the pre-commit audit ({@link axAuditDialogScript})
350
+ * re-reads every control through its own address before the OK press, so a
351
+ * wrongly-skipped field cannot commit.
94
352
  */
95
353
  export function axSetValueScript(path, value, attempts = 3) {
96
354
  const v = escapeAppleScript(value);
97
355
  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
- return `${SE}
356
+ return `${AX_FOCUS_GUARD_HANDLERS}
357
+
358
+ ${SE}
106
359
  set tf to (${path})
360
+ ${alreadyHoldsBlock("tf", v)}
107
361
  repeat ${n} times
362
+ my fgAssertFront("type \\"${v}\\" into the field")
108
363
  set focused of tf to true
109
364
  delay 0.15
110
- keystroke "a" using command down
111
- delay 0.1
365
+ ${focusedAssertBlock("tf", v)}
112
366
  keystroke "${v}"
113
367
  delay 0.1
114
368
  key code 48
@@ -121,6 +375,391 @@ export function axSetValueScript(path, value, attempts = 3) {
121
375
  error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
122
376
  end tell`;
123
377
  }
378
+ /**
379
+ * What a typing primitive returns when it typed NOTHING because the field
380
+ * already held the requested value (issue #620 item 7). Distinct from "OK" so
381
+ * the drive can disclose the skip — and so a lab cell can assert that no
382
+ * keystroke hop fired.
383
+ */
384
+ export const OK_ALREADY = "OK-ALREADY";
385
+ /**
386
+ * The read-back-first skip: two reads a settle apart, no keystroke either way.
387
+ * Shared verbatim by all three typing primitives so the law is one shape.
388
+ */
389
+ function alreadyHoldsBlock(ref, escapedValue) {
390
+ return ` set v0 to ""
391
+ try
392
+ set v0 to ((value of ${ref}) as text)
393
+ end try
394
+ if v0 is "${escapedValue}" then
395
+ delay 0.3
396
+ set v1 to ""
397
+ try
398
+ set v1 to ((value of ${ref}) as text)
399
+ end try
400
+ if v1 is "${escapedValue}" then return "${OK_ALREADY}"
401
+ end if`;
402
+ }
403
+ /**
404
+ * The element half of the focus guard: after asking for focus, PROVE the field
405
+ * took it before typing. A field that will not accept focus (the dialog is
406
+ * rebuilding, another sheet stole it) would otherwise receive the keystrokes
407
+ * somewhere else entirely.
408
+ */
409
+ function focusedAssertBlock(ref, escapedValue) {
410
+ return ` set gotFocus to false
411
+ try
412
+ set gotFocus to (focused of ${ref}) as boolean
413
+ end try
414
+ if not gotFocus then error "refused to type \\"${escapedValue}\\": the field did not take keyboard focus, so the keystrokes would have gone somewhere else"`;
415
+ }
416
+ /**
417
+ * set-group-number: drive ONE of the Repeat dialog's two numeric fields —
418
+ * the cadence INTERVAL or the ENDS-AFTER COUNT — addressed by the LABEL ROW it
419
+ * sits on (HXPC1, docs/lab/hxpc1-picker-assert.md §A; hardened by CGRD1,
420
+ * docs/lab/cgrd1-precommit-audit.md §A).
421
+ *
422
+ * Both fields used to be spelled `text field 1 of group 1`, which is the same
423
+ * control at different moments. Measured on Things 3.23 (build 32300036):
424
+ *
425
+ * Ends: never → group text fields = 1 · tf1 = interval @[311,283]
426
+ * Ends: after N → group text fields = 2 · tf1 = COUNT @[402,372]
427
+ * tf2 = interval @[311,283]
428
+ *
429
+ * i.e. selecting the "after" bound INSERTS the count ahead of the interval. The
430
+ * create path got away with it by driving the interval while it was still the
431
+ * sole field, but a RESCHEDULE opens the dialog pre-populated: a rule that
432
+ * already ends after N presents both fields from the first step, so the interval
433
+ * drive wrote the requested interval into the count field, the count drive then
434
+ * overwrote it, and the interval silently never changed.
435
+ *
436
+ * The addresses are the {@link AX_CADENCE_HANDLERS} laws — POSITIVE label-row
437
+ * matches wherever the app offers a label to match, a uniqueness check where it
438
+ * does not, and a fail-closed refusal naming the whole numeric-field inventory
439
+ * otherwise. The write itself is the {@link axSetValueScript} closed loop —
440
+ * focus, type, Tab-commit, read back, bounded retries — and, like it, sends NO
441
+ * select-all keystroke: the ⌘A that used to open it is the macOS alert beep
442
+ * (BEEP1, docs/lab/beep1-numeric-field-beep.md), and focusing the field already
443
+ * selects its whole content.
444
+ *
445
+ * The read-back this loop performs is SELF-REFERENTIAL by construction — it
446
+ * re-reads the field it addressed, so it can only prove the keystrokes landed
447
+ * where they were aimed, never that they were aimed at the right control. The
448
+ * PRE-COMMIT AUDIT ({@link axAuditDialogScript}) is what closes that: it re-reads
449
+ * every control through these same handlers just before the OK press.
450
+ */
451
+ export function axSetGroupNumberScript(groupPath, target, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
452
+ const v = escapeAppleScript(value);
453
+ const n = Math.max(1, Math.trunc(attempts));
454
+ const tol = Math.max(1, Math.trunc(rowTolerance));
455
+ return `${AX_CADENCE_HANDLERS}
456
+
457
+ ${AX_FOCUS_GUARD_HANDLERS}
458
+
459
+ ${SE}
460
+ set g to (${groupPath})
461
+ my cgSettle(g)
462
+ set tf to my cgField(g, "${target}", ${tol})
463
+ ${alreadyHoldsBlock("tf", v)}
464
+ repeat ${n} times
465
+ my fgAssertFront("type \\"${v}\\" into the ${target} field")
466
+ set focused of tf to true
467
+ delay 0.15
468
+ ${focusedAssertBlock("tf", v)}
469
+ keystroke "${v}"
470
+ delay 0.1
471
+ key code 48
472
+ delay 0.2
473
+ try
474
+ if ((value of tf) as text) is "${v}" then return "OK"
475
+ end try
476
+ delay 0.3
477
+ end repeat
478
+ error "the ${target} field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
479
+ end tell`;
480
+ }
481
+ /**
482
+ * set-row-field: drive a Repeat-dialog text field addressed by the pinned English
483
+ * LABEL sharing its row — the same discrimination law as
484
+ * {@link axSetGroupNumberScript}, applied to a field that lives on the dialog
485
+ * SHELL rather than in the cadence group.
486
+ *
487
+ * Its one caller is the "and start [N] days earlier" offset the "Add deadlines"
488
+ * checkbox reveals, which shipped as `text field 1` of the shell. That address
489
+ * was the HXPC1 error class exactly: a value-bearing text field picked by index
490
+ * out of a STATE-DEPENDENT tree (the field does not exist at all until the
491
+ * checkbox is ticked), verified only by re-reading the same index it wrote. It
492
+ * happened to be right on 3.23 — measured, the shell carries 0 direct text fields
493
+ * with deadlines off and exactly 1 with them on, whether or not reminders are also
494
+ * on (CGRD1 §B census) — but nothing in the address said so, and an AX tree is an
495
+ * undocumented private surface that may add a second field in any release.
496
+ *
497
+ * The label anchor is `days earlier` (y=413 against the field's y=409, CGRD1 §B).
498
+ * A missing label, or anything other than exactly one field on its row, FAILS
499
+ * CLOSED naming the shell's whole text-field inventory. The write is the
500
+ * {@link axSetValueScript} closed loop.
501
+ */
502
+ export function axSetRowFieldScript(containerPath, rowLabel, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
503
+ const v = escapeAppleScript(value);
504
+ const label = escapeAppleScript(rowLabel);
505
+ const n = Math.max(1, Math.trunc(attempts));
506
+ const tol = Math.max(1, Math.trunc(rowTolerance));
507
+ return `${AX_CADENCE_HANDLERS}
508
+
509
+ ${AX_FOCUS_GUARD_HANDLERS}
510
+
511
+ ${SE}
512
+ set c to (${containerPath})
513
+ set tf to my rfField(c, "${label}", ${tol})
514
+ ${alreadyHoldsBlock("tf", v)}
515
+ repeat ${n} times
516
+ my fgAssertFront("type \\"${v}\\" into the \\"${label}\\" field")
517
+ set focused of tf to true
518
+ delay 0.15
519
+ ${focusedAssertBlock("tf", v)}
520
+ keystroke "${v}"
521
+ delay 0.1
522
+ key code 48
523
+ delay 0.2
524
+ try
525
+ if ((value of tf) as text) is "${v}" then return "OK"
526
+ end try
527
+ delay 0.3
528
+ end repeat
529
+ error "the \\"${label}\\" field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
530
+ end tell`;
531
+ }
532
+ /** AppleScript list literal of quoted strings. */
533
+ function asList(values) {
534
+ return `{${values.map((v) => `"${escapeAppleScript(v)}"`).join(", ")}}`;
535
+ }
536
+ /** The intended value(s) as the mismatch report should read them. */
537
+ function intendedText(control) {
538
+ if (control.expectedLabel !== undefined)
539
+ return control.expectedLabel;
540
+ return control.expected.map((v) => `\\"${escapeAppleScript(v)}\\"`).join(" or ");
541
+ }
542
+ /**
543
+ * audit-dialog: RE-READ EVERY CONTROL THIS DRIVE SET, through each control's own
544
+ * discriminated address, and refuse to commit if any one of them does not hold
545
+ * the value the drive intended.
546
+ *
547
+ * This exists because a per-step read-back is SELF-REFERENTIAL. Every setter here
548
+ * confirms its write by re-reading the element it addressed, so it proves the
549
+ * keystrokes landed where they were aimed — and nothing else. The #589 wrong-field
550
+ * write reported OK for exactly that reason: the interval drive typed into the
551
+ * ends-count field, then read the ends-count field back and found its own number
552
+ * sitting there. The address was wrong; a read-back through the same address
553
+ * cannot see that.
554
+ *
555
+ * The audit is the outside view. It is assembled from the recipe's OWN step list
556
+ * (so no control the recipe drives can be left out of the audit by omission) and
557
+ * runs as the last step before the OK press, comparing the dialog's complete
558
+ * intended state against what the dialog actually shows: frequency, the
559
+ * after-completion cadence unit, interval, ends bound and its count, the
560
+ * deadline/reminder checkboxes, the start-days-earlier offset, the weekday set,
561
+ * the monthly/yearly anchor pop-ups and the 3.23 first-occurrence pop-up. A
562
+ * mismatch is an `error` naming EVERY differing control with both values, which
563
+ * aborts the drive fail-closed BEFORE the commit and runs the standard clean-abort
564
+ * path — nothing reaches the database.
565
+ *
566
+ * Deterministic throughout: the cadence group is settled on its own shape
567
+ * signature (the BEEP1 two-agreeing-reads gate), never on a sleep, and every field
568
+ * is found by its label row rather than by index ({@link AX_CADENCE_HANDLERS}).
569
+ *
570
+ * The dialog's three `AXDateTimeArea` controls are audited separately — their
571
+ * values are NSDates no System Events read can reach, so they ride
572
+ * {@link axAuditDateAreasScript} through the same ObjC bridge that writes them.
573
+ */
574
+ export function axAuditDialogScript(spec, rowTolerance = ROW_TOLERANCE) {
575
+ const tol = Math.max(1, Math.trunc(rowTolerance));
576
+ const body = spec.controls
577
+ .map((c, i) => {
578
+ const name = escapeAppleScript(c.label);
579
+ const want = asList(c.expected);
580
+ const intended = intendedText(c);
581
+ const miss = `set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\")"`;
582
+ switch (c.kind) {
583
+ case "popup":
584
+ return ` set v${i} to "(unreadable)"
585
+ try
586
+ set v${i} to (value of (${c.path ?? ""})) as text
587
+ end try
588
+ if not (my aqAny(v${i}, ${want})) then ${miss}`;
589
+ case "occurrence-popup":
590
+ return ` set v${i} to "(unreadable)"
591
+ try
592
+ set v${i} to (value of (${c.path ?? ""})) as text
593
+ end try
594
+ set d${i} to my aqYMD(v${i})
595
+ if d${i} is missing value then
596
+ ${miss}
597
+ else if not (my aqAny(d${i}, ${want})) then
598
+ set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\" = " & d${i} & ")"
599
+ end if`;
600
+ case "checkbox":
601
+ return ` set v${i} to "(unreadable)"
602
+ try
603
+ set v${i} to ((value of (${c.path ?? ""})) as integer) as text
604
+ end try
605
+ if not (my aqAny(v${i}, ${want})) then set end of bad to "${name} (intended ${intended}, dialog shows " & (my aqTick(v${i})) & ")"`;
606
+ case "group-number":
607
+ return ` set v${i} to "(unreadable)"
608
+ try
609
+ set v${i} to ((value of (my cgField(g, "${c.numberTarget ?? "interval"}", ${tol}))) as text)
610
+ end try
611
+ if not (my aqAny(v${i}, ${want})) then ${miss}`;
612
+ case "row-field":
613
+ return ` set v${i} to "(unreadable)"
614
+ try
615
+ set v${i} to ((value of (my rfField(sh, "${escapeAppleScript(c.rowLabel ?? "")}", ${tol}))) as text)
616
+ end try
617
+ if not (my aqAny(v${i}, ${want})) then ${miss}`;
618
+ case "weekdays":
619
+ return ` set got${i} to {}
620
+ repeat with k from ${Math.max(1, Math.trunc(c.weekdayBase ?? 2))} to (count of pop up buttons of g)
621
+ set end of got${i} to ((value of pop up button k of g) as text)
622
+ end repeat
623
+ set v${i} to my aqJoin(got${i}, ",")
624
+ set off${i} to false
625
+ repeat with w in ${want}
626
+ if not (my aqAny(w as text, got${i})) then set off${i} to true
627
+ end repeat
628
+ repeat with w in got${i}
629
+ if not (my aqAny(w as text, ${want})) then set off${i} to true
630
+ end repeat
631
+ if off${i} then ${miss}`;
632
+ }
633
+ })
634
+ .join("\n");
635
+ return `${AX_CADENCE_HANDLERS}
636
+
637
+ on aqAny(v, lst)
638
+ repeat with c in lst
639
+ if (v as text) is (c as text) then return true
640
+ end repeat
641
+ return false
642
+ end aqAny
643
+
644
+ on aqJoin(lst, sep)
645
+ set out to ""
646
+ repeat with x in lst
647
+ if out is not "" then set out to out & sep
648
+ set out to out & (x as text)
649
+ end repeat
650
+ return out
651
+ end aqJoin
652
+
653
+ on aqTick(v)
654
+ if (v as text) is "1" then return "checked"
655
+ if (v as text) is "0" then return "unchecked"
656
+ return "\\"" & (v as text) & "\\""
657
+ end aqTick
658
+
659
+ on aqPad2(n)
660
+ set s to (n as integer) as text
661
+ if (length of s) < 2 then set s to "0" & s
662
+ return s
663
+ end aqPad2
664
+
665
+ on aqStamp(d)
666
+ return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
667
+ end aqStamp
668
+
669
+ on aqRelative(s)
670
+ -- The first-occurrence control renders NEAR dates RELATIVELY — "Today" for the
671
+ -- current day — and a relative word can never be string-compared against a
672
+ -- typed ISO date (#625: make-repeating --when <today> refused its own correct
673
+ -- write, every time, because the audit compared "2026-07-05" against "Today").
674
+ -- Resolve the word against the app's own clock, the same way the selector
675
+ -- already does, rather than rebuilding the app's display string.
676
+ set rightNow to current date
677
+ if s is "Today" then return my aqStamp(rightNow)
678
+ if s is "Tomorrow" then return my aqStamp(rightNow + 86400)
679
+ if s is "Yesterday" then return my aqStamp(rightNow - 86400)
680
+ -- A weekday-only rendering names a day inside the coming week; anything
681
+ -- further out is rendered as a date, so the search is bounded at 7 days and a
682
+ -- word that resolves to nothing falls through to the date parse (and, failing
683
+ -- that, to a fail-closed mismatch — never a guess).
684
+ set wdNames to {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
685
+ repeat with i from 1 to 7
686
+ if s is (item i of wdNames) then
687
+ repeat with k from 1 to 7
688
+ set cand to rightNow + (k * 86400)
689
+ if ((weekday of cand) as text) is (item i of wdNames) then return my aqStamp(cand)
690
+ end repeat
691
+ end if
692
+ end repeat
693
+ return missing value
694
+ end aqRelative
695
+
696
+ on aqYMD(t)
697
+ -- Occurrence-pop-up titles are LOCALIZED ("Sun, Jul 12, 2026") and, for near
698
+ -- dates, RELATIVE ("Today") — so the match is made by RESOLVING the title to a
699
+ -- calendar date and comparing components, never by rebuilding the app's
700
+ -- display string (the axSelectNextOccurrenceScript law).
701
+ set s to t as text
702
+ set rel to my aqRelative(s)
703
+ if rel is not missing value then return rel
704
+ try
705
+ set d to date s
706
+ return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
707
+ end try
708
+ try
709
+ set ofs to offset of ", " in s
710
+ if ofs > 0 then
711
+ set d to date (text (ofs + 2) thru -1 of s)
712
+ return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
713
+ end if
714
+ end try
715
+ return missing value
716
+ end aqYMD
717
+
718
+ ${SE}
719
+ set sh to (${spec.shell})
720
+ set g to (${spec.group})
721
+ my cgSettle(g)
722
+ set bad to {}
723
+ ${body}
724
+ if (count of bad) is 0 then return "OK"
725
+ error "the Repeat dialog does not hold what this drive entered — " & (count of bad) & " control(s) differ: " & my aqJoin(bad, "; ")
726
+ end tell`;
727
+ }
728
+ /**
729
+ * The pre-commit audit's DATE-AREA leg. The dialog's first-occurrence, ends-on and
730
+ * reminder controls are `AXDateTimeArea`s whose value is an NSDate — unreachable
731
+ * from System Events — so they are re-read through the SAME ObjC bridge, the same
732
+ * shell-scoped walk and the SAME deterministic `pick` discriminator that
733
+ * {@link axSetDateTimeScript} writes them through. A control the audit cannot find,
734
+ * or one holding a different date/time, throws naming every area the dialog does
735
+ * present (y-position + time-of-day), so the drive aborts before the OK press.
736
+ */
737
+ export function axAuditDateAreasScript(areas) {
738
+ return `${AX_DATE_AREA_PRELUDE}
739
+ function run(){
740
+ var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
741
+ if(!apps || apps.count===0) throw new Error('Things not running');
742
+ var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
743
+ var wanted=${JSON.stringify(areas)};
744
+ var cal=$.NSCalendar.currentCalendar;
745
+ var found=[]; try{ var shell=findShell(app); if(shell) collect(shell,'AXDateTimeArea',16,found); }catch(e){ found=[]; }
746
+ var bad=[];
747
+ for(var i=0;i<wanted.length;i++){
748
+ var w=wanted[i];
749
+ var dt=pick(found,w.target);
750
+ if(!dt){ bad.push(w.label+' (intended '+w.spec+', but this dialog state presents no '+w.target+' control among ['+inv(found)+'])'); continue; }
751
+ if(w.spec.indexOf('date:')===0){
752
+ var got=ymdStr(dt,cal), want=w.spec.slice(5);
753
+ if(got!==want) bad.push(w.label+' (intended '+want+', dialog shows '+(got||'(no value)')+')');
754
+ } else {
755
+ var gott=hmStr(dt,cal), p=w.spec.slice(5).split(':'), wantt=(+p[0])+':'+('0'+(+p[1])).slice(-2);
756
+ if(gott!==wantt) bad.push(w.label+' (intended '+wantt+', dialog shows '+(gott||'(no value)')+')');
757
+ }
758
+ }
759
+ if(bad.length) throw new Error('the Repeat dialog does not hold what this drive entered — '+bad.length+' control(s) differ: '+bad.join('; '));
760
+ return 'OK';
761
+ }`;
762
+ }
124
763
  /**
125
764
  * ensure-checkbox: converge a dialog checkbox to a target state through a
126
765
  * DETERMINISTIC CLOSED LOOP (RRD1, determinism doctrine) — never a blind toggle.
@@ -310,6 +949,9 @@ ${SE}
310
949
  set theMenu to menu 1 of pu
311
950
  set clickedTitle to ""
312
951
  set levelsSeen to 0
952
+ set opener to (value of pu) as text
953
+ set sample to ""
954
+ set sampled to 0
313
955
  if isToday then
314
956
  set nms to name of every menu item of theMenu
315
957
  if (count of nms) > 0 then
@@ -330,6 +972,13 @@ ${SE}
330
972
  repeat with i from 1 to (count of nms)
331
973
  set nm to item i of nms
332
974
  if nm is not missing value then
975
+ -- Keep a short sample of what THIS menu actually offered, so a miss can
976
+ -- report the dates the dialog had rather than only the one it lacked.
977
+ if sampled < ${SAMPLE_ITEMS} then
978
+ if sample is not "" then set sample to sample & ", "
979
+ set sample to sample & (nm as text)
980
+ set sampled to sampled + 1
981
+ end if
333
982
  set ymd to my parsedYMD(nm)
334
983
  if ymd is not missing value then
335
984
  if (item 1 of ymd) is wantY and (item 2 of ymd) is wantM and (item 3 of ymd) is wantD then
@@ -363,7 +1012,7 @@ ${SE}
363
1012
  end repeat
364
1013
  if clickedTitle is "" then
365
1014
  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."
1015
+ 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
1016
  end if
368
1017
  delay 0.4
369
1018
  set shown to (value of pu) as text
@@ -373,6 +1022,48 @@ ${SE}
373
1022
  return "OK"
374
1023
  end tell`;
375
1024
  }
1025
+ /**
1026
+ * settle-occurrences: let the 3.23 `Next:` pop-up ABSORB the rule change the
1027
+ * preceding steps made, before the drive touches the dialog again (NEXTPOP1).
1028
+ *
1029
+ * MEASURED (golden-v4 / Things 3.23, `research-nextpop1.sh` DIAG3/DIAG4): the
1030
+ * dialog recomputes the first-occurrence pop-up — its displayed value AND the
1031
+ * menu of occurrences behind it — ASYNCHRONOUSLY. After the yearly anchor was
1032
+ * moved from Aug 6 to Aug 20 the control flipped at **t+0.4s** with nothing else
1033
+ * driven; when the very next step (the "Add deadlines" checkbox) was pressed
1034
+ * inside that window instead, the control NEVER caught up — it still read
1035
+ * `Thu, Aug 6, 2026`, and its menu still enumerated the Aug-6 series, six
1036
+ * seconds later. A cancelled recompute does not retry.
1037
+ *
1038
+ * That is what made every deadlined monthly/yearly promote fail closed on 3.23:
1039
+ * the anchor drive is followed immediately by the deadline controls, so by the
1040
+ * time `select-next-occurrence` opened the menu it was the SEED's series, and
1041
+ * the requested date — the rule's own first due date — was genuinely not in it
1042
+ * (VMRES1 §4.3, reproduced and explained in NEXTPOP1).
1043
+ *
1044
+ * The wait is closed-loop in the direction that matters: it exits the moment the
1045
+ * control MOVES, which is the case that needs waiting for. When the rule change
1046
+ * did not move the first occurrence there is nothing to observe, so the budget
1047
+ * bounds it — deliberately over-cautious, since the cost is a fraction of a
1048
+ * second and the alternative is a series that starts on the wrong date.
1049
+ */
1050
+ export function axSettleOccurrencesScript(popupPath, budgetMs = OCCURRENCE_SETTLE_MS, pollMs = OCCURRENCE_POLL_MS) {
1051
+ const poll = Math.max(50, Math.trunc(pollMs)) / 1000;
1052
+ const reads = Math.max(1, Math.ceil(Math.max(1, Math.trunc(budgetMs)) / Math.max(50, pollMs)));
1053
+ // `before` and `after` are AppleScript's own positional keywords and `now` is
1054
+ // taken too — `set before to …` does not even COMPILE (osacompile: "Expected
1055
+ // expression but found “to”"), and osascript reports that as a drive failure at
1056
+ // run time, mid-dialog. Hence the deliberately dull variable names.
1057
+ return `${SE}
1058
+ set wasValue to (value of ${popupPath}) as text
1059
+ repeat ${reads} times
1060
+ delay ${poll}
1061
+ set curValue to (value of ${popupPath}) as text
1062
+ if curValue is not wasValue then return "moved: " & wasValue & " -> " & curValue
1063
+ end repeat
1064
+ return "unchanged: " & wasValue
1065
+ end tell`;
1066
+ }
376
1067
  /**
377
1068
  * converge-weekdays: drive the weekly dialog's weekday ROWS onto an exact target
378
1069
  * set through a deterministic closed loop (RDLG2 — the RRD1 fix).
@@ -435,7 +1126,10 @@ ${SE}
435
1126
  delay 0.3
436
1127
  end repeat
437
1128
  if not (exists menu item wantVal of menu 1 of pu) then
438
- key code 53
1129
+ -- No Escape here (issue #620): a keystroke reaches whatever owns the
1130
+ -- screen, and this error path is exactly when that is least certain.
1131
+ -- The open menu is left for the driver's audited cleanup, which is the
1132
+ -- ONE place an Escape is decided.
439
1133
  error "converge-weekdays: the weekday pop-up offers no item \\"" & wantVal & "\\" (the app may not be in English)"
440
1134
  end if
441
1135
  click menu item wantVal of menu 1 of pu
@@ -588,20 +1282,206 @@ return "OK"`;
588
1282
  export function axActivateScript() {
589
1283
  return `tell application "Things3" to activate`;
590
1284
  }
591
- /** key: a space-separated keystroke spec (e.g. "down down return"). */
1285
+ /**
1286
+ * key: a space-separated keystroke spec (e.g. "down down return").
1287
+ *
1288
+ * Frontmost-guarded in-script (issue #620): `key code`/`keystroke` reach
1289
+ * whatever application owns the screen, so the script refuses — naming that
1290
+ * application — rather than firing keys into someone else's window.
1291
+ */
592
1292
  export function axKeyScript(keys) {
593
1293
  const KEY_CODES = { return: 36, escape: 53, down: 125, up: 126, tab: 48 };
1294
+ const spec = keys.trim();
594
1295
  const lines = keys
595
1296
  .split(/\s+/)
596
1297
  .filter((k) => k !== "")
597
1298
  .map((k) => KEY_CODES[k] !== undefined
598
1299
  ? `key code ${KEY_CODES[k]}`
599
1300
  : `keystroke "${escapeAppleScript(k)}"`);
600
- return `tell application "System Events" to tell process "Things3"\n ${lines.join("\n ")}\nend tell`;
1301
+ return `${AX_FOCUS_GUARD_HANDLERS}
1302
+
1303
+ my fgAssertFront("send the keystrokes \\"${escapeAppleScript(spec)}\\"")
1304
+ tell application "System Events" to tell process "Things3"
1305
+ ${lines.join("\n ")}
1306
+ end tell`;
601
1307
  }
602
- /** The abort keystroke sent to dismiss a half-open sheet/popover on failure. */
1308
+ /**
1309
+ * type-text: send literal text to whatever control holds focus (HXPC1). The
1310
+ * Move… picker focuses its own filter field the instant it opens, and that field
1311
+ * is NOT addressable as a direct child of the picker window — so there is no
1312
+ * element to hand `set-value`, whose select-all + Tab commit would be wrong for a
1313
+ * search field regardless (a popover filter has no next key view for Tab to move
1314
+ * to). Unlike {@link axKeyScript}, which splits its spec on whitespace and would
1315
+ * drop the spaces out of a multi-word project title, this sends the string as
1316
+ * ONE keystroke. It is deliberately not self-verifying: the `click-picker-row`
1317
+ * step that follows resolves the destination row by name and fails closed when
1318
+ * the filter did not produce it, so a keystroke that landed elsewhere can never
1319
+ * be committed. One stable command shape.
1320
+ */
1321
+ export function axTypeTextScript(text) {
1322
+ return `${AX_FOCUS_GUARD_HANDLERS}
1323
+
1324
+ my fgAssertFront("type into the focused field")
1325
+ ${SE}
1326
+ keystroke "${escapeAppleScript(text)}"
1327
+ end tell`;
1328
+ }
1329
+ /**
1330
+ * resolve-frame for a control nested inside a CONTENT-TABLE ROW: walk the
1331
+ * table's rows → cells → cell children and return the frame of the one whose
1332
+ * `AXDescription` equals `description` (HXPC1, docs/lab/hxpc1-picker-assert.md
1333
+ * §B0). Same "x y w h" contract as {@link axFrameScript}.
1334
+ *
1335
+ * This exists because the heading row's `…` button — the only content-row
1336
+ * control that carries its own title (`"More. <heading title>"`, the HEADXPROJ
1337
+ * enabler) — sits at `UI element N of cell 1 of row M of the table`, and
1338
+ * `first UI element of <table> whose description is …` searches the table's
1339
+ * DIRECT children only. Those are the rows, which carry no description, so the
1340
+ * shipped one-level spelling matched nothing and the ellipsis drives
1341
+ * (`project.move-heading-to-project`, `project.dissolve-heading`) died at their
1342
+ * own frame resolution before any click — measured on Things 3.23 against a
1343
+ * heading whose button the raw Accessibility API resolves at the same instant.
1344
+ * The row/cell indices are never guessed: every row is walked and the match is
1345
+ * exact, so a heading whose title changed under us fails closed by name.
1346
+ */
1347
+ export function axRowCellFrameScript(tablePath, description) {
1348
+ const d = escapeAppleScript(description);
1349
+ return `${SE}
1350
+ set t to (${tablePath})
1351
+ repeat with r in rows of t
1352
+ repeat with c in UI elements of r
1353
+ repeat with e in UI elements of c
1354
+ try
1355
+ if ((description of e) as text) is "${d}" then
1356
+ set _p to position of e
1357
+ set _s to size of e
1358
+ 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)
1359
+ end if
1360
+ end try
1361
+ end repeat
1362
+ end repeat
1363
+ end repeat
1364
+ error "no row of this project's list exposes \\"${d}\\" — the heading may have been renamed, moved or deleted since it was read"
1365
+ end tell`;
1366
+ }
1367
+ /**
1368
+ * resolve-frame for the Move… picker ROW carrying an exact project title — the
1369
+ * step that replaced the recipe's blind Return (HXPC1,
1370
+ * docs/lab/hxpc1-picker-assert.md §B).
1371
+ *
1372
+ * The picker exposes no `AXSelected` / `AXFocused` / `AXHighlighted` on any row
1373
+ * (measured — only its filter field is focused), so there is nothing to read
1374
+ * back from a keyboard commit and no way to assert what Return would take. What
1375
+ * it does expose is one `AXUnknown` per row whose `AXDescription` IS the project
1376
+ * title, and — whenever the filter holds text — a trailing
1377
+ * `New Project "<typed text>"` row that CREATES a project when committed. That
1378
+ * row is what the blind Return took whenever the destination was missing from
1379
+ * the picker, which an ordinary database-resolved destination reaches: a
1380
+ * COMPLETED or CANCELED project appears nowhere in the picker, so the drive
1381
+ * minted a second project of the same title and moved the heading into it
1382
+ * (measured 3.23: projects 14 → 15, heading re-parented to the new row).
1383
+ *
1384
+ * So the commit is addressed instead of guessed. The script requires:
1385
+ * - the picker to be the window it claims (its `AXIdentifier` begins
1386
+ * `MovePopUpDialog-`) — a positive identity check, so a different detached
1387
+ * window can never be clicked into;
1388
+ * - EXACTLY ONE row whose description equals the destination title (the
1389
+ * New-Project row's description is the quoted form, so an exact match cannot
1390
+ * hit it);
1391
+ * - that row's centre to lie inside the picker's own scroll area — the CNCAC1
1392
+ * off-screen hazard, where a row scrolled past the fold still resolves a
1393
+ * frame and a click at it lands on the desktop.
1394
+ * Any miss FAILS CLOSED naming the destination and listing every row the picker
1395
+ * actually offered, so the caller learns what the app was willing to move to.
1396
+ */
1397
+ export function axPickerRowFrameScript(pickerPath, title) {
1398
+ const t = escapeAppleScript(title);
1399
+ return `${SE}
1400
+ set w to (${pickerPath})
1401
+ set pickerId to ""
1402
+ try
1403
+ set pickerId to (value of attribute "AXIdentifier" of w) as text
1404
+ end try
1405
+ if pickerId does not start with "MovePopUpDialog-" then
1406
+ error "the front dialog is not the Move… project picker (window id \\"" & pickerId & "\\") — nothing was committed"
1407
+ end if
1408
+ -- positional-ok: the picker window holds exactly one scroll area (MEASURED,
1409
+ -- HXPC1 §B2: "direct text fields=0 scroll areas=1"), and the window's own
1410
+ -- AXIdentifier was checked above, so this is a container handle inside an
1411
+ -- already-identified window — the ROW is addressed by exact title below.
1412
+ set sa to scroll area 1 of w
1413
+ set saPos to position of sa
1414
+ set saSize to size of sa
1415
+ set saTop to item 2 of saPos
1416
+ set saBottom to saTop + (item 2 of saSize)
1417
+ set hits to {}
1418
+ set offered to ""
1419
+ repeat with i from 1 to (count of UI elements of sa)
1420
+ set e to UI element i of sa
1421
+ set d to ""
1422
+ try
1423
+ set d to (description of e) as text
1424
+ end try
1425
+ if d is not "" and (role of e) is "AXUnknown" then
1426
+ set offered to offered & " [" & d & "]"
1427
+ if d is "${t}" then set end of hits to e
1428
+ end if
1429
+ end repeat
1430
+ if (count of hits) is 0 then
1431
+ 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."
1432
+ end if
1433
+ if (count of hits) > 1 then
1434
+ error "the Move… picker offers " & (count of hits) & " rows named \\"${t}\\" — it offered:" & offered
1435
+ end if
1436
+ set row1 to item 1 of hits
1437
+ set rp to position of row1
1438
+ set rs to size of row1
1439
+ set cy to (item 2 of rp) + ((item 2 of rs) / 2)
1440
+ if cy < saTop or cy > saBottom then
1441
+ 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"
1442
+ end if
1443
+ 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)
1444
+ end tell`;
1445
+ }
1446
+ /**
1447
+ * The abort keystroke, sent ONLY from the audited cleanup ladder (issue #620)
1448
+ * and only once that ladder has proven Things owns the screen. It is scoped to
1449
+ * the Things process for readability, but scoping is not what makes it safe —
1450
+ * a synthetic key goes to whatever is frontmost, which is why the script
1451
+ * carries the same in-script frontmost assertion every other keystroke does.
1452
+ */
603
1453
  export function axAbortScript() {
604
- return `tell application "System Events" to key code 53`; // Escape
1454
+ return `${AX_FOCUS_GUARD_HANDLERS}
1455
+
1456
+ my fgAssertFront("dismiss the open dialog with Escape")
1457
+ ${SE}
1458
+ key code 53
1459
+ end tell`;
1460
+ }
1461
+ /**
1462
+ * Dismiss the open dialog by PRESSING ITS OWN CANCEL BUTTON (issue #620).
1463
+ *
1464
+ * Preferred over Escape wherever it works, for two independent reasons: an
1465
+ * AXPress is addressed at an ELEMENT, so it cannot leak into another
1466
+ * application the way a keystroke can, and it works while Things is in the
1467
+ * BACKGROUND — the cleanup never has to steal the user's focus to undo its own
1468
+ * half-finished dialog. The button is addressed by its pinned English title,
1469
+ * exactly like every other selector in this vector, and the dialog shell is
1470
+ * resolved the same two ways the census resolves it (attached sheet, or the
1471
+ * detached editor window Things presents when it is not frontmost).
1472
+ *
1473
+ * Returns "OK" after pressing, or a diagnostic ("NO-DIALOG" / "NO-CANCEL") the
1474
+ * ladder falls through on — it never claims a dismissal; the caller re-reads
1475
+ * the census to decide that.
1476
+ */
1477
+ export function axCancelDialogScript() {
1478
+ return `${SE}
1479
+ ${AX_DIALOG_SHELL_SNIPPET}
1480
+ if shellRef is missing value then return "NO-DIALOG"
1481
+ if not (exists button "Cancel" of shellRef) then return "NO-CANCEL"
1482
+ click button "Cancel" of shellRef
1483
+ return "OK"
1484
+ end tell`;
605
1485
  }
606
1486
  /**
607
1487
  * The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
@@ -619,6 +1499,9 @@ export function axAbortScript() {
619
1499
  export function axCloseReopenActivateScript() {
620
1500
  return `tell application "Things3"
621
1501
  try
1502
+ -- positional-ok: an APP-LEVEL command to the Things scripting dictionary, not
1503
+ -- an Accessibility element path — "close the front window", whichever it is,
1504
+ -- which is the whole intent of the maneuver (a stuck sheet goes with it).
622
1505
  close window 1
623
1506
  end try
624
1507
  reopen
@@ -640,6 +1523,8 @@ export function axSheetOpenScript() {
640
1523
  return `${SE}
641
1524
  set sheetOpen to false
642
1525
  try
1526
+ -- positional-ok: an EXISTENCE probe over the one attached sheet a window can
1527
+ -- present; no element is read or written through this path.
643
1528
  if (exists sheet 1 of (first window whose subrole is "AXStandardWindow")) then set sheetOpen to true
644
1529
  end try
645
1530
  try
@@ -656,36 +1541,152 @@ async function sheetStillOpen(run) {
656
1541
  return !(res.ok && res.stdout.trim() === "false");
657
1542
  }
658
1543
  /**
659
- * Clear a half-open sheet/popover a failed drive left behind HONESTLY (SESSGATE
660
- * #480 fix; supersedes the old verifiedAbort, whose AX-blind sheet probe returned
661
- * "gone" it could not actually see, letting the still-open modal freeze the
662
- * app-wide AppleScript mutations the caller then attempted the auto-trash
663
- * silent-noop). Escape first; then:
664
- * - AX-BLIND (a not-reachable probe): Escape may never have reached the sheet
665
- * and the sheet probe cannot see it, so run the app-level close+reopen that
666
- * works blind (it takes the stuck sheet with the window). Reported as
667
- * "cleared-blind"never falsely "confirmed gone".
668
- * - REACHABLE: the sheet probe is trustworthy — confirm the dismissal (retry
669
- * Escape once), and if it will not go, warn "may remain".
1544
+ * Is the dialog the census found OURS to dismiss? `expected` is the kind this
1545
+ * drive was observed driving (latched from the census the drive itself ran). A
1546
+ * kind that does not match is left strictly alone: between our failure and this
1547
+ * cleanup, the person at the keyboard may have opened something of their own,
1548
+ * and dismissing it would be a mutation nobody asked for.
1549
+ *
1550
+ * With nothing latched (a drive that failed before any dialog was observed) the
1551
+ * two kinds this vector's recipes actually open are still treated as ours —
1552
+ * they are the dialogs our own steps would have opened while an unrecognized
1553
+ * modal never is.
1554
+ */
1555
+ function oursToDismiss(kind, expected) {
1556
+ if (kind === "none")
1557
+ return false;
1558
+ if (expected !== null)
1559
+ return kind === expected;
1560
+ return kind === "repeat" || kind === "move-picker";
1561
+ }
1562
+ /** How many stacked dialogs the cleanup will unwind before falling to the next rung. */
1563
+ const MAX_DISMISS_ROUNDS = 4;
1564
+ /** Press the dialog's own Cancel button (element-addressed, background-safe). */
1565
+ async function pressCancel(run) {
1566
+ const res = await run({
1567
+ primitive: "dismiss-dialog",
1568
+ label: "dismiss the open dialog (its Cancel button)",
1569
+ script: axCancelDialogScript(),
1570
+ }, STEP_TIMEOUT_MS);
1571
+ return res.ok && res.stdout.trim() === "OK";
1572
+ }
1573
+ /**
1574
+ * Clear a half-open dialog a failed drive left behind — AUDITED at every rung
1575
+ * (issue #620; supersedes the unconditional Escape, which was measured firing
1576
+ * into a foreign application's modal while the Things sheet it was meant for
1577
+ * stayed open all night).
1578
+ *
1579
+ * The ladder, cheapest and least disruptive first, re-reading the census after
1580
+ * every rung so nothing is ever CLAIMED to be dismissed:
1581
+ *
1582
+ * 0. census. No dialog + a reachable session → nothing to do. A dialog that
1583
+ * is not ours → left alone, reported.
1584
+ * 1. press the dialog's own CANCEL button — element-addressed, so it needs
1585
+ * neither focus nor the frontmost slot, and it cannot leak into another
1586
+ * app. This is the rung that clears the ordinary case.
1587
+ * 2. Escape, but only from a state where Things demonstrably owns the screen:
1588
+ * if it does not, RE-ACTIVATE Things, RE-AUDIT, and only then send it.
1589
+ * 3. the app-level close+reopen (SESSGATE) — the maneuver that works with no
1590
+ * Accessibility tree at all, and the documented recovery for the app-wide
1591
+ * AppleScript freeze a stuck sheet imposes (oddities §9cc), which is what
1592
+ * makes a caller's follow-up cleanup mutations land again.
1593
+ *
1594
+ * `expected` is the dialog kind this drive was observed driving; see
1595
+ * {@link oursToDismiss}.
670
1596
  */
671
- async function clearDialog(run) {
672
- const escape = () => run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
673
- await escape();
674
- const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
675
- if (!reach.reachable) {
676
- // Cannot trust Escape or the sheet probe while AX-blind — use the proven
677
- // app-level maneuver, which clears a stuck sheet without the Accessibility tree.
1597
+ async function clearDialog(run, expected = null) {
1598
+ const census = await readUiState(run, STEP_TIMEOUT_MS);
1599
+ const owner = census !== null && !census.thingsFrontmost ? { focusOwner: describeFocusOwner(census) } : {};
1600
+ const readable = census !== null && census.inspectable;
1601
+ // 0. A clean, readable "no dialog" — but only trustworthy on a session whose
1602
+ // windows are AX-visible at all: a locked screen / full-screen Space
1603
+ // enumerates ZERO windows, so the census would report "no dialog" for a
1604
+ // sheet that is very much open (SESSGATE). Confirm before believing it.
1605
+ if (readable && census.sheetOpen === false) {
1606
+ const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
1607
+ if (reach.reachable)
1608
+ return { state: "none" };
1609
+ // AX-blind: System Events enumerates zero windows for EVERY app, so the
1610
+ // census cannot see a sheet that is open — and equally cannot confirm one
1611
+ // is gone. Run the blind-proof maneuver and report it as unconfirmed.
1612
+ return closeReopenRung(run, expected, owner, true);
1613
+ }
1614
+ // 0b. A dialog someone else opened — never touched.
1615
+ if (readable && !oursToDismiss(census.sheetKind, expected)) {
1616
+ return { state: "foreign", sheetKind: census.sheetKind, ...owner };
1617
+ }
1618
+ const kind = readable ? { sheetKind: census.sheetKind } : {};
1619
+ // 1. Its own Cancel button — repeated while a STACK unwinds, because dialogs
1620
+ // nest and dismiss strictly LIFO (MODALX1 §6), re-reading between presses
1621
+ // so a dialog that is not ours stops the loop rather than being clicked.
1622
+ if (readable) {
1623
+ for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
1624
+ if (!(await pressCancel(run)))
1625
+ break;
1626
+ const after = await readUiState(run, STEP_TIMEOUT_MS);
1627
+ if (after === null || !after.inspectable)
1628
+ break;
1629
+ if (!after.sheetOpen)
1630
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
1631
+ if (!oursToDismiss(after.sheetKind, expected)) {
1632
+ return { state: "foreign", sheetKind: after.sheetKind, ...owner };
1633
+ }
1634
+ }
1635
+ }
1636
+ // 2. Escape — from a state where Things owns the screen, re-activating and
1637
+ // RE-AUDITING first when it does not (never a blind key into the unknown).
1638
+ let front = readable && census.thingsFrontmost;
1639
+ if (readable && !front) {
678
1640
  await run({
679
- primitive: "resolve",
680
- label: "clear a stuck dialog (close the Things window and reopen it)",
681
- script: axCloseReopenActivateScript(),
1641
+ primitive: "activate",
1642
+ label: "bring Things forward to dismiss its dialog",
1643
+ script: axActivateScript(),
682
1644
  }, STEP_TIMEOUT_MS);
683
- return { state: "cleared-blind" };
1645
+ const reaudit = await readUiState(run, STEP_TIMEOUT_MS);
1646
+ if (reaudit !== null && reaudit.inspectable) {
1647
+ if (!reaudit.sheetOpen)
1648
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
1649
+ if (!oursToDismiss(reaudit.sheetKind, expected)) {
1650
+ return { state: "foreign", sheetKind: reaudit.sheetKind, ...owner };
1651
+ }
1652
+ front = reaudit.thingsFrontmost;
1653
+ }
684
1654
  }
685
- if (!(await sheetStillOpen(run)))
686
- return { state: "dismissed" };
687
- await escape(); // one retry
688
- return { state: (await sheetStillOpen(run)) ? "may-remain" : "dismissed" };
1655
+ if (front) {
1656
+ await run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
1657
+ const after = await readUiState(run, STEP_TIMEOUT_MS);
1658
+ if (after !== null && after.inspectable && !after.sheetOpen) {
1659
+ return { state: "dismissed", how: "escape", ...kind, ...owner };
1660
+ }
1661
+ }
1662
+ // 3. The blind-proof maneuver, last: it discards the half-entered dialog with
1663
+ // the window, and unwedges the app-wide AppleScript freeze with it.
1664
+ return closeReopenRung(run, expected, owner);
1665
+ }
1666
+ /**
1667
+ * The final rung: close+reopen the Things window, then re-audit if we can.
1668
+ * `blind` says the session itself is AX-blind, in which case NOTHING the census
1669
+ * reports afterwards is evidence — the outcome is honestly unconfirmed.
1670
+ */
1671
+ async function closeReopenRung(run, expected, owner, blind = false) {
1672
+ await run({
1673
+ primitive: "resolve",
1674
+ label: "clear a stuck dialog (close the Things window and reopen it)",
1675
+ script: axCloseReopenActivateScript(),
1676
+ }, STEP_TIMEOUT_MS);
1677
+ if (blind)
1678
+ return { state: "cleared-blind", how: "window-close", ...owner };
1679
+ const after = await readUiState(run, STEP_TIMEOUT_MS);
1680
+ if (after === null || !after.inspectable) {
1681
+ return { state: "cleared-blind", how: "window-close", ...owner };
1682
+ }
1683
+ if (!after.sheetOpen)
1684
+ return { state: "dismissed", how: "window-close", ...owner };
1685
+ return {
1686
+ state: oursToDismiss(after.sheetKind, expected) ? "may-remain" : "foreign",
1687
+ sheetKind: after.sheetKind,
1688
+ ...owner,
1689
+ };
689
1690
  }
690
1691
  /**
691
1692
  * The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
@@ -807,45 +1808,7 @@ function clickPointCommand(x, y, label) {
807
1808
  * calendar date at midnight). One stable JXA shape.
808
1809
  */
809
1810
  export function axSetDateTimeScript(spec, target) {
810
- return `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
811
- function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
812
- function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
813
- 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; }
814
- 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); }
815
- function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
816
- 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; }
817
- 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; }
818
- // Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
819
- // small subtree — never the app-wide tree, whose main-window list content is the
820
- // 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
821
- // presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
822
- // priority order the System-Events pathCandidates use: an attached AXSheet on the
823
- // standard window (Things frontmost), then a detached top-level AXUnknown window
824
- // that is not the 40x40 utility window (Things backgrounded). null when neither is
825
- // present — the caller then falls through to the same named "presents 0 date
826
- // area(s)" error the app-root walk threw when the dialog was absent.
827
- function findShell(app){
828
- var wins=windowsOf(app);
829
- 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]; } }
830
- 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]; } }
831
- return null;
832
- }
833
- 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; }
834
- 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); }
835
- function pick(areas,target){
836
- if(areas.length===0) return null;
837
- var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
838
- if(target==='reminder'){
839
- var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
840
- return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
841
- }
842
- var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
843
- if(midnight.length===0) midnight=sorted;
844
- return target==='ends' ? midnight[midnight.length-1] : midnight[0];
845
- }
846
- 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)'; }
847
- 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); }
848
- 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); }
1811
+ return `${AX_DATE_AREA_PRELUDE}
849
1812
  function run(){
850
1813
  var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
851
1814
  if(!apps || apps.count===0) throw new Error('Things not running');
@@ -883,7 +1846,9 @@ function run(){
883
1846
  $.NSThread.sleepForTimeInterval(0.2);
884
1847
  // READ-BACK: a control can accept the AX write (err 0) yet reject the value —
885
1848
  // the macOS error beep — leaving its prior/default value. Fail the step loudly
886
- // rather than let a garbled commit verify as ok (YANCH1 #493).
1849
+ // rather than let a garbled commit verify as ok (YANCH1 #493). Like every
1850
+ // per-step read-back here it is SELF-REFERENTIAL (it re-reads the area it just
1851
+ // picked), so the pre-commit audit re-checks it from the outside.
887
1852
  if(spec.indexOf('date:')===0){
888
1853
  var got=ymdStr(dt,cal); var want=spec.slice(5);
889
1854
  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');
@@ -894,6 +1859,55 @@ function run(){
894
1859
  return 'OK';
895
1860
  }`;
896
1861
  }
1862
+ /**
1863
+ * The shared ObjC-bridge prelude for every `AXDateTimeArea` read or write: the
1864
+ * attribute helpers, the DIALOG-SHELL resolver (so the walk stays inside the
1865
+ * dialog's small subtree — the app-root descent PERF2 removed cost ~4.4s on a busy
1866
+ * host), and the deterministic {@link pick} target discriminator. The write
1867
+ * ({@link axSetDateTimeScript}) and the pre-commit read
1868
+ * ({@link axAuditDateAreasScript}) MUST agree on which area is which, so they
1869
+ * share one definition rather than two that can drift apart.
1870
+ */
1871
+ const AX_DATE_AREA_PRELUDE = `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
1872
+ function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
1873
+ function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
1874
+ 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; }
1875
+ 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); }
1876
+ function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
1877
+ 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; }
1878
+ 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; }
1879
+ // Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
1880
+ // small subtree — never the app-wide tree, whose main-window list content is the
1881
+ // 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
1882
+ // presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
1883
+ // priority order the System-Events pathCandidates use: an attached AXSheet on the
1884
+ // standard window (Things frontmost), then a detached top-level AXUnknown window
1885
+ // that is not the 40x40 utility window (Things backgrounded). null when neither is
1886
+ // present — the caller then falls through to the same named "presents 0 date
1887
+ // area(s)" error the app-root walk threw when the dialog was absent.
1888
+ function findShell(app){
1889
+ var wins=windowsOf(app);
1890
+ 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]; } }
1891
+ 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]; } }
1892
+ return null;
1893
+ }
1894
+ 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; }
1895
+ 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); }
1896
+ function pick(areas,target){
1897
+ if(areas.length===0) return null;
1898
+ var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
1899
+ if(target==='reminder'){
1900
+ var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
1901
+ return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
1902
+ }
1903
+ var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
1904
+ if(midnight.length===0) midnight=sorted;
1905
+ return target==='ends' ? midnight[midnight.length-1] : midnight[0];
1906
+ }
1907
+ 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)'; }
1908
+ 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); }
1909
+ 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); }
1910
+ `;
897
1911
  /**
898
1912
  * The converge-weekdays step encodes both of its inputs in `value` as
899
1913
  * `"<base>|<Weekday>,<Weekday>…"`: the base is the group pop-up index of the
@@ -944,6 +1958,15 @@ async function defaultRun(command, timeoutMs) {
944
1958
  ...(res.timedOut === true && { timedOut: true }),
945
1959
  };
946
1960
  }
1961
+ /**
1962
+ * Read the live window/focus census through the shipped dispatch seam — the
1963
+ * transport behind the `ui-state` diagnostic (src/ui-state.ts) and, in tests,
1964
+ * behind any injected runner. READ-ONLY: the census clicks nothing, types
1965
+ * nothing, and changes no state; see src/write/vectors/ui-state.ts.
1966
+ */
1967
+ export function readLiveUiState(run = defaultRun) {
1968
+ return readUiState(run, STEP_TIMEOUT_MS);
1969
+ }
947
1970
  /**
948
1971
  * Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
949
1972
  * step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
@@ -978,6 +2001,159 @@ function tracingRun(inner) {
978
2001
  return res;
979
2002
  };
980
2003
  }
2004
+ /**
2005
+ * PRIMITIVE CLASSIFICATION for the per-step guard (issue #620). What decides a
2006
+ * primitive's class is HOW macOS routes its effect, not what it looks like:
2007
+ *
2008
+ * - KEYSTROKE-CLASS — System Events `keystroke` / `key code`. The event is
2009
+ * handed to whatever application owns the screen, so these need Things
2010
+ * frontmost AND the dialog we opened still in front. The element half of
2011
+ * the guard (did the field actually take focus?) is asserted in-script, in
2012
+ * the same hop as the typing.
2013
+ * - POINTER-CLASS — mouse synthesis through the global HID event tap
2014
+ * (`CGEventPost(kCGHIDEventTap)`). It posts at the FOREGROUND surface
2015
+ * (NATIVE1-e: `CGEventPostToPid` is inert for Things' hit-testing), so a
2016
+ * click while another app is frontmost lands in that app's window. Frontmost
2017
+ * is required; focus is not (a click sets its own).
2018
+ * - Everything else is ELEMENT-ADDRESSED — `click <element>`, `set value`,
2019
+ * `set focused`, the ObjC `AXUIElementSetAttributeValue` date writes, and
2020
+ * every read. System Events delivers those to the element named, whether or
2021
+ * not the app is frontmost, so guarding them would only add a hop and
2022
+ * forbid perfectly good background work.
2023
+ * - `chord-post` is deliberately NOT guarded: it posts its key event with
2024
+ * `CGEventPostToPid`, which addresses the PROCESS rather than the focused
2025
+ * surface — the whole point of the heading-reorder gesture is that it runs
2026
+ * with Things in the background and the user's focus untouched (HEADORD1
2027
+ * 1h2a, CHORDMH1). A frontmost guard there would break a certified op.
2028
+ */
2029
+ const KEYSTROKE_CLASS = new Set([
2030
+ "key",
2031
+ "type-text",
2032
+ "set-value",
2033
+ "set-group-number",
2034
+ "set-row-field",
2035
+ ]);
2036
+ const POINTER_CLASS = new Set([
2037
+ "click-point",
2038
+ "sidebar-drag",
2039
+ "sidebar-held-drag",
2040
+ "sidebar-scroll",
2041
+ ]);
2042
+ /**
2043
+ * Judge one guard reading. Returns null to proceed, or the refusal sentence —
2044
+ * which always names who owns the screen, because that is the one fact the
2045
+ * person reading it cannot recover after the fact.
2046
+ *
2047
+ * Exported for the unit matrix: every branch here is a fail-closed decision
2048
+ * about synthetic input, and each one is worth a test.
2049
+ */
2050
+ export function judgeFocusGuard(state, expectedSheet, label) {
2051
+ const refuse = (why) => `refused to run "${label}": ${why}`;
2052
+ if (state === null) {
2053
+ return refuse("the window and focus state could not be read, so there is no proof the input would reach " +
2054
+ "Things — nothing was sent");
2055
+ }
2056
+ if (!state.inspectable) {
2057
+ return refuse(`${describeFocusOwner(state)}. Input sent now would go to it, not to Things — nothing was ` +
2058
+ "sent. Answer or dismiss the system dialog, then run the same command again");
2059
+ }
2060
+ if (!state.thingsFrontmost) {
2061
+ return refuse(`${describeFocusOwner(state)}, so the input would go there instead of to Things — nothing ` +
2062
+ "was sent. Leave Things in front while it is being driven, then run the same command again");
2063
+ }
2064
+ if (expectedSheet !== null && state.sheetKind !== expectedSheet) {
2065
+ return refuse(`the dialog this command opened is no longer the one in front (expected ${expectedSheet}, ` +
2066
+ `found ${state.sheetKind}) — it was closed or replaced while the command was running, so ` +
2067
+ "nothing was sent");
2068
+ }
2069
+ return null;
2070
+ }
2071
+ /**
2072
+ * Wrap the dispatch seam with the PER-STEP FOCUS GUARD (issue #620): before
2073
+ * every focus-routed hop, one cheap read-only census decides whether the input
2074
+ * can legitimately be delivered, and a violation ABORTS THE STEP rather than
2075
+ * typing into the void. Element-addressed hops pass straight through, so the
2076
+ * cost is paid only where it buys something.
2077
+ *
2078
+ * A closed loop, not a sleep: the census is a deterministic read of the live
2079
+ * state, taken immediately before the hop, and the in-script assertions close
2080
+ * the remaining milliseconds (UI-automation determinism doctrine; the #595
2081
+ * pre-commit audit and BEEP1 shape-settle are the same pattern).
2082
+ */
2083
+ function guardedRun(inner, latch) {
2084
+ return async (command, timeoutMs) => {
2085
+ if (!KEYSTROKE_CLASS.has(command.primitive) && !POINTER_CLASS.has(command.primitive)) {
2086
+ return inner(command, timeoutMs);
2087
+ }
2088
+ const state = await readUiState(inner, STEP_TIMEOUT_MS);
2089
+ // The dialog invariant applies to keystroke-class hops only: a pointer hop
2090
+ // is aimed at a frame it resolved a moment ago and fails closed on its own
2091
+ // if that frame moved.
2092
+ const expected = KEYSTROKE_CLASS.has(command.primitive) ? latch.sheet : null;
2093
+ const guardRefusal = judgeFocusGuard(state, expected, command.label);
2094
+ if (guardRefusal !== null) {
2095
+ trace(() => ({
2096
+ phase: "focus-guard",
2097
+ event: "refused",
2098
+ primitive: command.primitive,
2099
+ label: command.label,
2100
+ frontmost: state?.frontmostApp ?? null,
2101
+ sheetKind: state?.sheetKind ?? null,
2102
+ inspectable: state?.inspectable ?? false,
2103
+ }));
2104
+ return { ok: false, stdout: "", stderr: guardRefusal };
2105
+ }
2106
+ if (state !== null && state.sheetOpen && latch.sheet === null)
2107
+ latch.sheet = state.sheetKind;
2108
+ return inner(command, timeoutMs);
2109
+ };
2110
+ }
2111
+ /** How a dialog is named in a disclosure — behavior, not chrome. */
2112
+ function dialogNoun(kind) {
2113
+ switch (kind) {
2114
+ case "repeat":
2115
+ return "the repeat dialog";
2116
+ case "move-picker":
2117
+ return "the move-to-project chooser";
2118
+ default:
2119
+ return "a dialog";
2120
+ }
2121
+ }
2122
+ /**
2123
+ * The cleanup disclosure (issue #620). Every branch states what is TRUE of the
2124
+ * app right now, and — whenever a dialog may still be open — that Things Cloud
2125
+ * sync is held until someone dismisses it, which is the consequence a caller
2126
+ * cannot see and would otherwise discover hours later on another device.
2127
+ */
2128
+ export function describeCleanup(clear) {
2129
+ const owner = clear.focusOwner === undefined ? "" : ` (${clear.focusOwner} when cleanup started)`;
2130
+ switch (clear.state) {
2131
+ case "none":
2132
+ return "No dialog was left open in Things.";
2133
+ case "dismissed":
2134
+ return `${clear.how === "cancel-button"
2135
+ ? `${dialogNoun(clear.sheetKind)} was closed with its own Cancel button`
2136
+ : clear.how === "escape"
2137
+ ? `${dialogNoun(clear.sheetKind)} was dismissed with Escape`
2138
+ : `${dialogNoun(clear.sheetKind)} was cleared by closing and reopening the Things window`}, confirmed closed${owner}.`;
2139
+ case "cleared-blind":
2140
+ return ("Things had no window reachable on the current screen (the Mac may be locked, or a" +
2141
+ " full-screen app is covering the desktop), so the open dialog could not be confirmed" +
2142
+ " through the on-screen layer — the Things window was closed and reopened to clear it," +
2143
+ " discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
2144
+ " before retrying.");
2145
+ case "foreign":
2146
+ return (`A dialog is open in Things that this command did not open (${dialogNoun(clear.sheetKind)}),` +
2147
+ ` so it was left exactly as it is${owner}. Dismiss it yourself when you are ready — and note` +
2148
+ ` that ${SYNC_GATE_WARNING}.`);
2149
+ case "may-remain":
2150
+ return (`WARNING: ${dialogNoun(clear.sheetKind)} may still be open in Things${owner} — neither its` +
2151
+ " Cancel button, nor Escape, nor closing and reopening the window would clear it. Dismiss" +
2152
+ " it in Things (click Cancel, or press Escape with Things in front) before retrying: a" +
2153
+ ` leftover dialog also disables the menu bar, so the next attempt would refuse. Also note` +
2154
+ ` that ${SYNC_GATE_WARNING}.`);
2155
+ }
2156
+ }
981
2157
  /** The element paths the preflight canary resolves (static steps only). */
982
2158
  function canaryPaths(recipe) {
983
2159
  const out = [];
@@ -1051,6 +2227,33 @@ export function commandForStep(step, targetUuid) {
1051
2227
  label: step.label,
1052
2228
  script: axSetValueScript(step.path ?? "", step.value ?? ""),
1053
2229
  };
2230
+ case "set-group-number":
2231
+ return {
2232
+ primitive: "set-group-number",
2233
+ label: step.label,
2234
+ script: axSetGroupNumberScript(step.path ?? "", step.numberTarget ?? "interval", step.value ?? ""),
2235
+ };
2236
+ case "set-row-field":
2237
+ return {
2238
+ primitive: "set-row-field",
2239
+ label: step.label,
2240
+ script: axSetRowFieldScript(step.path ?? "", step.rowLabel ?? "", step.value ?? ""),
2241
+ };
2242
+ case "audit-dialog":
2243
+ // Compiled by driveDialogAudit, which resolves the live dialog shell and the
2244
+ // measured shape first (a control's path and its weekday base both depend on
2245
+ // them). This shape exists only so the step renders/compiles uniformly.
2246
+ return {
2247
+ primitive: "audit-dialog",
2248
+ label: step.label,
2249
+ script: "",
2250
+ };
2251
+ case "type-text":
2252
+ return {
2253
+ primitive: "type-text",
2254
+ label: step.label,
2255
+ script: axTypeTextScript(step.value ?? ""),
2256
+ };
1054
2257
  case "select-popup":
1055
2258
  return {
1056
2259
  primitive: "select-popup",
@@ -1084,6 +2287,12 @@ export function commandForStep(step, targetUuid) {
1084
2287
  label: step.label,
1085
2288
  script: axSelectNextOccurrenceScript(step.path ?? "", step.value ?? ""),
1086
2289
  };
2290
+ case "settle-occurrences":
2291
+ return {
2292
+ primitive: "settle-occurrences",
2293
+ label: step.label,
2294
+ script: axSettleOccurrencesScript(step.path ?? ""),
2295
+ };
1087
2296
  case "converge-weekdays":
1088
2297
  return {
1089
2298
  primitive: "converge-weekdays",
@@ -1117,11 +2326,26 @@ export function commandForStep(step, targetUuid) {
1117
2326
  case "click-element":
1118
2327
  // Phase 1 of the click: read the target's frame. driveClickElement runs
1119
2328
  // this, then posts the click at the resolved center and asserts the outcome.
2329
+ // A `rowCellDescription` step resolves its target by walking the addressed
2330
+ // content table's rows/cells instead (the heading `…` button, which sits
2331
+ // three levels below the table a `whose` clause can reach).
1120
2332
  return {
1121
2333
  primitive: "resolve-frame",
1122
2334
  label: step.label,
1123
2335
  lang: "applescript",
1124
- script: axFrameScript(step.path ?? ""),
2336
+ script: step.rowCellDescription !== undefined
2337
+ ? axRowCellFrameScript(step.path ?? "", step.rowCellDescription)
2338
+ : axFrameScript(step.path ?? ""),
2339
+ };
2340
+ case "click-picker-row":
2341
+ // Phase 1 of the picker commit: resolve the row carrying the destination's
2342
+ // exact title (identity-checked, uniqueness-checked, on-screen-checked).
2343
+ // driveClickElement then clicks it — the recipe never presses Return.
2344
+ return {
2345
+ primitive: "resolve-frame",
2346
+ label: step.label,
2347
+ lang: "applescript",
2348
+ script: axPickerRowFrameScript(step.path ?? "", step.value ?? ""),
1125
2349
  };
1126
2350
  case "drag-reorder":
1127
2351
  // Composite step: drive() hands it to the sidebar drag driver, which
@@ -1133,8 +2357,32 @@ export function commandForStep(step, targetUuid) {
1133
2357
  lang: "javascript",
1134
2358
  script: jxaSidebarSnapshotScript(),
1135
2359
  };
2360
+ case "chord-reorder":
2361
+ // Composite step: drive() hands it to the heading-chord driver, which
2362
+ // dispatches its own select/chord commands through `run` and asserts the
2363
+ // database between them. This shape only exists so the step renders and
2364
+ // compiles uniformly; the chord it names is the FIRST hop's, and the
2365
+ // driver recomputes every subsequent one from the live order.
2366
+ return chordCommand("up-one");
1136
2367
  }
1137
2368
  }
2369
+ /**
2370
+ * Recover the message an AppleScript `error "…"` raised from osascript's stderr,
2371
+ * dropping the wrapper osascript adds around it (`<line>:<col>: execution error:
2372
+ * <message> (-1728)`). Returns null when stderr carries no such message, so a
2373
+ * caller can fall back to its own wording. This is what lets a resolver script
2374
+ * REFUSE with a sentence the operator can act on — "the Move… picker offers no
2375
+ * project named X — it offered […]" — instead of the driver's generic guess.
2376
+ */
2377
+ function scriptErrorText(stderr) {
2378
+ const raw = stderr.trim();
2379
+ if (raw === "")
2380
+ return null;
2381
+ const marker = raw.lastIndexOf("execution error:");
2382
+ const body = marker >= 0 ? raw.slice(marker + "execution error:".length) : raw;
2383
+ const trimmed = body.replace(/\s*\(-?\d+\)\s*$/, "").trim();
2384
+ return trimmed === "" ? null : trimmed;
2385
+ }
1138
2386
  /**
1139
2387
  * Execute a `click-element` step: resolve the target's AX frame, synthesize a
1140
2388
  * mouse click at its center, then verify the declared post-click outcome. Fails
@@ -1146,10 +2394,17 @@ async function driveClickElement(step, run) {
1146
2394
  const frameRes = await run(commandForStep(step, ""), STEP_TIMEOUT_MS);
1147
2395
  const center = frameRes.ok ? parseFrameCenter(frameRes.stdout) : null;
1148
2396
  if (center === null) {
2397
+ // A resolver that REFUSED (rather than merely failing to find an element)
2398
+ // carries the diagnosis — which row it wanted, and what the surface offered
2399
+ // instead. Prefer it over the generic guess: that text is the whole point of
2400
+ // the picker-row and heading-button resolvers (HXPC1).
2401
+ const named = scriptErrorText(frameRes.stderr);
1149
2402
  return {
1150
2403
  ok: false,
1151
- why: "its on-screen position did not resolve — a Things update may have moved the control, " +
1152
- "or the app is not in the expected state; no click was sent",
2404
+ why: named !== null
2405
+ ? `${named} no click was sent`
2406
+ : "its on-screen position did not resolve — a Things update may have moved the control, " +
2407
+ "or the app is not in the expected state; no click was sent",
1153
2408
  };
1154
2409
  }
1155
2410
  const clickRes = await run(clickPointCommand(center.x, center.y, step.label), STEP_TIMEOUT_MS);
@@ -1178,7 +2433,130 @@ async function driveClickElement(step, run) {
1178
2433
  }
1179
2434
  return { ok: true };
1180
2435
  }
1181
- async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
2436
+ /**
2437
+ * Execute the PRE-COMMIT FULL-DIALOG AUDIT step (CGRD1): re-read every control the
2438
+ * drive set — through each control's own discriminated address — and refuse the
2439
+ * commit if any of them does not hold the intended value.
2440
+ *
2441
+ * Three resolutions happen here rather than in the recipe, because only the driver
2442
+ * knows them: WHICH dialog shell is live (attached sheet vs detached editor), which
2443
+ * SHAPE the dialog measured (the +1 group-index fork, and which first-occurrence
2444
+ * control class exists), and therefore which candidate path each control has. A
2445
+ * shell that does not resolve, or a shape-dependent control with no shape probed,
2446
+ * fails closed exactly like every other address here — an unaudited commit is not
2447
+ * an option, because the audit is the only non-self-referential check the drive has.
2448
+ *
2449
+ * The System Events sweep and the ObjC date-area sweep are separate commands
2450
+ * because the dialog's date/time controls hold NSDates that System Events cannot
2451
+ * read; both must pass.
2452
+ */
2453
+ async function driveDialogAudit(step, run, dialogShape) {
2454
+ const plan = step.audit;
2455
+ if (plan === undefined)
2456
+ return { ok: false, why: "no audit plan compiled (recipe bug)" };
2457
+ // Which of the two dialog shells is live, in the SAME priority order the drive's
2458
+ // own candidate resolution used — so the audit reads the dialog the drive wrote.
2459
+ let shellIndex = -1;
2460
+ for (let i = 0; i < plan.shells.length; i += 1) {
2461
+ const res = await run({
2462
+ primitive: "resolve",
2463
+ label: step.label,
2464
+ script: axResolveScript(plan.shells[i]),
2465
+ }, STEP_TIMEOUT_MS);
2466
+ if (res.ok && res.stdout.trim() === "true") {
2467
+ shellIndex = i;
2468
+ break;
2469
+ }
2470
+ }
2471
+ if (shellIndex < 0) {
2472
+ return {
2473
+ ok: false,
2474
+ why: "the Repeat dialog could not be re-read before committing (neither the attached sheet " +
2475
+ "nor the detached repeat editor window resolved), so what it holds could not be checked",
2476
+ };
2477
+ }
2478
+ const shell = plan.shells[shellIndex];
2479
+ const group = (plan.groups[shellIndex] ?? plan.groups[0]);
2480
+ const scriptControls = [];
2481
+ const dateAreas = [];
2482
+ for (const raw of plan.controls) {
2483
+ if (raw.onlyShape !== undefined || raw.shaped !== undefined) {
2484
+ if (dialogShape === null)
2485
+ return { ok: false, why: SHAPE_UNPROBED };
2486
+ if (raw.onlyShape !== undefined && raw.onlyShape !== dialogShape)
2487
+ continue;
2488
+ }
2489
+ const override = raw.shaped === undefined ? undefined : raw.shaped[dialogShape ?? "next-popup"];
2490
+ if (raw.shaped !== undefined && override === undefined) {
2491
+ return {
2492
+ ok: false,
2493
+ why: `the audit has no check for "${raw.label}" under the "${dialogShape ?? "unmeasured"}" Repeat dialog (recipe bug)`,
2494
+ };
2495
+ }
2496
+ const control = { ...raw, ...override };
2497
+ if (control.kind === "date-area") {
2498
+ dateAreas.push({
2499
+ label: control.label,
2500
+ target: control.dtTarget ?? "next",
2501
+ spec: control.dtSpec ?? "",
2502
+ });
2503
+ continue;
2504
+ }
2505
+ const candidates = control.pathCandidates;
2506
+ scriptControls.push({
2507
+ label: control.label,
2508
+ kind: control.kind,
2509
+ ...(candidates !== undefined && {
2510
+ path: (candidates[shellIndex] ?? candidates[0]),
2511
+ }),
2512
+ ...(control.numberTarget !== undefined && { numberTarget: control.numberTarget }),
2513
+ ...(control.rowLabel !== undefined && { rowLabel: control.rowLabel }),
2514
+ ...(control.weekdayBase !== undefined && { weekdayBase: control.weekdayBase }),
2515
+ expected: control.expected ?? [],
2516
+ ...(control.expectedLabel !== undefined && { expectedLabel: control.expectedLabel }),
2517
+ });
2518
+ }
2519
+ if (scriptControls.length > 0) {
2520
+ const res = await run({
2521
+ primitive: "audit-dialog",
2522
+ label: step.label,
2523
+ script: axAuditDialogScript({ shell, group, controls: scriptControls }),
2524
+ }, STEP_TIMEOUT_MS);
2525
+ if (!res.ok || res.stdout.trim() !== "OK") {
2526
+ return { ok: false, why: auditFailureText(res) };
2527
+ }
2528
+ }
2529
+ if (dateAreas.length > 0) {
2530
+ const res = await run({
2531
+ primitive: "audit-dialog",
2532
+ label: step.label,
2533
+ lang: "javascript",
2534
+ script: axAuditDateAreasScript(dateAreas),
2535
+ }, STEP_TIMEOUT_MS);
2536
+ if (!res.ok || res.stdout.trim() !== "OK") {
2537
+ return { ok: false, why: auditFailureText(res) };
2538
+ }
2539
+ }
2540
+ return { ok: true };
2541
+ }
2542
+ /** The audit's own refusal text, preferred over the driver's generic guess. */
2543
+ function auditFailureText(res) {
2544
+ const named = scriptErrorText(res.stderr);
2545
+ if (named !== null)
2546
+ return `${named} — nothing was committed`;
2547
+ if (res.timedOut === true)
2548
+ return "the pre-commit dialog audit timed out; nothing was committed";
2549
+ return "the Repeat dialog could not be re-read before committing; nothing was committed";
2550
+ }
2551
+ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
2552
+ // Every step below dispatches through the PER-STEP FOCUS GUARD (issue #620);
2553
+ // the latch records the dialog this drive is observed driving, so the cleanup
2554
+ // ladder can tell our own half-open dialog from one the user opened after us.
2555
+ const latch = { sheet: null };
2556
+ const run = guardedRun(rawRun, latch);
2557
+ // The cleanup ladder audits for itself (it is what decides whether a keystroke
2558
+ // may be sent at all), so it runs OUTSIDE the guard.
2559
+ const clearNow = () => clearDialog(rawRun, latch.sheet);
1182
2560
  const done = [];
1183
2561
  // The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
1184
2562
  // own timeout on a slow production database (large + Things-Cloud syncing
@@ -1196,7 +2574,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1196
2574
  // modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
1197
2575
  // whose OK press was mid-commit could still land — the pipeline re-verifies
1198
2576
  // and shapes the final result accordingly.
1199
- const clear = await clearDialog(run);
2577
+ const clear = await clearNow();
1200
2578
  trace(() => ({
1201
2579
  phase: "watchdog",
1202
2580
  budgetMs,
@@ -1235,19 +2613,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1235
2613
  */
1236
2614
  stepTimedOut = false) => {
1237
2615
  const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
1238
- const cleanup = clear === undefined
1239
- ? ""
1240
- : clear.state === "dismissed"
1241
- ? " The open sheet/popover was dismissed (Escape, confirmed gone)."
1242
- : clear.state === "cleared-blind"
1243
- ? " Things had no window reachable on the current screen (the Mac may be locked, or a" +
1244
- " full-screen app is covering the desktop), so the open dialog could not be confirmed" +
1245
- " through the on-screen layer — the Things window was closed and reopened to clear it," +
1246
- " discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
1247
- " before retrying."
1248
- : " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
1249
- " Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
1250
- " make the next drive's preflight fail).";
2616
+ const cleanup = clear === undefined ? "" : ` ${describeCleanup(clear)}`;
1251
2617
  // #512: name an environment failure as one. A cleanup that had to run BLIND
1252
2618
  // is direct evidence the session went AX-blind mid-drive; a step killed by
1253
2619
  // its own deadline is the window not answering. Either way the app was not
@@ -1307,6 +2673,21 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1307
2673
  "to open the dialog. ";
1308
2674
  }
1309
2675
  }
2676
+ // 0¾. OPEN-DIALOG PRECONDITION (MODALX1 §3/§4, issue #620). A dialog already
2677
+ // standing when a drive starts is not ours and cannot be driven around:
2678
+ // it disables the menu bar (so a menu recipe's canary would miss and
2679
+ // guess at why), and it SWALLOWS the chord recipes' key events, which
2680
+ // pass their canary happily and then move nothing. The census says so
2681
+ // directly, for every recipe, before anything is pressed — and a dialog
2682
+ // standing here also means the app is ignoring scripted changes app-wide
2683
+ // and holding Things Cloud sync, which is the operator's real problem.
2684
+ const startState = await readUiState(rawRun, STEP_TIMEOUT_MS);
2685
+ if (startState !== null && startState.inspectable && startState.sheetOpen) {
2686
+ return refusal(`ui preflight refused: a dialog is already open in Things (${startState.sheetKind}${startState.sheetDepth > 1 ? `, on top of ${startState.sheetDepth - 1} more` : ""}), most likely left over from an earlier command or opened by hand. While one is open the ` +
2687
+ "app disables its menu bar, ignores keyboard input aimed at anything else, and " +
2688
+ `${SYNC_GATE_WARNING}. Dismiss it in Things (click Cancel, or press Escape with Things in ` +
2689
+ "front), then run the same command again. Nothing was pressed.");
2690
+ }
1310
2691
  // 1. Recipe canary: resolve every statically-reachable element (now that the
1311
2692
  // target is selected). A miss refuses the whole drive before anything is
1312
2693
  // pressed. (This is also the localization check: English titles must resolve.)
@@ -1346,7 +2727,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1346
2727
  // control whose CLASS changed, and only the matching one runs.
1347
2728
  if (step.onlyShape !== undefined) {
1348
2729
  if (dialogShape === null) {
1349
- const clear = await clearDialog(run);
2730
+ const clear = await clearNow();
1350
2731
  return partial(step.label, SHAPE_UNPROBED, clear);
1351
2732
  }
1352
2733
  if (step.onlyShape !== dialogShape)
@@ -1356,12 +2737,12 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1356
2737
  // introduced, and the weekday-row base index).
1357
2738
  if (step.shaped !== undefined) {
1358
2739
  if (dialogShape === null) {
1359
- const clear = await clearDialog(run);
2740
+ const clear = await clearNow();
1360
2741
  return partial(step.label, SHAPE_UNPROBED, clear);
1361
2742
  }
1362
2743
  const override = step.shaped[dialogShape];
1363
2744
  if (override === undefined) {
1364
- const clear = await clearDialog(run);
2745
+ const clear = await clearNow();
1365
2746
  return partial(step.label, `this step has no drive for the "${dialogShape}" Repeat dialog (recipe bug)`, clear);
1366
2747
  }
1367
2748
  step = { ...step, ...override };
@@ -1378,7 +2759,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1378
2759
  const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
1379
2760
  if (!ok) {
1380
2761
  // the abort keystroke must land (and be verified) before returning the partial-state report
1381
- const clear = await clearDialog(run);
2762
+ const clear = await clearNow();
1382
2763
  return partial(step.label, "the expected element never appeared within the timeout", clear);
1383
2764
  }
1384
2765
  done.push(step.label);
@@ -1398,6 +2779,21 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1398
2779
  done.push(`${step.label} (${outcome.detail})`);
1399
2780
  continue;
1400
2781
  }
2782
+ if (step.primitive === "chord-reorder") {
2783
+ // The heading-chord driver runs its own select → chord → DB-assert loop
2784
+ // (ui-chord.ts): every chord is computed from the order it just read, and
2785
+ // a chord that moves nothing (or moves the wrong row) stops the drive
2786
+ // rather than being re-sent. No sheet is involved, so no dismissal clause.
2787
+ if (step.chord === undefined)
2788
+ return partial(step.label, "no chord spec compiled");
2789
+ const spec = step.chord;
2790
+ // the chord ladder depends on the UI state the reveal produced
2791
+ const outcome = await driveHeadingChordReorder(spec, run, aux.headingOrder, (ordinal) => axSelectHeadingRowScript(spec.tablePath, ordinal));
2792
+ if (!outcome.ok)
2793
+ return partial(step.label, outcome.detail);
2794
+ done.push(`${step.label} (${outcome.detail})`);
2795
+ continue;
2796
+ }
1401
2797
  // Resolve a candidate-addressed step's effective element before dispatch
1402
2798
  // (the sheet-vs-detached-window disjunction). A miss fails closed.
1403
2799
  if (step.pathCandidates !== undefined) {
@@ -1405,7 +2801,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1405
2801
  const effective = await resolveStepPath(step, run);
1406
2802
  if (effective === null) {
1407
2803
  // dismiss whatever opened (and verify) before reporting
1408
- const clear = await clearDialog(run);
2804
+ const clear = await clearNow();
1409
2805
  return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
1410
2806
  "detached repeat editor window)", clear);
1411
2807
  }
@@ -1421,7 +2817,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1421
2817
  const res = await run(command, STEP_TIMEOUT_MS);
1422
2818
  const verdict = res.stdout.trim();
1423
2819
  if (!res.ok || (verdict !== "next-popup" && verdict !== "legacy")) {
1424
- const clear = await clearDialog(run);
2820
+ const clear = await clearNow();
1425
2821
  return partial(step.label, res.ok
1426
2822
  ? 'its first-occurrence row ("Next:") holds neither an occurrence pop-up nor a date ' +
1427
2823
  "field, so the dialog matched neither known shape — a Things update has redesigned " +
@@ -1442,7 +2838,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1442
2838
  const res = await run(command, STEP_TIMEOUT_MS);
1443
2839
  if (!res.ok || res.stdout.trim() !== "OK") {
1444
2840
  // clear any transient state (and verify) before reporting
1445
- const clear = await clearDialog(run);
2841
+ const clear = await clearNow();
1446
2842
  const noMatch = step.primitive === "select-heading-row"
1447
2843
  ? "the project view exposed no selectable heading row at the target position — the " +
1448
2844
  "heading may have been converted/deleted already, or the project's headings changed"
@@ -1467,7 +2863,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1467
2863
  const verdict = res.stdout.trim();
1468
2864
  if (!res.ok || verdict !== "OK") {
1469
2865
  // clear any transient state (and verify) before reporting
1470
- const clear = await clearDialog(run);
2866
+ const clear = await clearNow();
1471
2867
  return partial(step.label, res.ok
1472
2868
  ? verdict !== ""
1473
2869
  ? verdict
@@ -1479,14 +2875,31 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1479
2875
  done.push(step.label);
1480
2876
  continue;
1481
2877
  }
1482
- if (step.primitive === "click-element") {
2878
+ if (step.primitive === "audit-dialog") {
2879
+ // The last thing before the commit: re-read EVERY control this drive set,
2880
+ // through each control's own discriminated address, and abort fail-closed if
2881
+ // any of them disagrees with what was requested (CGRD1). Every per-step
2882
+ // read-back before this point is self-referential — it re-reads the element it
2883
+ // addressed — so a wrong ADDRESS is invisible until here. A mismatch clears
2884
+ // the dialog through the standard clean-abort path, so nothing is committed.
2885
+ const outcome = await driveDialogAudit(step, run, dialogShape);
2886
+ if (!outcome.ok) {
2887
+ const clear = await clearNow();
2888
+ return partial(step.label, outcome.why ?? "the pre-commit dialog audit failed", clear);
2889
+ }
2890
+ done.push(step.label);
2891
+ continue;
2892
+ }
2893
+ if (step.primitive === "click-element" || step.primitive === "click-picker-row") {
1483
2894
  // A mouse click at an AX-resolved frame center (the NATIVE1 primitive),
1484
- // used only where AXPress is inert (Things' custom `…`/repeat-bar popover).
2895
+ // used only where AXPress is inert (Things' custom `…`/repeat-bar popover)
2896
+ // — and, for `click-picker-row`, where committing by keyboard would take
2897
+ // whatever the app highlighted, including the row that CREATES a project.
1485
2898
  // the click depends on the UI state the previous step produced
1486
2899
  const outcome = await driveClickElement(step, run);
1487
2900
  if (!outcome.ok) {
1488
2901
  // clear whatever the click opened (honest cleanup) before reporting
1489
- const clear = outcome.needsAbort === true ? await clearDialog(run) : undefined;
2902
+ const clear = outcome.needsAbort === true ? await clearNow() : undefined;
1490
2903
  return partial(step.label, outcome.why ?? "the click failed", clear);
1491
2904
  }
1492
2905
  done.push(step.label);
@@ -1497,12 +2910,13 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
1497
2910
  if (!res.ok) {
1498
2911
  // clear the half-open sheet/popover (honest — never claim an unconfirmed
1499
2912
  // dismissal) before reporting partial state
1500
- const clear = step.primitive !== "reveal" && step.primitive !== "activate"
1501
- ? await clearDialog(run)
1502
- : undefined;
2913
+ const clear = step.primitive !== "reveal" && step.primitive !== "activate" ? await clearNow() : undefined;
1503
2914
  return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear, res.timedOut === true);
1504
2915
  }
1505
- done.push(step.label);
2916
+ // A typing primitive that found the field ALREADY holding the requested
2917
+ // value typed nothing at all (issue #620 item 7) — disclosed, so the trail
2918
+ // says what the drive did rather than what it intended.
2919
+ done.push(res.stdout.trim() === OK_ALREADY ? `${step.label} (already set)` : step.label);
1506
2920
  }
1507
2921
  return {
1508
2922
  exitCode: 0,
@@ -1613,6 +3027,11 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
1613
3027
  }
1614
3028
  return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
1615
3029
  },
3030
+ // Pre-seed dialog seam for the promote orchestrators (MODALX1, #620): a
3031
+ // composite's FIRST leg mints a row through the URL scheme, which sails
3032
+ // straight past an open dialog — and every AppleScript leg after it then
3033
+ // fails, leaving a copy behind. The orchestrator asks this BEFORE it seeds.
3034
+ probeUiState: () => readUiState(tracedRun, STEP_TIMEOUT_MS),
1616
3035
  // Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
1617
3036
  // live session BEFORE they seed a row, so a locked/full-screen session refuses
1618
3037
  // with zero mutation. Present regardless of `enabled` (the orchestrator has