things-api 0.18.0 → 0.19.1

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 (224) hide show
  1. package/README.md +18 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -1
  4. package/deputy/helpers-Info.plist +4 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +3 -1
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +15 -2
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
  11. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  12. package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
  13. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +14 -2
  14. package/deputy/reader/Info.plist +2 -0
  15. package/deputy/reader/entitlements.plist +30 -0
  16. package/deputy/reader/main.swift +129 -40
  17. package/deputy/src/server.swift +25 -2
  18. package/deputy/src/tcc.swift +58 -0
  19. package/dist/audit/schema.d.ts +30 -1
  20. package/dist/audit/schema.js.map +1 -1
  21. package/dist/capability.d.ts +322 -0
  22. package/dist/capability.js +706 -0
  23. package/dist/capability.js.map +1 -0
  24. package/dist/cli/commands/doctor.d.ts +13 -1
  25. package/dist/cli/commands/doctor.js +141 -5
  26. package/dist/cli/commands/doctor.js.map +1 -1
  27. package/dist/cli/commands/helpers.js +189 -51
  28. package/dist/cli/commands/helpers.js.map +1 -1
  29. package/dist/cli/commands/mcp.js +8 -0
  30. package/dist/cli/commands/mcp.js.map +1 -1
  31. package/dist/cli/commands/op-result.js +6 -0
  32. package/dist/cli/commands/op-result.js.map +1 -1
  33. package/dist/cli/commands/reads.js +47 -1
  34. package/dist/cli/commands/reads.js.map +1 -1
  35. package/dist/cli/commands/repeat-flags.js +34 -2
  36. package/dist/cli/commands/repeat-flags.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +76 -83
  39. package/dist/cli/commands/setup.js.map +1 -1
  40. package/dist/cli/commands/writes.d.ts +1 -1
  41. package/dist/cli/commands/writes.js +311 -142
  42. package/dist/cli/commands/writes.js.map +1 -1
  43. package/dist/cli/dry-run.d.ts +1 -1
  44. package/dist/cli/dry-run.js +1 -1
  45. package/dist/cli/glyphs.d.ts +23 -4
  46. package/dist/cli/glyphs.js +46 -19
  47. package/dist/cli/glyphs.js.map +1 -1
  48. package/dist/cli/help.d.ts +1 -1
  49. package/dist/cli/help.js +49 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/interrupt.d.ts +54 -6
  52. package/dist/cli/interrupt.js +78 -12
  53. package/dist/cli/interrupt.js.map +1 -1
  54. package/dist/cli/main.js +8 -5
  55. package/dist/cli/main.js.map +1 -1
  56. package/dist/cli/period.d.ts +3 -1
  57. package/dist/cli/period.js +1 -1
  58. package/dist/cli/period.js.map +1 -1
  59. package/dist/cli/read-driver.js +26 -1
  60. package/dist/cli/read-driver.js.map +1 -1
  61. package/dist/cli/render.d.ts +31 -4
  62. package/dist/cli/render.js +127 -15
  63. package/dist/cli/render.js.map +1 -1
  64. package/dist/client.d.ts +23 -3
  65. package/dist/client.js +36 -13
  66. package/dist/client.js.map +1 -1
  67. package/dist/contracts.d.ts +8 -4
  68. package/dist/contracts.js +1 -1
  69. package/dist/contracts.js.map +1 -1
  70. package/dist/deputy/install.d.ts +234 -6
  71. package/dist/deputy/install.js +843 -31
  72. package/dist/deputy/install.js.map +1 -1
  73. package/dist/deputy/notices.js +2 -2
  74. package/dist/deputy/notices.js.map +1 -1
  75. package/dist/deputy/protocol.d.ts +59 -5
  76. package/dist/deputy/protocol.js +48 -11
  77. package/dist/deputy/protocol.js.map +1 -1
  78. package/dist/deputy/routing.d.ts +37 -0
  79. package/dist/deputy/routing.js +181 -27
  80. package/dist/deputy/routing.js.map +1 -1
  81. package/dist/deputy/wake.d.ts +61 -0
  82. package/dist/deputy/wake.js +149 -0
  83. package/dist/deputy/wake.js.map +1 -0
  84. package/dist/diagnose.d.ts +19 -4
  85. package/dist/diagnose.js +64 -8
  86. package/dist/diagnose.js.map +1 -1
  87. package/dist/direct-setup.d.ts +104 -0
  88. package/dist/direct-setup.js +658 -0
  89. package/dist/direct-setup.js.map +1 -0
  90. package/dist/host-access.d.ts +75 -0
  91. package/dist/host-access.js +177 -0
  92. package/dist/host-access.js.map +1 -0
  93. package/dist/index.d.ts +20 -5
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/mcp/server.d.ts +27 -1
  97. package/dist/mcp/server.js +308 -109
  98. package/dist/mcp/server.js.map +1 -1
  99. package/dist/op-result.d.ts +9 -0
  100. package/dist/op-result.js +13 -1
  101. package/dist/op-result.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +1 -1
  103. package/dist/read/filter-contract.js +4 -0
  104. package/dist/read/filter-contract.js.map +1 -1
  105. package/dist/read/shape.js +4 -0
  106. package/dist/read/shape.js.map +1 -1
  107. package/dist/read/views.d.ts +29 -0
  108. package/dist/read/views.js +60 -0
  109. package/dist/read/views.js.map +1 -1
  110. package/dist/session-grant.d.ts +48 -0
  111. package/dist/session-grant.js +185 -0
  112. package/dist/session-grant.js.map +1 -0
  113. package/dist/surface-copy.d.ts +19 -3
  114. package/dist/surface-copy.js +19 -3
  115. package/dist/surface-copy.js.map +1 -1
  116. package/dist/wizard.d.ts +58 -0
  117. package/dist/wizard.js +174 -0
  118. package/dist/wizard.js.map +1 -0
  119. package/dist/write/availability.d.ts +1 -16
  120. package/dist/write/availability.js +10 -65
  121. package/dist/write/availability.js.map +1 -1
  122. package/dist/write/batch.d.ts +20 -0
  123. package/dist/write/batch.js +111 -21
  124. package/dist/write/batch.js.map +1 -1
  125. package/dist/write/capabilities.d.ts +9 -0
  126. package/dist/write/capabilities.js +2 -0
  127. package/dist/write/capabilities.js.map +1 -1
  128. package/dist/write/clear-reminder.js +1 -1
  129. package/dist/write/clear-reminder.js.map +1 -1
  130. package/dist/write/commands.d.ts +22 -7
  131. package/dist/write/commands.js +231 -254
  132. package/dist/write/commands.js.map +1 -1
  133. package/dist/write/failure-hints.d.ts +32 -7
  134. package/dist/write/failure-hints.js +9 -14
  135. package/dist/write/failure-hints.js.map +1 -1
  136. package/dist/write/guards.js +22 -4
  137. package/dist/write/guards.js.map +1 -1
  138. package/dist/write/lock.d.ts +16 -13
  139. package/dist/write/lock.js +55 -0
  140. package/dist/write/lock.js.map +1 -1
  141. package/dist/write/make-repeating-project.js +9 -13
  142. package/dist/write/make-repeating-project.js.map +1 -1
  143. package/dist/write/move.js +9 -12
  144. package/dist/write/move.js.map +1 -1
  145. package/dist/write/operations.d.ts +51 -29
  146. package/dist/write/operations.js +16 -0
  147. package/dist/write/operations.js.map +1 -1
  148. package/dist/write/opid.d.ts +72 -14
  149. package/dist/write/opid.js +152 -8
  150. package/dist/write/opid.js.map +1 -1
  151. package/dist/write/param-schema.d.ts +80 -0
  152. package/dist/write/param-schema.js +754 -0
  153. package/dist/write/param-schema.js.map +1 -0
  154. package/dist/write/pipeline.d.ts +110 -5
  155. package/dist/write/pipeline.js +288 -14
  156. package/dist/write/pipeline.js.map +1 -1
  157. package/dist/write/pre-state.d.ts +38 -16
  158. package/dist/write/pre-state.js +117 -22
  159. package/dist/write/pre-state.js.map +1 -1
  160. package/dist/write/promote-clone.js +658 -444
  161. package/dist/write/promote-clone.js.map +1 -1
  162. package/dist/write/reorder.js +25 -8
  163. package/dist/write/reorder.js.map +1 -1
  164. package/dist/write/repeat-collateral.d.ts +31 -0
  165. package/dist/write/repeat-collateral.js +153 -0
  166. package/dist/write/repeat-collateral.js.map +1 -0
  167. package/dist/write/repeat-rule.d.ts +28 -1
  168. package/dist/write/repeat-rule.js +54 -4
  169. package/dist/write/repeat-rule.js.map +1 -1
  170. package/dist/write/resolution-timestamps.d.ts +11 -0
  171. package/dist/write/resolution-timestamps.js +108 -40
  172. package/dist/write/resolution-timestamps.js.map +1 -1
  173. package/dist/write/reversibility.js +4 -0
  174. package/dist/write/reversibility.js.map +1 -1
  175. package/dist/write/template-mutation.d.ts +137 -0
  176. package/dist/write/template-mutation.js +598 -0
  177. package/dist/write/template-mutation.js.map +1 -0
  178. package/dist/write/undo.js +10 -20
  179. package/dist/write/undo.js.map +1 -1
  180. package/dist/write/update-fields.d.ts +151 -0
  181. package/dist/write/update-fields.js +427 -0
  182. package/dist/write/update-fields.js.map +1 -0
  183. package/dist/write/vectors/applescript.js +6 -10
  184. package/dist/write/vectors/applescript.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +1 -1
  186. package/dist/write/vectors/registry.js +10 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/shortcuts.js +2 -2
  189. package/dist/write/vectors/shortcuts.js.map +1 -1
  190. package/dist/write/vectors/simulator.js +15 -4
  191. package/dist/write/vectors/simulator.js.map +1 -1
  192. package/dist/write/vectors/types.d.ts +268 -1
  193. package/dist/write/vectors/ui-certification.js +63 -13
  194. package/dist/write/vectors/ui-certification.js.map +1 -1
  195. package/dist/write/vectors/ui-chord.d.ts +144 -0
  196. package/dist/write/vectors/ui-chord.js +428 -0
  197. package/dist/write/vectors/ui-chord.js.map +1 -0
  198. package/dist/write/vectors/ui-drag.d.ts +8 -4
  199. package/dist/write/vectors/ui-drag.js +2 -0
  200. package/dist/write/vectors/ui-drag.js.map +1 -1
  201. package/dist/write/vectors/ui-recipes.d.ts +70 -3
  202. package/dist/write/vectors/ui-recipes.js +482 -44
  203. package/dist/write/vectors/ui-recipes.js.map +1 -1
  204. package/dist/write/vectors/ui.d.ts +280 -10
  205. package/dist/write/vectors/ui.js +1068 -77
  206. package/dist/write/vectors/ui.js.map +1 -1
  207. package/dist/write/vectors/url-scheme.js +3 -0
  208. package/dist/write/vectors/url-scheme.js.map +1 -1
  209. package/dist/write/verify/delta.d.ts +62 -0
  210. package/dist/write/verify/delta.js +36 -2
  211. package/dist/write/verify/delta.js.map +1 -1
  212. package/dist/write/verify/poller.d.ts +4 -1
  213. package/dist/write/verify/poller.js +14 -0
  214. package/dist/write/verify/poller.js.map +1 -1
  215. package/package.json +2 -1
  216. package/schema/envelope.schema.json +6 -4
  217. package/scripts/build-helpers.sh +5 -0
  218. package/skills/things-cli/SKILL.md +5 -3
  219. package/skills/things-cli/references/contracts.md +1 -0
  220. package/skills/things-cli/references/data-model.md +3 -0
  221. package/skills/things-cli/references/errors.md +1 -1
  222. package/skills/things-cli/references/gui.md +1 -0
  223. package/skills/things-cli/references/repeating.md +6 -0
  224. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -64,6 +64,36 @@ export function pauseRepeatRecipe(targetUuid) {
64
64
  ],
