things-api 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +31 -11
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/commands/area.d.ts +34 -2
  8. package/dist/cli/commands/area.js +140 -28
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +44 -5
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +23 -4
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +23 -1
  16. package/dist/cli/commands/project.js +82 -15
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.d.ts +6 -125
  19. package/dist/cli/commands/reads.js +649 -657
  20. package/dist/cli/commands/reads.js.map +1 -1
  21. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  22. package/dist/cli/commands/repeat-flags.js +76 -0
  23. package/dist/cli/commands/repeat-flags.js.map +1 -0
  24. package/dist/cli/commands/setup.js +2 -3
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.d.ts +1 -1
  27. package/dist/cli/commands/show.js +169 -18
  28. package/dist/cli/commands/show.js.map +1 -1
  29. package/dist/cli/commands/snapshot.js +1 -1
  30. package/dist/cli/commands/snapshot.js.map +1 -1
  31. package/dist/cli/commands/todo.d.ts +1 -1
  32. package/dist/cli/commands/todo.js +21 -9
  33. package/dist/cli/commands/todo.js.map +1 -1
  34. package/dist/cli/commands/writes.js +378 -148
  35. package/dist/cli/commands/writes.js.map +1 -1
  36. package/dist/cli/did-you-mean.d.ts +29 -0
  37. package/dist/cli/did-you-mean.js +53 -0
  38. package/dist/cli/did-you-mean.js.map +1 -0
  39. package/dist/cli/excess-args.d.ts +15 -0
  40. package/dist/cli/excess-args.js +51 -0
  41. package/dist/cli/excess-args.js.map +1 -0
  42. package/dist/cli/glyphs.d.ts +67 -7
  43. package/dist/cli/glyphs.js +202 -19
  44. package/dist/cli/glyphs.js.map +1 -1
  45. package/dist/cli/help.d.ts +54 -0
  46. package/dist/cli/help.js +355 -0
  47. package/dist/cli/help.js.map +1 -0
  48. package/dist/cli/main.js +36 -21
  49. package/dist/cli/main.js.map +1 -1
  50. package/dist/cli/period.d.ts +39 -0
  51. package/dist/cli/period.js +141 -0
  52. package/dist/cli/period.js.map +1 -0
  53. package/dist/cli/read-driver.d.ts +83 -0
  54. package/dist/cli/read-driver.js +215 -0
  55. package/dist/cli/read-driver.js.map +1 -0
  56. package/dist/cli/render.d.ts +246 -0
  57. package/dist/cli/render.js +806 -0
  58. package/dist/cli/render.js.map +1 -0
  59. package/dist/cli/resolve-invocation.d.ts +116 -0
  60. package/dist/cli/resolve-invocation.js +261 -0
  61. package/dist/cli/resolve-invocation.js.map +1 -0
  62. package/dist/cli/shell-quote.d.ts +9 -0
  63. package/dist/cli/shell-quote.js +12 -0
  64. package/dist/cli/shell-quote.js.map +1 -0
  65. package/dist/cli/tag-filters.d.ts +59 -0
  66. package/dist/cli/tag-filters.js +57 -0
  67. package/dist/cli/tag-filters.js.map +1 -0
  68. package/dist/cli/verb-hint.d.ts +25 -0
  69. package/dist/cli/verb-hint.js +138 -0
  70. package/dist/cli/verb-hint.js.map +1 -0
  71. package/dist/cli/width.d.ts +135 -0
  72. package/dist/cli/width.js +313 -0
  73. package/dist/cli/width.js.map +1 -0
  74. package/dist/client.d.ts +146 -24
  75. package/dist/client.js +106 -16
  76. package/dist/client.js.map +1 -1
  77. package/dist/config.d.ts +11 -0
  78. package/dist/config.js +3 -0
  79. package/dist/config.js.map +1 -1
  80. package/dist/contracts.d.ts +136 -1
  81. package/dist/contracts.js +44 -1
  82. package/dist/contracts.js.map +1 -1
  83. package/dist/db/fingerprint.d.ts +12 -0
  84. package/dist/db/fingerprint.js +15 -1
  85. package/dist/db/fingerprint.js.map +1 -1
  86. package/dist/db/locate.js +1 -1
  87. package/dist/db/locate.js.map +1 -1
  88. package/dist/diagnose.d.ts +63 -0
  89. package/dist/diagnose.js +38 -1
  90. package/dist/diagnose.js.map +1 -1
  91. package/dist/index.d.ts +30 -4
  92. package/dist/index.js +35 -2
  93. package/dist/index.js.map +1 -1
  94. package/dist/mcp/server.d.ts +9 -1
  95. package/dist/mcp/server.js +850 -128
  96. package/dist/mcp/server.js.map +1 -1
  97. package/dist/model/entities.d.ts +40 -7
  98. package/dist/model/entities.js.map +1 -1
  99. package/dist/model/mappers.d.ts +7 -0
  100. package/dist/model/mappers.js +20 -3
  101. package/dist/model/mappers.js.map +1 -1
  102. package/dist/model/occurrences.d.ts +6 -1
  103. package/dist/model/occurrences.js +9 -5
  104. package/dist/model/occurrences.js.map +1 -1
  105. package/dist/model/recurrence.d.ts +17 -8
  106. package/dist/model/recurrence.js.map +1 -1
  107. package/dist/model/serialize.d.ts +28 -0
  108. package/dist/model/serialize.js +80 -0
  109. package/dist/model/serialize.js.map +1 -0
  110. package/dist/model/when-sugar.d.ts +47 -0
  111. package/dist/model/when-sugar.js +45 -0
  112. package/dist/model/when-sugar.js.map +1 -0
  113. package/dist/read/area-view.d.ts +8 -1
  114. package/dist/read/area-view.js +49 -13
  115. package/dist/read/area-view.js.map +1 -1
  116. package/dist/read/caps.d.ts +14 -0
  117. package/dist/read/caps.js +21 -0
  118. package/dist/read/caps.js.map +1 -0
  119. package/dist/read/filter-contract.d.ts +117 -0
  120. package/dist/read/filter-contract.js +78 -0
  121. package/dist/read/filter-contract.js.map +1 -0
  122. package/dist/read/log-boundary.d.ts +9 -4
  123. package/dist/read/log-boundary.js.map +1 -1
  124. package/dist/read/predicates.d.ts +61 -0
  125. package/dist/read/predicates.js +78 -0
  126. package/dist/read/predicates.js.map +1 -0
  127. package/dist/read/project-view.d.ts +12 -1
  128. package/dist/read/project-view.js +39 -8
  129. package/dist/read/project-view.js.map +1 -1
  130. package/dist/read/queries.d.ts +156 -8
  131. package/dist/read/queries.js +261 -38
  132. package/dist/read/queries.js.map +1 -1
  133. package/dist/read/search-rank.d.ts +36 -0
  134. package/dist/read/search-rank.js +31 -0
  135. package/dist/read/search-rank.js.map +1 -0
  136. package/dist/read/sections.d.ts +51 -0
  137. package/dist/read/sections.js +37 -0
  138. package/dist/read/sections.js.map +1 -0
  139. package/dist/read/show-target.d.ts +13 -4
  140. package/dist/read/show-target.js +11 -2
  141. package/dist/read/show-target.js.map +1 -1
  142. package/dist/read/sidebar-order.d.ts +19 -0
  143. package/dist/read/sidebar-order.js +93 -0
  144. package/dist/read/sidebar-order.js.map +1 -0
  145. package/dist/read/tags.d.ts +27 -3
  146. package/dist/read/tags.js +83 -12
  147. package/dist/read/tags.js.map +1 -1
  148. package/dist/read/truncation.d.ts +71 -0
  149. package/dist/read/truncation.js +217 -0
  150. package/dist/read/truncation.js.map +1 -0
  151. package/dist/read/views.d.ts +177 -19
  152. package/dist/read/views.js +390 -213
  153. package/dist/read/views.js.map +1 -1
  154. package/dist/surface-copy.d.ts +42 -0
  155. package/dist/surface-copy.js +55 -0
  156. package/dist/surface-copy.js.map +1 -1
  157. package/dist/sync-health.d.ts +78 -0
  158. package/dist/sync-health.js +312 -0
  159. package/dist/sync-health.js.map +1 -0
  160. package/dist/write/accessibility-probe.d.ts +12 -0
  161. package/dist/write/accessibility-probe.js +63 -0
  162. package/dist/write/accessibility-probe.js.map +1 -0
  163. package/dist/write/automation-probe.d.ts +7 -0
  164. package/dist/write/automation-probe.js +8 -1
  165. package/dist/write/automation-probe.js.map +1 -1
  166. package/dist/write/batch.js +4 -2
  167. package/dist/write/batch.js.map +1 -1
  168. package/dist/write/capabilities.d.ts +8 -0
  169. package/dist/write/capabilities.js +13 -5
  170. package/dist/write/capabilities.js.map +1 -1
  171. package/dist/write/commands.d.ts +2 -0
  172. package/dist/write/commands.js +562 -48
  173. package/dist/write/commands.js.map +1 -1
  174. package/dist/write/edit-checklist.js +3 -2
  175. package/dist/write/edit-checklist.js.map +1 -1
  176. package/dist/write/guards.d.ts +2 -7
  177. package/dist/write/guards.js +64 -6
  178. package/dist/write/guards.js.map +1 -1
  179. package/dist/write/heading.js +2 -0
  180. package/dist/write/heading.js.map +1 -1
  181. package/dist/write/lock.d.ts +40 -2
  182. package/dist/write/lock.js +91 -14
  183. package/dist/write/lock.js.map +1 -1
  184. package/dist/write/make-repeating-project.d.ts +4 -0
  185. package/dist/write/make-repeating-project.js +253 -0
  186. package/dist/write/make-repeating-project.js.map +1 -0
  187. package/dist/write/operations.d.ts +145 -1
  188. package/dist/write/operations.js +48 -0
  189. package/dist/write/operations.js.map +1 -1
  190. package/dist/write/pipeline.d.ts +19 -0
  191. package/dist/write/pipeline.js +109 -26
  192. package/dist/write/pipeline.js.map +1 -1
  193. package/dist/write/pre-state.d.ts +68 -3
  194. package/dist/write/pre-state.js +102 -3
  195. package/dist/write/pre-state.js.map +1 -1
  196. package/dist/write/reopen.js +3 -2
  197. package/dist/write/reopen.js.map +1 -1
  198. package/dist/write/reorder.js +27 -17
  199. package/dist/write/reorder.js.map +1 -1
  200. package/dist/write/repeat-rule.d.ts +22 -0
  201. package/dist/write/repeat-rule.js +258 -0
  202. package/dist/write/repeat-rule.js.map +1 -0
  203. package/dist/write/reversibility.js +49 -0
  204. package/dist/write/reversibility.js.map +1 -1
  205. package/dist/write/tag-refs.d.ts +47 -0
  206. package/dist/write/tag-refs.js +126 -0
  207. package/dist/write/tag-refs.js.map +1 -0
  208. package/dist/write/undo.d.ts +74 -5
  209. package/dist/write/undo.js +494 -80
  210. package/dist/write/undo.js.map +1 -1
  211. package/dist/write/vectors/registry.d.ts +17 -1
  212. package/dist/write/vectors/registry.js +18 -2
  213. package/dist/write/vectors/registry.js.map +1 -1
  214. package/dist/write/vectors/types.d.ts +119 -3
  215. package/dist/write/vectors/ui-certification.d.ts +48 -0
  216. package/dist/write/vectors/ui-certification.js +46 -0
  217. package/dist/write/vectors/ui-certification.js.map +1 -0
  218. package/dist/write/vectors/ui-drag.d.ts +188 -0
  219. package/dist/write/vectors/ui-drag.js +1095 -0
  220. package/dist/write/vectors/ui-drag.js.map +1 -0
  221. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  222. package/dist/write/vectors/ui-recipes.js +558 -0
  223. package/dist/write/vectors/ui-recipes.js.map +1 -0
  224. package/dist/write/vectors/ui.d.ts +137 -0
  225. package/dist/write/vectors/ui.js +656 -0
  226. package/dist/write/vectors/ui.js.map +1 -0
  227. package/dist/write/verify/delta.d.ts +31 -3
  228. package/dist/write/verify/delta.js +22 -6
  229. package/dist/write/verify/delta.js.map +1 -1
  230. package/dist/write/verify/poller.js +1 -0
  231. package/dist/write/verify/poller.js.map +1 -1
  232. package/package.json +1 -1
  233. package/dist/cli/exit-codes.d.ts +0 -26
  234. package/dist/cli/exit-codes.js +0 -26
  235. package/dist/cli/exit-codes.js.map +0 -1
  236. package/dist/cli/output.d.ts +0 -42
  237. package/dist/cli/output.js +0 -16
  238. package/dist/cli/output.js.map +0 -1
