things-api 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +1 -1
  2. package/deputy/VERSION +1 -0
  3. package/deputy/helpers-Info.plist +23 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +20 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +115 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +23 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  10. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +125 -0
  11. package/deputy/reader/Info.plist +20 -0
  12. package/deputy/reader/entitlements.plist +15 -0
  13. package/deputy/reader/main.swift +491 -0
  14. package/deputy/src/main.swift +133 -0
  15. package/deputy/src/osascript.swift +102 -0
  16. package/deputy/src/server.swift +350 -0
  17. package/deputy/src/sqlite.swift +121 -0
  18. package/dist/cli/commands/doctor.js +55 -2
  19. package/dist/cli/commands/doctor.js.map +1 -1
  20. package/dist/cli/commands/helpers.d.ts +17 -0
  21. package/dist/cli/commands/helpers.js +148 -0
  22. package/dist/cli/commands/helpers.js.map +1 -0
  23. package/dist/cli/commands/op-result.d.ts +15 -0
  24. package/dist/cli/commands/op-result.js +39 -0
  25. package/dist/cli/commands/op-result.js.map +1 -0
  26. package/dist/cli/commands/repeat-flags.d.ts +3 -3
  27. package/dist/cli/commands/repeat-flags.js +90 -33
  28. package/dist/cli/commands/repeat-flags.js.map +1 -1
  29. package/dist/cli/commands/writes.js +79 -6
  30. package/dist/cli/commands/writes.js.map +1 -1
  31. package/dist/cli/help.js +13 -2
  32. package/dist/cli/help.js.map +1 -1
  33. package/dist/cli/helpers-check.d.ts +10 -0
  34. package/dist/cli/helpers-check.js +59 -0
  35. package/dist/cli/helpers-check.js.map +1 -0
  36. package/dist/cli/interrupt.d.ts +42 -0
  37. package/dist/cli/interrupt.js +105 -0
  38. package/dist/cli/interrupt.js.map +1 -0
  39. package/dist/cli/main.js +27 -1
  40. package/dist/cli/main.js.map +1 -1
  41. package/dist/client.js +19 -2
  42. package/dist/client.js.map +1 -1
  43. package/dist/config.d.ts +70 -0
  44. package/dist/config.js +25 -1
  45. package/dist/config.js.map +1 -1
  46. package/dist/contracts.d.ts +9 -2
  47. package/dist/contracts.js +1 -1
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/db/baselines/db-v26.js +8 -4
  50. package/dist/db/baselines/db-v26.js.map +1 -1
  51. package/dist/db/baselines/db-v27.d.ts +42 -0
  52. package/dist/db/baselines/db-v27.js +47 -0
  53. package/dist/db/baselines/db-v27.js.map +1 -0
  54. package/dist/db/baselines/index.js +2 -1
  55. package/dist/db/baselines/index.js.map +1 -1
  56. package/dist/db/locate.d.ts +4 -1
  57. package/dist/db/locate.js +3 -1
  58. package/dist/db/locate.js.map +1 -1
  59. package/dist/db/schema.d.ts +1 -1
  60. package/dist/db/schema.js +9 -0
  61. package/dist/db/schema.js.map +1 -1
  62. package/dist/deputy/bridge-worker.d.ts +1 -0
  63. package/dist/deputy/bridge-worker.js +79 -0
  64. package/dist/deputy/bridge-worker.js.map +1 -0
  65. package/dist/deputy/bridge.d.ts +14 -0
  66. package/dist/deputy/bridge.js +70 -0
  67. package/dist/deputy/bridge.js.map +1 -0
  68. package/dist/deputy/client.d.ts +19 -0
  69. package/dist/deputy/client.js +104 -0
  70. package/dist/deputy/client.js.map +1 -0
  71. package/dist/deputy/db-facade.d.ts +18 -0
  72. package/dist/deputy/db-facade.js +71 -0
  73. package/dist/deputy/db-facade.js.map +1 -0
  74. package/dist/deputy/files.d.ts +1 -0
  75. package/dist/deputy/files.js +16 -0
  76. package/dist/deputy/files.js.map +1 -0
  77. package/dist/deputy/install.d.ts +105 -0
  78. package/dist/deputy/install.js +467 -0
  79. package/dist/deputy/install.js.map +1 -0
  80. package/dist/deputy/notice.d.ts +6 -0
  81. package/dist/deputy/notice.js +28 -0
  82. package/dist/deputy/notice.js.map +1 -0
  83. package/dist/deputy/notices.d.ts +31 -0
  84. package/dist/deputy/notices.js +94 -0
  85. package/dist/deputy/notices.js.map +1 -0
  86. package/dist/deputy/osa.d.ts +21 -0
  87. package/dist/deputy/osa.js +72 -0
  88. package/dist/deputy/osa.js.map +1 -0
  89. package/dist/deputy/protocol.d.ts +86 -0
  90. package/dist/deputy/protocol.js +106 -0
  91. package/dist/deputy/protocol.js.map +1 -0
  92. package/dist/deputy/routing.d.ts +63 -0
  93. package/dist/deputy/routing.js +338 -0
  94. package/dist/deputy/routing.js.map +1 -0
  95. package/dist/deputy/shortcuts-exec.d.ts +10 -0
  96. package/dist/deputy/shortcuts-exec.js +50 -0
  97. package/dist/deputy/shortcuts-exec.js.map +1 -0
  98. package/dist/diagnose.d.ts +43 -1
  99. package/dist/diagnose.js +64 -2
  100. package/dist/diagnose.js.map +1 -1
  101. package/dist/index.d.ts +16 -2
  102. package/dist/index.js +17 -1
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp/server.js +25 -7
  105. package/dist/mcp/server.js.map +1 -1
  106. package/dist/model/mappers.d.ts +12 -3
  107. package/dist/model/mappers.js +6 -1
  108. package/dist/model/mappers.js.map +1 -1
  109. package/dist/model/occurrences.d.ts +14 -1
  110. package/dist/model/occurrences.js +20 -4
  111. package/dist/model/occurrences.js.map +1 -1
  112. package/dist/model/recurrence.d.ts +64 -9
  113. package/dist/model/recurrence.js +94 -19
  114. package/dist/model/recurrence.js.map +1 -1
  115. package/dist/model/template-projection.d.ts +26 -0
  116. package/dist/model/template-projection.js +113 -0
  117. package/dist/model/template-projection.js.map +1 -0
  118. package/dist/op-result.d.ts +46 -0
  119. package/dist/op-result.js +156 -0
  120. package/dist/op-result.js.map +1 -0
  121. package/dist/paths.d.ts +8 -0
  122. package/dist/paths.js +10 -0
  123. package/dist/paths.js.map +1 -1
  124. package/dist/read/detail.js +7 -1
  125. package/dist/read/detail.js.map +1 -1
  126. package/dist/read/queries.js +8 -1
  127. package/dist/read/queries.js.map +1 -1
  128. package/dist/read/views.d.ts +6 -4
  129. package/dist/read/views.js +60 -18
  130. package/dist/read/views.js.map +1 -1
  131. package/dist/sync-health.js +8 -3
  132. package/dist/sync-health.js.map +1 -1
  133. package/dist/trace/tracer.d.ts +87 -0
  134. package/dist/trace/tracer.js +167 -0
  135. package/dist/trace/tracer.js.map +1 -0
  136. package/dist/write/accessibility-probe.js +4 -2
  137. package/dist/write/accessibility-probe.js.map +1 -1
  138. package/dist/write/automation-probe.js +5 -1
  139. package/dist/write/automation-probe.js.map +1 -1
  140. package/dist/write/availability.js +7 -3
  141. package/dist/write/availability.js.map +1 -1
  142. package/dist/write/clear-reminder.js +24 -21
  143. package/dist/write/clear-reminder.js.map +1 -1
  144. package/dist/write/commands.js +95 -45
  145. package/dist/write/commands.js.map +1 -1
  146. package/dist/write/completion-context.d.ts +57 -0
  147. package/dist/write/completion-context.js +49 -0
  148. package/dist/write/completion-context.js.map +1 -0
  149. package/dist/write/experimental.d.ts +19 -0
  150. package/dist/write/experimental.js +50 -0
  151. package/dist/write/experimental.js.map +1 -1
  152. package/dist/write/failure-hints.d.ts +10 -1
  153. package/dist/write/failure-hints.js +8 -0
  154. package/dist/write/failure-hints.js.map +1 -1
  155. package/dist/write/guards.d.ts +1 -1
  156. package/dist/write/guards.js +10 -0
  157. package/dist/write/guards.js.map +1 -1
  158. package/dist/write/move.js +14 -6
  159. package/dist/write/move.js.map +1 -1
  160. package/dist/write/operations.d.ts +40 -7
  161. package/dist/write/pipeline.d.ts +41 -0
  162. package/dist/write/pipeline.js +289 -19
  163. package/dist/write/pipeline.js.map +1 -1
  164. package/dist/write/pre-state.d.ts +1 -1
  165. package/dist/write/pre-state.js +35 -17
  166. package/dist/write/pre-state.js.map +1 -1
  167. package/dist/write/promote-clone.js +434 -19
  168. package/dist/write/promote-clone.js.map +1 -1
  169. package/dist/write/reorder.js +76 -27
  170. package/dist/write/reorder.js.map +1 -1
  171. package/dist/write/repeat-anchor.d.ts +140 -0
  172. package/dist/write/repeat-anchor.js +403 -0
  173. package/dist/write/repeat-anchor.js.map +1 -0
  174. package/dist/write/repeat-asserts.d.ts +28 -0
  175. package/dist/write/repeat-asserts.js +169 -0
  176. package/dist/write/repeat-asserts.js.map +1 -0
  177. package/dist/write/repeat-rule.js +37 -12
  178. package/dist/write/repeat-rule.js.map +1 -1
  179. package/dist/write/vectors/applescript.js +7 -16
  180. package/dist/write/vectors/applescript.js.map +1 -1
  181. package/dist/write/vectors/session-reachability.d.ts +133 -0
  182. package/dist/write/vectors/session-reachability.js +151 -0
  183. package/dist/write/vectors/session-reachability.js.map +1 -0
  184. package/dist/write/vectors/shortcuts.js +5 -14
  185. package/dist/write/vectors/shortcuts.js.map +1 -1
  186. package/dist/write/vectors/simulator.d.ts +16 -1
  187. package/dist/write/vectors/simulator.js +122 -32
  188. package/dist/write/vectors/simulator.js.map +1 -1
  189. package/dist/write/vectors/types.d.ts +183 -10
  190. package/dist/write/vectors/ui-recipes.d.ts +8 -0
  191. package/dist/write/vectors/ui-recipes.js +246 -58
  192. package/dist/write/vectors/ui-recipes.js.map +1 -1
  193. package/dist/write/vectors/ui.d.ts +156 -14
  194. package/dist/write/vectors/ui.js +846 -84
  195. package/dist/write/vectors/ui.js.map +1 -1
  196. package/dist/write/verify/delta.d.ts +12 -14
  197. package/dist/write/verify/delta.js +35 -34
  198. package/dist/write/verify/delta.js.map +1 -1
  199. package/dist/write/verify/poller.d.ts +13 -0
  200. package/dist/write/verify/poller.js +38 -0
  201. package/dist/write/verify/poller.js.map +1 -1
  202. package/package.json +8 -1
  203. package/schema/envelope.schema.json +22 -0
  204. package/scripts/build-helpers.sh +104 -0
  205. package/scripts/deputy-cert-setup.sh +60 -0
  206. package/skills/things-cli/SKILL.md +11 -3
  207. package/skills/things-cli/references/errors.md +4 -0
  208. package/skills/things-cli/references/repeating.md +69 -0
