things-api 0.19.1 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +82 -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 +157 -0
  49. package/dist/write/vectors/ui-state.js +237 -0
  50. package/dist/write/vectors/ui-state.js.map +1 -0
  51. package/dist/write/vectors/ui.d.ts +120 -4
  52. package/dist/write/vectors/ui.js +542 -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, describeFocusOwner, 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,45 @@ 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`;
1303
1485
  }
1304
1486
  /**
1305
1487
  * The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
@@ -1359,36 +1541,152 @@ async function sheetStillOpen(run) {
1359
1541
  return !(res.ok && res.stdout.trim() === "false");
1360
1542
  }
1361
1543
  /**
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".
1544
+ * Is the dialog the census found OURS to dismiss? `expected` is the kind this
1545
+ * drive was observed driving (latched from the census the drive itself ran). A
1546
+ * kind that does not match is left strictly alone: between our failure and this
1547
+ * cleanup, the person at the keyboard may have opened something of their own,
1548
+ * and dismissing it would be a mutation nobody asked for.
1549
+ *
1550
+ * With nothing latched (a drive that failed before any dialog was observed) the
1551
+ * two kinds this vector's recipes actually open are still treated as ours —
1552
+ * they are the dialogs our own steps would have opened while an unrecognized
1553
+ * modal never is.
1373
1554
  */
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.
1555
+ function oursToDismiss(kind, expected) {
1556
+ if (kind === "none")
1557
+ return false;
1558
+ if (expected !== null)
1559
+ return kind === expected;
1560
+ return kind === "repeat" || kind === "move-picker";
1561
+ }
1562
+ /** How many stacked dialogs the cleanup will unwind before falling to the next rung. */
1563
+ const MAX_DISMISS_ROUNDS = 4;
1564
+ /** Press the dialog's own Cancel button (element-addressed, background-safe). */
1565
+ async function pressCancel(run) {
1566
+ const res = await run({
1567
+ primitive: "dismiss-dialog",
1568
+ label: "dismiss the open dialog (its Cancel button)",
1569
+ script: axCancelDialogScript(),
1570
+ }, STEP_TIMEOUT_MS);
1571
+ return res.ok && res.stdout.trim() === "OK";
1572
+ }
1573
+ /**
1574
+ * Clear a half-open dialog a failed drive left behind — AUDITED at every rung
1575
+ * (issue #620; supersedes the unconditional Escape, which was measured firing
1576
+ * into a foreign application's modal while the Things sheet it was meant for
1577
+ * stayed open all night).
1578
+ *
1579
+ * The ladder, cheapest and least disruptive first, re-reading the census after
1580
+ * every rung so nothing is ever CLAIMED to be dismissed:
1581
+ *
1582
+ * 0. census. No dialog + a reachable session → nothing to do. A dialog that
1583
+ * is not ours → left alone, reported.
1584
+ * 1. press the dialog's own CANCEL button — element-addressed, so it needs
1585
+ * neither focus nor the frontmost slot, and it cannot leak into another
1586
+ * app. This is the rung that clears the ordinary case.
1587
+ * 2. Escape, but only from a state where Things demonstrably owns the screen:
1588
+ * if it does not, RE-ACTIVATE Things, RE-AUDIT, and only then send it.
1589
+ * 3. the app-level close+reopen (SESSGATE) — the maneuver that works with no
1590
+ * Accessibility tree at all, and the documented recovery for the app-wide
1591
+ * AppleScript freeze a stuck sheet imposes (oddities §9cc), which is what
1592
+ * makes a caller's follow-up cleanup mutations land again.
1593
+ *
1594
+ * `expected` is the dialog kind this drive was observed driving; see
1595
+ * {@link oursToDismiss}.
1596
+ */
1597
+ async function clearDialog(run, expected = null) {
1598
+ const census = await readUiState(run, STEP_TIMEOUT_MS);
1599
+ const owner = census !== null && !census.thingsFrontmost ? { focusOwner: describeFocusOwner(census) } : {};
1600
+ const readable = census !== null && census.inspectable;
1601
+ // 0. A clean, readable "no dialog" — but only trustworthy on a session whose
1602
+ // windows are AX-visible at all: a locked screen / full-screen Space
1603
+ // enumerates ZERO windows, so the census would report "no dialog" for a
1604
+ // sheet that is very much open (SESSGATE). Confirm before believing it.
1605
+ if (readable && census.sheetOpen === false) {
1606
+ const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
1607
+ if (reach.reachable)
1608
+ return { state: "none" };
1609
+ // AX-blind: System Events enumerates zero windows for EVERY app, so the
1610
+ // census cannot see a sheet that is open — and equally cannot confirm one
1611
+ // is gone. Run the blind-proof maneuver and report it as unconfirmed.
1612
+ return closeReopenRung(run, expected, owner, true);
1613
+ }
1614
+ // 0b. A dialog someone else opened — never touched.
1615
+ if (readable && !oursToDismiss(census.sheetKind, expected)) {
1616
+ return { state: "foreign", sheetKind: census.sheetKind, ...owner };
1617
+ }
1618
+ const kind = readable ? { sheetKind: census.sheetKind } : {};
1619
+ // 1. Its own Cancel button — repeated while a STACK unwinds, because dialogs
1620
+ // nest and dismiss strictly LIFO (MODALX1 §6), re-reading between presses
1621
+ // so a dialog that is not ours stops the loop rather than being clicked.
1622
+ if (readable) {
1623
+ for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
1624
+ if (!(await pressCancel(run)))
1625
+ break;
1626
+ const after = await readUiState(run, STEP_TIMEOUT_MS);
1627
+ if (after === null || !after.inspectable)
1628
+ break;
1629
+ if (!after.sheetOpen)
1630
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
1631
+ if (!oursToDismiss(after.sheetKind, expected)) {
1632
+ return { state: "foreign", sheetKind: after.sheetKind, ...owner };
1633
+ }
1634
+ }
1635
+ }
1636
+ // 2. Escape — from a state where Things owns the screen, re-activating and
1637
+ // RE-AUDITING first when it does not (never a blind key into the unknown).
1638
+ let front = readable && census.thingsFrontmost;
1639
+ if (readable && !front) {
1381
1640
  await run({
1382
- primitive: "resolve",
1383
- label: "clear a stuck dialog (close the Things window and reopen it)",
1384
- script: axCloseReopenActivateScript(),
1641
+ primitive: "activate",
1642
+ label: "bring Things forward to dismiss its dialog",
1643
+ script: axActivateScript(),
1385
1644
  }, STEP_TIMEOUT_MS);
1386
- return { state: "cleared-blind" };
1645
+ const reaudit = await readUiState(run, STEP_TIMEOUT_MS);
1646
+ if (reaudit !== null && reaudit.inspectable) {
1647
+ if (!reaudit.sheetOpen)
1648
+ return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
1649
+ if (!oursToDismiss(reaudit.sheetKind, expected)) {
1650
+ return { state: "foreign", sheetKind: reaudit.sheetKind, ...owner };
1651
+ }
1652
+ front = reaudit.thingsFrontmost;
1653
+ }
1387
1654
  }
1388
- if (!(await sheetStillOpen(run)))
1389
- return { state: "dismissed" };
1390
- await escape(); // one retry
1391
- return { state: (await sheetStillOpen(run)) ? "may-remain" : "dismissed" };
1655
+ if (front) {
1656
+ await run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
1657
+ const after = await readUiState(run, STEP_TIMEOUT_MS);
1658
+ if (after !== null && after.inspectable && !after.sheetOpen) {
1659
+ return { state: "dismissed", how: "escape", ...kind, ...owner };
1660
+ }
1661
+ }
1662
+ // 3. The blind-proof maneuver, last: it discards the half-entered dialog with
1663
+ // the window, and unwedges the app-wide AppleScript freeze with it.
1664
+ return closeReopenRung(run, expected, owner);
1665
+ }
1666
+ /**
1667
+ * The final rung: close+reopen the Things window, then re-audit if we can.
1668
+ * `blind` says the session itself is AX-blind, in which case NOTHING the census
1669
+ * reports afterwards is evidence — the outcome is honestly unconfirmed.
1670
+ */
1671
+ async function closeReopenRung(run, expected, owner, blind = false) {
1672
+ await run({
1673
+ primitive: "resolve",
1674
+ label: "clear a stuck dialog (close the Things window and reopen it)",
1675
+ script: axCloseReopenActivateScript(),
1676
+ }, STEP_TIMEOUT_MS);
1677
+ if (blind)
1678
+ return { state: "cleared-blind", how: "window-close", ...owner };
1679
+ const after = await readUiState(run, STEP_TIMEOUT_MS);
1680
+ if (after === null || !after.inspectable) {
1681
+ return { state: "cleared-blind", how: "window-close", ...owner };
1682
+ }
1683
+ if (!after.sheetOpen)
1684
+ return { state: "dismissed", how: "window-close", ...owner };
1685
+ return {
1686
+ state: oursToDismiss(after.sheetKind, expected) ? "may-remain" : "foreign",
1687
+ sheetKind: after.sheetKind,
1688
+ ...owner,
1689
+ };
1392
1690
  }
1393
1691
  /**
1394
1692
  * The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
@@ -1660,6 +1958,15 @@ async function defaultRun(command, timeoutMs) {
1660
1958
  ...(res.timedOut === true && { timedOut: true }),
1661
1959
  };
1662
1960
  }
1961
+ /**
1962
+ * Read the live window/focus census through the shipped dispatch seam — the
1963
+ * transport behind the `ui-state` diagnostic (src/ui-state.ts) and, in tests,
1964
+ * behind any injected runner. READ-ONLY: the census clicks nothing, types
1965
+ * nothing, and changes no state; see src/write/vectors/ui-state.ts.
1966
+ */
1967
+ export function readLiveUiState(run = defaultRun) {
1968
+ return readUiState(run, STEP_TIMEOUT_MS);
1969
+ }
1663
1970
  /**
1664
1971
  * Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
1665
1972
  * step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
@@ -1694,6 +2001,159 @@ function tracingRun(inner) {
1694
2001
  return res;
1695
2002
  };
1696
2003
  }
2004
+ /**
2005
+ * PRIMITIVE CLASSIFICATION for the per-step guard (issue #620). What decides a
2006
+ * primitive's class is HOW macOS routes its effect, not what it looks like:
2007
+ *
2008
+ * - KEYSTROKE-CLASS — System Events `keystroke` / `key code`. The event is
2009
+ * handed to whatever application owns the screen, so these need Things
2010
+ * frontmost AND the dialog we opened still in front. The element half of
2011
+ * the guard (did the field actually take focus?) is asserted in-script, in
2012
+ * the same hop as the typing.
2013
+ * - POINTER-CLASS — mouse synthesis through the global HID event tap
2014
+ * (`CGEventPost(kCGHIDEventTap)`). It posts at the FOREGROUND surface
2015
+ * (NATIVE1-e: `CGEventPostToPid` is inert for Things' hit-testing), so a
2016
+ * click while another app is frontmost lands in that app's window. Frontmost
2017
+ * is required; focus is not (a click sets its own).
2018
+ * - Everything else is ELEMENT-ADDRESSED — `click <element>`, `set value`,
2019
+ * `set focused`, the ObjC `AXUIElementSetAttributeValue` date writes, and
2020
+ * every read. System Events delivers those to the element named, whether or
2021
+ * not the app is frontmost, so guarding them would only add a hop and
2022
+ * forbid perfectly good background work.
2023
+ * - `chord-post` is deliberately NOT guarded: it posts its key event with
2024
+ * `CGEventPostToPid`, which addresses the PROCESS rather than the focused
2025
+ * surface — the whole point of the heading-reorder gesture is that it runs
2026
+ * with Things in the background and the user's focus untouched (HEADORD1
2027
+ * 1h2a, CHORDMH1). A frontmost guard there would break a certified op.
2028
+ */
2029
+ const KEYSTROKE_CLASS = new Set([
2030
+ "key",
2031
+ "type-text",
2032
+ "set-value",
2033
+ "set-group-number",
2034
+ "set-row-field",
2035
+ ]);
2036
+ const POINTER_CLASS = new Set([
2037
+ "click-point",
2038
+ "sidebar-drag",
2039
+ "sidebar-held-drag",
2040
+ "sidebar-scroll",
2041
+ ]);
2042
+ /**
2043
+ * Judge one guard reading. Returns null to proceed, or the refusal sentence —
2044
+ * which always names who owns the screen, because that is the one fact the
2045
+ * person reading it cannot recover after the fact.
2046
+ *
2047
+ * Exported for the unit matrix: every branch here is a fail-closed decision
2048
+ * about synthetic input, and each one is worth a test.
2049
+ */
2050
+ export function judgeFocusGuard(state, expectedSheet, label) {
2051
+ const refuse = (why) => `refused to run "${label}": ${why}`;
2052
+ if (state === null) {
2053
+ return refuse("the window and focus state could not be read, so there is no proof the input would reach " +
2054
+ "Things — nothing was sent");
2055
+ }
2056
+ if (!state.inspectable) {
2057
+ return refuse(`${describeFocusOwner(state)}. Input sent now would go to it, not to Things — nothing was ` +
2058
+ "sent. Answer or dismiss the system dialog, then run the same command again");
2059
+ }
2060
+ if (!state.thingsFrontmost) {
2061
+ return refuse(`${describeFocusOwner(state)}, so the input would go there instead of to Things — nothing ` +
2062
+ "was sent. Leave Things in front while it is being driven, then run the same command again");
2063
+ }
2064
+ if (expectedSheet !== null && state.sheetKind !== expectedSheet) {
2065
+ return refuse(`the dialog this command opened is no longer the one in front (expected ${expectedSheet}, ` +
2066
+ `found ${state.sheetKind}) — it was closed or replaced while the command was running, so ` +
2067
+ "nothing was sent");
2068
+ }
2069
+ return null;
2070
+ }
2071
+ /**
2072
+ * Wrap the dispatch seam with the PER-STEP FOCUS GUARD (issue #620): before
2073
+ * every focus-routed hop, one cheap read-only census decides whether the input
2074
+ * can legitimately be delivered, and a violation ABORTS THE STEP rather than
2075
+ * typing into the void. Element-addressed hops pass straight through, so the
2076
+ * cost is paid only where it buys something.
2077
+ *
2078
+ * A closed loop, not a sleep: the census is a deterministic read of the live
2079
+ * state, taken immediately before the hop, and the in-script assertions close
2080
+ * the remaining milliseconds (UI-automation determinism doctrine; the #595
2081
+ * pre-commit audit and BEEP1 shape-settle are the same pattern).
2082
+ */
2083
+ function guardedRun(inner, latch) {
2084
+ return async (command, timeoutMs) => {
2085
+ if (!KEYSTROKE_CLASS.has(command.primitive) && !POINTER_CLASS.has(command.primitive)) {
2086
+ return inner(command, timeoutMs);
2087
+ }
2088
+ const state = await readUiState(inner, STEP_TIMEOUT_MS);
2089
+ // The dialog invariant applies to keystroke-class hops only: a pointer hop
2090
+ // is aimed at a frame it resolved a moment ago and fails closed on its own
2091
+ // if that frame moved.
2092
+ const expected = KEYSTROKE_CLASS.has(command.primitive) ? latch.sheet : null;
2093
+ const guardRefusal = judgeFocusGuard(state, expected, command.label);
2094
+ if (guardRefusal !== null) {
2095
+ trace(() => ({
2096
+ phase: "focus-guard",
2097
+ event: "refused",
2098
+ primitive: command.primitive,
2099
+ label: command.label,
2100
+ frontmost: state?.frontmostApp ?? null,
2101
+ sheetKind: state?.sheetKind ?? null,
2102
+ inspectable: state?.inspectable ?? false,
2103
+ }));
2104
+ return { ok: false, stdout: "", stderr: guardRefusal };
2105
+ }
2106
+ if (state !== null && state.sheetOpen && latch.sheet === null)
2107
+ latch.sheet = state.sheetKind;
2108
+ return inner(command, timeoutMs);
2109
+ };
2110
+ }
2111
+ /** How a dialog is named in a disclosure — behavior, not chrome. */
2112
+ function dialogNoun(kind) {
2113
+ switch (kind) {
2114
+ case "repeat":
2115
+ return "the repeat dialog";
2116
+ case "move-picker":
2117
+ return "the move-to-project chooser";
2118
+ default:
2119
+ return "a dialog";
2120
+ }
2121
+ }
2122
+ /**
2123
+ * The cleanup disclosure (issue #620). Every branch states what is TRUE of the
2124
+ * app right now, and — whenever a dialog may still be open — that Things Cloud
2125
+ * sync is held until someone dismisses it, which is the consequence a caller
2126
+ * cannot see and would otherwise discover hours later on another device.
2127
+ */
2128
+ export function describeCleanup(clear) {
2129
+ const owner = clear.focusOwner === undefined ? "" : ` (${clear.focusOwner} when cleanup started)`;
2130
+ switch (clear.state) {
2131
+ case "none":
2132
+ return "No dialog was left open in Things.";
2133
+ case "dismissed":
2134
+ return `${clear.how === "cancel-button"
2135
+ ? `${dialogNoun(clear.sheetKind)} was closed with its own Cancel button`
2136
+ : clear.how === "escape"
2137
+ ? `${dialogNoun(clear.sheetKind)} was dismissed with Escape`
2138
+ : `${dialogNoun(clear.sheetKind)} was cleared by closing and reopening the Things window`}, confirmed closed${owner}.`;
2139
+ case "cleared-blind":
2140
+ return ("Things had no window reachable on the current screen (the Mac may be locked, or a" +
2141
+ " full-screen app is covering the desktop), so the open dialog could not be confirmed" +
2142
+ " through the on-screen layer — the Things window was closed and reopened to clear it," +
2143
+ " discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
2144
+ " before retrying.");
2145
+ case "foreign":
2146
+ return (`A dialog is open in Things that this command did not open (${dialogNoun(clear.sheetKind)}),` +
2147
+ ` so it was left exactly as it is${owner}. Dismiss it yourself when you are ready — and note` +
2148
+ ` that ${SYNC_GATE_WARNING}.`);
2149
+ case "may-remain":
2150
+ return (`WARNING: ${dialogNoun(clear.sheetKind)} may still be open in Things${owner} — neither its` +
2151
+ " Cancel button, nor Escape, nor closing and reopening the window would clear it. Dismiss" +
2152
+ " it in Things (click Cancel, or press Escape with Things in front) before retrying: a" +
2153
+ ` leftover dialog also disables the menu bar, so the next attempt would refuse. Also note` +
2154
+ ` that ${SYNC_GATE_WARNING}.`);
2155
+ }
2156
+ }
1697
2157
  /** The element paths the preflight canary resolves (static steps only). */
1698
2158
  function canaryPaths(recipe) {
1699
2159
  const out = [];
@@ -2088,7 +2548,15 @@ function auditFailureText(res) {
2088
2548
  return "the pre-commit dialog audit timed out; nothing was committed";
2089
2549
  return "the Repeat dialog could not be re-read before committing; nothing was committed";
2090
2550
  }
2091
- async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
2551
+ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
2552
+ // Every step below dispatches through the PER-STEP FOCUS GUARD (issue #620);
2553
+ // the latch records the dialog this drive is observed driving, so the cleanup
2554
+ // ladder can tell our own half-open dialog from one the user opened after us.
2555
+ const latch = { sheet: null };
2556
+ const run = guardedRun(rawRun, latch);
2557
+ // The cleanup ladder audits for itself (it is what decides whether a keystroke
2558
+ // may be sent at all), so it runs OUTSIDE the guard.
2559
+ const clearNow = () => clearDialog(rawRun, latch.sheet);
2092
2560
  const done = [];
2093
2561
  // The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
2094
2562
  // own timeout on a slow production database (large + Things-Cloud syncing
@@ -2106,7 +2574,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2106
2574
  // modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
2107
2575
  // whose OK press was mid-commit could still land — the pipeline re-verifies
2108
2576
  // and shapes the final result accordingly.
2109
- const clear = await clearDialog(run);
2577
+ const clear = await clearNow();
2110
2578
  trace(() => ({
2111
2579
  phase: "watchdog",
2112
2580
  budgetMs,
@@ -2145,19 +2613,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2145
2613
  */
2146
2614
  stepTimedOut = false) => {
2147
2615
  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).";
2616
+ const cleanup = clear === undefined ? "" : ` ${describeCleanup(clear)}`;
2161
2617
  // #512: name an environment failure as one. A cleanup that had to run BLIND
2162
2618
  // is direct evidence the session went AX-blind mid-drive; a step killed by
2163
2619
  // its own deadline is the window not answering. Either way the app was not
@@ -2217,6 +2673,21 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2217
2673
  "to open the dialog. ";
2218
2674
  }
2219
2675
  }
2676
+ // 0¾. OPEN-DIALOG PRECONDITION (MODALX1 §3/§4, issue #620). A dialog already
2677
+ // standing when a drive starts is not ours and cannot be driven around:
2678
+ // it disables the menu bar (so a menu recipe's canary would miss and
2679
+ // guess at why), and it SWALLOWS the chord recipes' key events, which
2680
+ // pass their canary happily and then move nothing. The census says so
2681
+ // directly, for every recipe, before anything is pressed — and a dialog
2682
+ // standing here also means the app is ignoring scripted changes app-wide
2683
+ // and holding Things Cloud sync, which is the operator's real problem.
2684
+ const startState = await readUiState(rawRun, STEP_TIMEOUT_MS);
2685
+ if (startState !== null && startState.inspectable && startState.sheetOpen) {
2686
+ return refusal(`ui preflight refused: a dialog is already open in Things (${startState.sheetKind}${startState.sheetDepth > 1 ? `, on top of ${startState.sheetDepth - 1} more` : ""}), most likely left over from an earlier command or opened by hand. While one is open the ` +
2687
+ "app disables its menu bar, ignores keyboard input aimed at anything else, and " +
2688
+ `${SYNC_GATE_WARNING}. Dismiss it in Things (click Cancel, or press Escape with Things in ` +
2689
+ "front), then run the same command again. Nothing was pressed.");
2690
+ }
2220
2691
  // 1. Recipe canary: resolve every statically-reachable element (now that the
2221
2692
  // target is selected). A miss refuses the whole drive before anything is
2222
2693
  // pressed. (This is also the localization check: English titles must resolve.)
@@ -2256,7 +2727,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2256
2727
  // control whose CLASS changed, and only the matching one runs.
2257
2728
  if (step.onlyShape !== undefined) {
2258
2729
  if (dialogShape === null) {
2259
- const clear = await clearDialog(run);
2730
+ const clear = await clearNow();
2260
2731
  return partial(step.label, SHAPE_UNPROBED, clear);
2261
2732
  }
2262
2733
  if (step.onlyShape !== dialogShape)
@@ -2266,12 +2737,12 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2266
2737
  // introduced, and the weekday-row base index).
2267
2738
  if (step.shaped !== undefined) {
2268
2739
  if (dialogShape === null) {
2269
- const clear = await clearDialog(run);
2740
+ const clear = await clearNow();
2270
2741
  return partial(step.label, SHAPE_UNPROBED, clear);
2271
2742
  }
2272
2743
  const override = step.shaped[dialogShape];
2273
2744
  if (override === undefined) {
2274
- const clear = await clearDialog(run);
2745
+ const clear = await clearNow();
2275
2746
  return partial(step.label, `this step has no drive for the "${dialogShape}" Repeat dialog (recipe bug)`, clear);
2276
2747
  }
2277
2748
  step = { ...step, ...override };
@@ -2288,7 +2759,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2288
2759
  const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
2289
2760
  if (!ok) {
2290
2761
  // the abort keystroke must land (and be verified) before returning the partial-state report
2291
- const clear = await clearDialog(run);
2762
+ const clear = await clearNow();
2292
2763
  return partial(step.label, "the expected element never appeared within the timeout", clear);
2293
2764
  }
2294
2765
  done.push(step.label);
@@ -2330,7 +2801,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2330
2801
  const effective = await resolveStepPath(step, run);
2331
2802
  if (effective === null) {
2332
2803
  // dismiss whatever opened (and verify) before reporting
2333
- const clear = await clearDialog(run);
2804
+ const clear = await clearNow();
2334
2805
  return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
2335
2806
  "detached repeat editor window)", clear);
2336
2807
  }
@@ -2346,7 +2817,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2346
2817
  const res = await run(command, STEP_TIMEOUT_MS);
2347
2818
  const verdict = res.stdout.trim();
2348
2819
  if (!res.ok || (verdict !== "next-popup" && verdict !== "legacy")) {
2349
- const clear = await clearDialog(run);
2820
+ const clear = await clearNow();
2350
2821
  return partial(step.label, res.ok
2351
2822
  ? 'its first-occurrence row ("Next:") holds neither an occurrence pop-up nor a date ' +
2352
2823
  "field, so the dialog matched neither known shape — a Things update has redesigned " +
@@ -2367,7 +2838,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2367
2838
  const res = await run(command, STEP_TIMEOUT_MS);
2368
2839
  if (!res.ok || res.stdout.trim() !== "OK") {
2369
2840
  // clear any transient state (and verify) before reporting
2370
- const clear = await clearDialog(run);
2841
+ const clear = await clearNow();
2371
2842
  const noMatch = step.primitive === "select-heading-row"
2372
2843
  ? "the project view exposed no selectable heading row at the target position — the " +
2373
2844
  "heading may have been converted/deleted already, or the project's headings changed"
@@ -2392,7 +2863,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2392
2863
  const verdict = res.stdout.trim();
2393
2864
  if (!res.ok || verdict !== "OK") {
2394
2865
  // clear any transient state (and verify) before reporting
2395
- const clear = await clearDialog(run);
2866
+ const clear = await clearNow();
2396
2867
  return partial(step.label, res.ok
2397
2868
  ? verdict !== ""
2398
2869
  ? verdict
@@ -2413,7 +2884,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2413
2884
  // the dialog through the standard clean-abort path, so nothing is committed.
2414
2885
  const outcome = await driveDialogAudit(step, run, dialogShape);
2415
2886
  if (!outcome.ok) {
2416
- const clear = await clearDialog(run);
2887
+ const clear = await clearNow();
2417
2888
  return partial(step.label, outcome.why ?? "the pre-commit dialog audit failed", clear);
2418
2889
  }
2419
2890
  done.push(step.label);
@@ -2428,7 +2899,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2428
2899
  const outcome = await driveClickElement(step, run);
2429
2900
  if (!outcome.ok) {
2430
2901
  // clear whatever the click opened (honest cleanup) before reporting
2431
- const clear = outcome.needsAbort === true ? await clearDialog(run) : undefined;
2902
+ const clear = outcome.needsAbort === true ? await clearNow() : undefined;
2432
2903
  return partial(step.label, outcome.why ?? "the click failed", clear);
2433
2904
  }
2434
2905
  done.push(step.label);
@@ -2439,12 +2910,13 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
2439
2910
  if (!res.ok) {
2440
2911
  // clear the half-open sheet/popover (honest — never claim an unconfirmed
2441
2912
  // dismissal) before reporting partial state
2442
- const clear = step.primitive !== "reveal" && step.primitive !== "activate"
2443
- ? await clearDialog(run)
2444
- : undefined;
2913
+ const clear = step.primitive !== "reveal" && step.primitive !== "activate" ? await clearNow() : undefined;
2445
2914
  return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear, res.timedOut === true);
2446
2915
  }
2447
- done.push(step.label);
2916
+ // A typing primitive that found the field ALREADY holding the requested
2917
+ // value typed nothing at all (issue #620 item 7) — disclosed, so the trail
2918
+ // says what the drive did rather than what it intended.
2919
+ done.push(res.stdout.trim() === OK_ALREADY ? `${step.label} (already set)` : step.label);
2448
2920
  }
2449
2921
  return {
2450
2922
  exitCode: 0,
@@ -2555,6 +3027,11 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
2555
3027
  }
2556
3028
  return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
2557
3029
  },
3030
+ // Pre-seed dialog seam for the promote orchestrators (MODALX1, #620): a
3031
+ // composite's FIRST leg mints a row through the URL scheme, which sails
3032
+ // straight past an open dialog — and every AppleScript leg after it then
3033
+ // fails, leaving a copy behind. The orchestrator asks this BEFORE it seeds.
3034
+ probeUiState: () => readUiState(tracedRun, STEP_TIMEOUT_MS),
2558
3035
  // Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
2559
3036
  // live session BEFORE they seed a row, so a locked/full-screen session refuses
2560
3037
  // with zero mutation. Present regardless of `enabled` (the orchestrator has