@@ -0,0 +1,1095 @@
1
+ /**
2
+ * Sidebar AREA drag-reorder driver for the ui vector (the AXDRAG1/AXDRAG2 lab
3
+ * campaigns). Moves an area to a new sidebar rank by synthesizing a real mouse
4
+ * drag through the NATIVE1 JXA ObjC-bridge / HID-tap path.
5
+ *
6
+ * Doctrine (docs/design/ui-vector.md + ax-initiative.md standing constraints):
7
+ * every click/drag anchor derives from AX-RESOLVED element frames read from the
8
+ * live tree immediately before the gesture — never a guessed pixel. Slot-
9
+ * boundary offsets computed FROM resolved frames are geometry, not guessing.
10
+ * Scroll-wheel synthesis is positionless and allowed. Fail-closed: an
11
+ * unresolvable sidebar/row refuses BEFORE any synthesis; every hop is followed
12
+ * by a database assert (order progressed as aimed; the area count and every
13
+ * to-do/project area assignment are unchanged), and an assert failure triggers
14
+ * one verified recovery drag back to the pre-op position before the op errors.
15
+ *
16
+ * The visibility LADDER (design rulings 2026-07-15, amended: edge-hover
17
+ * auto-scroll is REJECTED for production — app-controlled scroll velocity is
18
+ * too brittle; AXDRAG1-c stays lab evidence only):
19
+ * - Rung 1 (common case): pre-scroll until the source row and the drop
20
+ * boundary are simultaneously visible, then one certified AXDRAG1 drag.
21
+ * - Rung 2 (scroll-while-held): grab the source, synthesize SCROLL-WHEEL
22
+ * events while the drag is held so the list scrolls underneath the held
23
+ * item, re-resolve the target row's live frame, drop at the computed slot
24
+ * boundary. Gated on the AXDRAG2-a probe (mid-drag AX frame resolution AND
25
+ * mid-drag wheel delivery); not shipped unless both halves pass.
26
+ * - Rung 3 (multi-hop fallback, the correctness floor): move the area one
27
+ * viewport per hop — drop at the furthest visible slot toward the target,
28
+ * re-scroll, re-grab, repeat. The DB is asserted after EVERY hop, and an
29
+ * INFINITE-LOOP GUARD enforces termination: each hop must STRICTLY reduce
30
+ * the remaining distance to the target rank (one retry allowed, then the
31
+ * op aborts reporting where the area ended up — a partially-moved area is
32
+ * benign), under an absolute hop cap of ceil(areas / visible-slots) + 2.
33
+ *
34
+ * Load-bearing AXDRAG1 geometry — with NO hardcoded pixel geometry (design
35
+ * amendment 2026-07-15): every aimed coordinate and distance is derived at
36
+ * runtime from the live AX-resolved frames of the SAME snapshot generation
37
+ * (the lab observed 24px rows / 16px spacers / 40px slots, but the driver
38
+ * never assumes those numbers, so a text-size change only rescales it):
39
+ * - The drop boundary that inserts ABOVE a row is the midpoint of the region
40
+ * between it and the row above it — the spacer row's center when a spacer
41
+ * row sits between entity rows, the shared edge otherwise.
42
+ * - Slot pitch, where an estimate is needed (hop-cap sizing, span fallback),
43
+ * is the median adjacent y-delta of the resolved area rows.
44
+ * - Lifting the source collapses its slot (its whole group — nested project
45
+ * rows travel with it), so for DOWNWARD drags every static coordinate below
46
+ * the source shifts up by the source's group span. The span is computed
47
+ * from resolved frames (next area row top − source row top), so areas with
48
+ * visible nested projects stay correct.
49
+ * - Off-viewport rows still expose valid virtualized frames; visibility must
50
+ * be cross-checked against the scroll-area viewport rect.
51
+ * - Row identity: the AppleScript-seeded sidebar rows carry an EMPTY
52
+ * AXDescription — areas are identified by descendant AXStaticText segments.
53
+ * - Index semantics: TMArea."index" ascending == sidebar order; a drag may
54
+ * renumber a NEIGHBOR rather than the dragged row, so all asserts compare
55
+ * RELATIVE positions, never index values.
56
+ */
57
+ import { createHash } from "node:crypto";
58
+ /** The client-side default aux: reads area order + assignments from the DB. */
59
+ export function createUiDriveAux(db) {
60
+ return {
61
+ areaState() {
62
+ const areas = db
63
+ .prepare(`SELECT uuid, title, "index" AS idx FROM TMArea ORDER BY "index", uuid`)
64
+ .all();
65
+ const assignments = db
66
+ .prepare("SELECT uuid, COALESCE(area, '') AS a FROM TMTask WHERE trashed = 0 ORDER BY uuid")
67
+ .all();
68
+ const hash = createHash("sha256");
69
+ for (const row of assignments)
70
+ hash.update(`${row.uuid}:${row.a}\n`);
71
+ return {
72
+ areas: areas.map((a) => ({ uuid: a.uuid, title: a.title, index: a.idx })),
73
+ assignmentsDigest: hash.digest("hex"),
74
+ };
75
+ },
76
+ };
77
+ }
78
+ // -------------------------------------------------- JXA command shapes
79
+ // One stable script shape per primitive (NATIVE1/AXDRAG1 incantations,
80
+ // verbatim where they are load-bearing). All dispatch through the injectable
81
+ // UiRunner seam, so ladder logic is unit-testable without a GUI.
82
+ const JXA_PRELUDE = `ObjC.import('AppKit');
83
+ ObjC.import('ApplicationServices');
84
+ ObjC.import('CoreGraphics');
85
+ function pidOf(n){ return Application('System Events').processes.byName(n).unixId() }
86
+ function sleep(ms){ $.NSThread.sleepForTimeInterval(ms/1000) }
87
+ function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]) }
88
+ function sv(el,name){ var v=attr(el,name); return v? v.js : '' }
89
+ function frame(el){ var p=attr(el,'AXPosition'), z=attr(el,'AXSize'); if(!p||!z) return null;
90
+ var pd=ObjC.castRefToObject($.CFCopyDescription(p)).js, zd=ObjC.castRefToObject($.CFCopyDescription(z)).js;
91
+ var pm=pd.match(/x:([-0-9.]+) y:([-0-9.]+)/), zm=zd.match(/w:([-0-9.]+) h:([-0-9.]+)/);
92
+ return (pm&&zm)?{x:+pm[1],y:+pm[2],w:+zm[1],h:+zm[2]}:null }
93
+ function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a }
94
+ function findAll(el, wantRole, depth, acc){ acc=acc||[]; if(depth<0) return acc; var ch=kids(el);
95
+ for(var i=0;i<ch.length;i++){ if(sv(ch[i],'AXRole')===wantRole) acc.push(ch[i]); findAll(ch[i], wantRole, depth-1, acc) } return acc }
96
+ function appEl(){ return $.AXUIElementCreateApplication(pidOf('Things3')) }
97
+ function stdWindow(){ var ws=kids(appEl()); for(var i=0;i<ws.length;i++){ if(sv(ws[i],'AXSubrole')==='AXStandardWindow') return ws[i] } return ws.length?ws[0]:null }
98
+ function sidebarTable(){ var w=stdWindow(); if(!w) return null; var tables=findAll(w,'AXTable',12,[]); var best=null;
99
+ for(var i=0;i<tables.length;i++){ var f=frame(tables[i]); if(!f) continue; if(f.w<400){ if(!best||f.w<best.f.w) best={el:tables[i],f:f} } }
100
+ return best?best.el:null }
101
+ var MOVED=5, DOWN=1, UP=2, DRAG=6;
102
+ function mev(t,x,y,cs){ var e=$.CGEventCreateMouseEvent($(), t, $.CGPointMake(x,y), 0); if(cs) $.CGEventSetIntegerValueField(e,1,cs); return e }
103
+ function postHID(ev){ $.CGEventPost($.kCGHIDEventTap, ev) }`;
104
+ /** Snapshot: sidebar rows (text + frames), viewport rect, scroll fraction. */
105
+ export function jxaSidebarSnapshotScript() {
106
+ return `${JXA_PRELUDE}
107
+ function allText(el, acc, depth){ acc=acc||[]; depth=depth==null?6:depth; if(depth<0) return acc;
108
+ var v=sv(el,'AXValue'); if(v) acc.push(v); var d=sv(el,'AXDescription'); if(d) acc.push(d);
109
+ var t=sv(el,'AXTitle'); if(t) acc.push(t); var ch=kids(el); for(var i=0;i<ch.length;i++) allText(ch[i],acc,depth-1); return acc }
110
+ var t = sidebarTable();
111
+ var out = { viewport: null, scroll: null, rows: [] };
112
+ if (t !== null) {
113
+ var w = stdWindow();
114
+ var areas = findAll(w,'AXScrollArea',12,[]);
115
+ for (var i=0;i<areas.length;i++){ var f=frame(areas[i]); if(f && f.w<400){ out.viewport=f;
116
+ var bars=findAll(areas[i],'AXScrollBar',4,[]);
117
+ for (var b=0;b<bars.length;b++){ var v=attr(bars[b],'AXValue'); if(v===null) continue;
118
+ var d=ObjC.castRefToObject($.CFCopyDescription(v)).js; var m=d.match(/value = ([+\\-0-9.]+)/);
119
+ if(m){ out.scroll = +m[1]; break } }
120
+ break } }
121
+ var ch = kids(t);
122
+ for (var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
123
+ if (role==='AXRow'||role==='AXTableRow'){ var rf=frame(ch[r]);
124
+ out.rows.push({ text: allText(ch[r],[],6).join('|'), x: rf?rf.x:null, y: rf?rf.y:null, w: rf?rf.w:null, h: rf?rf.h:null }) } }
125
+ }
126
+ JSON.stringify(out)`;
127
+ }
128
+ /**
129
+ * Scroll: move the pointer over the sidebar center (wheel events target the
130
+ * surface under the cursor), then post `clicks` line-unit wheel events.
131
+ * Positive clicks move the CONTENT down (earlier rows return, row y grows);
132
+ * negative clicks reveal lower rows (row y shrinks) — AXDRAG1-b.
133
+ */
134
+ export function jxaSidebarScrollScript(clicks) {
135
+ const n = Math.trunc(clicks);
136
+ return `${JXA_PRELUDE}
137
+ var w = stdWindow();
138
+ var sb = null;
139
+ if (w !== null) { var sas = findAll(w,'AXScrollArea',12,[]);
140
+ for (var i=0;i<sas.length;i++){ var f=frame(sas[i]); if(f && f.w<400){ sb=f; break } } }
141
+ if (sb === null) { 'NO_SIDEBAR' } else {
142
+ postHID(mev(MOVED, sb.x + sb.w/2, sb.y + sb.h/2, 0)); sleep(50);
143
+ var n = ${n}, dir = n < 0 ? -1 : 1;
144
+ for (var i = 0; i < Math.abs(n); i++) {
145
+ var ev = $.CGEventCreateScrollWheelEvent($(), $.kCGScrollEventUnitLine, 1, dir * 3);
146
+ postHID(ev); sleep(60);
147
+ }
148
+ 'DONE'
149
+ }`;
150
+ }
151
+ /**
152
+ * Drag: the NATIVE1 gesture verbatim — move, down, 3px wiggle to open the drag
153
+ * session, ~25 interpolated drag events, a settle so the drop indicator locks,
154
+ * then up. Coordinates are AX-resolved frames + slot-boundary geometry computed
155
+ * by the caller in the SAME snapshot generation.
156
+ */
157
+ export function jxaSidebarDragScript(sx, sy, tx, ty) {
158
+ const [a, b, c, d] = [sx, sy, tx, ty].map(Math.round);
159
+ return `${JXA_PRELUDE}
160
+ var sx=${a}, sy=${b}, tx=${c}, ty=${d}, steps=25;
161
+ postHID(mev(MOVED, sx, sy, 0)); sleep(30);
162
+ postHID(mev(DOWN, sx, sy, 1)); sleep(120);
163
+ postHID(mev(DRAG, sx, sy - 3, 1)); sleep(30);
164
+ for (var i = 1; i <= steps; i++) { postHID(mev(DRAG, sx + (tx-sx)*i/steps, sy + (ty-sy)*i/steps, 1)); sleep(25) }
165
+ postHID(mev(DRAG, tx, ty, 1)); sleep(400);
166
+ postHID(mev(UP, tx, ty, 1));
167
+ 'DONE'`;
168
+ }
169
+ function snapshotCommand() {
170
+ return {
171
+ primitive: "sidebar-snapshot",
172
+ label: "read the sidebar rows and viewport",
173
+ lang: "javascript",
174
+ script: jxaSidebarSnapshotScript(),
175
+ };
176
+ }
177
+ function scrollCommand(clicks) {
178
+ return {
179
+ primitive: "sidebar-scroll",
180
+ label: `scroll the sidebar (${clicks} clicks)`,
181
+ lang: "javascript",
182
+ script: jxaSidebarScrollScript(clicks),
183
+ };
184
+ }
185
+ function dragCommand(sx, sy, tx, ty) {
186
+ return {
187
+ primitive: "sidebar-drag",
188
+ label: "drag the area row to the computed slot boundary",
189
+ lang: "javascript",
190
+ script: jxaSidebarDragScript(sx, sy, tx, ty),
191
+ };
192
+ }
193
+ /**
194
+ * Rung 2 — scroll-while-held (AXDRAG2-a: wheel events DO scroll the list while
195
+ * a drag is held, and AX frames re-resolve fresh mid-drag). One atomic
196
+ * gesture: grab the source, post wheel events until the anchor row's LIVE
197
+ * frame enters the visible band (direction re-derived every tick from the
198
+ * live frame), then move to the live-computed boundary and drop. No static
199
+ * corrections are needed — the mid-drag layout already reflects the lifted
200
+ * source. If the anchor never arrives within the tick budget, the script
201
+ * Escape-aborts (AXDRAG1-d: byte-identical index vector) and reports it.
202
+ */
203
+ export function jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, // null = drop below the last row (to-last)
204
+ maxTicks) {
205
+ const [a, b] = [sx, sy].map(Math.round);
206
+ const anchor = JSON.stringify(anchorTitle);
207
+ return `${JXA_PRELUDE}
208
+ function allText(el, acc, depth){ acc=acc||[]; depth=depth==null?6:depth; if(depth<0) return acc;
209
+ var v=sv(el,'AXValue'); if(v) acc.push(v); var d=sv(el,'AXDescription'); if(d) acc.push(d);
210
+ var t=sv(el,'AXTitle'); if(t) acc.push(t); var ch=kids(el); for(var i=0;i<ch.length;i++) allText(ch[i],acc,depth-1); return acc }
211
+ function liveRows(){ var t=sidebarTable(); if(!t) return []; var out=[]; var ch=kids(t);
212
+ for(var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
213
+ if(role==='AXRow'||role==='AXTableRow'){ var f=frame(ch[r]);
214
+ if(f) out.push({text:allText(ch[r],[],6).join('|'), f:f}) } }
215
+ out.sort(function(p,q){ return p.f.y-q.f.y }); return out }
216
+ function matches(text, title){ var segs=text.split('|');
217
+ for(var j=0;j<segs.length;j++){ if(segs[j]===title||segs[j]===title+'.') return true } return false }
218
+ function viewportRect(){ var w=stdWindow(); if(!w) return null; var sas=findAll(w,'AXScrollArea',12,[]);
219
+ for(var i=0;i<sas.length;i++){ var f=frame(sas[i]); if(f && f.w<400) return f } return null }
220
+ var sx=${a}, sy=${b}, anchorTitle=${anchor}, maxTicks=${Math.trunc(maxTicks)};
221
+ var vp = viewportRect();
222
+ if (vp === null) { JSON.stringify({aborted:true, why:'no sidebar viewport'}) } else {
223
+ var bandTop = vp.y + 6, bandBot = vp.y + vp.h - 6;
224
+ postHID(mev(MOVED, sx, sy, 0)); sleep(30);
225
+ postHID(mev(DOWN, sx, sy, 1)); sleep(120);
226
+ postHID(mev(DRAG, sx, sy - 3, 1)); sleep(30);
227
+ postHID(mev(DRAG, sx, sy, 1)); sleep(100);
228
+ function boundaryNow(){
229
+ var rows = liveRows(); if (rows.length === 0) return null;
230
+ if (anchorTitle === null) {
231
+ var last = rows[rows.length - 1];
232
+ var y = last.text === '' ? last.f.y + last.f.h/2 : last.f.y + last.f.h + last.f.h/4;
233
+ return { y: y, ready: (y >= bandTop && y <= bandBot) ? 1 : 0, dir: y > bandBot ? -3 : 3 };
234
+ }
235
+ var anchor = null, above = null;
236
+ for (var i = 0; i < rows.length; i++) {
237
+ if (matches(rows[i].text, anchorTitle)) { anchor = rows[i]; above = i > 0 ? rows[i-1] : null; break }
238
+ }
239
+ if (anchor === null) return null;
240
+ var y;
241
+ if (above === null) y = anchor.f.y + anchor.f.h/4;
242
+ else if (above.text === '') y = above.f.y + above.f.h/2;
243
+ else y = (above.f.y + above.f.h + anchor.f.y) / 2;
244
+ return { y: y, ready: (y >= bandTop && y <= bandBot) ? 1 : 0, dir: y > bandBot ? -3 : 3 };
245
+ }
246
+ var b0 = boundaryNow(), ticks = 0, result = null;
247
+ if (b0 === null) {
248
+ var kd=$.CGEventCreateKeyboardEvent($(),53,true), ku=$.CGEventCreateKeyboardEvent($(),53,false);
249
+ postHID(kd); sleep(20); postHID(ku); sleep(150); postHID(mev(UP, sx, sy, 1));
250
+ result = {aborted:true, why:'anchor row did not resolve mid-drag'};
251
+ } else {
252
+ while (b0 !== null && b0.ready === 0 && ticks < maxTicks) {
253
+ var ev = $.CGEventCreateScrollWheelEvent($(), $.kCGScrollEventUnitLine, 1, b0.dir);
254
+ postHID(ev); sleep(60);
255
+ postHID(mev(DRAG, sx, sy, 1)); sleep(90);
256
+ b0 = boundaryNow(); ticks++;
257
+ }
258
+ if (b0 === null || b0.ready === 0) {
259
+ var kd2=$.CGEventCreateKeyboardEvent($(),53,true), ku2=$.CGEventCreateKeyboardEvent($(),53,false);
260
+ postHID(kd2); sleep(20); postHID(ku2); sleep(150); postHID(mev(UP, sx, sy, 1));
261
+ result = {aborted:true, why:'anchor never entered the band', ticks:ticks};
262
+ } else {
263
+ // Post-wheel SETTLE: the list can drift a few px after the last tick
264
+ // (AXDRAG2-a saw ~8px). Wait until the live boundary is stable across
265
+ // two consecutive reads before aiming.
266
+ var stable = 0, lastY = null, w = 0;
267
+ for (w = 0; w < 12 && stable < 2; w++) {
268
+ postHID(mev(DRAG, sx, sy, 1)); sleep(140);
269
+ var bs = boundaryNow(); if (bs === null) break;
270
+ if (lastY !== null && Math.abs(bs.y - lastY) < 1) stable++;
271
+ else stable = 0;
272
+ lastY = bs.y; b0 = bs;
273
+ }
274
+ if (b0 === null || b0.ready === 0 || stable < 2) {
275
+ var kd3=$.CGEventCreateKeyboardEvent($(),53,true), ku3=$.CGEventCreateKeyboardEvent($(),53,false);
276
+ postHID(kd3); sleep(20); postHID(ku3); sleep(150); postHID(mev(UP, sx, sy, 1));
277
+ result = {aborted:true, why:'boundary never stabilized in the band before the drop', ticks:ticks};
278
+ } else {
279
+ var ty = b0.y;
280
+ for (var s = 1; s <= 15; s++) { postHID(mev(DRAG, sx, sy + (ty-sy)*s/15, 1)); sleep(25) }
281
+ // Final re-resolve at the destination: aim the LAST event at the
282
+ // freshest boundary in case anything shifted during the approach.
283
+ var bf = boundaryNow();
284
+ if (bf !== null && bf.ready === 1) ty = bf.y;
285
+ postHID(mev(DRAG, sx, ty, 1)); sleep(400);
286
+ postHID(mev(UP, sx, ty, 1));
287
+ result = {dropped:true, ticks:ticks, dropY:ty};
288
+ }
289
+ }
290
+ }
291
+ JSON.stringify(result)
292
+ }`;
293
+ }
294
+ function heldScrollDragCommand(sx, sy, anchorTitle, maxTicks) {
295
+ return {
296
+ primitive: "sidebar-held-drag",
297
+ label: "held-scroll drag toward the destination",
298
+ lang: "javascript",
299
+ script: jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, maxTicks),
300
+ meta: { sx, sy, anchorTitle, maxTicks },
301
+ };
302
+ }
303
+ // ---------------------------------------------------------- pure geometry
304
+ // NO hardcoded pixel geometry: aimed coordinates derive from resolved frames
305
+ // of the current snapshot (amendment 2026-07-15). The only fixed numbers are
306
+ // TOLERANCES (visibility margins), never aim points.
307
+ /** Visibility tolerance: keep grabs/drops at least this far inside the band. */
308
+ const BAND_PAD = 6;
309
+ /** Median of a non-empty number list (helper for frame-derived estimates). */
310
+ function median(values) {
311
+ if (values.length === 0)
312
+ return null;
313
+ const sorted = values.toSorted((a, b) => a - b);
314
+ return sorted[Math.floor(sorted.length / 2)] ?? null;
315
+ }
316
+ /** Median height of the table's spacer rows (rows with no static text). */
317
+ export function medianSpacerHeight(rows) {
318
+ return median(rows.filter((r) => r.text === "").map((r) => r.h));
319
+ }
320
+ /**
321
+ * Slot pitch estimate: the median y-delta between adjacent resolved area rows
322
+ * (falls back to entity-row height + spacer height when fewer than two area
323
+ * rows resolve).
324
+ */
325
+ export function slotPitch(orderedAreaRows, allRows) {
326
+ const deltas = [];
327
+ for (let i = 1; i < orderedAreaRows.length; i++) {
328
+ const prev = orderedAreaRows[i - 1];
329
+ const curr = orderedAreaRows[i];
330
+ deltas.push(curr.row.y - prev.row.y);
331
+ }
332
+ const m = median(deltas);
333
+ if (m !== null && m > 0)
334
+ return m;
335
+ const entityH = median(allRows.filter((r) => r.text !== "").map((r) => r.h)) ?? 24;
336
+ const spacerH = medianSpacerHeight(allRows) ?? entityH / 2;
337
+ return entityH + spacerH;
338
+ }
339
+ /**
340
+ * The boundary Y that inserts ABOVE `ref`: the midpoint of the region between
341
+ * `ref` and the row immediately above it (the spacer row's center when a
342
+ * spacer sits there; the shared edge when rows are contiguous). With no row
343
+ * above, half a spacer height above `ref` (derived, not assumed).
344
+ */
345
+ export function boundaryAboveRow(allRows, ref) {
346
+ const above = allRows.filter((r) => r !== ref && r.y < ref.y).toSorted((a, b) => b.y - a.y)[0];
347
+ if (above === undefined) {
348
+ // Nothing above (no gap exists): aim inside the row's TOP QUARTER — a
349
+ // drop in a row's top half resolves to insert-BEFORE it (AXDRAG1-a/D1).
350
+ return ref.y + ref.h / 4;
351
+ }
352
+ if (above.text === "")
353
+ return above.y + above.h / 2; // spacer center
354
+ return (above.y + above.h + ref.y) / 2; // shared-edge midpoint
355
+ }
356
+ /**
357
+ * The boundary Y that drops BELOW the final table row (to-last). The drop
358
+ * zone below the last entity IS the trailing spacer row when one exists
359
+ * (AXDRAG1-b landed to-last inside it) — its center keeps the drop in the
360
+ * scrollable band even when the list is pinned to the bottom.
361
+ */
362
+ export function boundaryBelowLast(allRows) {
363
+ const last = allRows.toSorted((a, b) => a.y - b.y).at(-1);
364
+ if (last === undefined)
365
+ return null;
366
+ if (last.text === "")
367
+ return last.y + last.h / 2; // trailing spacer center
368
+ const half = (medianSpacerHeight(allRows) ?? last.h / 2) / 2;
369
+ return last.y + last.h + half;
370
+ }
371
+ export function parseSidebarSnapshot(stdout) {
372
+ try {
373
+ const parsed = JSON.parse(stdout.trim());
374
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.rows))
375
+ return null;
376
+ return {
377
+ viewport: parsed.viewport ?? null,
378
+ scroll: parsed.scroll ?? null,
379
+ rows: parsed.rows.filter((r) => typeof r.y === "number" && typeof r.x === "number" && typeof r.h === "number"),
380
+ };
381
+ }
382
+ catch {
383
+ return null;
384
+ }
385
+ }
386
+ /**
387
+ * Does a row's static-text carry this exact title as a segment? Sidebar row
388
+ * text concatenates descendant static texts with "|" (AXDRAG1: e.g.
389
+ * "Area-05.|Source Toggle Template|Area-05") — an exact segment match avoids
390
+ * substring collisions; the trailing-dot variant covers the AXDescription-like
391
+ * first segment some rows carry.
392
+ */
393
+ export function rowMatchesTitle(text, title) {
394
+ return text.split("|").some((seg) => seg === title || seg === `${title}.`);
395
+ }
396
+ /** All rows matching known area titles, in visual (y) order. */
397
+ export function areaRowsInOrder(rows, titles) {
398
+ const out = [];
399
+ for (const row of rows) {
400
+ const title = titles.find((t) => rowMatchesTitle(row.text, t));
401
+ if (title !== undefined)
402
+ out.push({ title, row });
403
+ }
404
+ return out.toSorted((a, b) => a.row.y - b.row.y);
405
+ }
406
+ /** Find a single area row by title; ambiguous or missing → null. */
407
+ export function findAreaRow(rows, title) {
408
+ const matches = rows.filter((r) => rowMatchesTitle(r.text, title));
409
+ return matches.length === 1 ? (matches[0] ?? null) : null;
410
+ }
411
+ /**
412
+ * The source's group span: lifting an area collapses its row AND its visible
413
+ * nested project rows. Computed from resolved frames as the distance from the
414
+ * source area row's top to the NEXT area row's top (falls back to the median
415
+ * slot pitch when the source is the last area).
416
+ */
417
+ export function sourceGroupSpan(orderedAreaRows, sourceTitle, allRows) {
418
+ const pitch = slotPitch(orderedAreaRows, allRows);
419
+ const idx = orderedAreaRows.findIndex((a) => a.title === sourceTitle);
420
+ if (idx < 0)
421
+ return pitch;
422
+ const source = orderedAreaRows[idx];
423
+ const next = orderedAreaRows[idx + 1];
424
+ if (source === undefined || next === undefined)
425
+ return pitch;
426
+ const span = next.row.y - source.row.y;
427
+ return span > 0 ? span : pitch;
428
+ }
429
+ /**
430
+ * Static drop boundary Y for the requested placement, computed against the
431
+ * CURRENT snapshot. Every placement reduces to "insert above area R" (the
432
+ * mid-gap above R's row) except to-last, which drops below the final table row.
433
+ */
434
+ export function staticBoundaryY(orderedAreaRows, allRows, sourceTitle, placement) {
435
+ const others = orderedAreaRows.filter((a) => a.title !== sourceTitle);
436
+ if (others.length === 0)
437
+ return { error: "no other area rows resolved in the sidebar" };
438
+ const belowLast = () => {
439
+ const y = boundaryBelowLast(allRows);
440
+ if (y === null)
441
+ return { error: "no sidebar rows resolved" };
442
+ return { y, anchor: "the end of the sidebar list" };
443
+ };
444
+ switch (placement.kind) {
445
+ case "first": {
446
+ const first = others[0];
447
+ return { y: boundaryAboveRow(allRows, first.row), anchor: first.title };
448
+ }
449
+ case "last":
450
+ return belowLast();
451
+ case "before": {
452
+ const ref = others.find((a) => a.title === placement.title);
453
+ if (ref === undefined)
454
+ return { error: `the sidebar row for "${placement.title}" did not resolve` };
455
+ return { y: boundaryAboveRow(allRows, ref.row), anchor: ref.title };
456
+ }
457
+ case "after": {
458
+ const refIdx = others.findIndex((a) => a.title === placement.title);
459
+ if (refIdx < 0)
460
+ return { error: `the sidebar row for "${placement.title}" did not resolve` };
461
+ const next = others[refIdx + 1];
462
+ if (next === undefined)
463
+ return belowLast();
464
+ return { y: boundaryAboveRow(allRows, next.row), anchor: next.title };
465
+ }
466
+ }
467
+ }
468
+ /**
469
+ * Correct a static boundary for the mid-drag layout: a DOWNWARD drag (boundary
470
+ * below the source) sees everything below the source shift up by the source's
471
+ * group span the instant it is picked up (AXDRAG1-a). Upward drags use static
472
+ * coordinates unchanged.
473
+ */
474
+ export function correctedDropY(staticY, sourceCenterY, span) {
475
+ return staticY > sourceCenterY ? staticY - span : staticY;
476
+ }
477
+ export function inBand(y, viewport, pad = BAND_PAD) {
478
+ return y >= viewport.y + pad && y <= viewport.y + viewport.h - pad;
479
+ }
480
+ /**
481
+ * Grab/drop x: a fixed FRACTION of the row's resolved width (≈ the label area
482
+ * NATIVE1 clicked at x+170 on a 240px row), clear of the leading icon and the
483
+ * trailing counters — derived from the frame, not a pixel offset.
484
+ */
485
+ export function grabPoint(row) {
486
+ return { x: row.x + row.w * 0.7, y: row.y + row.h / 2 };
487
+ }
488
+ // ------------------------------------------------------------ DB position
489
+ function positionOf(state, uuid) {
490
+ return state.areas.findIndex((a) => a.uuid === uuid);
491
+ }
492
+ function hasRankTies(state) {
493
+ const seen = new Set();
494
+ for (const a of state.areas) {
495
+ if (seen.has(a.index))
496
+ return true;
497
+ seen.add(a.index);
498
+ }
499
+ return false;
500
+ }
501
+ /** Is the requested placement already satisfied in this DB state? */
502
+ export function placementSatisfied(state, targetUuid, placement) {
503
+ const pos = positionOf(state, targetUuid);
504
+ if (pos < 0)
505
+ return false;
506
+ switch (placement.kind) {
507
+ case "first":
508
+ return pos === 0;
509
+ case "last":
510
+ return pos === state.areas.length - 1;
511
+ case "before":
512
+ return positionOf(state, placement.uuid) === pos + 1;
513
+ case "after":
514
+ return positionOf(state, placement.uuid) === pos - 1;
515
+ }
516
+ }
517
+ // ---------------------------------------------------------------- driver
518
+ /** Absolute ceiling on hops regardless of the computed cap (safety net). */
519
+ const MAX_HOPS_CEILING = 40;
520
+ const MAX_SCROLL_ITER = 18;
521
+ /**
522
+ * Seed for the FIRST scroll's travel-per-click estimate only; every later
523
+ * scroll uses the travel MEASURED from the frames the previous scroll moved
524
+ * (no assumed pixel distances — amendment 2026-07-15).
525
+ */
526
+ const PX_PER_CLICK_SEED = 30;
527
+ /** DB assert poll: attempts × delay (Things writes the index on drop). */
528
+ const ASSERT_ATTEMPTS = 12;
529
+ const ASSERT_DELAY_MS = 250;
530
+ const STEP_TIMEOUT_MS = 30_000;
531
+ async function runCmd(ctx, cmd) {
532
+ return ctx.run(cmd, STEP_TIMEOUT_MS);
533
+ }
534
+ async function takeSnapshot(ctx) {
535
+ const res = await runCmd(ctx, snapshotCommand());
536
+ if (!res.ok)
537
+ return null;
538
+ const snap = parseSidebarSnapshot(res.stdout);
539
+ if (snap === null || snap.viewport === null || snap.rows.length === 0)
540
+ return null;
541
+ return snap;
542
+ }
543
+ /**
544
+ * Scroll until `wanted(snapshot)` returns a zero-ish error, re-resolving
545
+ * frames after every scroll (AXDRAG1: frames must be re-read post-scroll).
546
+ * `wanted` returns the pixel error to correct (positive = rows must move down)
547
+ * or null when satisfied. Self-calibrating: if a scroll moves the rows the
548
+ * wrong way, the direction factor flips once.
549
+ */
550
+ async function scrollUntil(ctx, wanted, goodEnough) {
551
+ let dirFactor = 1;
552
+ let pxPerClick = PX_PER_CLICK_SEED; // replaced by measured travel after scroll 1
553
+ let lastErr = null;
554
+ let lastClicks = 0;
555
+ let stalls = 0;
556
+ for (let iter = 0; iter < MAX_SCROLL_ITER; iter++) {
557
+ // each scroll must observe the frames the previous scroll produced
558
+ const snap = await takeSnapshot(ctx);
559
+ if (snap === null)
560
+ return null;
561
+ const err = wanted(snap);
562
+ if (err === null)
563
+ return snap;
564
+ if (lastErr !== null && lastClicks !== 0) {
565
+ const moved = lastErr - err; // px the content actually travelled
566
+ if (Math.abs(moved) < 2) {
567
+ stalls += 1;
568
+ if (stalls >= 2) {
569
+ // Pinned at the end of the scroll range: settle for the achieved
570
+ // state when the caller says it is workable.
571
+ return goodEnough !== undefined && goodEnough(snap) ? snap : null;
572
+ }
573
+ }
574
+ else {
575
+ stalls = 0;
576
+ // Calibrate from the MEASURED travel of the previous scroll.
577
+ pxPerClick = Math.min(120, Math.max(4, Math.abs(moved / lastClicks)));
578
+ // Moved the wrong way → the wheel sign convention is flipped here.
579
+ if (Math.sign(moved) !== Math.sign(lastClicks))
580
+ dirFactor = -dirFactor;
581
+ }
582
+ }
583
+ lastErr = err;
584
+ const clicks = Math.max(-12, Math.min(12, Math.round(err / pxPerClick) || Math.sign(err))) * dirFactor;
585
+ lastClicks = clicks;
586
+ // strictly sequential scroll-and-remeasure loop
587
+ const res = await runCmd(ctx, scrollCommand(clicks));
588
+ if (!res.ok)
589
+ return null;
590
+ }
591
+ return null;
592
+ }
593
+ /** Poll the DB until `check` passes or the attempts run out. */
594
+ async function pollState(ctx, check) {
595
+ for (let i = 0; i < ASSERT_ATTEMPTS; i++) {
596
+ const state = ctx.state();
597
+ if (check(state))
598
+ return state;
599
+ // polling the same DB condition is inherently sequential
600
+ await ctx.sleep(ASSERT_DELAY_MS);
601
+ }
602
+ return null;
603
+ }
604
+ /** Resolve source row + corrected drop Y against ONE snapshot generation. */
605
+ function planDrop(snap, spec, areaTitles, placement) {
606
+ const source = findAreaRow(snap.rows, spec.targetTitle);
607
+ if (source === null) {
608
+ return {
609
+ error: `the sidebar row for "${spec.targetTitle}" did not resolve uniquely by its visible ` +
610
+ "name — after many drags in one app session a sidebar row can stop exposing its " +
611
+ "name until Things is relaunched (AXDRAG2); quit and reopen Things, then retry",
612
+ };
613
+ }
614
+ const ordered = areaRowsInOrder(snap.rows, areaTitles);
615
+ const boundary = staticBoundaryY(ordered, snap.rows, spec.targetTitle, placement);
616
+ if ("error" in boundary)
617
+ return boundary;
618
+ const span = sourceGroupSpan(ordered, spec.targetTitle, snap.rows);
619
+ const sourceCenter = source.y + source.h / 2;
620
+ return {
621
+ source,
622
+ dropY: correctedDropY(boundary.y, sourceCenter, span),
623
+ anchor: boundary.anchor,
624
+ };
625
+ }
626
+ /** One drag gesture from the source row to the corrected boundary. */
627
+ async function performDrag(ctx, drop) {
628
+ const grab = grabPoint(drop.source);
629
+ const res = await runCmd(ctx, dragCommand(grab.x, grab.y, grab.x, drop.dropY));
630
+ return res.ok && res.stdout.includes("DONE");
631
+ }
632
+ /**
633
+ * The rung-2 anchor: every placement reduces to "drop above this area row"
634
+ * (title) or "drop below the last row" (null). `undefined` = unresolvable.
635
+ */
636
+ export function rung2AnchorTitle(rows, areaTitles, sourceTitle, placement) {
637
+ const others = areaRowsInOrder(rows, areaTitles).filter((a) => a.title !== sourceTitle);
638
+ if (others.length === 0)
639
+ return undefined;
640
+ switch (placement.kind) {
641
+ case "first":
642
+ return others[0].title;
643
+ case "last":
644
+ return null;
645
+ case "before":
646
+ return others.some((a) => a.title === placement.title) ? placement.title : undefined;
647
+ case "after": {
648
+ const i = others.findIndex((a) => a.title === placement.title);
649
+ if (i < 0)
650
+ return undefined;
651
+ const next = others[i + 1];
652
+ return next === undefined ? null : next.title;
653
+ }
654
+ }
655
+ }
656
+ function parseHeldDragResult(res) {
657
+ if (!res.ok)
658
+ return { dropped: false };
659
+ try {
660
+ const parsed = JSON.parse(res.stdout.trim());
661
+ return parsed.dropped === true
662
+ ? { dropped: true, ...(typeof parsed.ticks === "number" && { ticks: parsed.ticks }) }
663
+ : { dropped: false };
664
+ }
665
+ catch {
666
+ return { dropped: false };
667
+ }
668
+ }
669
+ function invariantsHold(pre, now) {
670
+ return now.areas.length === pre.areas.length && now.assignmentsDigest === pre.assignmentsDigest;
671
+ }
672
+ /**
673
+ * Drive the full ladder. `preState` is captured ONCE up front for the whole
674
+ * move (recovery + the caller's undo capture both key off it, not per hop).
675
+ */
676
+ export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
677
+ if (aux.areaState === undefined) {
678
+ return {
679
+ ok: false,
680
+ detail: "the sidebar drag driver has no database seam on this surface — this operation can " +
681
+ "only run through the full client",
682
+ };
683
+ }
684
+ const ctx = { run, state: aux.areaState, sleep };
685
+ const pre = ctx.state();
686
+ const preTies = hasRankTies(pre);
687
+ const areaTitles = pre.areas.map((a) => a.title);
688
+ if (positionOf(pre, spec.targetUuid) < 0) {
689
+ return { ok: false, detail: `area ${spec.targetUuid} no longer exists` };
690
+ }
691
+ if (!preTies && placementSatisfied(pre, spec.targetUuid, spec.placement)) {
692
+ return { ok: true, detail: "already in the requested position — nothing to move" };
693
+ }
694
+ // Remaining-distance metric for the infinite-loop guard (design amendment
695
+ // 2026-07-15): the number of area positions between the target's current
696
+ // slot and its destination slot, DB-read after every hop. Each hop must
697
+ // STRICTLY reduce it; one retry is allowed, then the op aborts reporting
698
+ // where the area ended up (a partially-moved area is benign).
699
+ const distanceIn = (state) => {
700
+ const placement = spec.placement;
701
+ const others = state.areas.filter((a) => a.uuid !== spec.targetUuid);
702
+ const cur = positionOf(state, spec.targetUuid);
703
+ if (cur < 0)
704
+ return null;
705
+ let want;
706
+ switch (placement.kind) {
707
+ case "first":
708
+ want = 0;
709
+ break;
710
+ case "last":
711
+ want = others.length;
712
+ break;
713
+ case "before": {
714
+ const i = others.findIndex((a) => a.uuid === placement.uuid);
715
+ if (i < 0)
716
+ return null;
717
+ want = i;
718
+ break;
719
+ }
720
+ case "after": {
721
+ const i = others.findIndex((a) => a.uuid === placement.uuid);
722
+ if (i < 0)
723
+ return null;
724
+ want = i + 1;
725
+ break;
726
+ }
727
+ }
728
+ return Math.abs(want - cur);
729
+ };
730
+ let hops = 0;
731
+ let remaining = preTies ? null : distanceIn(pre);
732
+ let retried = false;
733
+ // Rung 2 (scroll-while-held) is BUILT and probe-certified (AXDRAG2-a) but
734
+ // ships DISABLED: production certification exposed an app-side instability
735
+ // — after drag+scroll churn the sidebar's AX mirror can drop or blank row
736
+ // elements until Things is relaunched (AXDRAG2-c / oddities), and held
737
+ // travel beyond ~1.5 viewports is the strongest trigger. The certified
738
+ // ladder is rung 1 + the multi-hop floor; THINGS_UI_DRAG_LADDER=held-scroll
739
+ // re-enables rung 2 for lab work. Attempted at most ONCE per move; a clean
740
+ // abort falls through to the floor.
741
+ let heldScrollTried = process.env["THINGS_UI_DRAG_LADDER"] !== "held-scroll";
742
+ // Transient-render tolerance: the first unresolved snapshot gets one
743
+ // settle-and-retry before the drive refuses (the app may still be
744
+ // materializing sidebar rows right after launch/navigation).
745
+ let resolveRetried = false;
746
+ // A benign off-slot landing (invariants intact) lets the ladder CONTINUE
747
+ // from wherever the drop ended — one retry of the final placement, then an
748
+ // honest positional abort. Only invariant damage recovers-and-refuses.
749
+ let finalRetried = false;
750
+ // Absolute backstop: ceil(areas / visible-slots) + 2, refined from the first
751
+ // snapshot's viewport height (each hop covers roughly one viewport).
752
+ let hopCap = Math.min(MAX_HOPS_CEILING, pre.areas.length + 2);
753
+ for (let attempt = 0; attempt <= MAX_HOPS_CEILING; attempt++) {
754
+ // every hop depends on the layout the previous hop produced
755
+ const snap = await takeSnapshot(ctx);
756
+ if (snap === null) {
757
+ return refuseOrRecover(ctx, pre, spec, hops, "the sidebar did not resolve (is the window open and the sidebar visible?)");
758
+ }
759
+ const viewport = snap.viewport;
760
+ {
761
+ // ceil(areas / visible-slots) + 2, from the frame-derived slot pitch.
762
+ const pitch = slotPitch(areaRowsInOrder(snap.rows, areaTitles), snap.rows);
763
+ const visibleSlots = Math.max(1, Math.floor(viewport.h / pitch) - 2);
764
+ hopCap = Math.min(MAX_HOPS_CEILING, Math.ceil(pre.areas.length / visibleSlots) + 2);
765
+ }
766
+ const finalPlan = planDrop(snap, spec, areaTitles, spec.placement);
767
+ if ("error" in finalPlan) {
768
+ if (!resolveRetried) {
769
+ resolveRetried = true;
770
+ // one settle pause before re-reading the tree
771
+ await ctx.sleep(2000);
772
+ continue;
773
+ }
774
+ return refuseOrRecover(ctx, pre, spec, hops, finalPlan.error);
775
+ }
776
+ resolveRetried = false;
777
+ const grab = grabPoint(finalPlan.source);
778
+ // Rung 1: both the grab point and the drop boundary visible (or scrollable
779
+ // into simultaneous view) → one certified drag.
780
+ const spanNeeded = Math.abs(grab.y - finalPlan.dropY);
781
+ if (spanNeeded < viewport.h - 4 * BAND_PAD) {
782
+ let ready = snap;
783
+ if (!inBand(grab.y, viewport) || !inBand(finalPlan.dropY, viewport)) {
784
+ // pre-scroll must land before the drag
785
+ ready = await scrollUntil(ctx, (s) => {
786
+ const p = planDrop(s, spec, areaTitles, spec.placement);
787
+ if ("error" in p || s.viewport === null)
788
+ return null;
789
+ const g = grabPoint(p.source);
790
+ if (inBand(g.y, s.viewport) && inBand(p.dropY, s.viewport))
791
+ return null;
792
+ const mid = (g.y + p.dropY) / 2;
793
+ const bandMid = s.viewport.y + s.viewport.h / 2;
794
+ return bandMid - mid;
795
+ });
796
+ }
797
+ if (ready !== null) {
798
+ const plan = planDrop(ready, spec, areaTitles, spec.placement);
799
+ if (!("error" in plan) && ready.viewport !== null) {
800
+ const g = grabPoint(plan.source);
801
+ if (inBand(g.y, ready.viewport) && inBand(plan.dropY, ready.viewport)) {
802
+ // the gesture must land before the DB assert
803
+ const landed = await performDrag(ctx, plan);
804
+ if (!landed) {
805
+ return refuseOrRecover(ctx, pre, spec, hops, "the drag gesture did not complete");
806
+ }
807
+ // the final assert gates success
808
+ const finalState = await pollState(ctx, (s) => invariantsHold(pre, s) && placementSatisfied(s, spec.targetUuid, spec.placement));
809
+ if (finalState === null) {
810
+ const latest = ctx.state();
811
+ if (!invariantsHold(pre, latest)) {
812
+ return refuseOrRecover(ctx, pre, spec, hops, "the drop changed the area count or an area assignment (it should never)");
813
+ }
814
+ // Benign off-slot landing: let the ladder re-aim once from the
815
+ // new position before giving up.
816
+ if (!finalRetried) {
817
+ finalRetried = true;
818
+ continue;
819
+ }
820
+ return abortPartial(ctx, spec, hops, "the drop kept landing off the requested slot (retried once)");
821
+ }
822
+ return {
823
+ ok: true,
824
+ detail: hops === 0
825
+ ? "moved with one drag (source and destination shared a viewport)"
826
+ : `moved with ${hops} intermediate hop(s) + the final drag (multi-hop fallback)`,
827
+ };
828
+ }
829
+ }
830
+ }
831
+ // fall through to a hop if simultaneous visibility could not be arranged
832
+ }
833
+ // Rung 2: scroll-while-held (AXDRAG2-a GO) — one atomic gesture that
834
+ // scrolls the list under the held item and drops at the LIVE-resolved
835
+ // boundary. A clean abort (Escape, no drop) falls through to rung 3.
836
+ if (!heldScrollTried) {
837
+ heldScrollTried = true;
838
+ // The source must be grabbable first.
839
+ // the pre-grab scroll must land before the gesture
840
+ const grabbable = await scrollUntil(ctx, (s) => {
841
+ if (s.viewport === null)
842
+ return null;
843
+ const src = findAreaRow(s.rows, spec.targetTitle);
844
+ if (src === null)
845
+ return null;
846
+ const g = grabPoint(src);
847
+ if (inBand(g.y, s.viewport))
848
+ return null;
849
+ return s.viewport.y + s.viewport.h / 2 - g.y;
850
+ }, (s) => {
851
+ if (s.viewport === null)
852
+ return false;
853
+ const src = findAreaRow(s.rows, spec.targetTitle);
854
+ return src !== null && inBand(grabPoint(src).y, s.viewport);
855
+ });
856
+ if (grabbable !== null && grabbable.viewport !== null) {
857
+ const src = findAreaRow(grabbable.rows, spec.targetTitle);
858
+ const anchor = rung2AnchorTitle(grabbable.rows, areaTitles, spec.targetTitle, spec.placement);
859
+ if (src !== null && anchor !== undefined) {
860
+ const g = grabPoint(src);
861
+ const plan2 = planDrop(grabbable, spec, areaTitles, spec.placement);
862
+ const travel = "error" in plan2 ? viewport.h * 4 : Math.abs(plan2.dropY - g.y);
863
+ // TRAVEL CAP (AXDRAG2-c): held-scroll is proven up to ~1.5 viewport
864
+ // heights; beyond that the app's AX mirror can lose row names for
865
+ // the rest of the session (oddities: sidebar ghost rows), so far
866
+ // moves go straight to the multi-hop floor.
867
+ if (travel > viewport.h * 1.5)
868
+ continue;
869
+ const maxTicks = Math.min(400, Math.max(20, Math.ceil(travel / 15)));
870
+ // the held gesture must complete before its DB assert
871
+ const res = await runCmd(ctx, heldScrollDragCommand(g.x, g.y, anchor, maxTicks));
872
+ const parsed = parseHeldDragResult(res);
873
+ if (parsed.dropped) {
874
+ // the final assert gates success
875
+ const finalState = await pollState(ctx, (s) => invariantsHold(pre, s) && placementSatisfied(s, spec.targetUuid, spec.placement));
876
+ if (finalState !== null) {
877
+ return {
878
+ ok: true,
879
+ detail: `moved with one scroll-while-held drag (${parsed.ticks ?? "?"} wheel ticks` +
880
+ `${hops > 0 ? `, after ${hops} hop(s)` : ""})`,
881
+ };
882
+ }
883
+ const latest = ctx.state();
884
+ if (!invariantsHold(pre, latest)) {
885
+ return refuseOrRecover(ctx, pre, spec, hops, "the scroll-while-held drop changed the area count or an area assignment " +
886
+ "(it should never)");
887
+ }
888
+ // Benign off-slot landing: rungs 1/3 finish the move from here.
889
+ continue;
890
+ }
891
+ // Clean abort (Escape, nothing dropped) → the multi-hop floor.
892
+ }
893
+ }
894
+ continue;
895
+ }
896
+ // Rung 3: hop one viewport toward the target.
897
+ hops += 1;
898
+ if (hops > hopCap) {
899
+ return abortPartial(ctx, spec, hops - 1, `exceeded the hop cap (${hopCap}) without converging`);
900
+ }
901
+ const down = finalPlan.dropY > grab.y;
902
+ // Bring the source into the band, parked toward the trailing edge so the
903
+ // viewport ahead of it is maximal.
904
+ // the hop's park scroll must land before its gesture
905
+ const parked = await scrollUntil(ctx, (s) => {
906
+ if (s.viewport === null)
907
+ return null;
908
+ const src = findAreaRow(s.rows, spec.targetTitle);
909
+ if (src === null)
910
+ return null;
911
+ const g = grabPoint(src);
912
+ const edge = s.viewport.h * 0.15; // park near the trailing edge (frame fraction)
913
+ const desired = down ? s.viewport.y + edge : s.viewport.y + s.viewport.h - edge;
914
+ const err = desired - g.y;
915
+ const tolerance = s.viewport.h * 0.05;
916
+ return Math.abs(err) <= tolerance && inBand(g.y, s.viewport) ? null : err;
917
+ },
918
+ // Scroll pinned before reaching the parking spot: grabbable is enough.
919
+ (s) => {
920
+ if (s.viewport === null)
921
+ return false;
922
+ const src = findAreaRow(s.rows, spec.targetTitle);
923
+ return src !== null && inBand(grabPoint(src).y, s.viewport);
924
+ });
925
+ if (parked === null || parked.viewport === null) {
926
+ return refuseOrRecover(ctx, pre, spec, hops, "could not scroll the area's row into view");
927
+ }
928
+ const ordered = areaRowsInOrder(parked.rows, areaTitles);
929
+ const srcIdx = ordered.findIndex((a) => a.title === spec.targetTitle);
930
+ const source = ordered[srcIdx];
931
+ if (srcIdx < 0 || source === undefined) {
932
+ return refuseOrRecover(ctx, pre, spec, hops, "the area's row vanished after scrolling");
933
+ }
934
+ const span = sourceGroupSpan(ordered, spec.targetTitle, parked.rows);
935
+ const sourceCenter = source.row.y + source.row.h / 2;
936
+ // Candidate anchors: area rows toward the target whose corrected boundary
937
+ // stays inside the visible band; take the furthest for maximum progress.
938
+ let hopAnchor = null;
939
+ for (let i = 0; i < ordered.length; i++) {
940
+ if (i === srcIdx)
941
+ continue;
942
+ const cand = ordered[i];
943
+ const isDownCand = i > srcIdx;
944
+ if (isDownCand !== down)
945
+ continue;
946
+ const visualDelta = Math.abs(i - srcIdx);
947
+ // Dropping ABOVE the anchor: downward needs ≥2 rows of travel to be a
948
+ // real move; upward ≥1.
949
+ if (down && visualDelta < 2)
950
+ continue;
951
+ const dropY = correctedDropY(boundaryAboveRow(parked.rows, cand.row), sourceCenter, span);
952
+ if (!inBand(dropY, parked.viewport))
953
+ continue;
954
+ const uuid = pre.areas.find((a) => a.title === cand.title)?.uuid ?? "";
955
+ if (hopAnchor === null || visualDelta > hopAnchor.visualDelta) {
956
+ hopAnchor = { title: cand.title, uuid, dropY, visualDelta };
957
+ }
958
+ }
959
+ if (hopAnchor === null) {
960
+ return refuseOrRecover(ctx, pre, spec, hops, "no drop slot toward the destination fits the visible sidebar — the viewport is too " +
961
+ "small to make progress");
962
+ }
963
+ const anchorUuid = hopAnchor.uuid;
964
+ // the hop gesture must land before its DB assert
965
+ const landed = await performDrag(ctx, {
966
+ source: source.row,
967
+ dropY: hopAnchor.dropY,
968
+ anchor: hopAnchor.title,
969
+ });
970
+ if (!landed) {
971
+ return refuseOrRecover(ctx, pre, spec, hops, "a hop drag gesture did not complete");
972
+ }
973
+ // DB assert after EVERY hop: the source should now sit immediately above
974
+ // the anchor, with the count + assignments invariant.
975
+ // the hop assert gates the next hop
976
+ const hopState = await pollState(ctx, (s) => invariantsHold(pre, s) &&
977
+ positionOf(s, spec.targetUuid) >= 0 &&
978
+ positionOf(s, spec.targetUuid) + 1 === positionOf(s, anchorUuid));
979
+ if (hopState !== null) {
980
+ // Landed as aimed. Infinite-loop guard: the remaining distance must
981
+ // STRICTLY shrink hop over hop.
982
+ const d = distanceIn(hopState);
983
+ if (remaining !== null && d !== null && d >= remaining) {
984
+ if (retried) {
985
+ return abortPartial(ctx, spec, hops, "two consecutive hops made no progress");
986
+ }
987
+ retried = true;
988
+ }
989
+ else {
990
+ retried = false;
991
+ if (d !== null)
992
+ remaining = d;
993
+ }
994
+ continue;
995
+ }
996
+ // The aimed adjacency never showed. Damage (count/assignment change) gets
997
+ // a recovery drag; a benign off-by-slots landing that still progressed is
998
+ // accepted; anything else burns the single retry, then aborts.
999
+ const latest = ctx.state();
1000
+ if (!invariantsHold(pre, latest)) {
1001
+ return refuseOrRecover(ctx, pre, spec, hops, `hop ${hops} changed the area count or an area assignment (it should never) `);
1002
+ }
1003
+ const d = distanceIn(latest);
1004
+ if (d !== null && (remaining === null || d < remaining)) {
1005
+ remaining = d;
1006
+ retried = false;
1007
+ continue;
1008
+ }
1009
+ if (!retried) {
1010
+ retried = true;
1011
+ continue;
1012
+ }
1013
+ return abortPartial(ctx, spec, hops, `hop ${hops} did not land immediately above "${hopAnchor.title}" and made no progress ` +
1014
+ "(retried once)");
1015
+ }
1016
+ return abortPartial(ctx, spec, hops, "exceeded the hop budget without converging");
1017
+ }
1018
+ /**
1019
+ * Abort mid-ladder WITHOUT recovery (design amendment: a partially-moved area
1020
+ * is benign) — send Escape as a safety valve and report exactly where the
1021
+ * area ended up so the caller can re-run or finish by hand.
1022
+ */
1023
+ async function abortPartial(ctx, spec, hops, why) {
1024
+ await runCmd(ctx, {
1025
+ primitive: "key",
1026
+ label: "abort (Escape)",
1027
+ script: `tell application "System Events" to key code 53`,
1028
+ });
1029
+ const state = ctx.state();
1030
+ const pos = positionOf(state, spec.targetUuid);
1031
+ const where = pos < 0
1032
+ ? "the area no longer resolves in the database"
1033
+ : `the area now sits at sidebar position ${pos + 1} of ${state.areas.length}` +
1034
+ (pos > 0 ? `, below "${state.areas[pos - 1]?.title ?? "?"}"` : ", at the top");
1035
+ return {
1036
+ ok: false,
1037
+ detail: `${why} after ${hops} hop(s); ${where}. Its to-dos and projects are untouched — ` +
1038
+ "re-run the move or finish it in the app.",
1039
+ };
1040
+ }
1041
+ /**
1042
+ * One verified recovery attempt: put the area back where the pre-op capture
1043
+ * says it was (before its old successor, or to-last), then report the original
1044
+ * failure with the recovery outcome appended. Never recurses.
1045
+ */
1046
+ async function refuseOrRecover(ctx, pre, spec, hops, why) {
1047
+ const now = ctx.state();
1048
+ const moved = positionOf(now, spec.targetUuid) !== positionOf(pre, spec.targetUuid) ||
1049
+ now.areas.map((a) => a.uuid).join(",") !== pre.areas.map((a) => a.uuid).join(",");
1050
+ if (!moved || hasRankTies(pre)) {
1051
+ return {
1052
+ ok: false,
1053
+ detail: `${why}. No sidebar change was left behind${hops > 0 ? ` after ${hops} hop(s)` : ""}.`,
1054
+ };
1055
+ }
1056
+ const preIdx = positionOf(pre, spec.targetUuid);
1057
+ const successor = pre.areas[preIdx + 1];
1058
+ const placement = successor !== undefined
1059
+ ? { kind: "before", uuid: successor.uuid, title: successor.title }
1060
+ : { kind: "last" };
1061
+ const areaTitles = pre.areas.map((a) => a.title);
1062
+ let recovered = false;
1063
+ // A bounded, single-pass recovery: same rung-1 mechanics, no hop budget.
1064
+ const snap = await scrollUntil(ctx, (s) => {
1065
+ const p = planDrop(s, spec, areaTitles, placement);
1066
+ if ("error" in p || s.viewport === null)
1067
+ return null;
1068
+ const g = grabPoint(p.source);
1069
+ if (inBand(g.y, s.viewport) && inBand(p.dropY, s.viewport))
1070
+ return null;
1071
+ return s.viewport.y + s.viewport.h / 2 - (g.y + p.dropY) / 2;
1072
+ });
1073
+ if (snap !== null && snap.viewport !== null) {
1074
+ const plan = planDrop(snap, spec, areaTitles, placement);
1075
+ if (!("error" in plan)) {
1076
+ const g = grabPoint(plan.source);
1077
+ if (inBand(g.y, snap.viewport) && inBand(plan.dropY, snap.viewport)) {
1078
+ const landed = await performDrag(ctx, plan);
1079
+ if (landed) {
1080
+ const state = await pollState(ctx, (s) => positionOf(s, spec.targetUuid) === preIdx);
1081
+ recovered = state !== null;
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+ return {
1087
+ ok: false,
1088
+ recovered,
1089
+ detail: recovered
1090
+ ? `${why}. The area was dragged back to its previous position (verified).`
1091
+ : `${why}. RECOVERY DID NOT VERIFY: the area may be at an intermediate position after ` +
1092
+ `${hops} hop(s) — check the sidebar and re-run, or move it back in the app.`,
1093
+ };
1094
+ }
1095
+ //# sourceMappingURL=ui-drag.js.map