@@ -1,11 +1,14 @@
1
1
  import { undoToken } from "../audit/schema.js";
2
+ import { addDaysIso, decodePackedDate, localToday } from "../model/dates.js";
2
3
  import { byUuid } from "../read/detail.js";
3
4
  import { resolveProjectWriteTarget, resolveTaskUuidPrefix } from "../read/queries.js";
4
5
  import { runCloneProject, runCloneTodo } from "./clone.js";
5
6
  import { promoteProjectViaGui } from "./make-repeating-project.js";
6
7
  import { fingerprintLabel, runMutation, } from "./pipeline.js";
8
+ import { assessOffRuleFirst, daysBetweenIso, deriveFixedAnchor, isIsoDate, } from "./repeat-anchor.js";
7
9
  import { assertRepeatRule, ruleToInverseParams } from "./repeat-rule.js";
8
10
  import { createDbReader } from "./verify/delta.js";
11
+ import { H_UI_SESSION_UNREACHABLE } from "./vectors/session-reachability.js";
9
12
  const PLACEMENT_NOTE = "the series' current instance lands at its container's default position — its prior slot was " +
10
13
  "not automatically restored (best-effort placement is not yet wired for this container); " +
11
14
  "reposition it with `things reorder` if the order matters";
@@ -50,8 +53,78 @@ function blockedUiDrive(op) {
50
53
  "`things config set ui-enabled true` and Accessibility granted to this process (see docs/setup.md)",
51
54
  };
