things-api 0.19.1 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  3. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  5. package/dist/cli/commands/doctor.js +28 -8
  6. package/dist/cli/commands/doctor.js.map +1 -1
  7. package/dist/cli/commands/ui-state.d.ts +11 -0
  8. package/dist/cli/commands/ui-state.js +27 -0
  9. package/dist/cli/commands/ui-state.js.map +1 -0
  10. package/dist/cli/help.js +2 -0
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/main.js +2 -0
  13. package/dist/cli/main.js.map +1 -1
  14. package/dist/contracts.d.ts +2 -2
  15. package/dist/contracts.js +1 -1
  16. package/dist/contracts.js.map +1 -1
  17. package/dist/deputy/osa.d.ts +42 -2
  18. package/dist/deputy/osa.js +79 -5
  19. package/dist/deputy/osa.js.map +1 -1
  20. package/dist/deputy/routing.d.ts +18 -0
  21. package/dist/deputy/routing.js +25 -0
  22. package/dist/deputy/routing.js.map +1 -1
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/ui-state.d.ts +46 -0
  27. package/dist/ui-state.js +82 -0
  28. package/dist/ui-state.js.map +1 -0
  29. package/dist/write/accessibility-probe.js +4 -1
  30. package/dist/write/accessibility-probe.js.map +1 -1
  31. package/dist/write/automation-probe.js +7 -1
  32. package/dist/write/automation-probe.js.map +1 -1
  33. package/dist/write/failure-hints.d.ts +7 -1
  34. package/dist/write/failure-hints.js +24 -0
  35. package/dist/write/failure-hints.js.map +1 -1
  36. package/dist/write/field-limits.d.ts +100 -0
  37. package/dist/write/field-limits.js +144 -0
  38. package/dist/write/field-limits.js.map +1 -0
  39. package/dist/write/param-schema.d.ts +17 -0
  40. package/dist/write/param-schema.js +64 -4
  41. package/dist/write/param-schema.js.map +1 -1
  42. package/dist/write/pipeline.js +30 -0
  43. package/dist/write/pipeline.js.map +1 -1
  44. package/dist/write/promote-clone.d.ts +26 -0
  45. package/dist/write/promote-clone.js +201 -23
  46. package/dist/write/promote-clone.js.map +1 -1
  47. package/dist/write/vectors/types.d.ts +25 -3
  48. package/dist/write/vectors/ui-state.d.ts +157 -0
  49. package/dist/write/vectors/ui-state.js +237 -0
  50. package/dist/write/vectors/ui-state.js.map +1 -0
  51. package/dist/write/vectors/ui.d.ts +120 -4
  52. package/dist/write/vectors/ui.js +542 -65
  53. package/dist/write/vectors/ui.js.map +1 -1
  54. package/package.json +1 -1
  55. package/schema/envelope.schema.json +1 -0
  56. package/skills/things-cli/SKILL.md +2 -2
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The READ-ONLY window/focus census (issue #620, field report M1).
3
+ *
4
+ * One stable osascript shape answers four questions without touching anything —
5
+ * no click, no keystroke, no menu opened, no state changed:
6
+ *
7
+ * - is a Things sheet/dialog open, and WHICH one (identified from its control
8
+ * census, never from a localized title alone);
9
+ * - is Things the frontmost application;
10
+ * - who owns keyboard focus instead (application + element ROLE);
11
+ * - could the frontmost surface be inspected at all (a secure system modal —
12
+ * a macOS privacy/consent dialog — belongs to no application's
13
+ * Accessibility tree, and that is reported as exactly that rather than
14
+ * guessed at).
15
+ *
16
+ * It has three consumers, deliberately ONE shape for all of them:
17
+ * 1. the PER-STEP GUARD before every keystroke-class hop (ui.ts) — a keystroke
18
+ * goes to whatever is frontmost, so typing while another app owns the
19
+ * screen types into the void, or into someone else's window;
20
+ * 2. the PRE-CLEANUP oracle — Escape is itself a keystroke, so the abort path
21
+ * must know who would receive it before sending it;
22
+ * 3. the `ui-state` diagnostic (and `things doctor --ui-state`).
23
+ *
24
+ * PRIVACY: the census emits ROLE COUNTS and element ROLES only — never a
25
+ * control's value, title or description, and never a window title. A Things
26
+ * sheet routinely displays the user's own to-do text, and a diagnostic that
27
+ * gets pasted into a bug report must not carry it.
28
+ */
29
+ import type { UiCommand, UiRunResult } from "./ui.ts";
30
+ /**
31
+ * Which dialog is up, decided by STRUCTURE (the control census measured in
32
+ * docs/lab/rdlg1-323-repeat-dialog-census.md §2.1) rather than by a localized
33
+ * string:
34
+ *
35
+ * - `repeat` — the Repeat sheet: exactly two checkboxes, exactly one
36
+ * direct pop-up button (the frequency), exactly two buttons
37
+ * (OK / Cancel), no direct text field, and one cadence group
38
+ * carrying its own field/pop-up. That shape holds in every
39
+ * mode of the 3.23 dialog and in both of its forms (attached
40
+ * sheet when Things is frontmost, detached editor window when
41
+ * it is not), and no other Things sheet presents it.
42
+ * - `move-picker` — the Move… project picker, identified POSITIVELY by its own
43
+ * `AXIdentifier` prefix (`MovePopUpDialog-`) — the same
44
+ * identity check the picker-row resolver makes.
45
+ * - `other` — something modal is up that is neither of those.
46
+ * - `none` — no attached sheet and no detached editor window.
47
+ */
48
+ export type UiSheetKind = "none" | "repeat" | "move-picker" | "other";
49
+ /** How the dialog presents: attached to the standard window, or detached (Things backgrounded). */
50
+ export type UiSheetForm = "none" | "attached" | "detached";
51
+ /**
52
+ * Resolve the dialog IN FRONT — the innermost of a stack — and record how deep
53
+ * the stack goes. Written as a snippet because the census and the dismissal must
54
+ * agree on which dialog they mean, to the element.
55
+ *
56
+ * Sheets STACK (MODALX1 §6, golden-v4 / 3.23): a dialog raised while another is
57
+ * standing becomes an `AXSheet` CHILD of the one below it, not a sibling on the
58
+ * window — measured with two `things:///add` URL-consent alerts nesting inside a
59
+ * Repeat sheet — and dismissal is strictly LIFO. So a census that reads only the
60
+ * window's own `sheet 1` sees the bottom of the stack and misidentifies what
61
+ * actually owns the screen, and a dismissal aimed there presses a button behind
62
+ * a modal. Both walk to the top instead.
63
+ *
64
+ * Runs inside a `tell process "Things3"` block; leaves `shellRef` (or
65
+ * `missing value`), `sheetForm` and `sheetDepth` bound.
66
+ */
67
+ export declare const AX_DIALOG_SHELL_SNIPPET = "\t\tset shellRef to missing value\n\t\tset sheetForm to \"none\"\n\t\tset sheetDepth to 0\n\t\ttry\n\t\t\t-- positional-ok: the one attached sheet a window can present, which is\n\t\t\t-- the BOTTOM of any stack; the walk below climbs to the top.\n\t\t\tset shellRef to sheet 1 of (first window whose subrole is \"AXStandardWindow\")\n\t\t\tset sheetForm to \"attached\"\n\t\t\tset sheetDepth to 1\n\t\tend try\n\t\tif shellRef is missing value then\n\t\t\ttry\n\t\t\t\tset dws to (windows whose subrole is \"AXUnknown\" and size is not {40, 40})\n\t\t\t\tif (count of dws) > 0 then\n\t\t\t\t\tset shellRef to item 1 of dws\n\t\t\t\t\tset sheetForm to \"detached\"\n\t\t\t\t\tset sheetDepth to 1\n\t\t\t\tend if\n\t\t\tend try\n\t\tend if\n\t\tif shellRef is not missing value then\n\t\t\trepeat 6 times\n\t\t\t\tset nested to missing value\n\t\t\t\ttry\n\t\t\t\t\tif (exists sheet 1 of shellRef) then set nested to sheet 1 of shellRef\n\t\t\t\tend try\n\t\t\t\tif nested is missing value then exit repeat\n\t\t\t\tset shellRef to nested\n\t\t\t\tset sheetDepth to sheetDepth + 1\n\t\t\tend repeat\n\t\tend if";
68
+ /** Who owns keyboard focus. Role only — never the element's value or title (see PRIVACY). */
69
+ export interface UiFocusOwner {
70
+ /** The frontmost application's process name (e.g. "Things3"). */
71
+ app: string;
72
+ /** The focused element's `AXRole`, or "" when the tree would not answer. */
73
+ role: string;
74
+ /** The focused element's `AXSubrole`, or null when it has none. */
75
+ subrole: string | null;
76
+ }
77
+ export interface UiState {
78
+ /** Is the Things process present at all? */
79
+ thingsRunning: boolean;
80
+ /** Is Things the frontmost application — the surface a keystroke would reach? */
81
+ thingsFrontmost: boolean;
82
+ /** The frontmost application's process name; null when it could not be read. */
83
+ frontmostApp: string | null;
84
+ /** Is a modal sheet / detached editor open in Things? */
85
+ sheetOpen: boolean;
86
+ /** What the dialog IN FRONT is — the innermost of a stack (see {@link AX_DIALOG_SHELL_SNIPPET}). */
87
+ sheetKind: UiSheetKind;
88
+ sheetForm: UiSheetForm;
89
+ /**
90
+ * How many dialogs are stacked (0 when none, 1 for the ordinary case). Sheets
91
+ * nest as children of the sheet below and dismiss strictly LIFO, so a depth
92
+ * above 1 means one dismissal is not enough — and that the thing in front is
93
+ * NOT the dialog a drive opened (MODALX1 §6).
94
+ */
95
+ sheetDepth: number;
96
+ /**
97
+ * The open dialog's control census — role COUNTS only, the evidence behind
98
+ * `sheetKind` (e.g. `cb:2 pu:1 bt:2 gp:1 tf:0`). Null when no dialog is open.
99
+ */
100
+ sheetControls: string | null;
101
+ /** Who owns keyboard focus; null when nothing could be read. */
102
+ focusOwner: UiFocusOwner | null;
103
+ /**
104
+ * False when the frontmost surface could not be walked at all — the signature
105
+ * of a SECURE SYSTEM MODAL (a macOS privacy/consent dialog), which belongs to
106
+ * no application's Accessibility tree. Everything else in the census is then
107
+ * "what could still be proven", never a guess.
108
+ */
109
+ inspectable: boolean;
110
+ }
111
+ /** A recognizable token in the script so a test runner can key off the ui-state command. */
112
+ export declare const UI_STATE_MARKER = "-- ui-state census (read-only)";
113
+ /** The label every ui-state dispatch carries (one stable command shape). */
114
+ export declare const UI_STATE_LABEL = "read the window and focus state";
115
+ /** The Things application's process name — the frontmost value that means "us". */
116
+ export declare const THINGS_PROCESS = "Things3";
117
+ /**
118
+ * The census script. Every read is wrapped: a surface that will not answer
119
+ * degrades that ONE field rather than failing the whole census, because the
120
+ * cases this exists for — a foreign modal, an AX-blind session — are exactly
121
+ * the cases where half the tree is unreadable.
122
+ */
123
+ export declare function axUiStateScript(): string;
124
+ /**
125
+ * Parse the census record into a {@link UiState}. Returns null when the output
126
+ * carries none of the expected keys (a transport error, or a script that never
127
+ * ran) — every caller treats that as UNKNOWN, never as "all clear".
128
+ */
129
+ export declare function parseUiState(stdout: string): UiState | null;
130
+ /**
131
+ * Read the census through the injected runner. Returns null on a transport
132
+ * failure — an UNKNOWN state, which every caller treats fail-closed (the guard
133
+ * refuses; the cleanup path does not send a blind Escape).
134
+ */
135
+ export declare function readUiState(run: (command: UiCommand, timeoutMs: number) => Promise<UiRunResult>, timeoutMs: number): Promise<UiState | null>;
136
+ /**
137
+ * How to name the surface that owns the screen, for a refusal someone has to
138
+ * act on. Names the APPLICATION and the focused element's ROLE — never its
139
+ * contents. An un-inspectable frontmost surface is named as what it is: a
140
+ * system dialog macOS does not expose, which is the one case where the person
141
+ * at the keyboard has to look at the screen themselves.
142
+ */
143
+ export declare function describeFocusOwner(state: UiState | null): string;
144
+ /** A one-line human summary of the census, for a diagnostic line or a warning. */
145
+ export declare function describeUiState(state: UiState): string;
146
+ /**
147
+ * The warning an OPEN Things dialog earns, wherever it is reported.
148
+ *
149
+ * FIELD-MEASURED (2026-08-27, controlled A/B on the maintainer's Mac): with a
150
+ * Repeat sheet left open, a write landed in the local database but Things Cloud
151
+ * did NOT attempt a sync — not on the write, and not when Things was brought
152
+ * back to the front. Dismissing the sheet released the queued sync immediately,
153
+ * with no further write. A stranded dialog is therefore not cosmetic: it holds
154
+ * the account's sync until someone dismisses it. Recorded in
155
+ * docs/things-app-oddities.md (the open-dialog sync gate).
156
+ */
157
+ export declare const SYNC_GATE_WARNING: string;
@@ -0,0 +1,237 @@
1
+ /** How deep a stack of dialogs the walk will follow before giving up. */
2
+ const MAX_SHEET_DEPTH = 6;
3
+ /**
4
+ * Resolve the dialog IN FRONT — the innermost of a stack — and record how deep
5
+ * the stack goes. Written as a snippet because the census and the dismissal must
6
+ * agree on which dialog they mean, to the element.
7
+ *
8
+ * Sheets STACK (MODALX1 §6, golden-v4 / 3.23): a dialog raised while another is
9
+ * standing becomes an `AXSheet` CHILD of the one below it, not a sibling on the
10
+ * window — measured with two `things:///add` URL-consent alerts nesting inside a
11
+ * Repeat sheet — and dismissal is strictly LIFO. So a census that reads only the
12
+ * window's own `sheet 1` sees the bottom of the stack and misidentifies what
13
+ * actually owns the screen, and a dismissal aimed there presses a button behind
14
+ * a modal. Both walk to the top instead.
15
+ *
16
+ * Runs inside a `tell process "Things3"` block; leaves `shellRef` (or
17
+ * `missing value`), `sheetForm` and `sheetDepth` bound.
18
+ */
19
+ export const AX_DIALOG_SHELL_SNIPPET = ` set shellRef to missing value
20
+ set sheetForm to "none"
21
+ set sheetDepth to 0
22
+ try
23
+ -- positional-ok: the one attached sheet a window can present, which is
24
+ -- the BOTTOM of any stack; the walk below climbs to the top.
25
+ set shellRef to sheet 1 of (first window whose subrole is "AXStandardWindow")
26
+ set sheetForm to "attached"
27
+ set sheetDepth to 1
28
+ end try
29
+ if shellRef is missing value then
30
+ try
31
+ set dws to (windows whose subrole is "AXUnknown" and size is not {40, 40})
32
+ if (count of dws) > 0 then
33
+ set shellRef to item 1 of dws
34
+ set sheetForm to "detached"
35
+ set sheetDepth to 1
36
+ end if
37
+ end try
38
+ end if
39
+ if shellRef is not missing value then
40
+ repeat ${MAX_SHEET_DEPTH} times
41
+ set nested to missing value
42
+ try
43
+ if (exists sheet 1 of shellRef) then set nested to sheet 1 of shellRef
44
+ end try
45
+ if nested is missing value then exit repeat
46
+ set shellRef to nested
47
+ set sheetDepth to sheetDepth + 1
48
+ end repeat
49
+ end if`;
50
+ /** A recognizable token in the script so a test runner can key off the ui-state command. */
51
+ export const UI_STATE_MARKER = "-- ui-state census (read-only)";
52
+ /** The label every ui-state dispatch carries (one stable command shape). */
53
+ export const UI_STATE_LABEL = "read the window and focus state";
54
+ /** The Things application's process name — the frontmost value that means "us". */
55
+ export const THINGS_PROCESS = "Things3";
56
+ /**
57
+ * The census script. Every read is wrapped: a surface that will not answer
58
+ * degrades that ONE field rather than failing the whole census, because the
59
+ * cases this exists for — a foreign modal, an AX-blind session — are exactly
60
+ * the cases where half the tree is unreadable.
61
+ */
62
+ export function axUiStateScript() {
63
+ return `${UI_STATE_MARKER}
64
+ set frontName to ""
65
+ set focusRole to ""
66
+ set focusSub to ""
67
+ set canInspect to true
68
+ set thingsRunning to false
69
+ set sheetForm to "none"
70
+ set sheetKind to "none"
71
+ set sheetDepth to 0
72
+ set census to ""
73
+ tell application "System Events"
74
+ try
75
+ set fp to first application process whose frontmost is true
76
+ set frontName to (name of fp) as text
77
+ try
78
+ set fe to value of attribute "AXFocusedUIElement" of fp
79
+ set focusRole to (role of fe) as text
80
+ try
81
+ set focusSub to (subrole of fe) as text
82
+ end try
83
+ on error
84
+ set canInspect to false
85
+ end try
86
+ on error
87
+ set canInspect to false
88
+ end try
89
+ try
90
+ if (exists application process "${THINGS_PROCESS}") then set thingsRunning to true
91
+ end try
92
+ end tell
93
+ if thingsRunning then
94
+ tell application "System Events" to tell process "${THINGS_PROCESS}"
95
+ ${AX_DIALOG_SHELL_SNIPPET}
96
+ if shellRef is not missing value then
97
+ set nCb to -1
98
+ set nPu to -1
99
+ set nBt to -1
100
+ set nGp to -1
101
+ set nTf to -1
102
+ try
103
+ set nCb to (count of checkboxes of shellRef)
104
+ end try
105
+ try
106
+ set nPu to (count of pop up buttons of shellRef)
107
+ end try
108
+ try
109
+ set nBt to (count of buttons of shellRef)
110
+ end try
111
+ try
112
+ set nGp to (count of groups of shellRef)
113
+ end try
114
+ try
115
+ set nTf to (count of text fields of shellRef)
116
+ end try
117
+ set census to "cb:" & nCb & " pu:" & nPu & " bt:" & nBt & " gp:" & nGp & " tf:" & nTf
118
+ set winId to ""
119
+ try
120
+ set winId to (value of attribute "AXIdentifier" of shellRef) as text
121
+ end try
122
+ if winId starts with "MovePopUpDialog-" then
123
+ set sheetKind to "move-picker"
124
+ else if nCb is 2 and nPu is 1 and nBt is 2 and nGp is 1 and nTf is 0 then
125
+ set groupOk to false
126
+ try
127
+ set g to group 1 of shellRef
128
+ if ((count of text fields of g) + (count of pop up buttons of g)) > 0 then set groupOk to true
129
+ end try
130
+ if groupOk then set sheetKind to "repeat"
131
+ end if
132
+ if sheetKind is "none" then set sheetKind to "other"
133
+ end if
134
+ end tell
135
+ end if
136
+ return "front=" & frontName & linefeed & "running=" & thingsRunning & linefeed & "form=" & sheetForm & linefeed & "depth=" & sheetDepth & linefeed & "kind=" & sheetKind & linefeed & "census=" & census & linefeed & "role=" & focusRole & linefeed & "subrole=" & focusSub & linefeed & "inspectable=" & canInspect`;
137
+ }
138
+ /**
139
+ * Parse the census record into a {@link UiState}. Returns null when the output
140
+ * carries none of the expected keys (a transport error, or a script that never
141
+ * ran) — every caller treats that as UNKNOWN, never as "all clear".
142
+ */
143
+ export function parseUiState(stdout) {
144
+ const fields = new Map();
145
+ for (const line of stdout.split(/\r?\n/)) {
146
+ const eq = line.indexOf("=");
147
+ if (eq <= 0)
148
+ continue;
149
+ fields.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
150
+ }
151
+ if (!fields.has("kind") || !fields.has("inspectable"))
152
+ return null;
153
+ const kindRaw = fields.get("kind") ?? "none";
154
+ const sheetKind = kindRaw === "repeat" || kindRaw === "move-picker" || kindRaw === "other" ? kindRaw : "none";
155
+ const formRaw = fields.get("form") ?? "none";
156
+ const sheetForm = formRaw === "attached" || formRaw === "detached" ? formRaw : "none";
157
+ const frontRaw = fields.get("front") ?? "";
158
+ const frontmostApp = frontRaw === "" ? null : frontRaw;
159
+ const role = fields.get("role") ?? "";
160
+ const subrole = fields.get("subrole") ?? "";
161
+ const census = fields.get("census") ?? "";
162
+ const depth = Number(fields.get("depth") ?? "0");
163
+ return {
164
+ thingsRunning: fields.get("running") === "true",
165
+ thingsFrontmost: frontmostApp === THINGS_PROCESS,
166
+ frontmostApp,
167
+ sheetOpen: sheetKind !== "none",
168
+ sheetKind,
169
+ sheetForm,
170
+ sheetDepth: Number.isFinite(depth) ? depth : 0,
171
+ sheetControls: census === "" ? null : census,
172
+ focusOwner: frontmostApp === null ? null : { app: frontmostApp, role, subrole: subrole || null },
173
+ inspectable: fields.get("inspectable") === "true",
174
+ };
175
+ }
176
+ /**
177
+ * Read the census through the injected runner. Returns null on a transport
178
+ * failure — an UNKNOWN state, which every caller treats fail-closed (the guard
179
+ * refuses; the cleanup path does not send a blind Escape).
180
+ */
181
+ export async function readUiState(run, timeoutMs) {
182
+ const res = await run({ primitive: "resolve", label: UI_STATE_LABEL, script: axUiStateScript() }, timeoutMs);
183
+ if (!res.ok)
184
+ return null;
185
+ return parseUiState(res.stdout);
186
+ }
187
+ /**
188
+ * How to name the surface that owns the screen, for a refusal someone has to
189
+ * act on. Names the APPLICATION and the focused element's ROLE — never its
190
+ * contents. An un-inspectable frontmost surface is named as what it is: a
191
+ * system dialog macOS does not expose, which is the one case where the person
192
+ * at the keyboard has to look at the screen themselves.
193
+ */
194
+ export function describeFocusOwner(state) {
195
+ if (state === null)
196
+ return "the window state could not be read";
197
+ if (!state.inspectable) {
198
+ return ("a system dialog owns the screen — macOS does not expose it to other apps, so it cannot be " +
199
+ `identified from here${state.frontmostApp === null ? "" : ` (frontmost application: ${state.frontmostApp})`}`);
200
+ }
201
+ const app = state.frontmostApp ?? "an unidentified application";
202
+ const role = state.focusOwner?.role ?? "";
203
+ return role === ""
204
+ ? `${app} is frontmost`
205
+ : `${app} is frontmost and keyboard focus is on a ${role}`;
206
+ }
207
+ /** A one-line human summary of the census, for a diagnostic line or a warning. */
208
+ export function describeUiState(state) {
209
+ const front = state.thingsFrontmost
210
+ ? "Things is frontmost"
211
+ : `${state.frontmostApp ?? "an unidentified application"} is frontmost`;
212
+ const sheet = state.sheetKind === "none"
213
+ ? "no dialog is open in Things"
214
+ : state.sheetKind === "repeat"
215
+ ? `the Repeat dialog is open (${state.sheetForm})`
216
+ : state.sheetKind === "move-picker"
217
+ ? `the Move… picker is open (${state.sheetForm})`
218
+ : `an unrecognized dialog is open in Things (${state.sheetForm})`;
219
+ // A stack means the thing in front is sitting ON another dialog, and each one
220
+ // has to be dismissed in turn (MODALX1 §6).
221
+ const stacked = state.sheetDepth > 1 ? `, on top of ${state.sheetDepth - 1} more` : "";
222
+ return `${front}; ${sheet}${stacked}`;
223
+ }
224
+ /**
225
+ * The warning an OPEN Things dialog earns, wherever it is reported.
226
+ *
227
+ * FIELD-MEASURED (2026-08-27, controlled A/B on the maintainer's Mac): with a
228
+ * Repeat sheet left open, a write landed in the local database but Things Cloud
229
+ * did NOT attempt a sync — not on the write, and not when Things was brought
230
+ * back to the front. Dismissing the sheet released the queued sync immediately,
231
+ * with no further write. A stranded dialog is therefore not cosmetic: it holds
232
+ * the account's sync until someone dismisses it. Recorded in
233
+ * docs/things-app-oddities.md (the open-dialog sync gate).
234
+ */
235
+ export const SYNC_GATE_WARNING = "while a dialog is open in Things the app stops sending changes to Things Cloud — anything " +
236
+ "written on this Mac stays on this Mac until the dialog is dismissed";
237
+ //# sourceMappingURL=ui-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui-state.js","sourceRoot":"","sources":["../../../src/write/vectors/ui-state.ts"],"names":[],"mappings":"AAqDA,yEAAyE;AACzE,MAAM,eAAe,GAAG,CAAC,CAAC;AAE1B;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG;;;;;;;;;;;;;;;;;;;;;YAqB3B,eAAe;;;;;;;;;SASlB,CAAC;AA+CV,4FAA4F;AAC5F,MAAM,CAAC,MAAM,eAAe,GAAG,gCAAgC,CAAC;AAEhE,4EAA4E;AAC5E,MAAM,CAAC,MAAM,cAAc,GAAG,iCAAiC,CAAC;AAEhE,mFAAmF;AACnF,MAAM,CAAC,MAAM,cAAc,GAAG,SAAS,CAAC;AAExC;;;;;GAKG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,GAAG,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;oCA2BS,cAAc;;;;qDAIG,cAAc;EACjE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;sTAyC6R,CAAC;AACvT,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QACzC,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,EAAE,IAAI,CAAC;YAAE,SAAS;QACtB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAClE,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC;QAAE,OAAO,IAAI,CAAC;IACnE,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC;IAC7C,MAAM,SAAS,GACb,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,aAAa,IAAI,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;IAC9F,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC;IAC7C,MAAM,SAAS,GACb,OAAO,KAAK,UAAU,IAAI,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;IACtE,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;IAC3C,MAAM,YAAY,GAAG,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC;IACvD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;IACtC,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;IAC5C,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IAC1C,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC,CAAC;IACjD,OAAO;QACL,aAAa,EAAE,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,MAAM;QAC/C,eAAe,EAAE,YAAY,KAAK,cAAc;QAChD,YAAY;QACZ,SAAS,EAAE,SAAS,KAAK,MAAM;QAC/B,SAAS;QACT,SAAS;QACT,UAAU,EAAE,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAC9C,aAAa,EAAE,MAAM,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;QAC5C,UAAU,EACR,YAAY,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE;QACtF,WAAW,EAAE,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,KAAK,MAAM;KAClD,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,GAAoE,EACpE,SAAiB;IAEjB,MAAM,GAAG,GAAG,MAAM,GAAG,CACnB,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,eAAe,EAAE,EAAE,EAC1E,SAAS,CACV,CAAC;IACF,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,OAAO,IAAI,CAAC;IACzB,OAAO,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;AAClC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAqB;IACtD,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,oCAAoC,CAAC;IAChE,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QACvB,OAAO,CACL,4FAA4F;YAC5F,uBACE,KAAK,CAAC,YAAY,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,4BAA4B,KAAK,CAAC,YAAY,GACnF,EAAE,CACH,CAAC;IACJ,CAAC;IACD,MAAM,GAAG,GAAG,KAAK,CAAC,YAAY,IAAI,6BAA6B,CAAC;IAChE,MAAM,IAAI,GAAG,KAAK,CAAC,UAAU,EAAE,IAAI,IAAI,EAAE,CAAC;IAC1C,OAAO,IAAI,KAAK,EAAE;QAChB,CAAC,CAAC,GAAG,GAAG,eAAe;QACvB,CAAC,CAAC,GAAG,GAAG,4CAA4C,IAAI,EAAE,CAAC;AAC/D,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,MAAM,KAAK,GAAG,KAAK,CAAC,eAAe;QACjC,CAAC,CAAC,qBAAqB;QACvB,CAAC,CAAC,GAAG,KAAK,CAAC,YAAY,IAAI,6BAA6B,eAAe,CAAC;IAC1E,MAAM,KAAK,GACT,KAAK,CAAC,SAAS,KAAK,MAAM;QACxB,CAAC,CAAC,6BAA6B;QAC/B,CAAC,CAAC,KAAK,CAAC,SAAS,KAAK,QAAQ;YAC5B,CAAC,CAAC,8BAA8B,KAAK,CAAC,SAAS,GAAG;YAClD,CAAC,CAAC,KAAK,CAAC,SAAS,KAAK,aAAa;gBACjC,CAAC,CAAC,6BAA6B,KAAK,CAAC,SAAS,GAAG;gBACjD,CAAC,CAAC,6CAA6C,KAAK,CAAC,SAAS,GAAG,CAAC;IAC1E,8EAA8E;IAC9E,4CAA4C;IAC5C,MAAM,OAAO,GAAG,KAAK,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,eAAe,KAAK,CAAC,UAAU,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;IACvF,OAAO,GAAG,KAAK,KAAK,KAAK,GAAG,OAAO,EAAE,CAAC;AACxC,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAC5B,4FAA4F;IAC5F,qEAAqE,CAAC"}
@@ -1,6 +1,7 @@
1
1
  import { type ThingsApiConfig } from "../../config.ts";
2
2
  import { type UiDriveAux } from "./ui-drag.ts";
3
- import type { UiPrimitive, UiStep, WriteVector } from "./types.ts";
3
+ import { type UiSheetKind, type UiState } from "./ui-state.ts";
4
+ import type { UiClearOutcome, UiPrimitive, UiStep, WriteVector } from "./types.ts";
4
5
  /**
5
6
  * Command-level primitives. Extends the recipe `UiPrimitive` set with the
6
7
  * INTERNAL sub-steps composite recipe steps decompose into: a `click-element`
@@ -12,7 +13,14 @@ import type { UiPrimitive, UiStep, WriteVector } from "./types.ts";
12
13
  */
13
14
  export type UiCommandPrimitive = UiPrimitive | "resolve-frame" | "click-point" | "sidebar-snapshot" | "sidebar-scroll" | "sidebar-drag" | "sidebar-held-drag"
14
15
  /** One modifier-bearing key event pair posted straight at the Things process (ui-chord.ts). */
15
- | "chord-post";
16
+ | "chord-post"
17
+ /**
18
+ * The cleanup ladder's first rung: press the open dialog's own Cancel button
19
+ * (issue #620). Its own primitive rather than a `press` so a recipe's
20
+ * actuations and the cleanup's are never confused — in a trace, in a test, or
21
+ * in the completed-steps trail.
22
+ */
23
+ | "dismiss-dialog";
16
24
  /** A single primitive dispatch — one stable shape per primitive. */
17
25
  export interface UiCommand {
18
26
  primitive: UiCommandPrimitive;
@@ -38,6 +46,21 @@ export interface UiRunResult {
38
46
  * (CLAUDE.md safety rails — the production app is never a valid target).
39
47
  */
40
48
  export type UiRunner = (command: UiCommand, timeoutMs: number) => Promise<UiRunResult>;
49
+ /**
50
+ * The IN-SCRIPT half of the per-step focus guard (issue #620).
51
+ *
52
+ * A synthetic keystroke is not addressed at an element — System Events hands it
53
+ * to whatever application owns the screen at that instant. So every script that
54
+ * types re-asserts, in the same osascript hop that will do the typing, that
55
+ * Things is still frontmost; the drive-level census (see {@link guardedRun})
56
+ * runs a moment earlier and cannot close the last few milliseconds. The
57
+ * assertion FAILS CLOSED and names the application that owns the screen
58
+ * instead — never the contents of its window.
59
+ *
60
+ * This is the cheapest possible check: one System Events property read, no
61
+ * sleeps, no polling (UI-automation determinism doctrine).
62
+ */
63
+ export declare const AX_FOCUS_GUARD_HANDLERS = "on fgFrontApp()\n\tset frontName to \"\"\n\ttry\n\t\ttell application \"System Events\" to set frontName to (name of first application process whose frontmost is true) as text\n\tend try\n\treturn frontName\nend fgFrontApp\n\non fgAssertFront(what)\n\tset f to my fgFrontApp()\n\tif f is \"Things3\" then return true\n\tif f is \"\" then\n\t\terror \"refused to \" & what & \": the frontmost application could not be read, so there is no proof the keystrokes would reach Things \u2014 nothing was typed\"\n\tend if\n\terror \"refused to \" & what & \": \" & f & \" is frontmost, not Things \u2014 a keystroke goes to whatever owns the screen, so nothing was typed\"\nend fgAssertFront";
41
64
  /** resolve-element: does the element exist right now? Returns "true"/"false". */
42
65
  export declare function axResolveScript(path: string): string;
43
66
  /** press: AXPress the element. */
@@ -78,8 +101,25 @@ export declare function axPressScript(path: string): string;
78
101
  * so a retry starts from a clean field without ⌘A. Fail-closed (an `error`, i.e.
79
102
  * a transport failure the pipeline re-verifies) if it never holds — the
80
103
  * create/reschedule delta's rule assertion is the final DB-level authority.
104
+ *
105
+ * READ-BACK FIRST (issue #620 item 7): a field that ALREADY holds the requested
106
+ * value is left alone and the script returns {@link OK_ALREADY} — the whole
107
+ * keystroke class disappears for the defaults, which is most drives (the field
108
+ * incident died typing interval `1` into a field already showing `1`). The skip
109
+ * is proven by TWO reads a settle apart, because the one way a matching value
110
+ * can go stale is the UIC7 re-layout revert, which lands within that window; and
111
+ * whatever this decides, the pre-commit audit ({@link axAuditDialogScript})
112
+ * re-reads every control through its own address before the OK press, so a
113
+ * wrongly-skipped field cannot commit.
81
114
  */
82
115
  export declare function axSetValueScript(path: string, value: string, attempts?: number): string;
116
+ /**
117
+ * What a typing primitive returns when it typed NOTHING because the field
118
+ * already held the requested value (issue #620 item 7). Distinct from "OK" so
119
+ * the drive can disclose the skip — and so a lab cell can assert that no
120
+ * keystroke hop fired.
121
+ */
122
+ export declare const OK_ALREADY = "OK-ALREADY";
83
123
  /**
84
124
  * set-group-number: drive ONE of the Repeat dialog's two numeric fields —
85
125
  * the cadence INTERVAL or the ENDS-AFTER COUNT — addressed by the LABEL ROW it
@@ -421,7 +461,13 @@ export declare function axSelectHeadingRowScript(tablePath: string, ordinal: num
421
461
  export declare function axAssertEligibleScript(targetUuid: string, menuItemPath: string): string;
422
462
  /** activate: foreground Things (the fallback preamble step). */
423
463
  export declare function axActivateScript(): string;
424
- /** key: a space-separated keystroke spec (e.g. "down down return"). */
464
+ /**
465
+ * key: a space-separated keystroke spec (e.g. "down down return").
466
+ *
467
+ * Frontmost-guarded in-script (issue #620): `key code`/`keystroke` reach
468
+ * whatever application owns the screen, so the script refuses — naming that
469
+ * application — rather than firing keys into someone else's window.
470
+ */
425
471
  export declare function axKeyScript(keys: string): string;
426
472
  /**
427
473
  * type-text: send literal text to whatever control holds focus (HXPC1). The
@@ -487,8 +533,31 @@ export declare function axRowCellFrameScript(tablePath: string, description: str
487
533
  * actually offered, so the caller learns what the app was willing to move to.
488
534
  */
489
535
  export declare function axPickerRowFrameScript(pickerPath: string, title: string): string;
490
- /** The abort keystroke sent to dismiss a half-open sheet/popover on failure. */
536
+ /**
537
+ * The abort keystroke, sent ONLY from the audited cleanup ladder (issue #620)
538
+ * and only once that ladder has proven Things owns the screen. It is scoped to
539
+ * the Things process for readability, but scoping is not what makes it safe —
540
+ * a synthetic key goes to whatever is frontmost, which is why the script
541
+ * carries the same in-script frontmost assertion every other keystroke does.
542
+ */
491
543
  export declare function axAbortScript(): string;
544
+ /**
545
+ * Dismiss the open dialog by PRESSING ITS OWN CANCEL BUTTON (issue #620).
546
+ *
547
+ * Preferred over Escape wherever it works, for two independent reasons: an
548
+ * AXPress is addressed at an ELEMENT, so it cannot leak into another
549
+ * application the way a keystroke can, and it works while Things is in the
550
+ * BACKGROUND — the cleanup never has to steal the user's focus to undo its own
551
+ * half-finished dialog. The button is addressed by its pinned English title,
552
+ * exactly like every other selector in this vector, and the dialog shell is
553
+ * resolved the same two ways the census resolves it (attached sheet, or the
554
+ * detached editor window Things presents when it is not frontmost).
555
+ *
556
+ * Returns "OK" after pressing, or a diagnostic ("NO-DIALOG" / "NO-CANCEL") the
557
+ * ladder falls through on — it never claims a dismissal; the caller re-reads
558
+ * the census to decide that.
559
+ */
560
+ export declare function axCancelDialogScript(): string;
492
561
  /**
493
562
  * The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
494
563
  * recovery): close the front Things window — which takes an attached modal sheet
@@ -514,6 +583,30 @@ export declare function axCloseReopenActivateScript(): string;
514
583
  * command shape.
515
584
  */
516
585
  export declare function axSheetOpenScript(): string;
586
+ /**
587
+ * The outcome of clearing a half-open dialog after a failed drive:
588
+ * - "none" — the census found no dialog open (nothing to clear);
589
+ * - "dismissed" — it is gone, and a fresh census CONFIRMED that;
590
+ * - "cleared-blind" — the session was AX-blind (locked / off-Space), so no
591
+ * census and no keystroke can be trusted; the PROVEN
592
+ * app-level close+reopen ran to clear it (cannot be
593
+ * AX-confirmed, but the maneuver works blind — SESSGATE);
594
+ * - "foreign" — a dialog is open that this drive did not open, so it was
595
+ * LEFT ALONE (a cleanup must never dismiss the dialog the
596
+ * person at the keyboard opened after our failure);
597
+ * - "may-remain" — ours, and nothing in the ladder would close it
598
+ * (fail-closed: report it precisely, with the sync gate).
599
+ */
600
+ export type ClearOutcome = UiClearOutcome;
601
+ export interface ClearResult {
602
+ state: ClearOutcome;
603
+ /** How it was closed, for the trace and the disclosure. */
604
+ how?: "cancel-button" | "escape" | "window-close";
605
+ /** What the census identified as open at cleanup time. */
606
+ sheetKind?: UiSheetKind;
607
+ /** Who owned the screen when the cleanup started, when it was not Things. */
608
+ focusOwner?: string;
609
+ }
517
610
  /**
518
611
  * resolve-frame: read the element's on-screen frame (top-left origin, points)
519
612
  * from the live AX tree and print "x y w h". Used by `click-element` to target
@@ -577,6 +670,29 @@ export declare function parseFrameCenter(stdout: string): {
577
670
  x: number;
578
671
  y: number;
579
672
  } | null;
673
+ /**
674
+ * Read the live window/focus census through the shipped dispatch seam — the
675
+ * transport behind the `ui-state` diagnostic (src/ui-state.ts) and, in tests,
676
+ * behind any injected runner. READ-ONLY: the census clicks nothing, types
677
+ * nothing, and changes no state; see src/write/vectors/ui-state.ts.
678
+ */
679
+ export declare function readLiveUiState(run?: UiRunner): Promise<UiState | null>;
680
+ /**
681
+ * Judge one guard reading. Returns null to proceed, or the refusal sentence —
682
+ * which always names who owns the screen, because that is the one fact the
683
+ * person reading it cannot recover after the fact.
684
+ *
685
+ * Exported for the unit matrix: every branch here is a fail-closed decision
686
+ * about synthetic input, and each one is worth a test.
687
+ */
688
+ export declare function judgeFocusGuard(state: UiState | null, expectedSheet: UiSheetKind | null, label: string): string | null;
689
+ /**
690
+ * The cleanup disclosure (issue #620). Every branch states what is TRUE of the
691
+ * app right now, and — whenever a dialog may still be open — that Things Cloud
692
+ * sync is held until someone dismisses it, which is the consequence a caller
693
+ * cannot see and would otherwise discover hours later on another device.
694
+ */
695
+ export declare function describeCleanup(clear: ClearResult): string;
580
696
  /** Compile one recipe step into its primitive command (no dispatch). */
581
697
  export declare function commandForStep(step: UiStep, targetUuid: string): UiCommand;
582
698
  /**