things-api 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +4 -4
  2. package/dist/audit/schema.d.ts +8 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/commands/install-skill.js +9 -2
  5. package/dist/cli/commands/install-skill.js.map +1 -1
  6. package/dist/cli/commands/repeat-flags.d.ts +14 -4
  7. package/dist/cli/commands/repeat-flags.js +113 -33
  8. package/dist/cli/commands/repeat-flags.js.map +1 -1
  9. package/dist/cli/commands/todo.js +49 -2
  10. package/dist/cli/commands/todo.js.map +1 -1
  11. package/dist/cli/commands/writes.js +241 -66
  12. package/dist/cli/commands/writes.js.map +1 -1
  13. package/dist/cli/help.js +23 -13
  14. package/dist/cli/help.js.map +1 -1
  15. package/dist/cli/interrupt.d.ts +42 -0
  16. package/dist/cli/interrupt.js +105 -0
  17. package/dist/cli/interrupt.js.map +1 -0
  18. package/dist/cli/main.js +5 -0
  19. package/dist/cli/main.js.map +1 -1
  20. package/dist/cli/read-driver.d.ts +2 -2
  21. package/dist/cli/skill.d.ts +24 -0
  22. package/dist/cli/skill.js +40 -0
  23. package/dist/cli/skill.js.map +1 -1
  24. package/dist/client.d.ts +53 -12
  25. package/dist/client.js +9 -1
  26. package/dist/client.js.map +1 -1
  27. package/dist/config.d.ts +39 -0
  28. package/dist/config.js +16 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/contracts.d.ts +9 -2
  31. package/dist/contracts.js +1 -1
  32. package/dist/contracts.js.map +1 -1
  33. package/dist/index.d.ts +5 -2
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/mcp/server.js +132 -31
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/model/entities.d.ts +35 -0
  39. package/dist/model/entities.js.map +1 -1
  40. package/dist/model/occurrences.d.ts +14 -1
  41. package/dist/model/occurrences.js +20 -4
  42. package/dist/model/occurrences.js.map +1 -1
  43. package/dist/model/recurrence.d.ts +60 -9
  44. package/dist/model/recurrence.js +76 -19
  45. package/dist/model/recurrence.js.map +1 -1
  46. package/dist/paths.d.ts +8 -0
  47. package/dist/paths.js +10 -0
  48. package/dist/paths.js.map +1 -1
  49. package/dist/read/detail.js +49 -1
  50. package/dist/read/detail.js.map +1 -1
  51. package/dist/read/queries.d.ts +12 -0
  52. package/dist/read/queries.js +14 -0
  53. package/dist/read/queries.js.map +1 -1
  54. package/dist/read/shape.d.ts +25 -22
  55. package/dist/read/shape.js +81 -55
  56. package/dist/read/shape.js.map +1 -1
  57. package/dist/read/stage.d.ts +5 -5
  58. package/dist/read/stage.js +2 -2
  59. package/dist/read/views.d.ts +1 -1
  60. package/dist/read/views.js +4 -4
  61. package/dist/surface-copy.d.ts +2 -2
  62. package/dist/surface-copy.js +2 -2
  63. package/dist/sync-health.js +2 -1
  64. package/dist/sync-health.js.map +1 -1
  65. package/dist/trace/tracer.d.ts +87 -0
  66. package/dist/trace/tracer.js +167 -0
  67. package/dist/trace/tracer.js.map +1 -0
  68. package/dist/write/batch.d.ts +28 -3
  69. package/dist/write/batch.js +162 -46
  70. package/dist/write/batch.js.map +1 -1
  71. package/dist/write/clear-reminder.js +24 -21
  72. package/dist/write/clear-reminder.js.map +1 -1
  73. package/dist/write/clone.d.ts +4 -0
  74. package/dist/write/clone.js +531 -0
  75. package/dist/write/clone.js.map +1 -0
  76. package/dist/write/commands.js +140 -49
  77. package/dist/write/commands.js.map +1 -1
  78. package/dist/write/completion-context.d.ts +57 -0
  79. package/dist/write/completion-context.js +49 -0
  80. package/dist/write/completion-context.js.map +1 -0
  81. package/dist/write/failure-hints.d.ts +10 -1
  82. package/dist/write/failure-hints.js +8 -0
  83. package/dist/write/failure-hints.js.map +1 -1
  84. package/dist/write/guards.d.ts +1 -1
  85. package/dist/write/guards.js +43 -5
  86. package/dist/write/guards.js.map +1 -1
  87. package/dist/write/make-repeating-project.d.ts +2 -3
  88. package/dist/write/make-repeating-project.js +2 -73
  89. package/dist/write/make-repeating-project.js.map +1 -1
  90. package/dist/write/move.js +8 -1
  91. package/dist/write/move.js.map +1 -1
  92. package/dist/write/operations.d.ts +140 -9
  93. package/dist/write/operations.js +4 -1
  94. package/dist/write/operations.js.map +1 -1
  95. package/dist/write/pipeline.d.ts +77 -0
  96. package/dist/write/pipeline.js +298 -20
  97. package/dist/write/pipeline.js.map +1 -1
  98. package/dist/write/pre-state.d.ts +1 -1
  99. package/dist/write/pre-state.js +9 -3
  100. package/dist/write/pre-state.js.map +1 -1
  101. package/dist/write/preserve-modified.d.ts +55 -0
  102. package/dist/write/preserve-modified.js +106 -0
  103. package/dist/write/preserve-modified.js.map +1 -0
  104. package/dist/write/promote-clone.d.ts +32 -0
  105. package/dist/write/promote-clone.js +867 -0
  106. package/dist/write/promote-clone.js.map +1 -0
  107. package/dist/write/repeat-anchor.d.ts +110 -0
  108. package/dist/write/repeat-anchor.js +238 -0
  109. package/dist/write/repeat-anchor.js.map +1 -0
  110. package/dist/write/repeat-asserts.d.ts +28 -0
  111. package/dist/write/repeat-asserts.js +169 -0
  112. package/dist/write/repeat-asserts.js.map +1 -0
  113. package/dist/write/repeat-rule.js +27 -12
  114. package/dist/write/repeat-rule.js.map +1 -1
  115. package/dist/write/resolution-timestamps.js +22 -0
  116. package/dist/write/resolution-timestamps.js.map +1 -1
  117. package/dist/write/reversibility.js +27 -14
  118. package/dist/write/reversibility.js.map +1 -1
  119. package/dist/write/undo.d.ts +11 -1
  120. package/dist/write/undo.js +126 -10
  121. package/dist/write/undo.js.map +1 -1
  122. package/dist/write/vectors/session-reachability.d.ts +78 -0
  123. package/dist/write/vectors/session-reachability.js +103 -0
  124. package/dist/write/vectors/session-reachability.js.map +1 -0
  125. package/dist/write/vectors/simulator.js +100 -30
  126. package/dist/write/vectors/simulator.js.map +1 -1
  127. package/dist/write/vectors/types.d.ts +117 -10
  128. package/dist/write/vectors/ui-certification.d.ts +1 -1
  129. package/dist/write/vectors/ui-certification.js +10 -3
  130. package/dist/write/vectors/ui-certification.js.map +1 -1
  131. package/dist/write/vectors/ui-recipes.d.ts +8 -0
  132. package/dist/write/vectors/ui-recipes.js +120 -22
  133. package/dist/write/vectors/ui-recipes.js.map +1 -1
  134. package/dist/write/vectors/ui.d.ts +79 -13
  135. package/dist/write/vectors/ui.js +415 -59
  136. package/dist/write/vectors/ui.js.map +1 -1
  137. package/dist/write/verify/delta.d.ts +12 -14
  138. package/dist/write/verify/delta.js +35 -34
  139. package/dist/write/verify/delta.js.map +1 -1
  140. package/package.json +1 -1
  141. package/schema/envelope.schema.json +22 -0
  142. package/skills/things-cli/SKILL.md +7 -3
  143. package/skills/things-cli/references/banner.md +1 -1
  144. package/skills/things-cli/references/contracts.md +17 -9
  145. package/skills/things-cli/references/data-model.md +2 -1