52
55
  }
53
- /** Pick the rule fields (frequency/interval + calendar anchors) as a RepeatRuleParams. */
54
- function ruleParamsFor(uuid, rule) {
56
+ /**
57
+ * SESSGATE (#480) pre-seed reachability gate. A promote composite is NOT atomic:
58
+ * it SEEDS a row (clone / add) before the GUI promote leg drives the dialog. If
59
+ * the Mac's session is AX-blind (screen locked / full-screen Space), the dialog
60
+ * would open on an unreachable window and the drive would fail — leaving an
61
+ * orphan seed. So probe the live session BEFORE seeding and refuse fast (zero
62
+ * mutation) on the certain-failure LOCKED signature. Only "session" scope refuses
63
+ * here: a window merely on another Space is left for the in-drive relocation (the
64
+ * reveal has not run yet, so refusing before the seed would be a false positive).
65
+ * No ui vector (simulator / bench), ui disabled, or a fail-open probe → proceed
66
+ * (the promote leg's own gate + cleanup remain the backstop).
67
+ */
68
+ async function gateSessionReachability(deps, op) {
69
+ if (!deps.config.ui.enabled)
70
+ return null;
71
+ const ui = deps.vectors.find((v) => v.probeReachability !== undefined);
72
+ if (ui?.probeReachability === undefined)
73
+ return null;
74
+ const verdict = await ui.probeReachability();
75
+ if (verdict.reachable || verdict.scope !== "session")
76
+ return null;
77
+ return {
78
+ kind: "blocked",
79
+ op,
80
+ reason: "hazard",
81
+ hazard: H_UI_SESSION_UNREACHABLE,
82
+ detail: verdict.detail,
83
+ remediation: verdict.remediation,
84
+ };
85
+ }
86
+ /**
87
+ * Auto-trash a promote composite's own seeded item after its promote leg failed
88
+ * (RATIFIED RULING 2026-08-15, issue #480). The add/add-repeating legs are NOT
89
+ * atomic: the seed persists even when the promote no-ops. The seed is OUR
90
+ * artifact — recreatable verbatim from the command args — and the Trash is
91
+ * recoverable, so we trash it inside the same txn and disclose it. The
92
+ * distinction the failure MUST make honest: an auto-trash that SUCCEEDS points
93
+ * the caller at `restore` (the row is in the Trash); one that FAILS points at
94
+ * `delete` with the seed's REAL, resolvable uuid (never a buried, non-actionable
95
+ * uuid — the #480 second bug). Returns a `detail` patch appended to the promote
96
+ * result's own message (best-effort: a non-`detail` result shape is left as-is).
97
+ */
98
+ async function cleanupSeed(deps, kind, createdUuid, promote, options, txnId) {
99
+ const expectedType = kind === "project" ? "project" : "to-do";
100
+ const trashOp = kind === "project" ? "project.delete" : "todo.delete";
101
+ const trashed = await runMutation(deps, trashOp, { uuid: createdUuid }, legOptions(options, txnId));
102
+ const cleanupNote = trashed.kind === "ok"
103
+ ? `the seeded ${expectedType} (uuid ${createdUuid}) was created but the promote did not land, ` +
104
+ `so it was moved to the Trash — recreate it from the command args, or restore it with ` +
105
+ `\`things ${kind} restore ${createdUuid}\``
106
+ : `the seeded ${expectedType} (uuid ${createdUuid}) was created but the promote did not land, ` +
107
+ `and it could NOT be auto-trashed — remove it with \`things ${kind} delete ${createdUuid}\``;
108
+ return "detail" in promote
109
+ ? { detail: `${promote.detail} — ${cleanupNote}` }
110
+ : {};
111
+ }
112
+ /**
113
+ * Pick the rule fields (frequency/interval + calendar anchors + deadline offset)
114
+ * as a RepeatRuleParams, plus the requested first-occurrence date to drive into
115
+ * the dialog's "Next:" field (ANCH2, issue #476). `nextIso` is the item's
116
+ * scheduled date; omitted (or after-completion) leaves Next at the app default.
117
+ *
118
+ * The `rule` bag is the SUPERSET the make/add legs actually carry: make-repeating
119
+ * passes a full {@link RepeatRuleParams} (rule-level `deadline`/`startDaysEarlier`
120
+ * included), add-repeating an {@link AddRepeatingRuleFields} (which OMITS those —
121
+ * the base add owns the item's own deadline). Every field is copied THROUGH so
122
+ * neither is silently dropped: an earlier version keyed the param type to
123
+ * AddRepeatingRuleFields and stripped `deadline`/`startDaysEarlier` from the
124
+ * make-repeating promote entirely (the same class as the RRX1 reminder drop —
125
+ * a deadlined make-repeating produced a NON-deadlined template, YANCH1 #493).
126
+ */
127
+ function ruleParamsFor(uuid, rule, nextIso) {
55
128
  return {
56
129
  uuid,
57
130
  frequency: rule.frequency,
@@ -61,6 +134,148 @@ function ruleParamsFor(uuid, rule) {
61
134
  ...(rule.monthly !== undefined && { monthly: rule.monthly }),
62
135
  ...(rule.yearly !== undefined && { yearly: rule.yearly }),
63
136
  ...(rule.ends !== undefined && { ends: rule.ends }),
137
+ // The repeat reminder picker is drivable (ANCH2). make-repeating carries a
138
+ // rule-level reminder through; add-repeating threads the base to-do's
139
+ // --reminder here too (ADR1 #480), since the dialog conversion otherwise drops
140
+ // the seed's one-off reminder from the series.
141
+ ...(rule.reminder !== undefined && { reminder: rule.reminder }),
142
+ // The deadline offset is a rule-level field on make-repeating (the "Add
143
+ // deadlines" checkbox + "start N days earlier"); it must ride the promote or a
144
+ // `make-repeating --deadline` lands a non-deadlined series (YANCH1 #493).
145
+ ...(rule.deadline !== undefined && { deadline: rule.deadline }),
146
+ ...(rule.startDaysEarlier !== undefined && { startDaysEarlier: rule.startDaysEarlier }),
147
+ ...(nextIso !== undefined && rule.afterCompletion !== true && { next: nextIso }),
148
+ };
149
+ }
150
+ const UNIT_SINGULAR = {
151
+ daily: "day",
152
+ weekly: "week",
153
+ monthly: "month",
154
+ yearly: "year",
155
+ };
156
+ const UNIT_PLURAL = {
157
+ daily: "days",
158
+ weekly: "weeks",
159
+ monthly: "months",
160
+ yearly: "years",
161
+ };
162
+ /**
163
+ * A one-line echo of the LANDED series so a caller can eyeball what actually
164
+ * committed — the cadence, the verified first-occurrence START, and the deadline
165
+ * offset (YANCH1 #493 item 5). It rides the verified data (the anchor is asserted
166
+ * == requested by the post-drive verify), so it describes the START date rather
167
+ * than the internal deadline anchor, which for a deadlined rule is N days later.
168
+ */
169
+ function landedRuleEcho(rule, startIso) {
170
+ const cadence = rule.interval === 1
171
+ ? `every ${UNIT_SINGULAR[rule.frequency]}`
172
+ : `every ${rule.interval} ${UNIT_PLURAL[rule.frequency]}`;
173
+ if (rule.afterCompletion === true) {
174
+ return `landed: the series repeats ${cadence} after each occurrence is completed`;
175
+ }
176
+ const first = startIso !== null ? `; the first occurrence is ${startIso}` : "";
177
+ const offset = rule.startDaysEarlier ?? 0;
178
+ const deadlineNote = offset > 0
179
+ ? `, with a deadline ${offset} day${offset === 1 ? "" : "s"} later`
180
+ : rule.deadline === true
181
+ ? ", with a deadline on each occurrence"
182
+ : "";
183
+ return `landed: the series repeats ${cadence}${first}${deadlineNote}`;
184
+ }
185
+ /**
186
+ * The DACON1 off-rule-first disclosure line, or null when the request is on-rule.
187
+ * When an explicit anchor disagrees with `--when` (deadline-shift-aware) the app
188
+ * lands an OFF-RULE first occurrence (honored for weekly/yearly); this states both
189
+ * halves of the landed pattern so the caller can eyeball the two-phase series.
190
+ * The dishonored monthly shape is refused upstream (assertRepeatRule), so only the
191
+ * honored disclosure ever reaches here.
192
+ */
193
+ function offRuleFirstNote(params) {
194
+ const assessment = assessOffRuleFirst(params);
195
+ return assessment?.kind === "honored" ? assessment.disclosure.message : null;
196
+ }
197
+ /**
198
+ * The template's app-materialized first occurrence (`rt1_instanceCreationStartDate`
199
+ * = the date the current instance is dated at) as an ISO date, for the post-drive
200
+ * Next-honored check. `null` when the row/date is absent.
201
+ */
202
+ function firstOccurrenceOf(db, templateUuid) {
203
+ const row = db
204
+ .prepare("SELECT rt1_instanceCreationStartDate AS ic FROM TMTask WHERE uuid = ?")
205
+ .get(templateUuid);
206
+ if (row === undefined || row.ic === null)
207
+ return null;
208
+ return decodePackedDate(row.ic);
209
+ }
210
+ /** A repeating instance's own scheduled start date (`startDate`) as ISO, or null. */
211
+ function instanceStartDate(db, instanceUuid) {
212
+ const row = db.prepare("SELECT startDate AS s FROM TMTask WHERE uuid = ?").get(instanceUuid);
213
+ if (row === undefined || row.s === null)
214
+ return null;
215
+ return decodePackedDate(row.s);
216
+ }
217
+ /**
218
+ * DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md, golden-v3 / Things 3.22.14):
219
+ * a promote whose source is PRESERVED (SRCFATE deadline / terminal-element trigger)
220
+ * relinks that source IN PLACE as the current-occurrence instance. When the first
221
+ * occurrence is FUTURE-dated this double-books: the hidden template's cursor
222
+ * (`rt1_nextInstanceStartDate`) points at the SAME occurrence with
223
+ * `rt1_instanceCreationCount = 0` — the cursor does not know the occurrence is already
224
+ * materialized — so when the date ARRIVES the app spawns a SECOND instance alongside
225
+ * the preserved one (cell C: two rows dated the same day, icCount 0→1). A genuine
226
+ * duplicate factory, not cosmetic.
227
+ *
228
+ * So a promote composite trashes the redundant preserved FUTURE instance inside the
229
+ * txn: its content mirrors the template, and the cursor will mint the single real
230
+ * occurrence when the date arrives (matching a normal future-first series, which holds
231
+ * no materialized instance until then). A today/past-dated preserved instance is the
232
+ * LEGITIMATE current occurrence (the cursor has already advanced past it) — left
233
+ * untouched. Returns the disclosure warning (and the trashed uuid) or null when the
234
+ * promote did not preserve a future instance. Best-effort: a failed trash is reported
235
+ * in the warning rather than failing the whole compound (the series is already sound).
236
+ */
237
+ async function trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now) {
238
+ const rep = promote.repeating;
239
+ // Preserved iff the native promote relinked the source (replacedUuid === null) AND
240
+ // there is a materialized instance. A DELETE-fate promote reports replacedUuid !==
241
+ // null and never leaves a future instance to double-book.
242
+ if (rep === undefined || rep.replacedUuid !== null || rep.instanceUuid === null)
243
+ return null;
244
+ const instanceUuid = rep.instanceUuid;
245
+ const startIso = instanceStartDate(deps.db, instanceUuid);
246
+ if (startIso === null)
247
+ return null;
248
+ const todayIso = localToday(now, deps.zone);
249
+ if (daysBetweenIso(todayIso, startIso) <= 0)
250
+ return null; // today or past — legitimate
251
+ const trashOp = kind === "project" ? "project.delete" : "todo.delete";
252
+ const trashed = await runMutation(deps, trashOp, { uuid: instanceUuid }, legOptions(options, txnId));
253
+ const kindWord = kind === "project" ? "project" : "to-do";
254
+ const warning = trashed.kind === "ok"
255
+ ? `the source ${kindWord} was kept by the app as a pre-materialized first occurrence dated ` +
256
+ `${startIso}; because that date is in the future the series would have spawned a DUPLICATE ` +
257
+ `there, so the redundant occurrence was moved to the Trash — the series mints a single ` +
258
+ `occurrence when ${startIso} arrives`
259
+ : `the app kept the source ${kindWord} as a pre-materialized first occurrence dated ${startIso} ` +
260
+ `(a future date the series would DUPLICATE), and it could NOT be auto-trashed — remove it ` +
261
+ `with \`things ${kind} delete ${instanceUuid}\``;
262
+ return { warning, trashedUuid: instanceUuid };
263
+ }
264
+ /**
265
+ * The verify-failed result when the driven "Next:" first occurrence did not land
266
+ * (issue #476, ANCH2). The series exists but on the wrong phase — report it
267
+ * fail-closed rather than as a silent ok.
268
+ */
269
+ function nextMismatch(op, templateUuid, requestedIso, landedIso) {
270
+ return {
271
+ kind: "verify-failed",
272
+ op,
273
+ reason: "mismatch",
274
+ expected: { mode: "update", uuid: templateUuid, assert: [] },
275
+ observed: null,
276
+ detail: `the repeating series was created but its first occurrence landed on ` +
277
+ `${landedIso ?? "an undetermined date"}, not the requested ${requestedIso} — the Next-field ` +
278
+ "drive did not take; the series exists and can be corrected with `things reschedule-repeat`",
64
279
  };
65
280
  }
66
281
  /** The discovered template/instance from a promote leg's result. */
@@ -159,6 +374,33 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
159
374
  : "verify the uuid with `things show <uuid>`, or use `things project make-repeating` for a project",
160
375
  };
