things-api 0.19.1 → 0.19.3

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 (56) hide show
  1. package/README.md +1 -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/cli/commands/doctor.js +28 -8
  6. package/dist/cli/commands/doctor.js.map +1 -1
  7. package/dist/cli/commands/ui-state.d.ts +11 -0
  8. package/dist/cli/commands/ui-state.js +27 -0
  9. package/dist/cli/commands/ui-state.js.map +1 -0
  10. package/dist/cli/help.js +2 -0
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/main.js +2 -0
  13. package/dist/cli/main.js.map +1 -1
  14. package/dist/contracts.d.ts +2 -2
  15. package/dist/contracts.js +1 -1
  16. package/dist/contracts.js.map +1 -1
  17. package/dist/deputy/osa.d.ts +42 -2
  18. package/dist/deputy/osa.js +79 -5
  19. package/dist/deputy/osa.js.map +1 -1
  20. package/dist/deputy/routing.d.ts +18 -0
  21. package/dist/deputy/routing.js +25 -0
  22. package/dist/deputy/routing.js.map +1 -1
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/ui-state.d.ts +46 -0
  27. package/dist/ui-state.js +107 -0
  28. package/dist/ui-state.js.map +1 -0
  29. package/dist/write/accessibility-probe.js +4 -1
  30. package/dist/write/accessibility-probe.js.map +1 -1
  31. package/dist/write/automation-probe.js +7 -1
  32. package/dist/write/automation-probe.js.map +1 -1
  33. package/dist/write/failure-hints.d.ts +7 -1
  34. package/dist/write/failure-hints.js +24 -0
  35. package/dist/write/failure-hints.js.map +1 -1
  36. package/dist/write/field-limits.d.ts +100 -0
  37. package/dist/write/field-limits.js +144 -0
  38. package/dist/write/field-limits.js.map +1 -0
  39. package/dist/write/param-schema.d.ts +17 -0
  40. package/dist/write/param-schema.js +64 -4
  41. package/dist/write/param-schema.js.map +1 -1
  42. package/dist/write/pipeline.js +30 -0
  43. package/dist/write/pipeline.js.map +1 -1
  44. package/dist/write/promote-clone.d.ts +26 -0
  45. package/dist/write/promote-clone.js +201 -23
  46. package/dist/write/promote-clone.js.map +1 -1
  47. package/dist/write/vectors/types.d.ts +25 -3
  48. package/dist/write/vectors/ui-state.d.ts +245 -0
  49. package/dist/write/vectors/ui-state.js +452 -0
  50. package/dist/write/vectors/ui-state.js.map +1 -0
  51. package/dist/write/vectors/ui.d.ts +136 -4
  52. package/dist/write/vectors/ui.js +665 -65
  53. package/dist/write/vectors/ui.js.map +1 -1
  54. package/package.json +1 -1
  55. package/schema/envelope.schema.json +1 -0
  56. package/skills/things-cli/SKILL.md +2 -2
@@ -35,6 +35,7 @@ import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachabi
35
35
  import { certificationOf } from "./ui-certification.js";
36
36
  import { chordCommand, driveHeadingChordReorder } from "./ui-chord.js";
37
37
  import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