@@ -26,8 +26,11 @@
26
26
  * v1 shells out to `osascript` with ONE stable command shape per primitive.
27
27
  */
28
28
  import { execFile } from "node:child_process";
29
+ import { DEFAULT_UI_DRIVE_BUDGET_MS } from "../../config.js";
30
+ import { noteInflightStep, trace, traceActive, tracePath } from "../../trace/tracer.js";
29
31
  import { UI_DRIVE_OPS } from "../operations.js";
30
32
  import { escapeAppleScript } from "./applescript.js";
33
+ import { H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
31
34
  import { certificationOf } from "./ui-certification.js";
32
35
  import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
33
36
  /** GUI driving can stall on an unanswered sheet; give each step headroom. */
@@ -98,6 +101,39 @@ export function axSetValueScript(path, value, attempts = 3) {
98
101
  error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
99
102
  end tell`;
100
103
  }
104
+ /**
105
+ * ensure-checkbox: converge a dialog checkbox to a target state through a
106
+ * DETERMINISTIC CLOSED LOOP (RRD1, determinism doctrine) — never a blind toggle.
107
+ * It reads the checkbox's `AXValue` (0 = unchecked, 1 = checked), presses it ONLY
108
+ * when the observed value differs from `target`, then RE-READS to confirm the new
109
+ * value equals `target`. A press that did not register (or a value that has not
110
+ * settled) is retried a bounded number of times; if it never converges the script
111
+ * FAILS CLOSED (an `error`, i.e. a transport failure the pipeline re-verifies).
112
+ *
113
+ * This is what makes the "Add deadlines" / "Add reminders" checkboxes safe on a
114
+ * PRE-POPULATED reschedule dialog: the dialog opens with the item's CURRENT
115
+ * deadline/reminder state already ticked, so the old unconditional `click`
116
+ * FLIPPED an already-correct box the wrong way — the live #493-adjacent bug where
117
+ * a blind "Add deadlines" press UNCHECKED an already-deadlined rule and hid the
118
+ * "start N days earlier" field, collapsing the drive. Reading before pressing
119
+ * makes an already-correct box a no-op. One stable command shape per primitive.
120
+ */
121
+ export function axEnsureCheckboxScript(path, target, attempts = 3) {
122
+ const want = target ? 1 : 0;
123
+ const n = Math.max(1, Math.trunc(attempts));
124
+ return `${SE}
125
+ set cb to (${path})
126
+ repeat ${n} times
127
+ set cur to (value of cb) as integer
128
+ if cur is ${want} then return "OK"
129
+ click cb
130
+ delay 0.2
131
+ end repeat
132
+ set cur to (value of cb) as integer
133
+ if cur is ${want} then return "OK"
134
+ error "checkbox did not converge to ${want} after ${n} attempt(s); still " & cur
135
+ end tell`;
136
+ }
101
137
  /**
102
138
  * select-popup: choose an item in a pop-up button by NAME. Setting `value` on a
103
139
  * Things pop-up button is a silent no-op (UIC1 / UI2-i) — the control must be
@@ -213,6 +249,41 @@ export function axSelectHeadingRowScript(tablePath, ordinal) {
213
249
  end tell
214
250
  return "NOMATCH"`;
215
251
  }
252
+ /**
253
+ * assert-eligible: after a `things:///show?id=` reveal, VERIFY the target to-do
254
+ * is genuinely the sole selection AND that the menu item that acts on it is
255
+ * enabled — before the menu is pressed (ADR1, issue #480). The reveal is assumed
256
+ * to select the row, but on some surfaces it can navigate without selecting; an
257
+ * AXPress on the resulting DISABLED `Items ▸ Repeat…` is a silent no-op, so the
258
+ * dialog never opens and the drive dies far downstream at the dialog-wait timeout
259
+ * with no hint of the real cause. Reading `Things3 → id of selected to dos` is
260
+ * uuid-precise (never a fuzzy title match), so a match GUARANTEES the intended row
261
+ * is selected. Returns "OK" only when exactly the target is selected and the menu
262
+ * item is enabled; otherwise a diagnostic (`NOTSEL…`/`WRONGSEL…`/`DISABLED…`)
263
+ * naming expected vs observed. Pure System Events + Things scripting, background-
264
+ * capable. One stable command shape per primitive.
265
+ */
266
+ export function axAssertEligibleScript(targetUuid, menuItemPath) {
267
+ const u = escapeAppleScript(targetUuid);
268
+ return `set selIds to {}
269
+ tell application "Things3"
270
+ try
271
+ set selIds to id of selected to dos
272
+ end try
273
+ end tell
274
+ if (count of selIds) is 0 then return "NOTSEL no to-do is selected after the reveal (expected ${u}) — the show URL navigated without selecting an eligible row"
275
+ if (count of selIds) is greater than 1 then return "NOTSEL " & (count of selIds) & " to-dos are selected, expected exactly the target ${u}"
276
+ set theId to (item 1 of selIds) as text
277
+ if theId is not "${u}" then return "WRONGSEL the selected to-do is " & theId & ", expected the target ${u}"
278
+ set repEnabled to false
279
+ tell application "System Events" to tell process "Things3"
280
+ try
281
+ set repEnabled to enabled of ${menuItemPath}
282
+ end try
283
+ end tell
284
+ if repEnabled is false then return "DISABLED the target ${u} is selected but its Repeat menu item is disabled (not an eligible row for this action)"
285
+ return "OK"`;
286
+ }
216
287
  /** activate: foreground Things (the fallback preamble step). */
217
288
  export function axActivateScript() {
218
289
  return `tell application "Things3" to activate`;
@@ -232,6 +303,29 @@ export function axKeyScript(keys) {
232
303
  export function axAbortScript() {
233
304
  return `tell application "System Events" to key code 53`; // Escape
234
305
  }
306
+ /**
307
+ * The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
308
+ * recovery): close the front Things window — which takes an attached modal sheet
309
+ * with it — then reopen and activate. Runs entirely through Things' own
310
+ * AppleScript dictionary, so it works WITHOUT the Accessibility tree (the exact
311
+ * property needed when the session is AX-blind). Two uses:
312
+ * - CLEANUP: clear a stuck modal sheet a failed drive left open (unblocks the
313
+ * app-wide AppleScript-mutation freeze that sheet imposes);
314
+ * - RELOCATION: pull a window that was on another Space back to the current one
315
+ * so its dialog can open AX-reachably (the wrong-Space recovery branch).
316
+ * `reopen` restores the default window on the CURRENT Space; `activate` foregrounds
317
+ * it. Returns "OK".
318
+ */
319
+ export function axCloseReopenActivateScript() {
320
+ return `tell application "Things3"
321
+ try
322
+ close window 1
323
+ end try
324
+ reopen
325
+ activate
326
+ end tell
327
+ return "OK"`;
328
+ }
235
329
  /**
236
330
  * sheet-open probe: is a modal SHEET attached to the Things standard window, OR
237
331
  * a detached repeat-editor / popover window (an `AXUnknown` that is not the
@@ -262,19 +356,80 @@ async function sheetStillOpen(run) {
262
356
  return !(res.ok && res.stdout.trim() === "false");
263
357
  }
264
358
  /**
265
- * Send Escape and VERIFY the sheet/popover is gone (0½ defect (d): the abort
266
- * must never claim dismissal it did not confirm). Retries Escape ONCE if the
267
- * first is not honored. Returns whether dismissal was verified the caller
268
- * words its partial-state report accordingly (fail-closed: on an unverifiable
269
- * dismissal it must warn the sheet may remain open).
359
+ * Clear a half-open sheet/popover a failed drive left behind HONESTLY (SESSGATE
360
+ * #480 fix; supersedes the old verifiedAbort, whose AX-blind sheet probe returned
361
+ * "gone" it could not actually see, letting the still-open modal freeze the
362
+ * app-wide AppleScript mutations the caller then attempted the auto-trash
363
+ * silent-noop). Escape first; then:
364
+ * - AX-BLIND (a not-reachable probe): Escape may never have reached the sheet
365
+ * and the sheet probe cannot see it, so run the app-level close+reopen that
366
+ * works blind (it takes the stuck sheet with the window). Reported as
367
+ * "cleared-blind" — never falsely "confirmed gone".
368
+ * - REACHABLE: the sheet probe is trustworthy — confirm the dismissal (retry
369
+ * Escape once), and if it will not go, warn "may remain".
270
370
  */
271
- async function verifiedAbort(run) {
371
+ async function clearDialog(run) {
272
372
  const escape = () => run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
273
373
  await escape();
374
+ const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
375
+ if (!reach.reachable) {
376
+ // Cannot trust Escape or the sheet probe while AX-blind — use the proven
377
+ // app-level maneuver, which clears a stuck sheet without the Accessibility tree.
378
+ await run({
379
+ primitive: "resolve",
380
+ label: "clear a stuck dialog (close the Things window and reopen it)",
381
+ script: axCloseReopenActivateScript(),
382
+ }, STEP_TIMEOUT_MS);
383
+ return { state: "cleared-blind" };
384
+ }
274
385
  if (!(await sheetStillOpen(run)))
275
- return { dismissed: true };
386
+ return { state: "dismissed" };
276
387
  await escape(); // one retry
277
- return { dismissed: !(await sheetStillOpen(run)) };
388
+ return { state: (await sheetStillOpen(run)) ? "may-remain" : "dismissed" };
389
+ }
390
+ /**
391
+ * The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
392
+ * activate preamble (which surfaces a window in a healthy session) and BEFORE any
393
+ * menu press. Three outcomes matched to the live session state:
394
+ * - reachable → proceed;
395
+ * - not reachable, "session" → REFUSE (locked screen / full-screen Space —
396
+ * the certain-failure case): block, zero mutation;
397
+ * - not reachable, "window" → RELOCATE: only Things' window is off the
398
+ * current Space, so run the app-level close+reopen
399
+ * that pulls it back, then RE-PROBE closed-loop.
400
+ * Reachable now → proceed (disclosed); still not →
401
+ * block with the Space remediation.
402
+ */
403
+ async function ensureWindowReachable(run) {
404
+ const first = await probeSessionReachability(run, STEP_TIMEOUT_MS);
405
+ if (first.reachable)
406
+ return { ok: true, relocated: false };
407
+ if (first.scope === "session")
408
+ return { ok: false, verdict: first };
409
+ // scope "window": Things' window is on another Space (or absent) while the
410
+ // session is otherwise fine — try to bring it to the current Space, then re-probe.
411
+ await run({
412
+ primitive: "resolve",
413
+ label: "move the Things window to the current desktop",
414
+ script: axCloseReopenActivateScript(),
415
+ }, STEP_TIMEOUT_MS);
416
+ const second = await probeSessionReachability(run, STEP_TIMEOUT_MS);
417
+ if (second.reachable)
418
+ return { ok: true, relocated: true };
419
+ return { ok: false, verdict: second.reachable ? first : second };
420
+ }
421
+ /** The blocked ExecuteResult a dialog-class op returns when the session is unreachable. */
422
+ function blockedReachability(verdict) {
423
+ return {
424
+ exitCode: 4,
425
+ stdout: "",
426
+ stderr: `${verdict.detail} ${verdict.remediation}`,
427
+ blocked: {
428
+ hazard: H_UI_SESSION_UNREACHABLE,
429
+ detail: verdict.detail,
430
+ remediation: verdict.remediation,
431
+ },
432
+ };
278
433
  }
279
434
  /**
280
435
  * resolve-frame: read the element's on-screen frame (top-left origin, points)
@@ -314,40 +469,78 @@ function clickPointCommand(x, y, label) {
314
469
  return { primitive: "click-point", label, lang: "javascript", script: jxaClickScript(x, y) };
315
470
  }
316
471
  /**
317
- * set-datetime: set the Repeat dialog's `AXDateTimeArea` (reminder time / "ends
318
- * on date" bound) via the ObjC AX bridge. Things' date/time control holds an
319
- * NSDate, and System Events cannot write it (`set value … to <date>` → -10000,
320
- * UIC6), so — like the mouse-synthesis primitive — this runs in JXA and calls
321
- * `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly. The control is
322
- * found by ROLE within Things' front dialog (there is exactly one during a
323
- * reminder/end-date step; the matrix never sets both at once), polled briefly
324
- * so it is caught right after the checkbox/pop-up that reveals it, and the
325
- * script THROWS when absent so the driver fails closed. `spec` is
326
- * `time:HH:mm` (keep the control's date, overwrite the time-of-day) or
327
- * `date:YYYY-MM-DD` (overwrite the date at midnight). One stable JXA shape.
472
+ * set-datetime: set ONE of the Repeat dialog's `AXDateTimeArea` controls via the
473
+ * ObjC AX bridge. Things' date/time controls hold an NSDate, and System Events
474
+ * cannot write them (`set value … to <date>` → -10000, UIC6), so — like the
475
+ * mouse-synthesis primitive — this runs in JXA and calls
476
+ * `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly.
477
+ *
478
+ * A fixed rule can expose up to THREE date areas at once "Next:" (first
479
+ * occurrence), "Ends: on date", and the reminder time (ANCH2 census). Targeting
480
+ * "the first AXDateTimeArea by role" is therefore AMBIGUOUS that ambiguity
481
+ * collapsed the series when `--ends-on` added a second area (oddities §8v, now
482
+ * retracted) and made the reminder look undrivable (UIC6-g, now retracted). This
483
+ * driver selects DETERMINISTICALLY by `target` (ANCH2, docs/lab/anch2-next-field.md):
484
+ * - `reminder` — the only area carrying a time-of-day (the date pickers sit at
485
+ * midnight); falls back to the bottom-most area if none carry a time.
486
+ * - `next` — the TOP (smallest-y) midnight date picker.
487
+ * - `ends` — the BOTTOM (largest-y) midnight date picker (present only once
488
+ * "Ends: on date" is selected).
489
+ * The areas are polled briefly (revealed a beat after the checkbox/pop-up), and
490
+ * the script THROWS a NAMED, structured error when the addressed control is
491
+ * absent — reporting which target was sought and the FULL date-area inventory of
492
+ * the current dialog state (count + per-area y / time-of-day) — so a dialog shape
493
+ * that does not present the target (e.g. the deadline-mode variant, YANCH1 #493)
494
+ * fails closed with an actionable message, never an uncaught `-[__NSArray0
495
+ * objectAtIndex:]` (-2700) from indexing an empty collection. It then READS THE
496
+ * CONTROL BACK after the write and throws if the committed value differs from the
497
+ * request: a control that silently rejects the write (the macOS error beep the
498
+ * user hears) must fail the step loudly, never leave a garbled/default value to be
499
+ * verified as ok (YANCH1; UIC6-g refuse-rather-than-commit precedent). `spec` is
500
+ * `time:HH:mm` (keep the date, set the time-of-day) or `date:YYYY-MM-DD` (set the
501
+ * calendar date at midnight). One stable JXA shape.
328
502
  */
329
- export function axSetDateTimeScript(spec) {
503
+ export function axSetDateTimeScript(spec, target) {
330
504
  return `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
331
505
  function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
332
506
  function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
333
507
  function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
334
- function find(el,role,depth){ if(depth<0) return null; if(rolestr(el)===role) return el; var ks=kids(el); for(var i=0;i<ks.length;i++){ var r=find(ks[i],role,depth-1); if(r) return r;} return null; }
508
+ 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); }
509
+ function posY(el){ var p=attr(el,'AXPosition'); if(!p) return 0; var d=ObjC.castRefToObject($.CFCopyDescription(p)).js; var m=String(d).match(/y:([-0-9.]+)/); return m? +m[1] : 0; }
510
+ function timeOfDay(el){ var v=attr(el,'AXValue'); if(!v) return -1; var cal=$.NSCalendar.currentCalendar; return cal.componentFromDate($.NSCalendarUnitHour,v)*60 + cal.componentFromDate($.NSCalendarUnitMinute,v); }
511
+ function pick(areas,target){
512
+ if(areas.length===0) return null;
513
+ var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
514
+ if(target==='reminder'){
515
+ var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
516
+ return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
517
+ }
518
+ var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
519
+ if(midnight.length===0) midnight=sorted;
520
+ return target==='ends' ? midnight[midnight.length-1] : midnight[0];
521
+ }
522
+ 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)'; }
523
+ 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); }
524
+ function hmStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var h=cal.componentFromDate($.NSCalendarUnitHour,v), mi=cal.componentFromDate($.NSCalendarUnitMinute,v); return h+':'+('0'+mi).slice(-2); }
335
525
  function run(){
336
526
  var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
337
527
  if(!apps || apps.count===0) throw new Error('Things not running');
338
- var pid=apps.objectAtIndex(0).processIdentifier;
339
- var app=$.AXUIElementCreateApplication(pid);
340
- var dt=null;
341
- for(var t=0;t<20 && !dt;t++){ dt=find(app,'AXDateTimeArea',16); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
342
- if(!dt) throw new Error('no AXDateTimeArea in the Repeat dialog');
528
+ var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
529
+ var target=${JSON.stringify(target)};
343
530
  var spec=${JSON.stringify(spec)};
531
+ // Poll for the addressed area. collect is wrapped so a stale-element ObjC
532
+ // exception during traversal cannot bubble as a raw -2700; pick guards the
533
+ // empty set, so dt is null (never a crash) when the target is absent.
534
+ var areas=[]; var dt=null;
535
+ for(var t=0;t<20 && !dt;t++){ areas=[]; try{ collect(app,'AXDateTimeArea',16,areas); }catch(e){ areas=[]; } dt=pick(areas,target); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
536
+ if(!dt) throw new Error('set-datetime '+target+': this Repeat-dialog state presents '+areas.length+' date area(s) ['+inv(areas)+'] but none is the '+target+' control — the requested first occurrence / bound cannot be set in this dialog shape');
344
537
  var cal=$.NSCalendar.currentCalendar;
345
538
  var d;
346
539
  if(spec.indexOf('time:')===0){
347
- // Set the time-of-day on the control's own (today's) date via the purpose-
348
- // built calendar API — component-bag mutation via JXA silently drops the
349
- // hour, leaking the current wall-clock hour into the reminder (UIC6).
350
- var cur=attr(dt,'AXValue'); if(!cur) throw new Error('date/time control has no value');
540
+ // Set the time-of-day on the control's own date via the purpose-built
541
+ // calendar API — component-bag mutation via JXA silently drops the hour,
542
+ // leaking the current wall-clock hour into the reminder (UIC6).
543
+ var cur=attr(dt,'AXValue'); if(!cur) throw new Error('set-datetime '+target+': the date/time control has no value to anchor the time on');
351
544
  var hm=spec.slice(5).split(':');
352
545
  d=cal.dateBySettingHourMinuteSecondOfDateOptions(+hm[0], +hm[1], 0, cur, 0);
353
546
  } else if(spec.indexOf('date:')===0){
@@ -358,8 +551,18 @@ function run(){
358
551
  } else { throw new Error('bad datetime spec: '+spec); }
359
552
  if(!d) throw new Error('could not build date from '+spec);
360
553
  var err=$.AXUIElementSetAttributeValue(dt,$('AXValue'),d);
361
- if(err!==0) throw new Error('AXValue set failed err='+err);
554
+ if(err!==0) throw new Error('set-datetime '+target+': the control refused the write (AX err='+err+')');
362
555
  $.NSThread.sleepForTimeInterval(0.2);
556
+ // READ-BACK: a control can accept the AX write (err 0) yet reject the value —
557
+ // the macOS error beep — leaving its prior/default value. Fail the step loudly
558
+ // rather than let a garbled commit verify as ok (YANCH1 #493).
559
+ if(spec.indexOf('date:')===0){
560
+ var got=ymdStr(dt,cal); var want=spec.slice(5);
561
+ 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');
562
+ } else {
563
+ var gott=hmStr(dt,cal); var wanth=spec.slice(5).split(':'); var wantt=(+wanth[0])+':'+('0'+(+wanth[1])).slice(-2);
564
+ if(gott!==wantt) throw new Error('set-datetime '+target+' rejected: the control committed '+(gott||'(no value)')+', not the requested '+wantt+' — the write did not take');
565
+ }
363
566
  return 'OK';
364
567
  }`;
365
568
  }
@@ -399,6 +602,40 @@ function defaultRun(command, timeoutMs) {
399
602
  });
400
603
  });
401
604
  }
605
+ /**
606
+ * Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
607
+ * step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
608
+ * even with tracing off), and — when tracing is on — a `ui-dispatch` start/end
609
+ * pair lands in the trace carrying the hop's duration and outcome. This
610
+ * per-osascript granularity is exactly what reconstructs a hang: the timeline
611
+ * shows which step's osascript was in flight, and for how long, when it stopped
612
+ * (TRACE1 #487). Overhead when tracing is off is one boolean check + a field write.
613
+ */
614
+ function tracingRun(inner) {
615
+ return async (command, timeoutMs) => {
616
+ noteInflightStep(command.label);
617
+ if (!traceActive())
618
+ return inner(command, timeoutMs);
619
+ const started = Date.now();
620
+ trace(() => ({
621
+ phase: "ui-dispatch",
622
+ event: "start",
623
+ primitive: command.primitive,
624
+ label: command.label,
625
+ }));
626
+ const res = await inner(command, timeoutMs);
627
+ trace(() => ({
628
+ phase: "ui-dispatch",
629
+ event: "end",
630
+ primitive: command.primitive,
631
+ label: command.label,
632
+ durationMs: Date.now() - started,
633
+ ok: res.ok,
634
+ timedOut: res.timedOut === true,
635
+ }));
636
+ return res;
637
+ };
638
+ }
402
639
  /** The element paths the preflight canary resolves (static steps only). */
403
640
  function canaryPaths(recipe) {
404
641
  const out = [];
@@ -485,7 +722,13 @@ export function commandForStep(step, targetUuid) {
485
722
  primitive: "set-datetime",
486
723
  label: step.label,
487
724
  lang: "javascript",
488
- script: axSetDateTimeScript(step.value ?? ""),
725
+ script: axSetDateTimeScript(step.value ?? "", step.dtTarget ?? "next"),
726
+ };
727
+ case "ensure-checkbox":
728
+ return {
729
+ primitive: "ensure-checkbox",
730
+ label: step.label,
731
+ script: axEnsureCheckboxScript(step.path ?? "", step.checkboxTarget === true),
489
732
  };
490
733
  case "wait":
491
734
  return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
@@ -501,6 +744,12 @@ export function commandForStep(step, targetUuid) {
501
744
  label: step.label,
502
745
  script: axSelectHeadingRowScript(step.path ?? "", Number(step.value ?? "0")),
503
746
  };
747
+ case "assert-eligible":
748
+ return {
749
+ primitive: "assert-eligible",
750
+ label: step.label,
751
+ script: axAssertEligibleScript(step.value ?? targetUuid, step.path ?? ""),
752
+ };
504
753
  case "key":
505
754
  return { primitive: "key", label: step.label, script: axKeyScript(step.keys ?? "") };
506
755
  case "click-element":
@@ -567,20 +816,69 @@ async function driveClickElement(step, run) {
567
816
  }
568
817
  return { ok: true };
569
818
  }
570
- async function drive(recipe, run, aux) {
819
+ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
571
820
  const done = [];
572
- // `dismissed`: true = abort verified the sheet gone; false = abort could NOT
573
- // confirm dismissal (warn it may remain open — 0½ defect (d): never claim a
574
- // dismissal we did not see); undefined = no sheet was opened / no abort ran.
575
- const partial = (failed, why, dismissed) => {
821
+ // The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
822
+ // own timeout on a slow production database (large + Things-Cloud syncing
823
+ // commits the Repeat dialog several times slower than the lab golden), which
824
+ // is how #487 fired: the caller's 30s kill left empty stdout and no retained
825
+ // exit code. This budget lets the CLI give up FIRST — clearing any open dialog
826
+ // and returning an honest, uncertain-outcome timeout — so the caller always
827
+ // receives structured output. Checked between steps (per-step execFile
828
+ // timeouts bound each osascript, so a step boundary is never far off).
829
+ const driveStart = Date.now();
830
+ const driveDeadline = driveStart + budgetMs;
831
+ const overBudget = () => Date.now() >= driveDeadline;
832
+ const watchdogResult = async (lastStep) => {
833
+ // Attempt the SESSGATE dialog clearance so the watchdog never leaves a stuck
834
+ // modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
835
+ // whose OK press was mid-commit could still land — the pipeline re-verifies
836
+ // and shapes the final result accordingly.
837
+ const clear = await clearDialog(run);
838
+ trace(() => ({
839
+ phase: "watchdog",
840
+ budgetMs,
841
+ elapsedMs: Date.now() - driveStart,
842
+ lastStep,
843
+ clear: clear.state,
844
+ completed: done,
845
+ }));
846
+ return {
847
+ exitCode: 1,
848
+ stdout: `ui drive watchdog stopped after ${done.length} step(s): ${done.join(" → ") || "nothing"}`,
849
+ stderr: `ui drive exceeded its ${Math.round(budgetMs / 1000)}s budget at "${lastStep}"`,
850
+ timedOut: true,
851
+ watchdog: {
852
+ budgetMs,
853
+ elapsedMs: Date.now() - driveStart,
854
+ lastStep,
855
+ clear: clear.state,
856
+ tracePath: tracePath(),
857
+ },
858
+ };
859
+ };
860
+ // A note prepended to the success summary when the drive had to RELOCATE the
861
+ // Things window to the current Space to open its dialog (SESSGATE wrong-Space
862
+ // recovery) — surfaced to the caller as a disclosure warning.
863
+ let relocationNote = "";
864
+ // `clear`: how a half-open sheet was cleaned up after a failure (honest — never
865
+ // claim a dismissal we could not see, SESSGATE #480); undefined = no sheet was
866
+ // opened / no cleanup ran (a benign preamble/canary failure).
867
+ const partial = (failed, why, clear) => {
576
868
  const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
577
- const cleanup = dismissed === true
578
- ? " The open sheet/popover was dismissed (Escape, confirmed gone)."
579
- : dismissed === false
580
- ? " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
581
- " Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
582
- " make the next drive's preflight fail)."
583
- : "";
869
+ const cleanup = clear === undefined
870
+ ? ""
871
+ : clear.state === "dismissed"
872
+ ? " The open sheet/popover was dismissed (Escape, confirmed gone)."
873
+ : clear.state === "cleared-blind"
874
+ ? " Things had no window reachable on the current screen (the Mac may be locked, or a" +
875
+ " full-screen app is covering the desktop), so the open dialog could not be confirmed" +
876
+ " through the on-screen layer — the Things window was closed and reopened to clear it," +
877
+ " discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
878
+ " before retrying."
879
+ : " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
880
+ " Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
881
+ " make the next drive's preflight fail).";
584
882
  return refusal(base + cleanup);
585
883
  };
586
884
  // 0. Run the leading reveal/activate preamble BEFORE the canary. The Items
@@ -603,6 +901,22 @@ async function drive(recipe, run, aux) {
603
901
  // Let the selection settle so the menu bar repopulates before the canary reads it.
604
902
  if (idx > 0)
605
903
  await new Promise((r) => setTimeout(r, SETTLE_AFTER_REVEAL_MS));
904
+ // 0½. Session-reachability GATE for dialog-class ops (SESSGATE, #480). A recipe
905
+ // that opens a sheet on the main window needs that window AX-reachable on
906
+ // the current Space. Probed AFTER the preamble (which surfaces a window in a
907
+ // healthy session) and BEFORE the canary/press (no mutation yet): a locked /
908
+ // full-screen session REFUSES (blocked, zero mutation); a window merely on
909
+ // another Space is RELOCATED back and disclosed. Menu-only recipes skip this.
910
+ if (recipe.needsWindowReachability === true) {
911
+ const reach = await ensureWindowReachable(run);
912
+ if (!reach.ok)
913
+ return blockedReachability(reach.verdict);
914
+ if (reach.relocated) {
915
+ relocationNote =
916
+ "the Things window was on another desktop, so it was moved to the desktop you're viewing " +
917
+ "to open the dialog. ";
918
+ }
919
+ }
606
920
  // 1. Recipe canary: resolve every statically-reachable element (now that the
607
921
  // target is selected). A miss refuses the whole drive before anything is
608
922
  // pressed. (This is also the localization check: English titles must resolve.)
@@ -631,6 +945,11 @@ async function drive(recipe, run, aux) {
631
945
  // 2. Execute the remaining steps in order; a dynamic element is waited-for.
632
946
  for (let i = idx; i < recipe.steps.length; i += 1) {
633
947
  let step = recipe.steps[i];
948
+ // Overall-drive watchdog: if the budget is spent, stop at THIS step boundary
949
+ // (the per-step execFile timeouts keep the boundary close), clear any open
950
+ // dialog, and return the honest uncertain-outcome timeout (TRACE1 #487).
951
+ if (overBudget())
952
+ return watchdogResult(step.label);
634
953
  if (step.primitive === "wait") {
635
954
  // A candidate-addressed wait polls for ANY of its shapes to appear (the
636
955
  // dialog opening as an attached sheet OR a detached AXUnknown window).
@@ -638,8 +957,8 @@ async function drive(recipe, run, aux) {
638
957
  const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
639
958
  if (!ok) {
640
959
  // the abort keystroke must land (and be verified) before returning the partial-state report
641
- const { dismissed } = await verifiedAbort(run);
642
- return partial(step.label, "the expected element never appeared within the timeout", dismissed);
960
+ const clear = await clearDialog(run);
961
+ return partial(step.label, "the expected element never appeared within the timeout", clear);
643
962
  }
644
963
  done.push(step.label);
645
964
  continue;
@@ -665,9 +984,9 @@ async function drive(recipe, run, aux) {
665
984
  const effective = await resolveStepPath(step, run);
666
985
  if (effective === null) {
667
986
  // dismiss whatever opened (and verify) before reporting
668
- const { dismissed } = await verifiedAbort(run);
987
+ const clear = await clearDialog(run);
669
988
  return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
670
- "detached repeat editor window)", dismissed);
989
+ "detached repeat editor window)", clear);
671
990
  }
672
991
  step = { ...step, path: effective };
673
992
  }
@@ -680,7 +999,7 @@ async function drive(recipe, run, aux) {
680
999
  const res = await run(command, STEP_TIMEOUT_MS);
681
1000
  if (!res.ok || res.stdout.trim() !== "OK") {
682
1001
  // clear any transient state (and verify) before reporting
683
- const { dismissed } = await verifiedAbort(run);
1002
+ const clear = await clearDialog(run);
684
1003
  const noMatch = step.primitive === "select-heading-row"
685
1004
  ? "the project view exposed no selectable heading row at the target position — the " +
686
1005
  "heading may have been converted/deleted already, or the project's headings changed"
@@ -690,7 +1009,29 @@ async function drive(recipe, run, aux) {
690
1009
  ? noMatch
691
1010
  : res.timedOut === true
692
1011
  ? "the row-selection step timed out"
693
- : res.stderr.trim() || "the row-selection step failed", dismissed);
1012
+ : res.stderr.trim() || "the row-selection step failed", clear);
1013
+ }
1014
+ done.push(step.label);
1015
+ continue;
1016
+ }
1017
+ if (step.primitive === "assert-eligible") {
1018
+ // ADR1 (#480): fail EARLY + NAMED when the reveal did not land an eligible
1019
+ // selection, rather than letting a disabled-menu no-op surface downstream
1020
+ // as an opaque dialog-wait timeout. The script returns "OK" or a diagnostic
1021
+ // (NOTSEL…/WRONGSEL…/DISABLED…) that IS the human-readable failure reason.
1022
+ // the selection/enabled state must be confirmed before the menu is pressed
1023
+ const res = await run(command, STEP_TIMEOUT_MS);
1024
+ const verdict = res.stdout.trim();
1025
+ if (!res.ok || verdict !== "OK") {
1026
+ // clear any transient state (and verify) before reporting
1027
+ const clear = await clearDialog(run);
1028
+ return partial(step.label, res.ok
1029
+ ? verdict !== ""
1030
+ ? verdict
1031
+ : "the target to-do was not confirmed selected/eligible after the reveal"
1032
+ : res.timedOut === true
1033
+ ? "the eligibility check timed out"
1034
+ : res.stderr.trim() || "the eligibility check failed", clear);
694
1035
  }
695
1036
  done.push(step.label);
696
1037
  continue;
@@ -701,9 +1042,9 @@ async function drive(recipe, run, aux) {
701
1042
  // the click depends on the UI state the previous step produced
702
1043
  const outcome = await driveClickElement(step, run);
703
1044
  if (!outcome.ok) {
704
- // dismiss whatever the click opened (and verify) before reporting
705
- const dismissed = outcome.needsAbort === true ? (await verifiedAbort(run)).dismissed : undefined;
706
- return partial(step.label, outcome.why ?? "the click failed", dismissed);
1045
+ // clear whatever the click opened (honest cleanup) before reporting
1046
+ const clear = outcome.needsAbort === true ? await clearDialog(run) : undefined;
1047
+ return partial(step.label, outcome.why ?? "the click failed", clear);
707
1048
  }
708
1049
  done.push(step.label);
709
1050
  continue;
@@ -711,15 +1052,20 @@ async function drive(recipe, run, aux) {
711
1052
  // each recipe step depends on the UI state the previous step produced; they cannot be parallelized
712
1053
  const res = await run(command, STEP_TIMEOUT_MS);
713
1054
  if (!res.ok) {
714
- // dismiss the half-open sheet/popover (and verify) before reporting partial state
715
- const dismissed = step.primitive !== "reveal" && step.primitive !== "activate"
716
- ? (await verifiedAbort(run)).dismissed
1055
+ // clear the half-open sheet/popover (honest never claim an unconfirmed
1056
+ // dismissal) before reporting partial state
1057
+ const clear = step.primitive !== "reveal" && step.primitive !== "activate"
1058
+ ? await clearDialog(run)
717
1059
  : undefined;
718
- return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", dismissed);
1060
+ return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear);
719
1061
  }
720
1062
  done.push(step.label);
721
1063
  }
722
- return { exitCode: 0, stdout: `drove ${done.length} step(s): ${done.join(" → ")}`, stderr: "" };
1064
+ return {
1065
+ exitCode: 0,
1066
+ stdout: `${relocationNote}drove ${done.length} step(s): ${done.join(" → ")}`,
1067
+ stderr: "",
1068
+ };
723
1069
  }
724
1070
  async function waitForElement(command, timeoutMs, run) {
725
1071
  const deadline = Date.now() + timeoutMs;
@@ -796,6 +1142,11 @@ function disabledMatrix() {
796
1142
  */
797
1143
  export function createUiVector(config, run = defaultRun, aux = {}) {
798
1144
  const enabled = config.ui.enabled;
1145
+ const budgetMs = config.ui.driveBudgetMs ?? DEFAULT_UI_DRIVE_BUDGET_MS;
1146
+ // Every osascript hop runs through the tracing seam: it notes the step on the
1147
+ // in-flight marker (for the signal handler) and, when tracing is on, records a
1148
+ // start/end pair with timing/outcome (TRACE1 #487).
1149
+ const tracedRun = tracingRun(run);
799
1150
  return {
800
1151
  id: "ui",
801
1152
  matrix: enabled ? enabledMatrix() : disabledMatrix(),
@@ -806,8 +1157,13 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
806
1157
  if (invocation.recipe === undefined) {
807
1158
  return refusal("ui invocation carried no recipe (compile bug).");
808
1159
  }
809
- return drive(invocation.recipe, run, aux);
1160
+ return drive(invocation.recipe, tracedRun, aux, budgetMs);
810
1161
  },
1162
+ // Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
1163
+ // live session BEFORE they seed a row, so a locked/full-screen session refuses
1164
+ // with zero mutation. Present regardless of `enabled` (the orchestrator has
1165
+ // already cleared the H-UI-DRIVE ack by the time it consults this).
1166
+ probeReachability: () => probeSessionReachability(tracedRun, STEP_TIMEOUT_MS),
811
1167
  };
812
1168
  }
813
1169
  //# sourceMappingURL=ui.js.map