161
376
  }
377
+ // ANCH2 (issue #476): the app's Repeat dialog HAS a "Next:" first-occurrence
378
+ // field; its default is the today-anchored next match, but it is editable and
379
+ // honored (docs/lab/anch2-next-field.md). Drive it with the requested first
380
+ // occurrence — an explicit `--when` if given, else the item's own scheduled
381
+ // date. YANCH1 (issue #493): also DERIVE the calendar anchor (weekly weekday /
382
+ // monthly day / yearly month+day) from that date when no explicit anchor was
383
+ // given, and drive the anchor pop-ups — otherwise the recurring rule keeps the
384
+ // dialog's untouched default (weekly Sunday, monthly 1st, yearly January 1) and
385
+ // only the first occurrence is correct (the #493 anchor-drop).
386
+ //
387
+ // DEADLINE-MODE ANCHORING (YANCH1 #493, in-lab golden-v3): a deadlined rule
388
+ // anchors on the DEADLINE, and each instance's START = anchor − startDaysEarlier
389
+ // (probe: anchor+Next driven to Oct-16 with start-14 → of=[Oct-16], instance
390
+ // start = Oct-02). `--when` is the scheduled START, so the date the dialog's
391
+ // anchor pop-ups + "Next:" field must carry is when + startDaysEarlier (the
392
+ // deadline); the app then back-shifts the start to `when`. For a non-deadlined
393
+ // rule the shift is 0 and the drive date equals `--when` (unchanged).
394
+ const whenIso = isIsoDate(params.next) ? params.next : src.startDate;
395
+ const deadlineShift = params.deadline === true || (params.startDaysEarlier ?? 0) > 0
396
+ ? (params.startDaysEarlier ?? 0)
397
+ : 0;
398
+ const driveIso = isIsoDate(whenIso) ? addDaysIso(whenIso, deadlineShift) : undefined;
399
+ // The dialog is driven with the deadline-adjusted date; the verify below expects
400
+ // the START to land back on the requested `--when`.
401
+ const nextIso = driveIso;
402
+ const expectedStartIso = isIsoDate(whenIso) ? whenIso : undefined;
403
+ const effParams = { ...params, ...deriveFixedAnchor(params, driveIso) };
162
404
  // The promote leg drives the GUI — block before minting a clone if the ack is missing.