38
+ import { AX_DIALOG_SHELL_SNIPPET, CENSUS_TIMEOUT_MS, censusUnverifiable, describeFocusOwner, describeUnprovenProbes, readUiState, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./ui-state.js";
38
39
  /** GUI driving can stall on an unanswered sheet; give each step headroom. */
39
40
  const STEP_TIMEOUT_MS = 15_000;
40
41
  /**
@@ -264,6 +265,36 @@ on rfField(c, rowLabel, tol)
264
265
  return item 1 of hits
265
266
  end tell
266
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`;
267
298
  /** resolve-element: does the element exist right now? Returns "true"/"false". */
268
299
  export function axResolveScript(path) {
269
300
  return `${SE} to return (exists (${path}))`;
@@ -308,15 +339,30 @@ export function axPressScript(path) {
308
339
  * so a retry starts from a clean field without ⌘A. Fail-closed (an `error`, i.e.
309
340
  * a transport failure the pipeline re-verifies) if it never holds — the
310
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.
311
352
  */
312
353
  export function axSetValueScript(path, value, attempts = 3) {
313
354
  const v = escapeAppleScript(value);
314
355
  const n = Math.max(1, Math.trunc(attempts));
315
- return `${SE}
356
+ return `${AX_FOCUS_GUARD_HANDLERS}
357
+
358
+ ${SE}
316
359
  set tf to (${path})
360
+ ${alreadyHoldsBlock("tf", v)}
317
361
  repeat ${n} times
362
+ my fgAssertFront("type \\"${v}\\" into the field")
318
363
  set focused of tf to true
319
364
  delay 0.15
365
+ ${focusedAssertBlock("tf", v)}
320
366
  keystroke "${v}"
321
367
  delay 0.1
322
368
  key code 48
@@ -329,6 +375,44 @@ export function axSetValueScript(path, value, attempts = 3) {
329
375
  error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
330
376
  end tell`;
331
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
+ }
332
416
  /**
333
417
  * set-group-number: drive ONE of the Repeat dialog's two numeric fields —
334
418
  * the cadence INTERVAL or the ENDS-AFTER COUNT — addressed by the LABEL ROW it
@@ -370,13 +454,18 @@ export function axSetGroupNumberScript(groupPath, target, value, attempts = 3, r
370
454
  const tol = Math.max(1, Math.trunc(rowTolerance));
371
455
  return `${AX_CADENCE_HANDLERS}
372
456
 
457
+ ${AX_FOCUS_GUARD_HANDLERS}
458
+
373
459
  ${SE}
374
460
  set g to (${groupPath})
375
461
  my cgSettle(g)
376
462
  set tf to my cgField(g, "${target}", ${tol})
463
+ ${alreadyHoldsBlock("tf", v)}
377
464
  repeat ${n} times
465
+ my fgAssertFront("type \\"${v}\\" into the ${target} field")
378
466
  set focused of tf to true
379
467
  delay 0.15
468
+ ${focusedAssertBlock("tf", v)}
380
469
  keystroke "${v}"
381
470
  delay 0.1
382
471
  key code 48
@@ -417,12 +506,17 @@ export function axSetRowFieldScript(containerPath, rowLabel, value, attempts = 3
417
506
  const tol = Math.max(1, Math.trunc(rowTolerance));
418
507
  return `${AX_CADENCE_HANDLERS}
419
508
 
509
+ ${AX_FOCUS_GUARD_HANDLERS}
510
+
420
511
  ${SE}
421
512
  set c to (${containerPath})
422
513
  set tf to my rfField(c, "${label}", ${tol})
514
+ ${alreadyHoldsBlock("tf", v)}
423
515
  repeat ${n} times
516
+ my fgAssertFront("type \\"${v}\\" into the \\"${label}\\" field")
424
517
  set focused of tf to true
425
518
  delay 0.15
519
+ ${focusedAssertBlock("tf", v)}
426
520
  keystroke "${v}"
427
521
  delay 0.1
428
522
  key code 48
@@ -568,11 +662,45 @@ on aqPad2(n)
568
662
  return s
569
663
  end aqPad2
570
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
+
571
696
  on aqYMD(t)
572
- -- Occurrence-pop-up titles are LOCALIZED ("Sun, Jul 12, 2026"), so the match is
573
- -- made by PARSING the title to a date and comparing calendar components — never
574
- -- by rebuilding the app's display string (the axSelectNextOccurrenceScript law).
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).
575
701
  set s to t as text
702
+ set rel to my aqRelative(s)
703
+ if rel is not missing value then return rel
576
704
  try
577
705
  set d to date s
578
706
  return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
@@ -998,7 +1126,10 @@ ${SE}
998
1126
  delay 0.3
999
1127
  end repeat
1000
1128
  if not (exists menu item wantVal of menu 1 of pu) then
1001
- 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.
1002
1133
  error "converge-weekdays: the weekday pop-up offers no item \\"" & wantVal & "\\" (the app may not be in English)"
1003
1134
  end if
1004
1135
  click menu item wantVal of menu 1 of pu
@@ -1151,16 +1282,28 @@ return "OK"`;
1151
1282
  export function axActivateScript() {
1152
1283
  return `tell application "Things3" to activate`;
1153
1284
  }
1154
- /** 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
+ */
1155
1292
  export function axKeyScript(keys) {
1156
1293
  const KEY_CODES = { return: 36, escape: 53, down: 125, up: 126, tab: 48 };
1294
+ const spec = keys.trim();
1157
1295
  const lines = keys
1158
1296
  .split(/\s+/)
1159
1297
  .filter((k) => k !== "")
1160
1298
  .map((k) => KEY_CODES[k] !== undefined
1161
1299
  ? `key code ${KEY_CODES[k]}`
1162
1300
  : `keystroke "${escapeAppleScript(k)}"`);
1163
- 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`;
1164
1307
  }
1165
1308
  /**
1166
1309
  * type-text: send literal text to whatever control holds focus (HXPC1). The
@@ -1176,7 +1319,10 @@ export function axKeyScript(keys) {
1176
1319
  * be committed. One stable command shape.
1177
1320
  */
1178
1321
  export function axTypeTextScript(text) {
1179
- return `${SE}
1322
+ return `${AX_FOCUS_GUARD_HANDLERS}
1323
+
1324
+ my fgAssertFront("type into the focused field")
1325
+ ${SE}
1180
1326
  keystroke "${escapeAppleScript(text)}"
1181
1327
  end tell`;
1182
1328
  }
@@ -1297,9 +1443,64 @@ export function axPickerRowFrameScript(pickerPath, title) {
1297
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)
1298
1444
  end tell`;
1299
1445
  }
1300
- /** The abort keystroke sent to dismiss a half-open sheet/popover on failure. */
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
+ */
1301
1453
  export function axAbortScript() {
1302
- 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`;
1485
+ }
1486
+ /**
1487
+ * The Cancel button's on-screen FRAME, resolved through the same addressed
1488
+ * dialog-shell path the AXPress dismissal uses (issue #629). Feeds the pointer
1489
+ * fallback: if `AXPress` on the button reports success and the dialog is still
1490
+ * standing, a real click at the button's own AX-resolved centre is the next
1491
+ * thing to try before discarding the window wholesale. The frame comes from the
1492
+ * tree, never from a remembered coordinate, so a moved dialog fails closed.
1493
+ */
1494
+ export function axCancelFrameScript() {
1495
+ return `${SE}
1496
+ ${AX_DIALOG_SHELL_SNIPPET}
1497
+ if shellRef is missing value then error "no dialog is open"
1498
+ if not (exists button "Cancel" of shellRef) then error "the open dialog has no Cancel button"
1499
+ set _b to button "Cancel" of shellRef
1500
+ set _p to position of _b
1501
+ set _s to size of _b
1502
+ 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)
1503
+ end tell`;
1303
1504
  }
1304
1505
  /**
1305
1506
  * The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
@@ -1359,36 +1560,233 @@ async function sheetStillOpen(run) {
1359
1560
  return !(res.ok && res.stdout.trim() === "false");
1360
1561
  }
1361
1562
  /**
1362
- * Clear a half-open sheet/popover a failed drive left behind HONESTLY (SESSGATE
1363
- * #480 fix; supersedes the old verifiedAbort, whose AX-blind sheet probe returned
1364
- * "gone" it could not actually see, letting the still-open modal freeze the
1365
- * app-wide AppleScript mutations the caller then attempted the auto-trash
1366
- * silent-noop). Escape first; then:
1367
- * - AX-BLIND (a not-reachable probe): Escape may never have reached the sheet
1368
- * and the sheet probe cannot see it, so run the app-level close+reopen that
1369
- * works blind (it takes the stuck sheet with the window). Reported as
1370
- * "cleared-blind"never falsely "confirmed gone".
1371
- * - REACHABLE: the sheet probe is trustworthy — confirm the dismissal (retry
1372
- * Escape once), and if it will not go, warn "may remain".
1563
+ * Is the dialog the census found OURS to dismiss? `expected` is the kind this
1564
+ * drive was observed driving (latched from the census the drive itself ran). A
1565
+ * kind that does not match is left strictly alone: between our failure and this
1566
+ * cleanup, the person at the keyboard may have opened something of their own,
1567
+ * and dismissing it would be a mutation nobody asked for.
1568
+ *
1569
+ * With nothing latched (a drive that failed before any dialog was observed) the
1570
+ * two kinds this vector's recipes actually open are still treated as ours —
1571
+ * they are the dialogs our own steps would have opened while an unrecognized
1572
+ * modal never is.
1573
+ */
1574
+ function oursToDismiss(kind, expected) {
1575
+ if (kind === "none")
1576
+ return false;
1577
+ if (expected !== null)
1578
+ return kind === expected;
1579
+ return kind === "repeat" || kind === "move-picker";
1580
+ }
1581
+ /** How many stacked dialogs the cleanup will unwind before falling to the next rung. */
1582
+ const MAX_DISMISS_ROUNDS = 4;
1583
+ /** Press the dialog's own Cancel button (element-addressed, background-safe). */
1584
+ async function pressCancel(run) {
1585
+ const res = await run({
1586
+ primitive: "dismiss-dialog",
1587
+ label: "dismiss the open dialog (its Cancel button)",
1588
+ script: axCancelDialogScript(),
1589
+ }, STEP_TIMEOUT_MS);
1590
+ return res.ok && res.stdout.trim() === "OK";
1591
+ }
1592
+ /**
1593
+ * The pointer fallback for the Cancel rung (issue #629): a real click at the
1594
+ * button's own AX-resolved centre, for the case where `AXPress` reports success
1595
+ * and the dialog is demonstrably still standing. Needs Things frontmost — the
1596
+ * HID tap posts at the foreground surface (NATIVE1-e) — so it activates first,
1597
+ * and re-reads the frame AFTERWARDS, because bringing Things forward can
1598
+ * re-attach a detached editor as a sheet and move the button.
1599
+ */
1600
+ async function clickCancel(run) {
1601
+ await run({
1602
+ primitive: "activate",
1603
+ label: "bring Things forward to click its dialog's Cancel button",
1604
+ script: axActivateScript(),
1605
+ }, STEP_TIMEOUT_MS);
1606
+ const frameRes = await run({
1607
+ primitive: "resolve-frame",
1608
+ label: "locate the open dialog's Cancel button",
1609
+ script: axCancelFrameScript(),
1610
+ }, STEP_TIMEOUT_MS);
1611
+ if (!frameRes.ok)
1612
+ return false;
1613
+ const center = parseFrameCenter(frameRes.stdout);
1614
+ if (center === null)
1615
+ return false;
1616
+ const res = await run(clickPointCommand(center.x, center.y, "click the open dialog's Cancel button"), STEP_TIMEOUT_MS);
1617
+ return res.ok;
1618
+ }
1619
+ /**
1620
+ * The dismissal that needs NO working inspection (issue #629): press the
1621
+ * dialog's own Cancel button, PROVE the dialog is gone with one addressed
1622
+ * existence read, and fall through to a real click at the button's frame if the
1623
+ * press reported success while the dialog stayed up.
1624
+ *
1625
+ * This is the rung the field incident needed and did not have. Its cleanup
1626
+ * re-ran the census that had just stalled, learned nothing three times over,
1627
+ * and ended in the AX-blind close+reopen — which left the sheet standing, and
1628
+ * with it the app-wide scripting freeze that stopped the composite trashing its
1629
+ * own disposable copy (MODALX1 §2.1) and the Things Cloud sync gate.
1630
+ *
1631
+ * Everything here is addressed inside `process "Things3"`: {@link
1632
+ * axCancelDialogScript} and {@link axSheetOpenScript} are the same shape as the
1633
+ * drive steps that kept working while the census did not.
1634
+ */
1635
+ async function semanticCancel(run, expected, owner) {
1636
+ const kind = expected === null ? {} : { sheetKind: expected };
1637
+ // A stack unwinds LIFO (MODALX1 §6), so press-and-verify in a loop rather
1638
+ // than pressing once and assuming.
1639
+ for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
1640
+ if (!(await pressCancel(run)))
1641
+ break;
1642
+ if (!(await sheetStillOpen(run))) {
1643
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner, unverified: true };
1644
+ }
1645
+ }
1646
+ if (await clickCancel(run)) {
1647
+ if (!(await sheetStillOpen(run))) {
1648
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner, unverified: true };
1649
+ }
1650
+ }
1651
+ return closeReopenRung(run, expected, owner, false, true);
1652
+ }
1653
+ /**
1654
+ * Clear a half-open dialog a failed drive left behind — AUDITED at every rung
1655
+ * (issue #620; supersedes the unconditional Escape, which was measured firing
1656
+ * into a foreign application's modal while the Things sheet it was meant for
1657
+ * stayed open all night).
1658
+ *
1659
+ * The ladder, cheapest and least disruptive first, re-reading the census after
1660
+ * every rung so nothing is ever CLAIMED to be dismissed:
1661
+ *
1662
+ * 0. census. No dialog + a reachable session → nothing to do. A dialog that
1663
+ * is not ours → left alone, reported.
1664
+ * 1. press the dialog's own CANCEL button — element-addressed, so it needs
1665
+ * neither focus nor the frontmost slot, and it cannot leak into another
1666
+ * app. This is the rung that clears the ordinary case.
1667
+ * 2. Escape, but only from a state where Things demonstrably owns the screen:
1668
+ * if it does not, RE-ACTIVATE Things, RE-AUDIT, and only then send it.
1669
+ * 3. the app-level close+reopen (SESSGATE) — the maneuver that works with no
1670
+ * Accessibility tree at all, and the documented recovery for the app-wide
1671
+ * AppleScript freeze a stuck sheet imposes (oddities §9cc), which is what
1672
+ * makes a caller's follow-up cleanup mutations land again.
1673
+ *
1674
+ * `expected` is the dialog kind this drive was observed driving; see
1675
+ * {@link oursToDismiss}.
1373
1676
  */
1374
- async function clearDialog(run) {
1375
- const escape = () => run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
1376
- await escape();
1377
- const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
1378
- if (!reach.reachable) {
1379
- // Cannot trust Escape or the sheet probe while AX-blind — use the proven
1380
- // app-level maneuver, which clears a stuck sheet without the Accessibility tree.
1677
+ async function clearDialog(run, expected = null, inspectionStalled = false) {
1678
+ // #629: the inspection already refused to answer once. Asking it again buys
1679
+ // nothing and costs the caller another deadline — go straight to the rung
1680
+ // that needs no inspection and proves itself with one addressed read.
1681
+ if (inspectionStalled)
1682
+ return semanticCancel(run, expected, {});
1683
+ const census = await readUiState(run, CENSUS_TIMEOUT_MS);
1684
+ if (censusUnverifiable(census))
1685
+ return semanticCancel(run, expected, {});
1686
+ const owner = census !== null && !census.thingsFrontmost ? { focusOwner: describeFocusOwner(census) } : {};
1687
+ const readable = census !== null && census.inspectable;
1688
+ // 0. A clean, readable "no dialog" — but only trustworthy on a session whose
1689
+ // windows are AX-visible at all: a locked screen / full-screen Space
1690
+ // enumerates ZERO windows, so the census would report "no dialog" for a
1691
+ // sheet that is very much open (SESSGATE). Confirm before believing it.
1692
+ if (readable && census.sheetOpen === false) {
1693
+ const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
1694
+ if (reach.reachable)
1695
+ return { state: "none" };
1696
+ // AX-blind: System Events enumerates zero windows for EVERY app, so the
1697
+ // census cannot see a sheet that is open — and equally cannot confirm one
1698
+ // is gone. Run the blind-proof maneuver and report it as unconfirmed.
1699
+ return closeReopenRung(run, expected, owner, true);
1700
+ }
1701
+ // 0b. A dialog someone else opened — never touched.
1702
+ if (readable && !oursToDismiss(census.sheetKind, expected)) {
1703
+ return { state: "foreign", sheetKind: census.sheetKind, ...owner };
1704
+ }
1705
+ const kind = readable ? { sheetKind: census.sheetKind } : {};
1706
+ // 1. Its own Cancel button — repeated while a STACK unwinds, because dialogs
1707
+ // nest and dismiss strictly LIFO (MODALX1 §6), re-reading between presses
1708
+ // so a dialog that is not ours stops the loop rather than being clicked.
1709
+ if (readable) {
1710
+ for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
1711
+ if (!(await pressCancel(run)))
1712
+ break;
1713
+ const after = await readUiState(run, CENSUS_TIMEOUT_MS);
1714
+ if (after === null || !after.inspectable)
1715
+ break;
1716
+ if (!after.sheetOpen)
1717
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
1718
+ if (!oursToDismiss(after.sheetKind, expected)) {
1719
+ return { state: "foreign", sheetKind: after.sheetKind, ...owner };
1720
+ }
1721
+ }
1722
+ }
1723
+ // 2. Escape — from a state where Things owns the screen, re-activating and
1724
+ // RE-AUDITING first when it does not (never a blind key into the unknown).
1725
+ let front = readable && census.thingsFrontmost;
1726
+ if (readable && !front) {
1381
1727
  await run({
1382
- primitive: "resolve",
1383
- label: "clear a stuck dialog (close the Things window and reopen it)",
1384
- script: axCloseReopenActivateScript(),
1728
+ primitive: "activate",
1729
+ label: "bring Things forward to dismiss its dialog",
1730
+ script: axActivateScript(),
1385
1731
  }, STEP_TIMEOUT_MS);
1386
- return { state: "cleared-blind" };
1732
+ const reaudit = await readUiState(run, CENSUS_TIMEOUT_MS);
1733
+ if (reaudit !== null && reaudit.inspectable) {
1734
+ if (!reaudit.sheetOpen)
1735
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
1736
+ if (!oursToDismiss(reaudit.sheetKind, expected)) {
1737
+ return { state: "foreign", sheetKind: reaudit.sheetKind, ...owner };
1738
+ }
1739
+ front = reaudit.thingsFrontmost;
1740
+ }
1387
1741
  }
1388
- if (!(await sheetStillOpen(run)))
1389
- return { state: "dismissed" };
1390
- await escape(); // one retry
1391
- return { state: (await sheetStillOpen(run)) ? "may-remain" : "dismissed" };
1742
+ if (front) {
1743
+ await run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
1744
+ const after = await readUiState(run, CENSUS_TIMEOUT_MS);
1745
+ if (after !== null && after.inspectable && !after.sheetOpen) {
1746
+ return { state: "dismissed", how: "escape", ...kind, ...owner };
1747
+ }
1748
+ }
1749
+ // 3. The blind-proof maneuver, last: it discards the half-entered dialog with
1750
+ // the window, and unwedges the app-wide AppleScript freeze with it.
1751
+ return closeReopenRung(run, expected, owner);
1752
+ }
1753
+ /**
1754
+ * The final rung: close+reopen the Things window, then re-audit if we can.
1755
+ * `blind` says the session itself is AX-blind, in which case NOTHING the census
1756
+ * reports afterwards is evidence — the outcome is honestly unconfirmed.
1757
+ */
1758
+ async function closeReopenRung(run, expected, owner, blind = false,
1759
+ /**
1760
+ * #629: the window-state inspection is not answering, so the outcome is
1761
+ * decided by the ADDRESSED sheet-open read instead of a fresh census. The
1762
+ * verdict is still proven — just proven by a narrower question.
1763
+ */
1764
+ inspectionStalled = false) {
1765
+ await run({
1766
+ primitive: "resolve",
1767
+ label: "clear a stuck dialog (close the Things window and reopen it)",
1768
+ script: axCloseReopenActivateScript(),
1769
+ }, STEP_TIMEOUT_MS);
1770
+ if (blind)
1771
+ return { state: "cleared-blind", how: "window-close", ...owner };
1772
+ if (inspectionStalled) {
1773
+ const kind = expected === null ? {} : { sheetKind: expected };
1774
+ if (!(await sheetStillOpen(run))) {
1775
+ return { state: "dismissed", how: "window-close", ...kind, ...owner, unverified: true };
1776
+ }
1777
+ return { state: "may-remain", ...kind, ...owner, unverified: true };
1778
+ }
1779
+ const after = await readUiState(run, CENSUS_TIMEOUT_MS);
1780
+ if (after === null || !after.inspectable) {
1781
+ return { state: "cleared-blind", how: "window-close", ...owner };
1782
+ }
1783
+ if (!after.sheetOpen)
1784
+ return { state: "dismissed", how: "window-close", ...owner };
1785
+ return {
1786
+ state: oursToDismiss(after.sheetKind, expected) ? "may-remain" : "foreign",
1787
+ sheetKind: after.sheetKind,
1788
+ ...owner,
1789
+ };
1392
1790
  }
1393
1791
  /**
1394
1792
  * The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
@@ -1660,6 +2058,15 @@ async function defaultRun(command, timeoutMs) {
1660
2058
  ...(res.timedOut === true && { timedOut: true }),
1661
2059
  };
1662
2060
  }
2061
+ /**
2062
+ * Read the live window/focus census through the shipped dispatch seam — the
2063
+ * transport behind the `ui-state` diagnostic (src/ui-state.ts) and, in tests,
2064
+ * behind any injected runner. READ-ONLY: the census clicks nothing, types
2065
+ * nothing, and changes no state; see src/write/vectors/ui-state.ts.
2066
+ */
2067
+ export function readLiveUiState(run = defaultRun) {
2068
+ return readUiState(run, CENSUS_TIMEOUT_MS);
2069
+ }
1663
2070
  /**
1664
2071
  * Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
1665
2072
  * step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
@@ -1694,6 +2101,176 @@ function tracingRun(inner) {
1694
2101
  return res;
1695
2102
  };
1696
2103
  }
2104
+ /**
2105
+ * PRIMITIVE CLASSIFICATION for the per-step guard (issue #620). What decides a
2106
+ * primitive's class is HOW macOS routes its effect, not what it looks like:
2107
+ *
2108
+ * - KEYSTROKE-CLASS — System Events `keystroke` / `key code`. The event is
2109
+ * handed to whatever application owns the screen, so these need Things
2110
+ * frontmost AND the dialog we opened still in front. The element half of
2111
+ * the guard (did the field actually take focus?) is asserted in-script, in
2112
+ * the same hop as the typing.
2113
+ * - POINTER-CLASS — mouse synthesis through the global HID event tap
2114
+ * (`CGEventPost(kCGHIDEventTap)`). It posts at the FOREGROUND surface
2115
+ * (NATIVE1-e: `CGEventPostToPid` is inert for Things' hit-testing), so a
2116
+ * click while another app is frontmost lands in that app's window. Frontmost
2117
+ * is required; focus is not (a click sets its own).
2118
+ * - Everything else is ELEMENT-ADDRESSED — `click <element>`, `set value`,
2119
+ * `set focused`, the ObjC `AXUIElementSetAttributeValue` date writes, and
2120
+ * every read. System Events delivers those to the element named, whether or
2121
+ * not the app is frontmost, so guarding them would only add a hop and
2122
+ * forbid perfectly good background work.
2123
+ * - `chord-post` is deliberately NOT guarded: it posts its key event with
2124
+ * `CGEventPostToPid`, which addresses the PROCESS rather than the focused
2125
+ * surface — the whole point of the heading-reorder gesture is that it runs
2126
+ * with Things in the background and the user's focus untouched (HEADORD1
2127
+ * 1h2a, CHORDMH1). A frontmost guard there would break a certified op.
2128
+ */
2129
+ const KEYSTROKE_CLASS = new Set([
2130
+ "key",
2131
+ "type-text",
2132
+ "set-value",
2133
+ "set-group-number",
2134
+ "set-row-field",
2135
+ ]);
2136
+ const POINTER_CLASS = new Set([
2137
+ "click-point",
2138
+ "sidebar-drag",
2139
+ "sidebar-held-drag",
2140
+ "sidebar-scroll",
2141
+ ]);
2142
+ /**
2143
+ * Judge one guard reading. Returns null to proceed, or the refusal sentence —
2144
+ * which always names who owns the screen, because that is the one fact the
2145
+ * person reading it cannot recover after the fact.
2146
+ *
2147
+ * Exported for the unit matrix: every branch here is a fail-closed decision
2148
+ * about synthetic input, and each one is worth a test.
2149
+ */
2150
+ export function judgeFocusGuard(state, expectedSheet, label) {
2151
+ const refuse = (why) => `refused to run "${label}": ${why}`;
2152
+ if (state === null) {
2153
+ return refuse("the window and focus state could not be read, so there is no proof the input would reach " +
2154
+ "Things — nothing was sent");
2155
+ }
2156
+ // #629: a probe that did not come back is a DIAGNOSTIC, not a state. Say so
2157
+ // in those words, name what could not be established, and route the drive
2158
+ // straight to its cleanup — the caller must not read this as "retry".
2159
+ if (censusUnverifiable(state)) {
2160
+ return refuse(`the window state inspection timed out — treating the dialog as unverifiable (${describeUnprovenProbes(state)}). Nothing was sent, and the dialog this command opened is being closed. Check that Things ` +
2161
+ "is responding, then run the same command again");
2162
+ }
2163
+ if (!state.inspectable) {
2164
+ return refuse(`${describeFocusOwner(state)}. Input sent now would go to it, not to Things — nothing was ` +
2165
+ "sent. Answer or dismiss the system dialog, then run the same command again");
2166
+ }
2167
+ if (!state.thingsFrontmost) {
2168
+ return refuse(`${describeFocusOwner(state)}, so the input would go there instead of to Things — nothing ` +
2169
+ "was sent. Leave Things in front while it is being driven, then run the same command again");
2170
+ }
2171
+ if (expectedSheet !== null && state.sheetKind !== expectedSheet) {
2172
+ return refuse(`the dialog this command opened is no longer the one in front (expected ${expectedSheet}, ` +
2173
+ `found ${state.sheetKind}) — it was closed or replaced while the command was running, so ` +
2174
+ "nothing was sent");
2175
+ }
2176
+ return null;
2177
+ }
2178
+ /**
2179
+ * Wrap the dispatch seam with the PER-STEP FOCUS GUARD (issue #620): before
2180
+ * every focus-routed hop, one cheap read-only census decides whether the input
2181
+ * can legitimately be delivered, and a violation ABORTS THE STEP rather than
2182
+ * typing into the void. Element-addressed hops pass straight through, so the
2183
+ * cost is paid only where it buys something.
2184
+ *
2185
+ * A closed loop, not a sleep: the census is a deterministic read of the live
2186
+ * state, taken immediately before the hop, and the in-script assertions close
2187
+ * the remaining milliseconds (UI-automation determinism doctrine; the #595
2188
+ * pre-commit audit and BEEP1 shape-settle are the same pattern).
2189
+ */
2190
+ function guardedRun(inner, latch) {
2191
+ return async (command, timeoutMs) => {
2192
+ if (!KEYSTROKE_CLASS.has(command.primitive) && !POINTER_CLASS.has(command.primitive)) {
2193
+ return inner(command, timeoutMs);
2194
+ }
2195
+ const state = await readUiState(inner, CENSUS_TIMEOUT_MS);
2196
+ // An inspection that would not answer poisons every later inspection's
2197
+ // credibility, so the cleanup ladder is told once and never asks again
2198
+ // (issue #629).
2199
+ if (state === null || censusUnverifiable(state))
2200
+ latch.inspectionStalled = true;
2201
+ // The dialog invariant applies to keystroke-class hops only: a pointer hop
2202
+ // is aimed at a frame it resolved a moment ago and fails closed on its own
2203
+ // if that frame moved.
2204
+ const expected = KEYSTROKE_CLASS.has(command.primitive) ? latch.sheet : null;
2205
+ const guardRefusal = judgeFocusGuard(state, expected, command.label);
2206
+ if (guardRefusal !== null) {
2207
+ trace(() => ({
2208
+ phase: "focus-guard",
2209
+ event: "refused",
2210
+ primitive: command.primitive,
2211
+ label: command.label,
2212
+ frontmost: state?.frontmostApp ?? null,
2213
+ sheetKind: state?.sheetKind ?? null,
2214
+ inspectable: state?.inspectable ?? false,
2215
+ stalled: state?.stalledProbes ?? null,
2216
+ }));
2217
+ return { ok: false, stdout: "", stderr: guardRefusal };
2218
+ }
2219
+ if (state !== null && state.sheetOpen && latch.sheet === null)
2220
+ latch.sheet = state.sheetKind;
2221
+ return inner(command, timeoutMs);
2222
+ };
2223
+ }
2224
+ /** How a dialog is named in a disclosure — behavior, not chrome. */
2225
+ function dialogNoun(kind) {
2226
+ switch (kind) {
2227
+ case "repeat":
2228
+ return "the repeat dialog";
2229
+ case "move-picker":
2230
+ return "the move-to-project chooser";
2231
+ default:
2232
+ return "a dialog";
2233
+ }
2234
+ }
2235
+ /**
2236
+ * The cleanup disclosure (issue #620). Every branch states what is TRUE of the
2237
+ * app right now, and — whenever a dialog may still be open — that Things Cloud
2238
+ * sync is held until someone dismisses it, which is the consequence a caller
2239
+ * cannot see and would otherwise discover hours later on another device.
2240
+ */
2241
+ export function describeCleanup(clear) {
2242
+ const owner = clear.focusOwner === undefined ? "" : ` (${clear.focusOwner} when cleanup started)`;
2243
+ // #629: when the window-state inspection stalled, the cleanup still ran and
2244
+ // still proved its outcome — through the narrower addressed read. Say which
2245
+ // it was, so nobody reads "confirmed closed" as more than it is.
2246
+ const how = clear.unverified === true ? " (the window state could not be inspected)" : "";
2247
+ switch (clear.state) {
2248
+ case "none":
2249
+ return "No dialog was left open in Things.";
2250
+ case "dismissed":
2251
+ return `${clear.how === "cancel-button"
2252
+ ? `${dialogNoun(clear.sheetKind)} was closed with its own Cancel button`
2253
+ : clear.how === "escape"
2254
+ ? `${dialogNoun(clear.sheetKind)} was dismissed with Escape`
2255
+ : `${dialogNoun(clear.sheetKind)} was cleared by closing and reopening the Things window`}, confirmed closed${how}${owner}.`;
2256
+ case "cleared-blind":
2257
+ return ("Things had no window reachable on the current screen (the Mac may be locked, or a" +
2258
+ " full-screen app is covering the desktop), so the open dialog could not be confirmed" +
2259
+ " through the on-screen layer — the Things window was closed and reopened to clear it," +
2260
+ " discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
2261
+ " before retrying.");
2262
+ case "foreign":
2263
+ return (`A dialog is open in Things that this command did not open (${dialogNoun(clear.sheetKind)}),` +
2264
+ ` so it was left exactly as it is${owner}. Dismiss it yourself when you are ready — and note` +
2265
+ ` that ${SYNC_GATE_WARNING}.`);
2266
+ case "may-remain":
2267
+ return (`WARNING: ${dialogNoun(clear.sheetKind)} may still be open in Things${owner} — neither its` +
2268
+ " Cancel button, nor Escape, nor closing and reopening the window would clear it. Dismiss" +
2269
+ " it in Things (click Cancel, or press Escape with Things in front) before retrying: a" +
2270
+ ` leftover dialog also disables the menu bar, so the next attempt would refuse. Also note` +
2271
+ ` that ${SYNC_GATE_WARNING}.`);
2272
+ }
2273
+ }
1697
2274
  /** The element paths the preflight canary resolves (static steps only). */
1698
2275
  function canaryPaths(recipe) {
1699
2276
  const out = [];
@@ -2088,7 +2665,16 @@ function auditFailureText(res) {
2088
2665
  return "the pre-commit dialog audit timed out; nothing was committed";
2089
2666
  return "the Repeat dialog could not be re-read before committing; nothing was committed";
2090
2667
  }
2091
- async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
2668
+ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
2669
+ // Every step below dispatches through the PER-STEP FOCUS GUARD (issue #620);
2670
+ // the latch records the dialog this drive is observed driving, so the cleanup
2671
+ // ladder can tell our own half-open dialog from one the user opened after us.
2672
+ const latch = { sheet: null, inspectionStalled: false };
2673
+ const run = guardedRun(rawRun, latch);
2674
+ // The cleanup ladder audits for itself (it is what decides whether a keystroke
2675
+ // may be sent at all), so it runs OUTSIDE the guard — and it is told when the
2676
+ // inspection has already stalled, so it never re-runs it (issue #629).
2677
+ const clearNow = () => clearDialog(rawRun, latch.sheet, latch.inspectionStalled);
2092
2678
  const done = [];
2093
2679
  // The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
2094
2680
  // own timeout on a slow production database (large + Things-Cloud syncing
@@ -2106,7 +2692,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2106
2692
  // modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
2107
2693
  // whose OK press was mid-commit could still land — the pipeline re-verifies
2108
2694
  // and shapes the final result accordingly.
2109
- const clear = await clearDialog(run);
2695
+ const clear = await clearNow();
2110
2696
  trace(() => ({
2111
2697
  phase: "watchdog",
2112
2698
  budgetMs,
@@ -2145,19 +2731,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2145
2731
  */
2146
2732
  stepTimedOut = false) => {
2147
2733
  const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
2148
- const cleanup = clear === undefined
2149
- ? ""
2150
- : clear.state === "dismissed"
2151
- ? " The open sheet/popover was dismissed (Escape, confirmed gone)."
2152
- : clear.state === "cleared-blind"
2153
- ? " Things had no window reachable on the current screen (the Mac may be locked, or a" +
2154
- " full-screen app is covering the desktop), so the open dialog could not be confirmed" +
2155
- " through the on-screen layer — the Things window was closed and reopened to clear it," +
2156
- " discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
2157
- " before retrying."
2158
- : " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
2159
- " Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
2160
- " make the next drive's preflight fail).";
2734
+ const cleanup = clear === undefined ? "" : ` ${describeCleanup(clear)}`;
2161
2735
  // #512: name an environment failure as one. A cleanup that had to run BLIND
2162
2736
  // is direct evidence the session went AX-blind mid-drive; a step killed by
2163
2737
  // its own deadline is the window not answering. Either way the app was not
@@ -2217,6 +2791,26 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2217
2791
  "to open the dialog. ";
2218
2792
  }
2219
2793
  }
2794
+ // 0¾. OPEN-DIALOG PRECONDITION (MODALX1 §3/§4, issue #620). A dialog already
2795
+ // standing when a drive starts is not ours and cannot be driven around:
2796
+ // it disables the menu bar (so a menu recipe's canary would miss and
2797
+ // guess at why), and it SWALLOWS the chord recipes' key events, which
2798
+ // pass their canary happily and then move nothing. The census says so
2799
+ // directly, for every recipe, before anything is pressed — and a dialog
2800
+ // standing here also means the app is ignoring scripted changes app-wide
2801
+ // and holding Things Cloud sync, which is the operator's real problem.
2802
+ const startState = await readUiState(rawRun, CENSUS_TIMEOUT_MS);
2803
+ // An inspection that stalls at the very first hop is remembered, so a later
2804
+ // failure's cleanup does not go asking it again (issue #629). The preflight
2805
+ // itself stays permissive — only a POSITIVE sighting refuses (MODALX1 §7).
2806
+ if (censusUnverifiable(startState))
2807
+ latch.inspectionStalled = true;
2808
+ if (startState !== null && startState.inspectable && startState.sheetOpen) {
2809
+ 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 ` +
2810
+ "app disables its menu bar, ignores keyboard input aimed at anything else, and " +
2811
+ `${SYNC_GATE_WARNING}. Dismiss it in Things (click Cancel, or press Escape with Things in ` +
2812
+ "front), then run the same command again. Nothing was pressed.");
2813
+ }
2220
2814
  // 1. Recipe canary: resolve every statically-reachable element (now that the
2221
2815
  // target is selected). A miss refuses the whole drive before anything is
2222
2816
  // pressed. (This is also the localization check: English titles must resolve.)
@@ -2256,7 +2850,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2256
2850
  // control whose CLASS changed, and only the matching one runs.
2257
2851
  if (step.onlyShape !== undefined) {
2258
2852
  if (dialogShape === null) {
2259
- const clear = await clearDialog(run);
2853
+ const clear = await clearNow();
2260
2854
  return partial(step.label, SHAPE_UNPROBED, clear);
2261
2855
  }
2262
2856
  if (step.onlyShape !== dialogShape)
@@ -2266,12 +2860,12 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2266
2860
  // introduced, and the weekday-row base index).
2267
2861
  if (step.shaped !== undefined) {
2268
2862
  if (dialogShape === null) {
2269
- const clear = await clearDialog(run);
2863
+ const clear = await clearNow();
2270
2864
  return partial(step.label, SHAPE_UNPROBED, clear);
2271
2865
  }
2272
2866
  const override = step.shaped[dialogShape];
2273
2867
  if (override === undefined) {
2274
- const clear = await clearDialog(run);
2868
+ const clear = await clearNow();
2275
2869
  return partial(step.label, `this step has no drive for the "${dialogShape}" Repeat dialog (recipe bug)`, clear);
2276
2870
  }
2277
2871
  step = { ...step, ...override };
@@ -2288,7 +2882,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2288
2882
  const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
2289
2883
  if (!ok) {
2290
2884
  // the abort keystroke must land (and be verified) before returning the partial-state report
2291
- const clear = await clearDialog(run);
2885
+ const clear = await clearNow();
2292
2886
  return partial(step.label, "the expected element never appeared within the timeout", clear);
2293
2887
  }
2294
2888
  done.push(step.label);
@@ -2330,7 +2924,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2330
2924
  const effective = await resolveStepPath(step, run);
2331
2925
  if (effective === null) {
2332
2926
  // dismiss whatever opened (and verify) before reporting
2333
- const clear = await clearDialog(run);
2927
+ const clear = await clearNow();
2334
2928
  return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
2335
2929
  "detached repeat editor window)", clear);
2336
2930
  }
@@ -2346,7 +2940,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2346
2940
  const res = await run(command, STEP_TIMEOUT_MS);
2347
2941
  const verdict = res.stdout.trim();
2348
2942
  if (!res.ok || (verdict !== "next-popup" && verdict !== "legacy")) {
2349
- const clear = await clearDialog(run);
2943
+ const clear = await clearNow();
2350
2944
  return partial(step.label, res.ok
2351
2945
  ? 'its first-occurrence row ("Next:") holds neither an occurrence pop-up nor a date ' +
2352
2946
  "field, so the dialog matched neither known shape — a Things update has redesigned " +
@@ -2367,7 +2961,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2367
2961
  const res = await run(command, STEP_TIMEOUT_MS);
2368
2962
  if (!res.ok || res.stdout.trim() !== "OK") {
2369
2963
  // clear any transient state (and verify) before reporting
2370
- const clear = await clearDialog(run);
2964
+ const clear = await clearNow();
2371
2965
  const noMatch = step.primitive === "select-heading-row"
2372
2966
  ? "the project view exposed no selectable heading row at the target position — the " +
2373
2967
  "heading may have been converted/deleted already, or the project's headings changed"
@@ -2392,7 +2986,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2392
2986
  const verdict = res.stdout.trim();
2393
2987
  if (!res.ok || verdict !== "OK") {
2394
2988
  // clear any transient state (and verify) before reporting
2395
- const clear = await clearDialog(run);
2989
+ const clear = await clearNow();
2396
2990
  return partial(step.label, res.ok
2397
2991
  ? verdict !== ""
2398
2992
  ? verdict
@@ -2413,7 +3007,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2413
3007
  // the dialog through the standard clean-abort path, so nothing is committed.
2414
3008
  const outcome = await driveDialogAudit(step, run, dialogShape);
2415
3009
  if (!outcome.ok) {
2416
- const clear = await clearDialog(run);
3010
+ const clear = await clearNow();
2417
3011
  return partial(step.label, outcome.why ?? "the pre-commit dialog audit failed", clear);
2418
3012
  }
2419
3013
  done.push(step.label);
@@ -2428,7 +3022,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2428
3022
  const outcome = await driveClickElement(step, run);
2429
3023
  if (!outcome.ok) {
2430
3024
  // clear whatever the click opened (honest cleanup) before reporting
2431
- const clear = outcome.needsAbort === true ? await clearDialog(run) : undefined;
3025
+ const clear = outcome.needsAbort === true ? await clearNow() : undefined;
2432
3026
  return partial(step.label, outcome.why ?? "the click failed", clear);
2433
3027
  }
2434
3028
  done.push(step.label);
@@ -2439,12 +3033,13 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2439
3033
  if (!res.ok) {
2440
3034
  // clear the half-open sheet/popover (honest — never claim an unconfirmed
2441
3035
  // dismissal) before reporting partial state
2442
- const clear = step.primitive !== "reveal" && step.primitive !== "activate"
2443
- ? await clearDialog(run)
2444
- : undefined;
3036
+ const clear = step.primitive !== "reveal" && step.primitive !== "activate" ? await clearNow() : undefined;
2445
3037
  return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear, res.timedOut === true);
2446
3038
  }
2447
- done.push(step.label);
3039
+ // A typing primitive that found the field ALREADY holding the requested
3040
+ // value typed nothing at all (issue #620 item 7) — disclosed, so the trail
3041
+ // says what the drive did rather than what it intended.
3042
+ done.push(res.stdout.trim() === OK_ALREADY ? `${step.label} (already set)` : step.label);
2448
3043
  }
2449
3044
  return {
2450
3045
  exitCode: 0,
@@ -2555,6 +3150,11 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
2555
3150
  }
2556
3151
  return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
2557
3152
  },
3153
+ // Pre-seed dialog seam for the promote orchestrators (MODALX1, #620): a
3154
+ // composite's FIRST leg mints a row through the URL scheme, which sails
3155
+ // straight past an open dialog — and every AppleScript leg after it then
3156
+ // fails, leaving a copy behind. The orchestrator asks this BEFORE it seeds.
3157
+ probeUiState: () => readUiState(tracedRun, CENSUS_TIMEOUT_MS),
2558
3158
  // Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
2559
3159
  // live session BEFORE they seed a row, so a locked/full-screen session refuses
2560
3160
  // with zero mutation. Present regardless of `enabled` (the orchestrator has