65
65
  };
66
66
  }
67
+ /**
68
+ * `Items ▸ Repeat ▸ Create Next Copy` — Things 3.23's "spawn the pending
69
+ * occurrence now" command, and the first half of every template mutation we
70
+ * ship (CNC1, docs/lab/cnc1-template-mutations.md).
71
+ *
72
+ * One press materializes the instance the projection cursor points at AND
73
+ * advances the series, and the template delta is FIELD FOR FIELD what the app's
74
+ * own `Make Exception` writes (`rt1_instanceCreationCount +1`,
75
+ * `rt1_instanceCreationStartDate` → consumed slot + 1, `rt1_nextInstanceStartDate`
76
+ * → the next rule date, `todayIndexReferenceDate` → the cursor, `umd` silent,
77
+ * rule blob untouched) — measured on a fixed weekly rule against REPX3 §1.2 and
78
+ * on a daily rule against §2.1. Mutating the minted instance afterwards is
79
+ * therefore the exception the chooser withholds from automation.
80
+ *
81
+ * Template-only, like every verb in this submenu: with an INSTANCE selected the
82
+ * `Items` menu carries no `Repeat` item at all (REPX1 §5.1), which is what the
83
+ * eligibility assert catches before the press.
84
+ */
85
+ export function createNextCopyRecipe(targetUuid) {
86
+ const item = `menu item "Create Next Copy" of menu 1 of menu item "Repeat" of ${ITEMS_MENU}`;
87
+ return {
88
+ op: "todo.create-next-copy",
89
+ targetUuid,
90
+ steps: [
91
+ ...preamble(targetUuid),
92
+ assertEligible(targetUuid, item),
93
+ menuPress("Items ▸ Repeat ▸ Create Next Copy", item, REPEAT_SUBMENU_ANCHOR),
94
+ ],
95
+ };
96
+ }
67
97
  export function resumeRepeatRecipe(targetUuid) {
68
98
  return {
69
99
  op: "todo.resume-repeat",
@@ -82,12 +112,20 @@ export function convertToProjectRecipe(op, targetUuid) {
82
112
  steps: [
83
113
  ...preamble(targetUuid),
84
114
  menuPress("Items ▸ Convert to Project…", `menu item "Convert to Project…" of ${ITEMS_MENU}`),
115
+ // positional-ok: a window presents at most ONE attached sheet at a time, so
116
+ // `sheet 1` is a uniqueness statement, not an index among peers. It is also
117
+ // only ever a WAIT target here — the control acted on inside it is
118
+ // AXIdentifier-addressed below.
85
119
  waitFor("the confirmation sheet", `sheet 1 of ${MAIN_WINDOW}`),
86
120
  {
87
121
  // The alert's primary button carries a stable, locale-proof
88
122
  // AXIdentifier "action-button-1" (UIC1); prefer it over the English title.
123
+ // positional-ok: the `sheet 1` container is the one-sheet-per-window law
124
+ // above; the BUTTON is discriminated by AXIdentifier, never by position.
89
125
  primitive: "press",
90
126
  label: 'confirm — press "Convert"',
127
+ // positional-ok: `sheet 1` is the one attached sheet a window can present;
128
+ // the BUTTON inside it is AXIdentifier-discriminated, never positional.
91
129
  path: `(first button of sheet 1 of ${MAIN_WINDOW} whose value of attribute "AXIdentifier" is "action-button-1")`,
92
130
  dynamic: true,
93
131
  addressing: "axidentifier",
@@ -107,6 +145,60 @@ export function convertToProjectRecipe(op, targetUuid) {
107
145
  * DB effect (UI2-d / HEADCERT1): the heading uuid dies, a new type=1 project is
108
146
  * promoted into the parent project's area, its children reparent (heading→NULL).
109
147
  */
148
+ /**
149
+ * Reorder a project's HEADINGS with the arrow chords (CHORDMH1, on the HEADORD1
150
+ * law). Things exposes heading order through four bare keybindings and nothing
151
+ * else — no menu item, no context-menu item, no AX action anywhere carries the
152
+ * equivalent — so the recipe has no element to press: it reveals the project,
153
+ * confirms the content table is there, and hands the whole move to the chord
154
+ * driver, which selects each heading positionally and posts one verified chord
155
+ * at a time (src/write/vectors/ui-chord.ts).
156
+ *
157
+ * NO `activate` step, deliberately. The reveal is a background `open -g`, the
158
+ * row selection is pure System Events, and the chord is posted to the Things
159
+ * PROCESS rather than to the focused surface — so the entire gesture runs with
160
+ * Things behind whatever the user is looking at. Measured end to end on Things
161
+ * 3.23 / golden-v4 with Finder frontmost at every stage and Things never
162
+ * activated at all (docs/lab/chordmh1-move-heading-build.md §1).
163
+ *
164
+ * `needsWindowReachability` is set even though no sheet opens: the heading rows
165
+ * only exist in a rendered project view, so a locked screen or a full-screen
166
+ * Space must refuse (SESSGATE) rather than post chords at a window nothing can
167
+ * read back.
168
+ */
169
+ export function moveHeadingChordRecipe(projectUuid, targetOrder, movees) {
170
+ const spec = {
171
+ projectUuid,
172
+ targetOrder,
173
+ movees,
174
+ tablePath: PROJECT_CONTENT_TABLE,
175
+ };
176
+ return {
177
+ op: "project.move-heading",
178
+ targetUuid: spec.projectUuid,
179
+ needsWindowReachability: true,
180
+ steps: [
181
+ {
182
+ primitive: "reveal",
183
+ label: "reveal the project in Things (things:///show?id=<project>)",
184
+ value: spec.projectUuid,
185
+ },
186
+ {
187
+ // Canaried: if the project view's content table is not there, the drive
188
+ // refuses before a single chord is posted.
189
+ primitive: "resolve",
190
+ label: "confirm the project view's content table is present",
191
+ path: PROJECT_CONTENT_TABLE,
192
+ addressing: "title",
193
+ },
194
+ {
195
+ primitive: "chord-reorder",
196
+ label: `reorder ${spec.movees.length} heading(s) with the arrow chords`,
197
+ chord: spec,
198
+ },
199
+ ],
200
+ };
201
+ }
110
202
  export function headingConvertToProjectRecipe(projectUuid, ordinal) {
111
203
  return {
112
204
  op: "project.promote-heading",
@@ -135,12 +227,17 @@ export function headingConvertToProjectRecipe(projectUuid, ordinal) {
135
227
  // With the heading selected, Convert to Project… is enabled (it exists
136
228
  // regardless, so the canary resolves it; the press lands post-selection).
137
229
  menuPress("Items ▸ Convert to Project…", `menu item "Convert to Project…" of ${ITEMS_MENU}`),
230
+ // positional-ok: one attached sheet per window (see convertToProjectRecipe).
138
231
  waitFor("the confirmation sheet", `sheet 1 of ${MAIN_WINDOW}`),
139
232
  {
140
233
  // The alert's primary button carries the locale-proof AXIdentifier
141
234
  // "action-button-1" (UIC1); prefer it over the English title.
235
+ // positional-ok: one attached sheet per window; the button itself is
236
+ // AXIdentifier-discriminated.
142
237
  primitive: "press",
143
238
  label: 'confirm — press "Convert"',
239
+ // positional-ok: `sheet 1` is the one attached sheet a window can present;
240
+ // the BUTTON inside it is AXIdentifier-discriminated, never positional.
144
241
  path: `(first button of sheet 1 of ${MAIN_WINDOW} whose value of attribute "AXIdentifier" is "action-button-1")`,
145
242
  dynamic: true,
146
243
  addressing: "axidentifier",
@@ -168,9 +265,16 @@ export function headingConvertToProjectRecipe(projectUuid, ordinal) {
168
265
  // The reminder-time control is undrivable (its AXDateTimeArea ignores AX writes),
169
266
  // so `--reminder` is refused upstream in assertRepeatRule; its recipe step is
170
267
  // retained but unreachable.
171
- /** The content list's table (row 0 = area/Someday header, then projects/to-dos). Confirmed UIC5. */
268
+ /** The content list's table (row 0 = area/Someday header, then projects/to-dos). Confirmed UIC5.
269
+ * positional-ok: a CONTAINER handle only — the main window's list area holds one
270
+ * scroll area holding one table (UIC5), and no value is read or written through
271
+ * this path. Every step that uses it resolves its target WITHIN the table by
272
+ * title readback (select-row), by selection-readback ordinal (select-heading-row)
273
+ * or by exact AXDescription (click-element's rowCellDescription walk, HXPC1 §B0). */
172
274
  const PROJECT_CONTENT_TABLE = `table 1 of scroll area 1 of ${MAIN_WINDOW}`;
173
- /** The Repeat editor when Things is frontmost — an attached sheet (interval nested in group 1, UIC1). */
275
+ /** The Repeat editor when Things is frontmost — an attached sheet (interval nested in group 1, UIC1).
276
+ * positional-ok: a window presents at most ONE attached sheet at a time, so
277
+ * `sheet 1` names the only sheet there can be rather than picking among peers. */
174
278
  const REPEAT_SHEET = `sheet 1 of ${MAIN_WINDOW}`;
175
279
  /** The Repeat editor when Things is backgrounded — a detached AXUnknown window (UIC4-a). Its
176
280
  * controls sit at the SAME depth as the sheet's (frequency a direct child, interval in group 1) — UIC5-e. */
@@ -182,10 +286,25 @@ function dualForm(inner) {
182
286
  return DIALOG_SHELLS.map((shell) => `${inner} of ${shell}`);
183
287
  }
184
288
  // --- CERTIFIED controls (UIC1/UIC5) --------------------------------------
185
- /** Frequency pop-up — a direct child of the dialog. */
289
+ //
290
+ // WHY THE POP-UPS BELOW MAY STAY POSITIONAL WHILE THE TEXT FIELDS MAY NOT.
291
+ // A pop-up drive is SELF-DISCRIMINATING: `select-popup` opens the control and
292
+ // clicks a menu item matched by its pinned English NAME, so a mis-addressed
293
+ // pop-up has no such item and the step fails closed ("none of the candidate menu
294
+ // items exist"). A text field accepts any string silently — which is precisely
295
+ // how #589 wrote an interval into the ends-count field. The pop-up label sets are
296
+ // also disjoint across the cadence group (`never`/`after`/`on date` for the ends
297
+ // bound; `day(s)`…`year(s)` for the after-completion unit; weekday names, ordinals
298
+ // and month names for the anchors), so a swapped index cannot land a plausible
299
+ // value. Belt and braces: the PRE-COMMIT AUDIT re-reads every one of them by value
300
+ // before the OK press, so a pop-up that took a wrong-but-existing item is caught
301
+ // before anything is committed.
302
+ /** Frequency pop-up — a direct child of the dialog.
303
+ * positional-ok: MEASURED sole direct-child pop-up of the dialog shell in every
304
+ * reachable state — the shell carries `popups=1`, every other pop-up living in the
305
+ * cadence group (CGRD1 §B census, all four frequencies + after-completion). Its
306
+ * menu items are matched by name, and the pre-commit audit re-reads its value. */
186
307
  const DIALOG_FREQUENCY = dualForm("pop up button 1");
187
- /** Interval field — nested in group 1 (UIC5-e). */
188
- const DIALOG_INTERVAL = dualForm("text field 1 of group 1");
189
308
  /** OK button. */
190
309
  const DIALOG_OK = dualForm(`button "OK"`);
191
310
  // --- UIC6-CERTIFIED controls, RE-INDEXED PER DIALOG SHAPE (RDLG2) ----------
@@ -199,18 +318,45 @@ const DIALOG_OK = dualForm(`button "OK"`);
199
318
  // driver measures in the open dialog (`probe-dialog-shape`), never to the app
200
319
  // version. Titles/`_NS:` ids are never used (both drift). Evidence:
201
320
  // docs/lab/uic6-rule-vocabulary.md (≤3.22) and docs/lab/rdlg2-323-recipe-cert.md.
202
- /** After-completion cadence unit pop-up — the ONLY group pop-up in that mode (both shapes). */
321
+ /** After-completion cadence unit pop-up — the ONLY group pop-up in that mode (both shapes).
322
+ * positional-ok: this and DIALOG_ENDS are the SAME spelling because they are the
323
+ * same slot in two MUTUALLY EXCLUSIVE dialog states, and the recipe emits exactly
324
+ * one of them per drive. Measured (CGRD1 §A census): an after-completion cadence
325
+ * group has `popups=1` and no `Ends:` label at all, while every fixed frequency
326
+ * puts the ends bound at group pop-up 1. Their menu-item sets are disjoint
327
+ * (`day(s)`…`year(s)` vs `never`/`after`/`on date`), so a wrong-state drive fails
328
+ * closed on the item name; the pre-commit audit re-reads whichever one ran. */
203
329
  const DIALOG_AC_UNIT = dualForm("pop up button 1 of group 1");
204
- /** "Ends" bound pop-up (`never` · `after` · `on date`) — always pop up button 1 of the group. */
330
+ /** "Ends" bound pop-up (`never` · `after` · `on date`) — always pop up button 1 of the group.
331
+ * positional-ok: see DIALOG_AC_UNIT — one slot, two exclusive states, name-matched
332
+ * items, audited before the commit. */
205
333
  const DIALOG_ENDS = dualForm("pop up button 1 of group 1");
206
- /** The cadence group itself — the handle the shape probe and the weekday converge address. */
334
+ /** The cadence group itself — the handle the shape probe and the weekday converge address.
335
+ * positional-ok: MEASURED sole group of the dialog shell (`groups=1` in every
336
+ * state, CGRD1 §B), and a CONTAINER handle only — every primitive that takes it
337
+ * (set-group-number, probe-dialog-shape, converge-weekdays, audit-dialog)
338
+ * discriminates WITHIN it by label row or control class and fails closed on
339
+ * anything but exactly one match. */
207
340
  const DIALOG_GROUP = dualForm("group 1");
208
- /** The 3.23 `Next:` first-occurrence pop-up — group pop-up 2 (this shape only). */
341
+ /** The 3.23 `Next:` first-occurrence pop-up — group pop-up 2 (this shape only).
342
+ * positional-ok: reachable ONLY under a MEASURED dialog shape — the recipe's
343
+ * probe-dialog-shape step must have returned `next-popup` (a positive match on the
344
+ * control class occupying the `Next:` row) before this step runs, and an
345
+ * unrecognized shape refuses the drive. The drive then matches a menu item by
346
+ * PARSED DATE and reads the pop-up back; the pre-commit audit re-parses it. */
209
347
  const DIALOG_NEXT_POPUP = dualForm("pop up button 2 of group 1");
210
- /** A group pop-up addressed at DIFFERENT indices in the two dialog shapes (RDLG2). */
348
+ /** A group pop-up addressed at DIFFERENT indices in the two dialog shapes (RDLG2).
349
+ * positional-ok: the index is not a guess but the MEASURED consequence of the
350
+ * shape the drive probed — CGRD1 §A counts the cadence group's pop-ups per
351
+ * frequency on 3.23 (daily 2 · weekly 3 · monthly 4 · yearly 5, i.e. Ends + Next +
352
+ * the per-frequency controls), which is the +1 fork these two index sets encode.
353
+ * A step carrying `shaped` with no shape probed fails closed. Every one of these
354
+ * is a name-matched pop-up, and all of them are re-read by the pre-commit audit. */
211
355
  function shapedPopup(nextPopupIndex, legacyIndex) {
212
356
  return {
357
+ // positional-ok: shape-MEASURED index, per this function's contract above.
213
358
  "next-popup": { pathCandidates: dualForm(`pop up button ${nextPopupIndex} of group 1`) },
359
+ // positional-ok: shape-MEASURED index, per this function's contract above.
214
360
  legacy: { pathCandidates: dualForm(`pop up button ${legacyIndex} of group 1`) },
215
361
  };
216
362
  }
@@ -228,13 +374,23 @@ const DIALOG_YEAR_ORDINAL = shapedPopup(5, 4);
228
374
  * the add button from live structure).
229
375
  */
230
376
  const WEEKDAY_BASE = { "next-popup": 3, legacy: 2 };
231
- /** "Ends after [n]" count field — becomes text field 1 of the group once shown (interval was set earlier while it was the sole field). */
232
- const DIALOG_ENDS_COUNT = dualForm("text field 1 of group 1");
233
377
  /** "Add reminders" checkbox (sheet-level, title-pinned). The time is an AXDateTimeArea driven by set-datetime. */
234
378
  const DIALOG_ADD_REMINDERS = dualForm(`checkbox "Add reminders"`);
235
- /** "Add deadlines" checkbox + the "start N days earlier" field it reveals as a DIRECT sheet child (text field 1 of the shell). */
379
+ /** "Add deadlines" checkbox + the "and start [n] days earlier" offset field it reveals. */
236
380
  const DIALOG_ADD_DEADLINES = dualForm(`checkbox "Add deadlines"`);
237
- const DIALOG_START_EARLIER = dualForm("text field 1");
381
+ /**
382
+ * The pinned English static text whose ROW the start-offset field sits on. The
383
+ * field shipped as `text field 1` of the dialog shell — a value-bearing numeric
384
+ * field picked by index out of a tree whose shape depends on the "Add deadlines"
385
+ * checkbox, verified only by re-reading the same index it wrote. That is the #589
386
+ * error class, and it happened to be right rather than provably right: measured on
387
+ * 3.23, the shell carries 0 direct text fields with deadlines OFF and exactly 1
388
+ * with them ON — `[and start] [ 0 ] [days earlier]`, the field at y=409 against the
389
+ * label's y=413 — whether or not reminders are also on (CGRD1 §B census). Now the
390
+ * field is found by that label's row, exactly like the cadence numbers, and
391
+ * anything but one match on it fails closed with the shell's field inventory.
392
+ */
393
+ const DIALOG_START_EARLIER_LABEL = "days earlier";
238
394
  /**
239
395
  * After-completion cadence-unit pop-up labels. The options are NOT the frequency
240
396
  * word (`weekly`) — they are the time unit, and the app PLURALIZES them by the
@@ -317,11 +473,52 @@ function selectPopupAny(label, pathCandidates, valueCandidates) {
317
473
  addressing: "title",
318
474
  };
319
475
  }
320
- function setField(label, pathCandidates, value) {
476
+ /**
477
+ * Drive a dialog-SHELL text field addressed by the pinned English label sharing
478
+ * its row — the {@link setGroupNumber} discrimination law, applied outside the
479
+ * cadence group. Its one caller is the start-days-earlier offset; see
480
+ * {@link DIALOG_START_EARLIER_LABEL} for why the old `text field 1` spelling had
481
+ * to go even though it resolved correctly on 3.23.
482
+ */
483
+ function setRowField(label, rowLabel, value) {
321
484
  return {
322
- primitive: "set-value",
485
+ primitive: "set-row-field",
323
486
  label,
324
- pathCandidates,
487
+ pathCandidates: DIALOG_SHELLS,
488
+ rowLabel,
489
+ value,
490
+ dynamic: true,
491
+ addressing: "title",
492
+ };
493
+ }
494
+ /**
495
+ * Drive one of the Repeat dialog's TWO numeric fields — the cadence interval
496
+ * ("Every [n] days") or the "Ends: after [n] times" count. Both are text fields
497
+ * of the cadence group, and WHICH INDEX each holds depends on the dialog's
498
+ * state rather than on which one the caller means. Measured on Things 3.23
499
+ * (HXPC1, docs/lab/hxpc1-picker-assert.md §A):
500
+ *
501
+ * Ends: never → 1 text field · #1 interval @[311,283]
502
+ * Ends: after N → 2 text fields · #1 COUNT @[402,372] (`Ends:` label y=375)
503
+ * #2 interval @[311,283] (`Every` label y=286)
504
+ *
505
+ * Both used to be spelled `text field 1 of group 1`, which the create path
506
+ * survived only because it drives the interval BEFORE selecting the ends bound,
507
+ * while it is still the sole field. A RESCHEDULE opens the dialog PRE-POPULATED:
508
+ * a rule that already ends after N presents both fields from the first step, so
509
+ * the interval drive wrote the requested interval into the count field and the
510
+ * count drive then overwrote it — the interval never changed. The driver now
511
+ * resolves each field by the ROW it sits on (the count shares the `Ends:`
512
+ * label's row, the interval does not), which is order-independent and covers the
513
+ * after-completion dialog too: that one offers no ends bound at all, so the
514
+ * interval is again the only field. See ui.ts `axSetGroupNumberScript`.
515
+ */
516
+ function setGroupNumber(label, numberTarget, value) {
517
+ return {
518
+ primitive: "set-group-number",
519
+ label,
520
+ pathCandidates: DIALOG_GROUP,
521
+ numberTarget,
325
522
  value,
326
523
  dynamic: true,
327
524
  addressing: "title",
@@ -423,7 +620,7 @@ function repeatDialogEntry(rule) {
423
620
  else {
424
621
  steps.push(selectPopup(`frequency = ${rule.frequency}`, DIALOG_FREQUENCY, rule.frequency));
425
622
  }
426
- steps.push(setField(`interval = ${rule.interval}`, DIALOG_INTERVAL, String(rule.interval)));
623
+ steps.push(setGroupNumber(`interval = ${rule.interval}`, "interval", String(rule.interval)));
427
624
  // MEASURE the dialog before touching any control the 3.23 redesign moved
428
625
  // (RDLG2). Emitted only when such a control is actually addressed, so the
429
626
  // certified two-control path (frequency + interval + OK) costs no extra hop —
@@ -471,6 +668,31 @@ function repeatDialogEntry(rule) {
471
668
  steps.push(selectPopupShaped(`yearly month = ${y.month}`, DIALOG_YEAR_MONTH, MONTH_TITLE[y.month - 1] ?? ""));
472
669
  steps.push(...monthlyAnchorSteps(y, DIALOG_YEAR_MODE, DIALOG_YEAR_ORDINAL));
473
670
  }
671
+ // LET THE `Next:` POP-UP ABSORB THE RULE (NEXTPOP1) before any further input.
672
+ //
673
+ // The 3.23 dialog recomputes its first-occurrence pop-up — the displayed date
674
+ // AND the menu of occurrences behind it — ASYNCHRONOUSLY, ~0.4s after the
675
+ // calendar anchor moves, and an input that lands inside that window CANCELS
676
+ // the recompute permanently: the control keeps describing the PREVIOUS rule.
677
+ // The very next thing this recipe drives is the deadline checkbox, which is
678
+ // exactly such an input, so every deadlined monthly/yearly promote reached
679
+ // `select-next-occurrence` with the SEED's occurrence series in the menu and
680
+ // failed closed on a date the rule really does produce (VMRES1 §4.3).
681
+ //
682
+ // Emitted for the pop-up shape only — the ≤3.22 `Next:` is a free date area
683
+ // with no menu to recompute — and never for after-completion, which has no
684
+ // first-occurrence control at all. It is a WAIT, not a setter, so it
685
+ // contributes no control to the pre-commit audit.
686
+ if (needsShape && rule.afterCompletion !== true) {
687
+ steps.push({
688
+ primitive: "settle-occurrences",
689
+ label: "let the first-occurrence pop-up absorb the rule",
690
+ pathCandidates: DIALOG_NEXT_POPUP,
691
+ onlyShape: "next-popup",
692
+ dynamic: true,
693
+ addressing: "title",
694
+ });
695
+ }
474
696
  // "Add deadlines" / "start N days earlier" — DEADLINE MODE, converged (RRD1)
475
697
  // BEFORE the "Next:" field is driven below. In deadline mode the "Next:" field IS
476
698
  // the deadline date and the instance start = deadline − startDaysEarlier (YANCH1
@@ -496,7 +718,7 @@ function repeatDialogEntry(rule) {
496
718
  // startDaysEarlier is requested-fields-only too: drive the offset field only
497
719
  // when it was given (>0), else leave it at its pre-populated value.
498
720
  if (deadlineTarget && (rule.startDaysEarlier ?? 0) > 0) {
499
- steps.push(setField(`start ${rule.startDaysEarlier} days earlier`, DIALOG_START_EARLIER, String(rule.startDaysEarlier)));
721
+ steps.push(setRowField(`start ${rule.startDaysEarlier} days earlier`, DIALOG_START_EARLIER_LABEL, String(rule.startDaysEarlier)));
500
722
  }
501
723
  }
502
724
  // Ends bound + "Next:" first-occurrence field (ANCH2, issue #476). ORDER MATTERS:
@@ -508,7 +730,7 @@ function repeatDialogEntry(rule) {
508
730
  const endsOnDate = rule.ends !== undefined && rule.ends.kind === "on-date" ? rule.ends : null;
509
731
  if (rule.ends !== undefined && rule.ends.kind === "after") {
510
732
  steps.push(selectPopup("ends = after", DIALOG_ENDS, "after"));
511
- steps.push(setField(`ends after = ${rule.ends.count}`, DIALOG_ENDS_COUNT, String(rule.ends.count)));
733
+ steps.push(setGroupNumber(`ends after = ${rule.ends.count}`, "ends-count", String(rule.ends.count)));
512
734
  }
513
735
  else if (endsOnDate !== null) {
514
736
  steps.push(selectPopup("ends = on date", DIALOG_ENDS, "on date"));
@@ -550,9 +772,159 @@ function repeatDialogEntry(rule) {
550
772
  steps.push(ensureCheckbox("Add reminders", DIALOG_ADD_REMINDERS, true));
551
773
  steps.push(setDateTime(`reminder = ${rule.reminder}`, `time:${rule.reminder}`, "reminder"));
552
774
  }
775
+ const audit = dialogAuditStep(steps);
776
+ if (audit !== null)
777
+ steps.push(audit);
553
778
  steps.push(pressControl('press "OK"', DIALOG_OK));
554
779
  return steps;
555
780
  }
781
+ /**
782
+ * Build the PRE-COMMIT FULL-DIALOG AUDIT step from the drive's OWN step list
783
+ * (CGRD1) — the last thing that runs before the OK press.
784
+ *
785
+ * Derivation from the steps, rather than a second hand-written list of controls,
786
+ * is the point: every setter the recipe emits contributes exactly one audit
787
+ * control, so a rule field added to `repeatDialogEntry` is audited the moment it
788
+ * is driven, and a control can never be silently left out. It also means the audit
789
+ * addresses each control through the SAME candidate paths / shape overrides / row
790
+ * anchors the drive used, so the two can never disagree about what was touched.
791
+ *
792
+ * Why it is needed at all: each setter's own read-back is SELF-REFERENTIAL — it
793
+ * re-reads the element it addressed, so it proves the keystrokes landed where they
794
+ * were aimed and nothing more. #589 was a wrong ADDRESS, invisible to that check.
795
+ * The audit is the outside view; any mismatch aborts the drive fail-closed before
796
+ * anything is committed.
797
+ *
798
+ * Steps that set nothing (waits, the menu press, the shape probe, the OK press)
799
+ * contribute nothing; a recipe that drove no control at all yields no audit step.
800
+ */
801
+ function weekdayBaseOfEncoded(value) {
802
+ const base = Number(value.split("|", 1)[0]);
803
+ return Number.isFinite(base) && base > 0 ? Math.trunc(base) : 2;
804
+ }
805
+ /** The per-shape PATH overrides of a step, in the audit control's shape. */
806
+ function shapedPaths(step) {
807
+ if (step.shaped === undefined)
808
+ return undefined;
809
+ return {
810
+ ...(step.shaped["next-popup"]?.pathCandidates !== undefined && {
811
+ "next-popup": { pathCandidates: step.shaped["next-popup"].pathCandidates },
812
+ }),
813
+ ...(step.shaped.legacy?.pathCandidates !== undefined && {
814
+ legacy: { pathCandidates: step.shaped.legacy.pathCandidates },
815
+ }),
816
+ };
817
+ }
818
+ function dialogAuditStep(steps) {
819
+ const controls = [];
820
+ for (const step of steps) {
821
+ const base = {
822
+ label: step.label,
823
+ ...(step.onlyShape !== undefined && { onlyShape: step.onlyShape }),
824
+ };
825
+ switch (step.primitive) {
826
+ case "select-popup": {
827
+ const shaped = shapedPaths(step);
828
+ controls.push({
829
+ ...base,
830
+ kind: "popup",
831
+ ...(step.pathCandidates !== undefined && { pathCandidates: step.pathCandidates }),
832
+ ...(shaped !== undefined && { shaped }),
833
+ // valueCandidates carries the singular/plural pair the app pluralizes by
834
+ // interval; ANY of them satisfies, exactly as the drive accepted any.
835
+ expected: step.valueCandidates ?? [step.value ?? ""],
836
+ });
837
+ break;
838
+ }
839
+ case "set-group-number":
840
+ controls.push({
841
+ ...base,
842
+ kind: "group-number",
843
+ numberTarget: step.numberTarget ?? "interval",
844
+ expected: [step.value ?? ""],
845
+ });
846
+ break;
847
+ case "set-row-field":
848
+ controls.push({
849
+ ...base,
850
+ kind: "row-field",
851
+ rowLabel: step.rowLabel ?? "",
852
+ expected: [step.value ?? ""],
853
+ });
854
+ break;
855
+ case "ensure-checkbox":
856
+ controls.push({
857
+ ...base,
858
+ kind: "checkbox",
859
+ ...(step.pathCandidates !== undefined && { pathCandidates: step.pathCandidates }),
860
+ expected: [step.checkboxTarget === true ? "1" : "0"],
861
+ expectedLabel: step.checkboxTarget === true ? "checked" : "unchecked",
862
+ });
863
+ break;
864
+ case "converge-weekdays": {
865
+ // The weekday step encodes "<base>|<Weekday>,<Weekday>…" per dialog shape
866
+ // (the base is the group pop-up index of the first weekday row, which the
867
+ // +1 fork moves). The audit reads every row pop-up from that base and
868
+ // compares as a SET: the converge law assigns EVERY row from the target set
869
+ // cycling, so a surplus row duplicates a target weekday rather than keeping
870
+ // a stale one, and set equality is the exact property to check.
871
+ const nextValue = step.shaped?.["next-popup"]?.value;
872
+ const legacyValue = step.shaped?.legacy?.value;
873
+ const encoded = nextValue ?? legacyValue;
874
+ if (encoded === undefined)
875
+ break;
876
+ const titles = encoded
877
+ .slice(encoded.indexOf("|") + 1)
878
+ .split(",")
879
+ .filter((t) => t !== "");
880
+ if (titles.length === 0)
881
+ break;
882
+ controls.push({
883
+ ...base,
884
+ kind: "weekdays",
885
+ expected: titles,
886
+ expectedLabel: titles.join(" + "),
887
+ shaped: {
888
+ ...(nextValue !== undefined && {
889
+ "next-popup": { weekdayBase: weekdayBaseOfEncoded(nextValue) },
890
+ }),
891
+ ...(legacyValue !== undefined && {
892
+ legacy: { weekdayBase: weekdayBaseOfEncoded(legacyValue) },
893
+ }),
894
+ },
895
+ });
896
+ break;
897
+ }
898
+ case "select-next-occurrence":
899
+ controls.push({
900
+ ...base,
901
+ kind: "occurrence-popup",
902
+ ...(step.pathCandidates !== undefined && { pathCandidates: step.pathCandidates }),
903
+ expected: [step.value ?? ""],
904
+ });
905
+ break;
906
+ case "set-datetime":
907
+ controls.push({
908
+ ...base,
909
+ kind: "date-area",
910
+ dtTarget: step.dtTarget ?? "next",
911
+ dtSpec: step.value ?? "",
912
+ });
913
+ break;
914
+ default:
915
+ break;
916
+ }
917
+ }
918
+ if (controls.length === 0)
919
+ return null;
920
+ return {
921
+ primitive: "audit-dialog",
922
+ label: "audit the Repeat dialog against the requested rule (before committing)",
923
+ audit: { shells: DIALOG_SHELLS, groups: DIALOG_GROUP, controls },
924
+ dynamic: true,
925
+ addressing: "title",
926
+ };
927
+ }
556
928
  export function makeRepeatingRecipe(targetUuid, frequency, interval, extras = {}) {
557
929
  return {
558
930
  op: "todo.make-repeating",
@@ -685,9 +1057,17 @@ export function rescheduleRepeatRecipe(targetUuid, frequency, interval, extras =
685
1057
  // repeat bar, the popover, and the popover items are best-guess structural
686
1058
  // paths derived from the UIC2 AX inventory; the certification pass confirms or
687
1059
  // corrects them exactly as the to-do recipes were corrected in UIC1.
688
- /** The header cell of the project view (row 1 of the content table). */
1060
+ /** The header cell of the project view (row 1 of the content table).
1061
+ * positional-ok: the project view's header IS its first content-table row by
1062
+ * construction (UIC2/UIC3) — the row the app renders above the item list — and
1063
+ * this path only ever reaches a CLICK target, never a value. */
689
1064
  const PROJECT_HEADER_CELL = `UI element 1 of row 1 of table 1 of scroll area 1 of ${MAIN_WINDOW}`;
690
- /** The always-visible repeat bar of a repeating project (UIC2/UIC3: text area 2). */
1065
+ /** The always-visible repeat bar of a repeating project (UIC2/UIC3: text area 2).
1066
+ * positional-ok: the bar carries no title or description to address it by (UIC2),
1067
+ * so its index in the header cell is the only handle the app offers. It is not a
1068
+ * value-bearing field: the step that uses it is a click whose `assertPath` REQUIRES
1069
+ * the repeat popover to appear afterwards, so a click that lands on the wrong text
1070
+ * area fails closed (Escape + abort) instead of cascading into blind presses. */
691
1071
  const PROJECT_REPEAT_BAR = `text area 2 of ${PROJECT_HEADER_CELL}`;
692
1072
  /**
693
1073
  * The popover opened by clicking the repeat bar. Confirmed by UIC3 discovery: it
@@ -698,6 +1078,9 @@ const PROJECT_REPEAT_BAR = `text area 2 of ${PROJECT_HEADER_CELL}`;
698
1078
  * utility window; its items live in the window's scroll area.
699
1079
  */
700
1080
  const PROJECT_REPEAT_POPOVER = `(first window whose subrole is "AXUnknown" and size is not {40, 40})`;
1081
+ // positional-ok: a CONTAINER handle only — these custom AXUnknown popovers hold
1082
+ // exactly one scroll area (MEASURED on the sibling Move… picker, HXPC1 §B2:
1083
+ // `scroll areas=1`), and every item inside is addressed by exact AXDescription.
701
1084
  const PROJECT_REPEAT_POPOVER_ITEMS = `scroll area 1 of ${PROJECT_REPEAT_POPOVER}`;
702
1085
  /** A project view + foreground preamble — the mouse segment needs Things frontmost. */
703
1086
  function projectPreamble(targetUuid) {
@@ -775,6 +1158,8 @@ export function projectRescheduleRepeatRecipe(targetUuid, frequency, interval, e
775
1158
  ...projectPreamble(targetUuid),
776
1159
  openProjectRepeatPopover(),
777
1160
  popoverItemClick("repeat menu ▸ Change…", "Change…", {
1161
+ // positional-ok: a post-click EXISTENCE assert on the one attached sheet a
1162
+ // window can present; nothing is read or written through this path.
778
1163
  path: `sheet 1 of ${MAIN_WINDOW}`,
779
1164
  label: "the Repeat dialog",
780
1165
  }),
@@ -794,27 +1179,74 @@ export function projectRescheduleRepeatRecipe(targetUuid, frequency, interval, e
794
1179
  // repeat popover. The popover (Archive / Move… / Convert to Project… / Delete) is
795
1180
  // the same custom AXUnknown-window shape as the repeat popover.
796
1181
  //
797
- // PROVISIONAL element paths (pending HXPC1 certification): the `…` button's
798
- // container path, the popover-item enumeration, and the picker's search field are
799
- // best-guess structural paths from the HEADXPROJ AX inventory; the certification
800
- // sitting confirms/corrects them exactly as UIC1 corrected the repeat recipes.
801
- /** The heading `…` "More. <title>" button provisionally in the content table. */
802
- function headingMoreButton(headingTitle) {
803
- return `(first UI element of ${PROJECT_CONTENT_TABLE} whose description is "More. ${headingTitle}")`;
1182
+ // CERTIFIED by HXPC1 (2026-08-25, Things 3.23 / golden-v4
1183
+ // docs/lab/hxpc1-picker-assert.md), which corrected three of the four provisional
1184
+ // paths the way UIC1 corrected the repeat recipes:
1185
+ // * the `…` button is three levels below the content table, so no `whose`
1186
+ // clause can reach it (§B0) the click resolves it by walking rows/cells;
1187
+ // * the picker is a DETACHED `MovePopUpDialog-` window, not a sheet of the main
1188
+ // window (§B2), and its filter field is not addressable as a child of it;
1189
+ // * the commit is a CLICK on the destination row, never a Return — the picker
1190
+ // publishes no highlight to read back, and its `New Project "<typed>"` row
1191
+ // turns an absent destination into a newly created project (§B4).
1192
+ // The popover-item enumeration (`whose description is "Move…"`) was the one
1193
+ // provisional path that measured correct: those items are direct children of the
1194
+ // popover's scroll area.
1195
+ /**
1196
+ * The heading `…` "More. <title>" button. It carries the heading's title in its
1197
+ * `AXDescription`, but it sits at `UI element N of cell 1 of row M` of the
1198
+ * content table — three levels down — and a `whose` clause searches only DIRECT
1199
+ * children, so the button is NOT addressable as a path. The click step names the
1200
+ * table here and the description separately (`rowCellDescription`), and the
1201
+ * driver walks rows → cells → children for the exact match (HXPC1 §B0; the
1202
+ * one-level spelling this replaces matched nothing on 3.23, which is why every
1203
+ * ellipsis drive died at its own frame resolution).
1204
+ */
1205
+ function headingMoreDescription(headingTitle) {
1206
+ return `More. ${headingTitle}`;
804
1207
  }
805
1208
  /** The ellipsis popover — a custom AXUnknown window (same shape as the repeat popover). */
806
1209
  const HEADING_ELLIPSIS_POPOVER = `(first window whose subrole is "AXUnknown" and size is not {40, 40})`;
1210
+ // positional-ok: container handle only — one scroll area per popover (HXPC1 §B2),
1211
+ // items addressed by exact AXDescription.
807
1212
  const HEADING_POPOVER_ITEMS = `scroll area 1 of ${HEADING_ELLIPSIS_POPOVER}`;
808
- /** The Move… project picker (a searchable list; provisionally a sheet of the main window). */
809
- const HEADING_MOVE_PICKER = `sheet 1 of ${MAIN_WINDOW}`;
810
- const HEADING_MOVE_PICKER_FIELD = `text field 1 of ${HEADING_MOVE_PICKER}`;
1213
+ /**
1214
+ * The Move… project picker. NOT a sheet of the main window (the shape this
1215
+ * recipe first assumed) a DETACHED top-level window of the same custom
1216
+ * `AXUnknown` class as the ellipsis popover, distinguished by an `AXIdentifier`
1217
+ * beginning `MovePopUpDialog-` (HXPC1 §B2). The popover that opened it is gone
1218
+ * by the time it appears, so the generic detached-window selector resolves it
1219
+ * unambiguously; the row-commit script re-checks the identifier before clicking
1220
+ * anything, so a different detached window can never be committed into.
1221
+ */
1222
+ const HEADING_MOVE_PICKER = `(first window whose subrole is "AXUnknown" and size is not {40, 40})`;
1223
+ /**
1224
+ * What proves the detached window that appeared IS the picker and not the
1225
+ * ellipsis popover it replaced: the picker carries a direct child described
1226
+ * `Move` (its header), the popover carries only its item list. Without this the
1227
+ * post-click assert would be satisfied by the popover that was already open —
1228
+ * i.e. by the click having done nothing (HXPC1 §B2).
1229
+ */
1230
+ const HEADING_MOVE_PICKER_MARKER = `(first UI element of ${HEADING_MOVE_PICKER} whose description is "Move")`;
811
1231
  /**
812
1232
  * Move a HEADING to a different project via the ellipsis `Move…` menu (HEADXPROJ).
813
1233
  * `sourceProjectUuid` is revealed to render the heading row; `headingTitle` is the
814
- * `"More. <title>"` click target; `destProjectTitle` is typed into the picker,
815
- * then Return selects the filtered match. DB effect (HEADXPROJ): the heading row's
816
- * `project` FK becomes the destination; its children follow via their intact
1234
+ * `"More. <title>"` click target; `destProjectTitle` narrows the picker's list and
1235
+ * then names the row that is CLICKED to commit. DB effect (HEADXPROJ): the heading
1236
+ * row's `project` FK becomes the destination; its children follow via their intact
817
1237
  * heading FK (a single-row change — no child rewrite, no index churn).
1238
+ *
1239
+ * The commit used to be a Return pressed BLIND on whatever the filter had
1240
+ * highlighted, which is how a move could CREATE a project instead of performing
1241
+ * one: the picker offers a trailing `New Project "<typed text>"` row, and it omits
1242
+ * completed and canceled projects entirely — so a destination our pre-state had
1243
+ * resolved from the database could be absent from the picker, leaving that row as
1244
+ * the only offer. Measured on Things 3.23: the heading landed in a brand-new
1245
+ * second project of the same name (HXPC1 §B4). The picker publishes no selection
1246
+ * or highlight attribute on any row, so there is nothing to read back from a
1247
+ * keyboard commit; the recipe therefore addresses the destination row by its exact
1248
+ * title and clicks it, and fails closed — naming every row the picker offered —
1249
+ * when that row is absent, duplicated, or scrolled out of view.
818
1250
  */
819
1251
  export function moveHeadingToProjectRecipe(sourceProjectUuid, headingTitle, destProjectTitle) {
820
1252
  return {
@@ -836,7 +1268,8 @@ export function moveHeadingToProjectRecipe(sourceProjectUuid, headingTitle, dest
836
1268
  {
837
1269
  primitive: "click-element",
838
1270
  label: `open the heading's ellipsis menu ("More. ${headingTitle}")`,
839
- path: headingMoreButton(headingTitle),
1271
+ path: PROJECT_CONTENT_TABLE,
1272
+ rowCellDescription: headingMoreDescription(headingTitle),
840
1273
  assertPath: HEADING_ELLIPSIS_POPOVER,
841
1274
  assertLabel: "the heading ellipsis menu",
842
1275
  assertTimeoutMs: 5000,
@@ -847,24 +1280,28 @@ export function moveHeadingToProjectRecipe(sourceProjectUuid, headingTitle, dest
847
1280
  primitive: "click-element",
848
1281
  label: "ellipsis menu ▸ Move…",
849
1282
  path: `(first UI element of ${HEADING_POPOVER_ITEMS} whose description is "Move…")`,
850
- assertPath: HEADING_MOVE_PICKER,
1283
+ assertPath: HEADING_MOVE_PICKER_MARKER,
851
1284
  assertLabel: "the Move… project picker",
852
1285
  assertTimeoutMs: 5000,
853
1286
  dynamic: true,
854
1287
  addressing: "title",
855
1288
  },
856
1289
  {
857
- primitive: "set-value",
858
- label: `type the destination "${destProjectTitle}" into the Move… picker`,
859
- path: HEADING_MOVE_PICKER_FIELD,
1290
+ // The picker focuses its own filter field as it opens, and that field is
1291
+ // not addressable as a child of the picker window so the destination is
1292
+ // typed at the focus. Nothing is committed on the strength of the
1293
+ // keystroke: the next step resolves the destination row by name.
1294
+ primitive: "type-text",
1295
+ label: `narrow the Move… picker to "${destProjectTitle}"`,
860
1296
  value: destProjectTitle,
861
1297
  dynamic: true,
862
1298
  addressing: "title",
863
1299
  },
864
1300
  {
865
- primitive: "key",
866
- label: "press Return to select the filtered destination project",
867
- keys: "return",
1301
+ primitive: "click-picker-row",
1302
+ label: `commit the Move… picker on the "${destProjectTitle}" row`,
1303
+ path: HEADING_MOVE_PICKER,
1304
+ value: destProjectTitle,
868
1305
  dynamic: true,
869
1306
  addressing: "title",
870
1307
  },
@@ -900,7 +1337,8 @@ export function dissolveHeadingRecipe(projectReveal, headingTitle) {
900
1337
  {
901
1338
  primitive: "click-element",
902
1339
  label: `open the heading's ellipsis menu ("More. ${headingTitle}")`,
903
- path: headingMoreButton(headingTitle),
1340
+ path: PROJECT_CONTENT_TABLE,
1341
+ rowCellDescription: headingMoreDescription(headingTitle),
904
1342
  assertPath: HEADING_ELLIPSIS_POPOVER,
905
1343
  assertLabel: "the heading ellipsis menu",
906
1344
  assertTimeoutMs: 5000,