163
405
  if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
164
406
  return blockedUiDrive(op);
@@ -172,7 +414,7 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
172
414
  vector: "ui",
173
415
  tier: 3,
174
416
  invocation: `clone ${srcUuid} (--preserve-created) → make-repeating the clone (Repeat… → ` +
175
- `frequency=${params.frequency}, interval=${params.interval}) → trash the original ${srcUuid}`,
417
+ `frequency=${effParams.frequency}, interval=${effParams.interval}) → trash the original ${srcUuid}`,
176
418
  expectedDelta: {
177
419
  mode: "create",
178
420
  probe: { title: src.title, type: expectedType, sinceEpoch: 0 },
@@ -182,6 +424,12 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
182
424
  },
183
425
  };
184
426
  }
427
+ // SESSGATE (#480): refuse a locked / full-screen session BEFORE minting a clone
428
+ // — otherwise the promote's dialog opens on an unreachable window and the whole
429
+ // compound fails, stranding a disposable clone. Zero mutation on refusal.
430
+ const gate = await gateSessionReachability(deps, op);
431
+ if (gate !== null)
432
+ return gate;
185
433
  const startedAt = now;
186
434
  const txnId = newTxnId(startedAt);
187
435
  // 1. Clone the source as a disposable, embedded leg (--preserve-created). The
@@ -235,8 +483,8 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
235
483
  : {}),
236
484
  };
237
485
  }
238
- // 3. Native-promote the clone.
239
- const rule = ruleParamsFor(cloneUuid, params);
486
+ // 3. Native-promote the clone (with the ANCH2 Next drive + derived weekday).
487
+ const rule = ruleParamsFor(cloneUuid, effParams, nextIso);
240
488
  const promote = kind === "project"
241
489
  ? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
242
490
  : await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
@@ -259,12 +507,37 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
259
507
  : {}),
260
508
  };
261
509
  }
262
- const { templateUuid, instanceUuid } = discoveryOf(promote);
510
+ const { templateUuid } = discoveryOf(promote);
511
+ let { instanceUuid } = discoveryOf(promote);
512
+ // Post-drive verify (ANCH2 + YANCH1): the instance START must have landed on the
513
+ // requested `--when` — for a deadlined rule the driven Next is the deadline
514
+ // (when + startDaysEarlier) and the app back-shifts the start to `--when`, so the
515
+ // check is against `expectedStartIso`, not the raw drive date. Fail closed on
516
+ // mismatch rather than report a wrong-phase ok.
517
+ if (expectedStartIso !== undefined) {
518
+ const landed = firstOccurrenceOf(deps.db, templateUuid);
519
+ if (landed !== expectedStartIso) {
520
+ return nextMismatch(op, templateUuid, expectedStartIso, landed);
521
+ }
522
+ }
263
523
  const warnings = [
524
+ landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
264
525
  `the original ${expectedType} (uuid ${srcUuid}) was moved to the Trash; \`things undo\` ` +
265
526
  "removes the new series (trash-both) and restores it",
266
527
  PLACEMENT_NOTE,
267
528
  ];
529
+ // DBLSPAWN1: if the promote PRESERVED the source (deadline / terminal-element
530
+ // trigger) as a FUTURE-dated instance, the app would spawn a duplicate on that date
531
+ // — trash the redundant occurrence and disclose (cursor mints the single real one).
532
+ const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now);
533
+ if (dbl !== null) {
534
+ warnings.push(dbl.warning);
535
+ if (instanceUuid === dbl.trashedUuid)
536
+ instanceUuid = null;
537
+ }
538
+ const offRule = offRuleFirstNote(effParams);
539
+ if (offRule !== null)
540
+ warnings.push(offRule);
268
541
  if (promote.warnings !== undefined)
269
542
  warnings.push(...promote.warnings);
270
543
  appendPromoteSummary(deps, {
@@ -275,7 +548,7 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
275
548
  instanceUuid,
276
549
  originalUuid: srcUuid,
277
550
  invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
278
- requested: params,
551
+ requested: effParams,
279
552
  ...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
280
553
  });
281
554
  return promoteOk({
@@ -303,6 +576,42 @@ export function runMakeRepeatingProject(deps, params, options = {}) {
303
576
  async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options) {
304
577
  const op = kind === "project" ? "project.add-repeating" : "todo.add-repeating";
305
578
  assertRepeatRule(rule);
579
+ // ANCH2 (issue #476): drive the Repeat dialog's "Next:" field with --when so the
580
+ // series starts on the requested date (the field's default is today-anchored but
581
+ // it is editable and honored). YANCH1 (issue #493): also DERIVE the calendar
582
+ // anchor (weekly weekday / monthly day / yearly month+day) from --when when no
583
+ // explicit anchor was given, and drive the anchor pop-ups — otherwise the
584
+ // recurring rule keeps the dialog's untouched default (weekly Sunday, monthly
585
+ // 1st, yearly January 1) and only the first occurrence is correct.
586
+ //
587
+ // DEADLINE-MODE (DBLSPAWN1): a deadlined rule anchors on the DEADLINE, and each
588
+ // instance's START = deadline − startDaysEarlier (DACON1 DC4). `--when` is the
589
+ // scheduled START, so the date the dialog's "Next:" field + anchor pop-ups must
590
+ // carry is when + startDaysEarlier (the deadline); the app back-shifts the start to
591
+ // `--when`. `runAddRepeatingTodo` maps a concrete item-level `--deadline` into the
592
+ // rule's deadline/startDaysEarlier here (and strips it from the seed, so the source
593
+ // is not SRCFATE-preserved as a double-booking future instance). For a non-deadlined
594
+ // rule the shift is 0 and the drive date equals `--when` (unchanged).
595
+ const whenIso = isIsoDate(addParams["when"]) ? addParams["when"] : null;
596
+ const deadlineShift = rule.deadline === true || (rule.startDaysEarlier ?? 0) > 0 ? (rule.startDaysEarlier ?? 0) : 0;
597
+ const driveIso = whenIso !== null ? addDaysIso(whenIso, deadlineShift) : null;
598
+ const nextIso = driveIso ?? undefined;
599
+ const expectedStartIso = whenIso ?? undefined;
600
+ const effRule = {
601
+ ...rule,
602
+ ...deriveFixedAnchor(rule, driveIso),
603
+ };
604
+ // ADR1 (issue #480, requested behavior #3): carry the base to-do's --reminder
605
+ // onto the SERIES. The create leg sets a one-off reminderTime on the seed, but
606
+ // the Repeat-dialog conversion does NOT preserve it — the dialog OWNS the repeat
607
+ // reminder via its "Add reminders" control — so a base reminder was silently
608
+ // dropped from the template (verified empty on golden-v2,
609
+ // docs/lab/adr1-add-repeating-reveal.md). Drive the dialog's reminder with the
610
+ // base time so every spawned occurrence carries it (ANCH2: the reminder picker
611
+ // commits reminderTime deterministically). Projects have no reminder vocabulary,
612
+ // so addParams never carries one there.
613
+ const baseReminder = typeof addParams["reminder"] === "string" ? addParams["reminder"] : undefined;
614
+ const effRuleWithReminder = baseReminder !== undefined ? { ...effRule, reminder: baseReminder } : effRule;
306
615
  // The promote leg drives the GUI — block before creating anything if the ack is missing.
307
616
  if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
308
617
  return blockedUiDrive(op);
@@ -327,6 +636,12 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
327
636
  },
328
637
  };
329
638
  }
639
+ // SESSGATE (#480): refuse a locked / full-screen session BEFORE seeding the row
640
+ // (the two legs are not atomic — a doomed promote would strand the seed). Zero
641
+ // mutation on refusal; a window merely on another Space is relocated in-drive.
642
+ const gate = await gateSessionReachability(deps, op);
643
+ if (gate !== null)
644
+ return gate;
330
645
  const startedAt = deps.now?.() ?? new Date();
331
646
  const txnId = newTxnId(startedAt);
332
647
  // 1. Create the item (full add vocabulary) as an embedded leg.
@@ -349,17 +664,53 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
349
664
  : { ...add, op };
350
665
  }
351
666
  const createdUuid = add.uuid;
352
- // 2. Native-promote the fresh row.
353
- const ruleParams = ruleParamsFor(createdUuid, rule);
667
+ // 2. Native-promote the fresh row (ANCH2 Next drive + derived weekday + the
668
+ // base reminder driven onto the series, ADR1).
669
+ const ruleParams = ruleParamsFor(createdUuid, effRuleWithReminder, nextIso);
354
670
  const promote = kind === "project"
355
671
  ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
356
672
  : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
357
673
  if (promote.kind !== "ok") {
358
- // Honest: the item was created (and persists) but the promote did not land.
359
- return { ...promote, op };
674
+ // The seed persists (the two legs are not atomic) but the promote did not
675
+ // land. RATIFIED RULING (2026-08-15, issue #480): auto-trash our OWN seed
676
+ // inside the txn — it is our artifact, recreatable verbatim from the command
677
+ // args, and the Trash is recoverable — then disclose it. If the auto-trash
678
+ // itself fails, the result carries the seed's REAL, resolvable uuid with a
679
+ // working `delete` remediation, so cleanup is never ambiguous (the #480
680
+ // second bug: a failed add-repeating left a residue whose reported uuid was
681
+ // not actionable).
682
+ const patch = await cleanupSeed(deps, kind, createdUuid, promote, options, txnId);
683
+ return { ...promote, op, ...patch };
360
684
  }
361
- const { templateUuid, instanceUuid } = discoveryOf(promote);
362
- const warnings = [PLACEMENT_NOTE];
685
+ const { templateUuid } = discoveryOf(promote);
686
+ let { instanceUuid } = discoveryOf(promote);
687
+ // Post-drive verify (ANCH2 + DBLSPAWN1): the instance START must have landed on the
688
+ // requested `--when` — for a deadlined rule the driven Next is the deadline (when +
689
+ // startDaysEarlier) and the app back-shifts the start to `--when`, so the check is
690
+ // against `expectedStartIso`, not the raw drive date. Fail closed on mismatch rather
691
+ // than report a wrong-phase ok. The series EXISTS here (promote landed) but on the
692
+ // wrong phase, so this is a genuine partial success, NOT a seed to trash.
693
+ if (expectedStartIso !== undefined) {
694
+ const landed = firstOccurrenceOf(deps.db, templateUuid);
695
+ if (landed !== expectedStartIso)
696
+ return nextMismatch(op, templateUuid, expectedStartIso, landed);
697
+ }
698
+ const warnings = [
699
+ landedRuleEcho(ruleParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
700
+ PLACEMENT_NOTE,
701
+ ];
702
+ // DBLSPAWN1 backstop: the deadline-mapping above keeps the seed deadline-free (no
703
+ // SRCFATE preserve), but any OTHER preserve trigger reaching the seed (defensive)
704
+ // would double-book a future first occurrence — trash the redundant instance.
705
+ const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, startedAt);
706
+ if (dbl !== null) {
707
+ warnings.push(dbl.warning);
708
+ if (instanceUuid === dbl.trashedUuid)
709
+ instanceUuid = null;
710
+ }
711
+ const offRule = offRuleFirstNote(ruleParams);
712
+ if (offRule !== null)
713
+ warnings.push(offRule);
363
714
  if (promote.warnings !== undefined)
364
715
  warnings.push(...promote.warnings);
365
716
  appendPromoteSummary(deps, {
@@ -369,7 +720,7 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
369
720
  templateUuid,
370
721
  instanceUuid,
371
722
  invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
372
- requested: { title, ...rule },
723
+ requested: { title, ...effRule },
373
724
  });
374
725
  return promoteOk({
375
726
  op,
@@ -381,9 +732,9 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
381
732
  warnings,
382
733
  });
383
734
  }
384
- export function runAddRepeatingTodo(deps, params, options = {}) {
385
- const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, ...add } = params;
386
- const rule = {
735
+ export async function runAddRepeatingTodo(deps, params, options = {}) {
736
+ const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, startDaysEarlier, ...add } = params;
737
+ const baseRule = {
387
738
  frequency,
388
739
  interval,
389
740
  ...(afterCompletion !== undefined && { afterCompletion }),
@@ -392,12 +743,71 @@ export function runAddRepeatingTodo(deps, params, options = {}) {
392
743
  ...(yearly !== undefined && { yearly }),
393
744
  ...(ends !== undefined && { ends }),
394
745
  };
746
+ // DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md): a concrete item-level
747
+ // `--deadline <date>` on add-repeating maps to the RULE's deadline — each occurrence
748
+ // is due `deadline − when` days after its start (the "Add deadlines" + "start N days
749
+ // earlier" dialog fields). This is the deadline the series actually wants, and it
750
+ // keeps the SEED deadline-free: a seed carrying a deadline is SRCFATE-preserved as a
751
+ // materialized instance, and when that first occurrence is future-dated the app
752
+ // DOUBLE-BOOKS it against the template cursor and spawns a duplicate on the date
753
+ // (cell C). The seed owning the deadline was the two-step dance the live agent hit
754
+ // (add-repeating dropped the rule deadline, forcing a follow-up reschedule-repeat);
755
+ // mapping it up front makes that unnecessary. A deadline needs a concrete `--when`
756
+ // (the per-occurrence offset is deadline − start) and must be on/after it.
757
+ //
758
+ // DEADLINE/OFFSET AGREEMENT (ruling 2026-08-18): the same rule-global offset can be
759
+ // named two ways — a concrete `--deadline <date>` (offset = deadline − when) OR an
760
+ // explicit `--start-days-earlier N` (offset = N, deadline derived as when + N). The
761
+ // geometry when/deadline/N is OVER-DETERMINED: the dialog's start-offset is
762
+ // rule-global and the first occurrence's start is derived as due − N, so there is no
763
+ // per-first-instance gap to absorb a disagreement (unlike DACON1's off-rule-first
764
+ // calendar freedom). So when BOTH are given they must AGREE (`deadline − when == N`)
765
+ // — an exact match is harmless redundancy, a mismatch is inexpressible and refused
766
+ // fast (zero mutation). Either input ALONE maps to the rule and keeps the seed
767
+ // deadline-free.
768
+ let rule = baseRule;
769
+ let seedDeadline = add.deadline;
770
+ if ((add.deadline !== undefined || startDaysEarlier !== undefined) && afterCompletion !== true) {
771
+ if (!isIsoDate(add.when)) {
772
+ throw new RangeError("a repeating --deadline or --start-days-earlier needs a concrete --when date (the deadline " +
773
+ "offset is measured from each occurrence's start) — schedule the series on a YYYY-MM-DD " +
774
+ "--when, or drop --deadline / --start-days-earlier");
775
+ }
776
+ let startEarlier;
777
+ if (add.deadline !== undefined) {
778
+ const derived = daysBetweenIso(add.when, add.deadline);
779
+ if (derived < 0) {
780
+ throw new RangeError(`--deadline (${add.deadline}) must be on or after --when (${add.when}) — a deadline cannot ` +
781
+ "precede the occurrence's own start");
782
+ }
783
+ if (startDaysEarlier !== undefined && startDaysEarlier !== derived) {
784
+ throw new RangeError(`--deadline (${add.deadline}) puts each occurrence's due date ${derived} day` +
785
+ `${derived === 1 ? "" : "s"} after its start (--when ${add.when}), but ` +
786
+ `--start-days-earlier says ${startDaysEarlier} — these disagree. Drop one, or make them ` +
787
+ `agree (--start-days-earlier ${derived}, or --deadline ${addDaysIso(add.when, startDaysEarlier)}).`);
788
+ }
789
+ startEarlier = derived;
790
+ }
791
+ else {
792
+ // `--start-days-earlier N` alone: the deadline is derived as when + N. A bad N
793
+ // (non-integer / negative) is refused by assertRepeatRule once mapped.
794
+ startEarlier = startDaysEarlier;
795
+ }
796
+ rule = { ...baseRule, deadline: true, startDaysEarlier: startEarlier };
797
+ seedDeadline = undefined; // the RULE owns the deadline; the seed carries none
798
+ }
799
+ else if (startDaysEarlier !== undefined) {
800
+ // afterCompletion === true here: an after-completion repeat has no calendar
801
+ // start to count a deadline back from — refuse rather than silently drop it.
802
+ throw new RangeError("--start-days-earlier applies only to a fixed-schedule deadline — an after-completion repeat " +
803
+ "has no calendar start to count back from; drop --after-completion or --start-days-earlier");
804
+ }
395
805
  const addParams = {
396
806
  title: add.title,
397
807
  ...(add.notes !== undefined && { notes: add.notes }),
398
808
  ...(add.when !== undefined && { when: add.when }),
399
809
  ...(add.reminder !== undefined && { reminder: add.reminder }),
400
- ...(add.deadline !== undefined && { deadline: add.deadline }),
810
+ ...(seedDeadline !== undefined && { deadline: seedDeadline }),
401
811
  ...(add.tags !== undefined && { tags: add.tags }),
402
812
  ...(add.checklistItems !== undefined && { checklistItems: add.checklistItems }),
403
813
  ...(add.project !== undefined && { project: add.project }),
@@ -453,7 +863,7 @@ function blockedCloneSource(op, detail, remediation) {
453
863
  * produce: two simultaneous end bounds, and a multi-anchor month/year rule.
454
864
  */
455
865
  function inexpressibleReason(rule) {
456
- if (rule.endDate !== null && rule.remainingCount !== null) {
866
+ if (rule.endDate !== null && rule.occurrenceCount !== null) {
457
867
  return ("the source rule ends on BOTH a date and an occurrence count, which the repeat vocabulary " +
458
868
  "cannot express (its Ends bound is a single choice)");
459
869
  }
@@ -531,6 +941,11 @@ export async function cloneTemplateViaRepromote(deps, kind, src, srcUuid, params
531
941
  },
532
942
  };
533
943
  }
944
+ // SESSGATE (#480): refuse a locked / full-screen session BEFORE minting the
945
+ // plain clone (a doomed promote would strand it). Zero mutation on refusal.
946
+ const gate = await gateSessionReachability(deps, op);
947
+ if (gate !== null)
948
+ return gate;
534
949
  const startedAt = deps.now?.() ?? new Date();
535
950
  const txnId = newTxnId(startedAt);
536
951
  // 4. Mint the plain clone as an embedded leg — cloneTemplateAsPlain reaches the