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
@@ -1,13 +1,15 @@
1
1
  import { undoToken } from "../audit/schema.js";
2
+ import { uiAllowed, uiCapability as uiCapabilityDefault } from "../capability.js";
2
3
  import { addDaysIso, decodePackedDate, localToday } from "../model/dates.js";
3
4
  import { byUuid } from "../read/detail.js";
4
5
  import { resolveProjectWriteTarget, resolveTaskUuidPrefix } from "../read/queries.js";
5
6
  import { runCloneProject, runCloneTodo } from "./clone.js";
6
7
  import { promoteProjectViaGui } from "./make-repeating-project.js";
7
- import { fingerprintLabel, runMutation, } from "./pipeline.js";
8
+ import { replayIfApplied } from "./opid.js";
9
+ import { fingerprintLabel, runComposite, runMutation, } from "./pipeline.js";
8
10
  import { assessOffRuleFirst, daysBetweenIso, deriveFixedAnchor, isIsoDate, } from "./repeat-anchor.js";
9
- import { assertRepeatRule, ruleToInverseParams } from "./repeat-rule.js";
10
- import { createDbReader } from "./verify/delta.js";
11
+ import { assertRepeatRule, ruleToInverseParams, splitAddRepeatingRule } from "./repeat-rule.js";
12
+ import { createDbReader, } from "./verify/delta.js";
11
13
  import { H_UI_SESSION_UNREACHABLE } from "./vectors/session-reachability.js";
12
14
  const PLACEMENT_NOTE = "the series' current instance lands at its container's default position — its prior slot was " +
13
15
  "not automatically restored (best-effort placement is not yet wired for this container); " +
@@ -50,22 +52,51 @@ function blockedUiDrive(op) {
50
52
  detail: "this operation promotes an item to a repeating series by driving the local Things app " +
51
53
  "through the Accessibility API (the Repeat… dialog) — it may briefly interact with the UI",
52
54
  remediation: "pass dangerouslyDriveGui (--dangerously-drive-gui) to proceed; the vector also requires " +
53
- "`things config set ui-enabled true` and Accessibility granted to this process (see docs/setup.md)",
55
+ "`things config set ui-enabled true` and `things helpers setup --gui`, which grants " +
56
+ "GUI-driving to the helper pair — the only identity it is granted to " +
57
+ "(docs/design/permissions-doctrine.md, Article IV)",
54
58
  };
55
59
  }
56
60
  /**
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).
61
+ * The pre-seed UI PREFLIGHT (SESSGATE #480 + issue #512). A promote composite is
62
+ * NOT atomic: it SEEDS a row (clone / add) before the GUI promote leg drives the
63
+ * dialog. Anything that will certainly stop that drive must be established BEFORE
64
+ * the seed exists, or the composite mutates, fails, and cleans up after itself for
65
+ * a reason it could have known up front. Two checks, cheapest and most decisive
66
+ * first, both prompt-free:
67
+ *
68
+ * 1. STANDING (permissions doctrine, Article IV). Driving the window needs
69
+ * Accessibility + Automation System Events, held by the helper pair and
70
+ * nothing else. The pipeline already gates this but only on the PROMOTE leg,
71
+ * which runs after the seed, so a machine without the grants used to create
72
+ * the row, refuse, and trash it. Same verdict, same copy, one leg earlier;
73
+ * nothing is created. Keyed on a vector's `drivesGui` DECLARATION exactly as
74
+ * the pipeline's gate is, never on its id, so a fake/simulator substituted
75
+ * under "ui" is not gated on the developer's own host state.
76
+ * 2. REACHABILITY (SESSGATE). If the Mac's session is AX-blind (screen locked /
77
+ * full-screen Space), the dialog would open on an unreachable window. Only
78
+ * "session" scope refuses here: a window merely on another Space is left for
79
+ * the in-drive relocation (the reveal has not run yet, so refusing before the
80
+ * seed would be a false positive). A fail-open probe proceeds — the promote
81
+ * leg's own gate + the seed cleanup remain the backstop.
82
+ *
83
+ * Running the standing check FIRST also keeps the probe itself doctrine-clean: no
84
+ * System Events call is attempted on a host that has not granted one.
67
85
  */
68
- async function gateSessionReachability(deps, op) {
86
+ async function gateUiPreflight(deps, op) {
87
+ const gui = deps.vectors.find((v) => v.drivesGui === true && v.simulates !== true);
88
+ if (gui !== undefined) {
89
+ const capability = (deps.uiCapability ?? (() => uiCapabilityDefault()))();
90
+ if (!uiAllowed(capability)) {
91
+ return {
92
+ kind: "blocked",
93
+ op,
94
+ reason: "environment",
95
+ detail: `this operation drives the Things window, and ${capability.detail} — nothing was created`,
96
+ remediation: capability.remediation.join("; "),
97
+ };
98
+ }
99
+ }
69
100
  if (!deps.config.ui.enabled)
70
101
  return null;
71
102
  const ui = deps.vectors.find((v) => v.probeReachability !== undefined);
@@ -117,35 +148,23 @@ async function cleanupSeed(deps, kind, createdUuid, promote, options, txnId) {
117
148
  *
118
149
  * The `rule` bag is the SUPERSET the make/add legs actually carry: make-repeating
119
150
  * 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).
151
+ * and the requested `next` included), add-repeating an
152
+ * {@link AddRepeatingRuleFields} plus whichever of the deadline pair its geometry
153
+ * folded in. Every field flows through by SPREAD, never by a hand-copied field
154
+ * list a list here is how the make-repeating promote once stripped
155
+ * `deadline`/`startDaysEarlier` (a deadlined make-repeating produced a
156
+ * NON-deadlined template, YANCH1 #493) and how the project promote later dropped
157
+ * `next` (#549). The ONE field that is deliberately re-derived rather than copied
158
+ * is `next`: the drive date is deadline-shifted by the caller, and an
159
+ * after-completion dialog has no first-occurrence field to drive at all.
126
160
  */
127
161
  function ruleParamsFor(uuid, rule, nextIso) {
128
- return {
129
- uuid,
130
- frequency: rule.frequency,
131
- interval: rule.interval,
132
- ...(rule.afterCompletion !== undefined && { afterCompletion: rule.afterCompletion }),
133
- ...(rule.weekdays !== undefined && { weekdays: rule.weekdays }),
134
- ...(rule.monthly !== undefined && { monthly: rule.monthly }),
135
- ...(rule.yearly !== undefined && { yearly: rule.yearly }),
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
- };
162
+ const out = { ...rule, uuid };
163
+ // Never the caller's raw `next` — see above; the drive date arrives as `nextIso`.
164
+ delete out.next;
165
+ if (nextIso !== undefined && rule.afterCompletion !== true)
166
+ out.next = nextIso;
167
+ return out;
149
168
  }
150
169
  const UNIT_SINGULAR = {
151
170
  daily: "day",
@@ -214,6 +233,32 @@ function instanceStartDate(db, instanceUuid) {
214
233
  return null;
215
234
  return decodePackedDate(row.s);
216
235
  }
236
+ /**
237
+ * The oracle for the post-drive first-occurrence check, picked by rule KIND
238
+ * (issue #508).
239
+ *
240
+ * A FIXED-schedule series is anchored on the calendar: the dialog's "Next:" field
241
+ * IS driven, and the template's own cursor (`rt1_instanceCreationStartDate`) holds
242
+ * the resulting first occurrence — the right thing to compare against.
243
+ *
244
+ * An AFTER-COMPLETION series has NO calendar anchor, so {@link ruleParamsFor}
245
+ * deliberately leaves "Next:" alone, and the app mints the template with an EMPTY
246
+ * cursor (RSIM2 / RSIM-P P4: no next / reference dates exist until a completion
247
+ * happens). Comparing that empty cursor against `--when` reported a FALSE
248
+ * `verify-failed:mismatch` on a perfectly correct creation (#508, reproduced 6/6
249
+ * on the live host). The requested date lives on the materialized INSTANCE's own
250
+ * `startDate` — the row the promote preserved and relinked — so that is the oracle
251
+ * for an after-completion series.
252
+ *
253
+ * Returns `null` when no oracle is reachable. For an after-completion rule that
254
+ * means "unverifiable" and the caller SKIPS the check (the create delta already
255
+ * proved the series landed); for a fixed rule an absent cursor is a genuine miss.
256
+ */
257
+ function landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion) {
258
+ if (!afterCompletion)
259
+ return firstOccurrenceOf(deps.db, templateUuid);
260
+ return instanceUuid === null ? null : instanceStartDate(deps.db, instanceUuid);
261
+ }
217
262
  /**
218
263
  * DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md, golden-v3 / Things 3.22.14):
219
264
  * a promote whose source is PRESERVED (SRCFATE deadline / terminal-element trigger)
@@ -234,7 +279,16 @@ function instanceStartDate(db, instanceUuid) {
234
279
  * promote did not preserve a future instance. Best-effort: a failed trash is reported
235
280
  * in the warning rather than failing the whole compound (the series is already sound).
236
281
  */
237
- async function trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now) {
282
+ async function trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now, afterCompletion) {
283
+ // AFTER-COMPLETION series are exempt: the double-book is a CURSOR phenomenon
284
+ // (`rt1_nextInstanceStartDate` pointing at an already-materialized occurrence),
285
+ // and an after-completion template is minted with NO cursor at all — the next
286
+ // occurrence is unknown until a completion happens (RSIM2 / RSIM-P P4). Its
287
+ // preserved instance is therefore the series' ONLY occurrence, whatever its
288
+ // date; trashing it would destroy the series' current occurrence rather than a
289
+ // duplicate. (Unreachable before #508, whose false verify-failed returned first.)
290
+ if (afterCompletion)
291
+ return null;
238
292
  const rep = promote.repeating;
239
293
  // Preserved iff the native promote relinked the source (replacedUuid === null) AND
240
294
  // there is a materialized instance. A DELETE-fate promote reports replacedUuid !==
@@ -287,6 +341,36 @@ function discoveryOf(promote) {
287
341
  };
288
342
  }
289
343
  // -------------------------------------------------------------- audit summary
344
+ /**
345
+ * The promote compound's presence oracle: "a repeating TEMPLATE with this title,
346
+ * created since this call started, exists". It is the same create-probe the
347
+ * compound's own dry-run plan states, TIME-BOUNDED to the call — the app mints a
348
+ * template with a fresh write-time creationDate even when the promoted row was
349
+ * created earlier (`--preserve-created`), so the bound is safe and it keeps an
350
+ * older namesake series from reading as this call's work. Recorded on an
351
+ * AMBIGUOUS summary so a resubmission can settle it (see {@link appendPromoteSummary}).
352
+ */
353
+ function promotePresenceDelta(title, type, startedAt) {
354
+ return {
355
+ mode: "create",
356
+ probe: { title, type, sinceEpoch: Math.floor(startedAt.getTime() / 1000) },
357
+ assert: [{ field: "repeating.isTemplate", equals: true }],
358
+ };
359
+ }
360
+ /**
361
+ * Append the promote compound's SUMMARY record — the ONE record that stands for
362
+ * the whole verb (its legs are `leg`-role records, excluded from direct undo),
363
+ * and the one record the caller's `opId` rides, so a resubmission matches the
364
+ * whole promote exactly once instead of matching a leg or nothing at all.
365
+ *
366
+ * Two shapes, one record either way:
367
+ * - the SUCCESS summary (default) — result `ok`, the single undoable unit;
368
+ * - an AMBIGUOUS summary (`ambiguous`) — result `verify-failed:timeout` with the
369
+ * presence oracle attached, written when a leg dispatched and never confirmed.
370
+ * It is not an undo target (undo reads `ok` records only); it exists so a
371
+ * resubmission carrying the same key re-reads state and decides instead of
372
+ * minting a second series.
373
+ */
290
374
  function appendPromoteSummary(deps, args) {
291
375
  const fp = deps.fingerprint();
292
376
  const observed = {
@@ -297,7 +381,7 @@ function appendPromoteSummary(deps, args) {
297
381
  const record = {
298
382
  v: 1,
299
383
  ts: args.startedAt.toISOString(),
300
- actor: deps.config.actor,
384
+ actor: args.options?.actor ?? deps.config.actor,
301
385
  host: deps.config.host,
302
386
  op: args.op,
303
387
  uuid: args.templateUuid,
@@ -306,9 +390,11 @@ function appendPromoteSummary(deps, args) {
306
390
  invocation: args.invocation,
307
391
  requested: args.requested,
308
392
  txn: { id: args.txnId, role: "summary" },
393
+ ...(args.options?.opId !== undefined && { opId: args.options.opId }),
394
+ ...(args.ambiguous !== undefined && { expected: args.ambiguous }),
309
395
  pre: null,
310
- observed,
311
- result: "ok",
396
+ observed: args.ambiguous === undefined ? observed : null,
397
+ result: args.ambiguous === undefined ? "ok" : "verify-failed:timeout",
312
398
  ...(args.preModDates !== undefined && { preModDates: args.preModDates }),
313
399
  verify: null,
314
400
  durationMs: (deps.now?.() ?? new Date()).getTime() - args.startedAt.getTime(),
@@ -320,6 +406,31 @@ function appendPromoteSummary(deps, args) {
320
406
  };
321
407
  deps.audit.append(record);
322
408
  }
409
+ /**
410
+ * Record the AMBIGUOUS summary when a promote compound's own outcome is a
411
+ * timeout — the one verdict where the series may or may not exist. Returns the
412
+ * outcome unchanged, so it wraps a composite body without re-shaping it. Only a
413
+ * keyed call records one: without an `opId` there is nothing to reconcile
414
+ * against later, and the leg records already carry the failure.
415
+ */
416
+ function recordAmbiguousPromote(deps, outcome, args) {
417
+ if (args.options.opId === undefined)
418
+ return outcome;
419
+ if (outcome.kind !== "verify-failed" || outcome.reason !== "timeout")
420
+ return outcome;
421
+ appendPromoteSummary(deps, {
422
+ startedAt: args.startedAt,
423
+ op: args.op,
424
+ txnId: args.txnId,
425
+ templateUuid: null,
426
+ instanceUuid: null,
427
+ invocation: args.invocation,
428
+ requested: args.requested,
429
+ options: args.options,
430
+ ambiguous: promotePresenceDelta(args.title, args.type, args.startedAt),
431
+ });
432
+ return outcome;
433
+ }
323
434
  /** Build the ok result for a promote (make/add-repeating). */
324
435
  function promoteOk(args) {
325
436
  const repeating = {
@@ -356,6 +467,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
356
467
  const op = kind === "project" ? "project.make-repeating" : "todo.make-repeating";
357
468
  // Validate the rule BEFORE anything (a bad rule must never mint a clone).
358
469
  assertRepeatRule(params);
470
+ // Idempotency FIRST, before the target is even resolved: a successful promote
471
+ // moves the original to the Trash and hands back a different uuid, so a
472
+ // resubmission of the same key must be answered from the trail rather than
473
+ // re-driven against a source that is no longer the item it names. The key
474
+ // rides the compound's single summary record, so a match is the whole verb.
475
+ const replay = replayIfApplied(deps, options);
476
+ if (replay !== null)
477
+ return replay;
359
478
  const now = deps.now?.() ?? new Date();
360
479
  const srcUuid = kind === "project"
361
480
  ? resolveProjectWriteTarget(deps.db, params.uuid)
@@ -374,6 +493,9 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
374
493
  : "verify the uuid with `things show <uuid>`, or use `things project make-repeating` for a project",
375
494
  };
376
495
  }
496
+ // Bound after the existence guard so the composite body (a hoisted function,
497
+ // where TypeScript cannot carry the narrowing) still sees a plain string.
498
+ const srcTitle = src.title;
377
499
  // ANCH2 (issue #476): the app's Repeat dialog HAS a "Next:" first-occurrence
378
500
  // field; its default is the today-anchored next match, but it is editable and
379
501
  // honored (docs/lab/anch2-next-field.md). Drive it with the requested first
@@ -396,10 +518,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
396
518
  ? (params.startDaysEarlier ?? 0)
397
519
  : 0;
398
520
  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;
521
+ // The ANCHOR is derived from the deadline-adjusted date (the anchor names the
522
+ // DUE date), but `next` is passed through as the requested START: the promote
523
+ // leg's own compile applies the "Next:" shift, and it is the ONLY place that
524
+ // does (NEXTPOP1 shifting here as well made every downstream consumer that
525
+ // shifts, `assessOffRuleFirst` among them, shift a second time). The verify
526
+ // below expects the START to land on the requested `--when` either way.
527
+ const nextIso = isIsoDate(whenIso) ? whenIso : undefined;
528
+ const expectedStartIso = nextIso;
403
529
  const effParams = { ...params, ...deriveFixedAnchor(params, driveIso) };
404
530
  // The promote leg drives the GUI — block before minting a clone if the ack is missing.
405
531
  if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
@@ -424,142 +550,168 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
424
550
  },
425
551
  };
426
552
  }
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);
553
+ // PRE-SEED UI PREFLIGHT (#480/#512): refuse a host that cannot drive the window,
554
+ // or a locked / full-screen session, BEFORE minting a clone otherwise the
555
+ // promote's dialog never opens and the whole compound fails, stranding a
556
+ // disposable clone. Zero mutation on refusal.
557
+ const gate = await gateUiPreflight(deps, op);
431
558
  if (gate !== null)
432
559
  return gate;
433
560
  const startedAt = now;
434
561
  const txnId = newTxnId(startedAt);
435
- // 1. Clone the source as a disposable, embedded leg (--preserve-created). The
436
- // clone has captured X's full content by the time it returns.
437
- const clone = kind === "project"
438
- ? await runCloneProject(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId))
439
- : await runCloneTodo(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId));
440
- if (clone.kind !== "ok" || clone.uuid === null) {
441
- // A clone refusal (nested repeating template, H-CLONE-SOURCE) surfaces
442
- // coherently here re-label it to the make-repeating op for the caller.
443
- return clone.kind === "ok"
444
- ? {
445
- kind: "verify-failed",
446
- op,
447
- reason: "mismatch",
448
- expected: {
449
- mode: "create",
450
- probe: { title: src.title, type: expectedType, sinceEpoch: 0 },
451
- assert: [],
452
- },
453
- observed: null,
454
- detail: "the disposable clone was created but its uuid was not discovered — nothing was promoted or trashed",
455
- }
456
- : { ...clone, op };
457
- }
458
- const cloneUuid = clone.uuid;
459
- // --preserve-modified: X is the ONLY pre-existing row the compound touches (the
460
- // clone/promote legs mint fresh rows). Capture its pre-write umd BEFORE the
461
- // trash bumps it — the trash leg restores it forward, and the value rides the
462
- // summary record's preModDates so the symmetric undo restore fires on the
463
- // revived X (undo.ts, 2026-08-13 ruling). The clone leg above reads X but never
464
- // writes it, so its umd is still pristine here.
465
- const preserveModified = options.preserveModified === true;
466
- const preUmd = preserveModified
467
- ? createDbReader(deps.db, now, deps.zone).modDateOf(srcUuid)
468
- : null;
469
- // 2. Trash the original BEFORE promoting — the clone already holds X's content,
470
- // and a live same-titled X would make the promote's project row-selection
471
- // ambiguous (H-PROJECT-REPEAT). X survives in the Trash (the recoverable half).
472
- const trash = await runMutation(deps, `${kind}.delete`, { uuid: srcUuid }, legOptions(options, txnId, undefined, preserveModified ? { preserveModified: true } : undefined));
473
- if (trash.kind !== "ok") {
474
- return {
475
- ...trash,
562
+ // COMPOSITE LOCK: everything below is ONE verb executed as several mutations
563
+ // (clone trash promote the DBLSPAWN1 clean-up), and they must not
564
+ // interleave with another writer's legs — the promote's row selection is by
565
+ // TITLE, so a concurrent clone of the same item makes it ambiguous. One lock,
566
+ // held to the end; each leg's own acquisition is a reentrant no-op.
567
+ return runComposite(deps, op, async () => {
568
+ const outcome = await promoteBody();
569
+ // An unconfirmed outcome gets the compound's AMBIGUOUS summary, so a
570
+ // resubmission of the same key reconciles instead of minting a second series.
571
+ return recordAmbiguousPromote(deps, outcome, {
572
+ startedAt,
476
573
  op,
477
- ...("detail" in trash
478
- ? {
479
- detail: `${trash.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
480
- `original ${srcUuid} could not be moved to the Trash, so it was NOT promoted; trash ` +
481
- "the clone and retry",
482
- }
483
- : {}),
484
- };
485
- }
486
- // 3. Native-promote the clone (with the ANCH2 Next drive + derived weekday).
487
- const rule = ruleParamsFor(cloneUuid, effParams, nextIso);
488
- const promote = kind === "project"
489
- ? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
490
- : await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
491
- if (promote.kind !== "ok") {
492
- // The clone persists but was not promoted; best-effort ROLL BACK the trash so
493
- // the original is not stranded in the Trash.
494
- const restoreOp = kind === "project" ? "project.restore" : "todo.restore";
495
- const rolledBack = await runMutation(deps, restoreOp, { uuid: srcUuid }, legOptions(options, txnId));
496
- const rollNote = rolledBack.kind === "ok"
497
- ? `the original ${srcUuid} was restored from the Trash`
498
- : `the original ${srcUuid} could NOT be restored from the Trash — restore it in the app`;
499
- return {
500
- ...promote,
501
- op,
502
- ...("detail" in promote
574
+ txnId,
575
+ title: srcTitle,
576
+ type: expectedType,
577
+ invocation: `${op}: clone ${srcUuid} trash ${srcUuid} promote (unconfirmed)`,
578
+ requested: effParams,
579
+ options,
580
+ });
581
+ });
582
+ async function promoteBody() {
583
+ // 1. Clone the source as a disposable, embedded leg (--preserve-created). The
584
+ // clone has captured X's full content by the time it returns.
585
+ const clone = kind === "project"
586
+ ? await runCloneProject(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId))
587
+ : await runCloneTodo(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId));
588
+ if (clone.kind !== "ok" || clone.uuid === null) {
589
+ // A clone refusal (nested repeating template, H-CLONE-SOURCE) surfaces
590
+ // coherently here re-label it to the make-repeating op for the caller.
591
+ return clone.kind === "ok"
503
592
  ? {
504
- detail: `${promote.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
505
- `promote did not land; ${rollNote}. Trash the clone and retry`,
593
+ kind: "verify-failed",
594
+ op,
595
+ reason: "mismatch",
596
+ expected: {
597
+ mode: "create",
598
+ probe: { title: srcTitle, type: expectedType, sinceEpoch: 0 },
599
+ assert: [],
600
+ },
601
+ observed: null,
602
+ detail: "the disposable clone was created but its uuid was not discovered — nothing was promoted or trashed",
506
603
  }
507
- : {}),
508
- };
509
- }
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);
604
+ : { ...clone, op };
521
605
  }
606
+ const cloneUuid = clone.uuid;
607
+ // --preserve-modified: X is the ONLY pre-existing row the compound touches (the
608
+ // clone/promote legs mint fresh rows). Capture its pre-write umd BEFORE the
609
+ // trash bumps it — the trash leg restores it forward, and the value rides the
610
+ // summary record's preModDates so the symmetric undo restore fires on the
611
+ // revived X (undo.ts, 2026-08-13 ruling). The clone leg above reads X but never
612
+ // writes it, so its umd is still pristine here.
613
+ const preserveModified = options.preserveModified === true;
614
+ const preUmd = preserveModified
615
+ ? createDbReader(deps.db, now, deps.zone).modDateOf(srcUuid)
616
+ : null;
617
+ // 2. Trash the original BEFORE promoting — the clone already holds X's content,
618
+ // and a live same-titled X would make the promote's project row-selection
619
+ // ambiguous (H-PROJECT-REPEAT). X survives in the Trash (the recoverable half).
620
+ const trash = await runMutation(deps, `${kind}.delete`, { uuid: srcUuid }, legOptions(options, txnId, undefined, preserveModified ? { preserveModified: true } : undefined));
621
+ if (trash.kind !== "ok") {
622
+ return {
623
+ ...trash,
624
+ op,
625
+ ...("detail" in trash
626
+ ? {
627
+ detail: `${trash.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
628
+ `original ${srcUuid} could not be moved to the Trash, so it was NOT promoted; trash ` +
629
+ "the clone and retry",
630
+ }
631
+ : {}),
632
+ };
633
+ }
634
+ // 3. Native-promote the clone (with the ANCH2 Next drive + derived weekday).
635
+ const rule = ruleParamsFor(cloneUuid, effParams, nextIso);
636
+ const promote = kind === "project"
637
+ ? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
638
+ : await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
639
+ if (promote.kind !== "ok") {
640
+ // The clone persists but was not promoted; best-effort ROLL BACK the trash so
641
+ // the original is not stranded in the Trash.
642
+ const restoreOp = kind === "project" ? "project.restore" : "todo.restore";
643
+ const rolledBack = await runMutation(deps, restoreOp, { uuid: srcUuid }, legOptions(options, txnId));
644
+ const rollNote = rolledBack.kind === "ok"
645
+ ? `the original ${srcUuid} was restored from the Trash`
646
+ : `the original ${srcUuid} could NOT be restored from the Trash — restore it in the app`;
647
+ return {
648
+ ...promote,
649
+ op,
650
+ ...("detail" in promote
651
+ ? {
652
+ detail: `${promote.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
653
+ `promote did not land; ${rollNote}. Trash the clone and retry`,
654
+ }
655
+ : {}),
656
+ };
657
+ }
658
+ const { templateUuid } = discoveryOf(promote);
659
+ let { instanceUuid } = discoveryOf(promote);
660
+ // Post-drive verify (ANCH2 + YANCH1): the instance START must have landed on the
661
+ // requested `--when` — for a deadlined rule the driven Next is the deadline
662
+ // (when + startDaysEarlier) and the app back-shifts the start to `--when`, so the
663
+ // check is against `expectedStartIso`, not the raw drive date. Fail closed on
664
+ // mismatch rather than report a wrong-phase ok. The ORACLE is rule-kind dependent
665
+ // (#508) — see landedFirstStart; an unverifiable after-completion series skips.
666
+ const afterCompletion = effParams.afterCompletion === true;
667
+ if (expectedStartIso !== undefined) {
668
+ const landed = landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion);
669
+ if (!(afterCompletion && landed === null) && landed !== expectedStartIso) {
670
+ return nextMismatch(op, templateUuid, expectedStartIso, landed);
671
+ }
672
+ }
673
+ const warnings = [
674
+ landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
675
+ `the original ${expectedType} (uuid ${srcUuid}) was moved to the Trash; \`things undo\` ` +
676
+ "removes the new series (trash-both) and restores it",
677
+ PLACEMENT_NOTE,
678
+ ];
679
+ // DBLSPAWN1: if the promote PRESERVED the source (deadline / terminal-element
680
+ // trigger) as a FUTURE-dated instance, the app would spawn a duplicate on that date
681
+ // — trash the redundant occurrence and disclose (cursor mints the single real one).
682
+ const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now, afterCompletion);
683
+ if (dbl !== null) {
684
+ warnings.push(dbl.warning);
685
+ if (instanceUuid === dbl.trashedUuid)
686
+ instanceUuid = null;
687
+ }
688
+ const offRule = offRuleFirstNote(effParams);
689
+ if (offRule !== null)
690
+ warnings.push(offRule);
691
+ if (promote.warnings !== undefined)
692
+ warnings.push(...promote.warnings);
693
+ appendPromoteSummary(deps, {
694
+ startedAt,
695
+ op,
696
+ txnId,
697
+ templateUuid,
698
+ instanceUuid,
699
+ originalUuid: srcUuid,
700
+ invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
701
+ requested: effParams,
702
+ options,
703
+ ...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
704
+ });
705
+ return promoteOk({
706
+ op,
707
+ templateUuid,
708
+ instanceUuid,
709
+ replacedUuid: cloneUuid,
710
+ title: srcTitle,
711
+ txnId,
712
+ warnings,
713
+ });
522
714
  }
523
- const warnings = [
524
- landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
525
- `the original ${expectedType} (uuid ${srcUuid}) was moved to the Trash; \`things undo\` ` +
526
- "removes the new series (trash-both) and restores it",
527
- PLACEMENT_NOTE,
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);
541
- if (promote.warnings !== undefined)
542
- warnings.push(...promote.warnings);
543
- appendPromoteSummary(deps, {
544
- startedAt,
545
- op,
546
- txnId,
547
- templateUuid,
548
- instanceUuid,
549
- originalUuid: srcUuid,
550
- invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
551
- requested: effParams,
552
- ...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
553
- });
554
- return promoteOk({
555
- op,
556
- templateUuid,
557
- instanceUuid,
558
- replacedUuid: cloneUuid,
559
- title: src.title,
560
- txnId,
561
- warnings,
562
- });
563
715
  }
564
716
  export function runMakeRepeatingTodo(deps, params, options = {}) {
565
717
  return makeRepeatingViaClone(deps, "todo", params, options);
@@ -576,6 +728,12 @@ export function runMakeRepeatingProject(deps, params, options = {}) {
576
728
  async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options) {
577
729
  const op = kind === "project" ? "project.add-repeating" : "todo.add-repeating";
578
730
  assertRepeatRule(rule);
731
+ // Idempotency FIRST: this verb CREATES, so a blind resubmission is a duplicate
732
+ // series. The key rides the compound's single summary record, so a match is the
733
+ // whole verb — the add leg and the promote leg together — not one of them.
734
+ const replay = replayIfApplied(deps, options);
735
+ if (replay !== null)
736
+ return replay;
579
737
  // ANCH2 (issue #476): drive the Repeat dialog's "Next:" field with --when so the
580
738
  // series starts on the requested date (the field's default is today-anchored but
581
739
  // it is editable and honored). YANCH1 (issue #493): also DERIVE the calendar
@@ -595,8 +753,11 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
595
753
  const whenIso = isIsoDate(addParams["when"]) ? addParams["when"] : null;
596
754
  const deadlineShift = rule.deadline === true || (rule.startDaysEarlier ?? 0) > 0 ? (rule.startDaysEarlier ?? 0) : 0;
597
755
  const driveIso = whenIso !== null ? addDaysIso(whenIso, deadlineShift) : null;
598
- const nextIso = driveIso ?? undefined;
599
- const expectedStartIso = whenIso ?? undefined;
756
+ // The ANCHOR is derived from the deadline-adjusted date; `next` passes through
757
+ // as the requested START and the promote leg's compile applies the "Next:"
758
+ // shift — the one place that does (NEXTPOP1; see promoteViaCloneAndMakeRepeating).
759
+ const nextIso = whenIso ?? undefined;
760
+ const expectedStartIso = nextIso;
600
761
  const effRule = {
601
762
  ...rule,
602
763
  ...deriveFixedAnchor(rule, driveIso),
@@ -636,165 +797,201 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
636
797
  },
637
798
  };
638
799
  }
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);
800
+ // PRE-SEED UI PREFLIGHT (#480/#512): refuse a host that cannot drive the window,
801
+ // or a locked / full-screen session, BEFORE seeding the row (the two legs are not
802
+ // atomic a doomed promote would strand the seed). Zero mutation on refusal; a
803
+ // window merely on another Space is relocated in-drive.
804
+ const gate = await gateUiPreflight(deps, op);
643
805
  if (gate !== null)
644
806
  return gate;
645
807
  const startedAt = deps.now?.() ?? new Date();
646
808
  const txnId = newTxnId(startedAt);
647
- // 1. Create the item (full add vocabulary) as an embedded leg.
648
- const addOp = kind === "project" ? "project.add" : "todo.add";
649
- const add = await runMutation(deps, addOp, addParams, legOptions(options, txnId, "url-scheme"));
650
- if (add.kind !== "ok" || add.uuid === null) {
651
- return add.kind === "ok"
652
- ? {
653
- kind: "verify-failed",
654
- op,
655
- reason: "mismatch",
656
- expected: {
657
- mode: "create",
658
- probe: { title, type: expectedType, sinceEpoch: 0 },
659
- assert: [],
660
- },
661
- observed: null,
662
- detail: `the ${expectedType} was created but its uuid was not discovered — it cannot be promoted to repeating`,
663
- }
664
- : { ...add, op };
809
+ // COMPOSITE LOCK: add promote ( the seed auto-trash / DBLSPAWN1 clean-up)
810
+ // is one verb, several mutations; hold one lock across all of them so a
811
+ // concurrent composite cannot land its own legs between ours.
812
+ return runComposite(deps, op, async () => {
813
+ const outcome = await addBody();
814
+ // An unconfirmed outcome gets the compound's AMBIGUOUS summary, so a
815
+ // resubmission of the same key reconciles instead of creating a second series.
816
+ return recordAmbiguousPromote(deps, outcome, {
817
+ startedAt,
818
+ op,
819
+ txnId,
820
+ title,
821
+ type: expectedType,
822
+ invocation: `${op}: add "${title}" → promote (unconfirmed)`,
823
+ requested: { title, ...effRule },
824
+ options,
825
+ });
826
+ });
827
+ async function addBody() {
828
+ // 1. Create the item (full add vocabulary) as an embedded leg.
829
+ const addOp = kind === "project" ? "project.add" : "todo.add";
830
+ const add = await runMutation(deps, addOp, addParams, legOptions(options, txnId, "url-scheme"));
831
+ if (add.kind !== "ok" || add.uuid === null) {
832
+ return add.kind === "ok"
833
+ ? {
834
+ kind: "verify-failed",
835
+ op,
836
+ reason: "mismatch",
837
+ expected: {
838
+ mode: "create",
839
+ probe: { title, type: expectedType, sinceEpoch: 0 },
840
+ assert: [],
841
+ },
842
+ observed: null,
843
+ detail: `the ${expectedType} was created but its uuid was not discovered — it cannot be promoted to repeating`,
844
+ }
845
+ : { ...add, op };
846
+ }
847
+ const createdUuid = add.uuid;
848
+ // 2. Native-promote the fresh row (ANCH2 Next drive + derived weekday + the
849
+ // base reminder driven onto the series, ADR1).
850
+ const ruleParams = ruleParamsFor(createdUuid, effRuleWithReminder, nextIso);
851
+ const promote = kind === "project"
852
+ ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
853
+ : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
854
+ if (promote.kind !== "ok") {
855
+ // The seed persists (the two legs are not atomic) but the promote did not
856
+ // land. RATIFIED RULING (2026-08-15, issue #480): auto-trash our OWN seed
857
+ // inside the txn — it is our artifact, recreatable verbatim from the command
858
+ // args, and the Trash is recoverable — then disclose it. If the auto-trash
859
+ // itself fails, the result carries the seed's REAL, resolvable uuid with a
860
+ // working `delete` remediation, so cleanup is never ambiguous (the #480
861
+ // second bug: a failed add-repeating left a residue whose reported uuid was
862
+ // not actionable).
863
+ const patch = await cleanupSeed(deps, kind, createdUuid, promote, options, txnId);
864
+ return { ...promote, op, ...patch };
865
+ }
866
+ const { templateUuid } = discoveryOf(promote);
867
+ let { instanceUuid } = discoveryOf(promote);
868
+ // Post-drive verify (ANCH2 + DBLSPAWN1): the instance START must have landed on the
869
+ // requested `--when` — for a deadlined rule the driven Next is the deadline (when +
870
+ // startDaysEarlier) and the app back-shifts the start to `--when`, so the check is
871
+ // against `expectedStartIso`, not the raw drive date. Fail closed on mismatch rather
872
+ // than report a wrong-phase ok. The series EXISTS here (promote landed) but on the
873
+ // wrong phase, so this is a genuine partial success, NOT a seed to trash. The
874
+ // ORACLE is rule-kind dependent (#508) — see landedFirstStart; an after-completion
875
+ // series verifies against its materialized instance, and skips when it has none.
876
+ const afterCompletion = effRuleWithReminder.afterCompletion === true;
877
+ if (expectedStartIso !== undefined) {
878
+ const landed = landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion);
879
+ if (!(afterCompletion && landed === null) && landed !== expectedStartIso)
880
+ return nextMismatch(op, templateUuid, expectedStartIso, landed);
881
+ }
882
+ const warnings = [
883
+ landedRuleEcho(ruleParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
884
+ PLACEMENT_NOTE,
885
+ ];
886
+ // DBLSPAWN1 backstop: the deadline-mapping above keeps the seed deadline-free (no
887
+ // SRCFATE preserve), but any OTHER preserve trigger reaching the seed (defensive)
888
+ // would double-book a future first occurrence — trash the redundant instance.
889
+ const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, startedAt, afterCompletion);
890
+ if (dbl !== null) {
891
+ warnings.push(dbl.warning);
892
+ if (instanceUuid === dbl.trashedUuid)
893
+ instanceUuid = null;
894
+ }
895
+ const offRule = offRuleFirstNote(ruleParams);
896
+ if (offRule !== null)
897
+ warnings.push(offRule);
898
+ if (promote.warnings !== undefined)
899
+ warnings.push(...promote.warnings);
900
+ appendPromoteSummary(deps, {
901
+ startedAt,
902
+ op,
903
+ txnId,
904
+ templateUuid,
905
+ instanceUuid,
906
+ invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
907
+ requested: { title, ...effRule },
908
+ options,
909
+ });
910
+ return promoteOk({
911
+ op,
912
+ templateUuid,
913
+ instanceUuid,
914
+ replacedUuid: createdUuid,
915
+ title,
916
+ txnId,
917
+ warnings,
918
+ });
665
919
  }
666
- const createdUuid = add.uuid;
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);
670
- const promote = kind === "project"
671
- ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
672
- : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
673
- if (promote.kind !== "ok") {
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 };
920
+ }
921
+ /**
922
+ * Fold a concrete item-level `deadline` (and/or an explicit `startDaysEarlier`)
923
+ * into the RULE, returning the rule to promote with and the deadline the SEED
924
+ * should keep (none — the rule owns it). Shared by the to-do and project
925
+ * add-repeating verbs so one geometry, and one set of refusals, serves both.
926
+ *
927
+ * DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md): each occurrence is due
928
+ * `deadline when` days after its start (the "Add deadlines" + "start N days
929
+ * earlier" dialog fields). This is the deadline the series actually wants, and
930
+ * mapping it up front is also what keeps the SEED deadline-free — a to-do seed
931
+ * carrying a deadline is SRCFATE-preserved as a materialized instance, and a
932
+ * future-dated first occurrence then DOUBLE-BOOKS against the template cursor
933
+ * and spawns a duplicate on the date (cell C). A project seed is DELETE-fate, so
934
+ * there the un-mapped deadline was simply LOST rather than duplicated — a
935
+ * quieter bug with the same cause and the same fix. A deadline needs a concrete
936
+ * `when` (the per-occurrence offset is measured from the start) on/after it.
937
+ *
938
+ * DEADLINE/OFFSET AGREEMENT (ruling 2026-08-18): the same rule-global offset can
939
+ * be named two ways — a concrete `deadline` (offset = deadline − when) OR an
940
+ * explicit `startDaysEarlier` N (deadline derived as when + N). The geometry
941
+ * when/deadline/N is OVER-DETERMINED: the dialog's start-offset is rule-global
942
+ * and the first occurrence's start is derived as due − N, so there is no
943
+ * per-first-instance gap to absorb a disagreement (unlike DACON1's off-rule-first
944
+ * calendar freedom). So when BOTH are given they must AGREE — an exact match is
945
+ * harmless redundancy, a mismatch is inexpressible and refused fast (zero
946
+ * mutation). Either input ALONE maps to the rule.
947
+ */
948
+ function mapDeadlineOntoRule(baseRule, when, deadline, startDaysEarlier) {
949
+ if (!isIsoDate(when)) {
950
+ throw new RangeError("a repeating --deadline or --start-days-earlier needs a concrete --when date (the deadline " +
951
+ "offset is measured from each occurrence's start) — schedule the series on a YYYY-MM-DD " +
952
+ "--when, or drop --deadline / --start-days-earlier");
684
953
  }
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);
954
+ let startEarlier;
955
+ if (deadline !== undefined) {
956
+ const derived = daysBetweenIso(when, deadline);
957
+ if (derived < 0) {
958
+ throw new RangeError(`--deadline (${deadline}) must be on or after --when (${when}) a deadline cannot ` +
959
+ "precede the occurrence's own start");
960
+ }
961
+ if (startDaysEarlier !== undefined && startDaysEarlier !== derived) {
962
+ throw new RangeError(`--deadline (${deadline}) puts each occurrence's due date ${derived} day` +
963
+ `${derived === 1 ? "" : "s"} after its start (--when ${when}), but ` +
964
+ `--start-days-earlier says ${startDaysEarlier} — these disagree. Drop one, or make them ` +
965
+ `agree (--start-days-earlier ${derived}, or --deadline ${addDaysIso(when, startDaysEarlier)}).`);
966
+ }
967
+ startEarlier = derived;
697
968
  }
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;
969
+ else {
970
+ // `--start-days-earlier N` alone: the deadline is derived as when + N. A bad N
971
+ // (non-integer / negative) is refused by assertRepeatRule once mapped.
972
+ startEarlier = startDaysEarlier;
710
973
  }
711
- const offRule = offRuleFirstNote(ruleParams);
712
- if (offRule !== null)
713
- warnings.push(offRule);
714
- if (promote.warnings !== undefined)
715
- warnings.push(...promote.warnings);
716
- appendPromoteSummary(deps, {
717
- startedAt,
718
- op,
719
- txnId,
720
- templateUuid,
721
- instanceUuid,
722
- invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
723
- requested: { title, ...effRule },
724
- });
725
- return promoteOk({
726
- op,
727
- templateUuid,
728
- instanceUuid,
729
- replacedUuid: createdUuid,
730
- title,
731
- txnId,
732
- warnings,
733
- });
974
+ // The RULE owns the deadline; the seed carries none.
975
+ return {
976
+ rule: { ...baseRule, deadline: true, startDaysEarlier: startEarlier },
977
+ seedDeadline: undefined,
978
+ };
734
979
  }
735
980
  export async function runAddRepeatingTodo(deps, params, options = {}) {
736
- const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, startDaysEarlier, ...add } = params;
737
- const baseRule = {
738
- frequency,
739
- interval,
740
- ...(afterCompletion !== undefined && { afterCompletion }),
741
- ...(weekdays !== undefined && { weekdays }),
742
- ...(monthly !== undefined && { monthly }),
743
- ...(yearly !== undefined && { yearly }),
744
- ...(ends !== undefined && { ends }),
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.
981
+ // The rule/add split is driven by the exhaustive key map (splitAddRepeatingRule),
982
+ // not by a hand-written destructure — a field added to either vocabulary lands on
983
+ // the right leg instead of falling between them (#549 / YANCH1 #493).
984
+ const { rule: baseRule, add } = splitAddRepeatingRule(params);
985
+ const { afterCompletion } = params;
986
+ const startDaysEarlier = add.startDaysEarlier;
987
+ // A concrete `--deadline` (or `--start-days-earlier`) belongs to the RULE, not
988
+ // the seed see mapDeadlineOntoRule for the geometry and the refusals. The
989
+ // to-do stakes: an un-mapped seed deadline makes the app preserve the seed as a
990
+ // future-dated instance that double-books the cursor (DBLSPAWN1 cell C).
768
991
  let rule = baseRule;
769
992
  let seedDeadline = add.deadline;
770
993
  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
994
+ ({ rule, seedDeadline } = mapDeadlineOntoRule(baseRule, add.when, add.deadline, startDaysEarlier));
798
995
  }
799
996
  else if (startDaysEarlier !== undefined) {
800
997
  // afterCompletion === true here: an after-completion repeat has no calendar
@@ -802,45 +999,54 @@ export async function runAddRepeatingTodo(deps, params, options = {}) {
802
999
  throw new RangeError("--start-days-earlier applies only to a fixed-schedule deadline — an after-completion repeat " +
803
1000
  "has no calendar start to count back from; drop --after-completion or --start-days-earlier");
804
1001
  }
805
- const addParams = {
806
- title: add.title,
807
- ...(add.notes !== undefined && { notes: add.notes }),
808
- ...(add.when !== undefined && { when: add.when }),
809
- ...(add.reminder !== undefined && { reminder: add.reminder }),
810
- ...(seedDeadline !== undefined && { deadline: seedDeadline }),
811
- ...(add.tags !== undefined && { tags: add.tags }),
812
- ...(add.checklistItems !== undefined && { checklistItems: add.checklistItems }),
813
- ...(add.project !== undefined && { project: add.project }),
814
- ...(add.area !== undefined && { area: add.area }),
815
- ...(add.heading !== undefined && { heading: add.heading }),
816
- ...(add.createdAt !== undefined && { createdAt: add.createdAt }),
817
- };
1002
+ // The seed carries the WHOLE add vocabulary by spread (a field added to
1003
+ // TodoAddRepeatingParams reaches the create leg without a line here), minus the
1004
+ // two the rule took: `startDaysEarlier` is rule-only, and the deadline is
1005
+ // whatever the geometry left for the seed (none, once mapped).
1006
+ const addParams = { ...add };
1007
+ delete addParams["startDaysEarlier"];
1008
+ delete addParams["deadline"];
1009
+ if (seedDeadline !== undefined)
1010
+ addParams["deadline"] = seedDeadline;
818
1011
  return addRepeatingViaCreate(deps, "todo", addParams, rule, add.title, options);
819
1012
  }
820
- export function runAddRepeatingProject(deps, params, options = {}) {
821
- const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, ...add } = params;
822
- const rule = {
823
- frequency,
824
- interval,
825
- ...(afterCompletion !== undefined && { afterCompletion }),
826
- ...(weekdays !== undefined && { weekdays }),
827
- ...(monthly !== undefined && { monthly }),
828
- ...(yearly !== undefined && { yearly }),
829
- ...(ends !== undefined && { ends }),
830
- };
1013
+ export async function runAddRepeatingProject(deps, params, options = {}) {
1014
+ // Same exhaustive rule/add split as the to-do verb (one key map, both verbs).
1015
+ const { rule: baseRule, add } = splitAddRepeatingRule(params);
1016
+ const { afterCompletion } = params;
1017
+ // A concrete `--deadline` maps to the RULE here exactly as it does on the to-do
1018
+ // verb (DBLSPAWN1 residual). The project stakes are quieter than the to-do's but
1019
+ // no more acceptable: a project seed is DELETE-fate, so the app does not preserve
1020
+ // it into a double-booked instance — it simply DROPS the deadline, and the series
1021
+ // came out undeadlined with nothing said. Mapped, every occurrence is due
1022
+ // `deadline when` days after its start.
1023
+ let rule = baseRule;
1024
+ let seedDeadline = add.deadline;
1025
+ if (add.deadline !== undefined) {
1026
+ if (afterCompletion === true) {
1027
+ // The to-do verb can leave a deadline on an after-completion seed because
1028
+ // that seed SURVIVES as the series' instance. An after-completion project's
1029
+ // seed is deleted and its instance is minted deadline-free (RSIM-P P4), so
1030
+ // the deadline would vanish either way — refuse rather than drop it quietly.
1031
+ throw new RangeError("an after-completion repeating project cannot carry a --deadline: the rule has no calendar " +
1032
+ "start to measure a per-occurrence deadline from, and the created project is replaced by " +
1033
+ "the series' own instance, which is created without one — drop --after-completion to " +
1034
+ "deadline every occurrence, or drop --deadline");
1035
+ }
1036
+ ({ rule, seedDeadline } = mapDeadlineOntoRule(baseRule, add.when, add.deadline, undefined));
1037
+ }
831
1038
  // Seed a pure-AX taxonomy: an area lands a selectable AREA-view row; otherwise
832
1039
  // create in Someday (UIC4-f) so the promote skips the anytime-header problem.
833
1040
  const seedWhen = add.when ?? (add.area === undefined ? "someday" : undefined);
834
- const addParams = {
835
- title: add.title,
836
- ...(add.notes !== undefined && { notes: add.notes }),
837
- ...(add.area !== undefined && { area: add.area }),
838
- ...(seedWhen !== undefined && { when: seedWhen }),
839
- ...(add.deadline !== undefined && { deadline: add.deadline }),
840
- ...(add.todos !== undefined && { todos: add.todos }),
841
- ...(add.items !== undefined && { items: add.items }),
842
- ...(add.createdAt !== undefined && { createdAt: add.createdAt }),
843
- };
1041
+ // The whole add vocabulary by spread (see the to-do verb), with the two the
1042
+ // promote owns re-derived: the seed taxonomy `when` and the mapped deadline.
1043
+ const addParams = { ...add };
1044
+ delete addParams["when"];
1045
+ delete addParams["deadline"];
1046
+ if (seedWhen !== undefined)
1047
+ addParams["when"] = seedWhen;
1048
+ if (seedDeadline !== undefined)
1049
+ addParams["deadline"] = seedDeadline;
844
1050
  return addRepeatingViaCreate(deps, "project", addParams, rule, add.title, options);
845
1051
  }
846
1052
  // ====================================================== template-direct clone
@@ -941,95 +1147,103 @@ export async function cloneTemplateViaRepromote(deps, kind, src, srcUuid, params
941
1147
  },
942
1148
  };
943
1149
  }
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);
1150
+ // PRE-SEED UI PREFLIGHT (#480/#512): refuse a host that cannot drive the window,
1151
+ // or a locked / full-screen session, BEFORE minting the plain clone (a doomed
1152
+ // promote would strand it). Zero mutation on refusal.
1153
+ const gate = await gateUiPreflight(deps, op);
947
1154
  if (gate !== null)
948
1155
  return gate;
949
- const startedAt = deps.now?.() ?? new Date();
950
- const txnId = newTxnId(startedAt);
951
- // 4. Mint the plain clone as an embedded leg — cloneTemplateAsPlain reaches the
952
- // clone orchestrator's plain-content path (recurrence + schedule stripped);
953
- // --title/--preserve-created ride through the CloneParams.
954
- const cloneParams = {
955
- uuid: srcUuid,
956
- ...(params.title !== undefined && { title: params.title }),
957
- ...(params.preserveCreated === true && { preserveCreated: true }),
958
- };
959
- const cloneOptions = { ...legOptions(options, txnId), cloneTemplateAsPlain: true };
960
- const clone = kind === "project"
961
- ? await runCloneProject(deps, cloneParams, cloneOptions)
962
- : await runCloneTodo(deps, cloneParams, cloneOptions);
963
- if (clone.kind !== "ok" || clone.uuid === null) {
964
- // A nested-repeater refusal (a template CONTAINING a nested repeater) or any
965
- // clone failure surfaces coherently here — re-label it to the compound op.
966
- return clone.kind === "ok"
967
- ? {
968
- kind: "verify-failed",
969
- op,
970
- reason: "mismatch",
971
- expected: {
972
- mode: "create",
973
- probe: { title, type: expectedType, sinceEpoch: 0 },
974
- assert: [],
975
- },
976
- observed: null,
977
- detail: "the plain clone was created but its uuid was not discovered — nothing was promoted",
978
- }
979
- : { ...clone, op };
980
- }
981
- const cloneUuid = clone.uuid;
982
- // 5. Native-promote the clone with the FULL decoded rule (ruleToInverseParams
983
- // carries deadline/start-earlier + the calendar anchors + ends).
984
- const ruleParams = { uuid: cloneUuid, ...inverse };
985
- const promote = kind === "project"
986
- ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
987
- : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
988
- if (promote.kind !== "ok") {
989
- // The plain clone persists but was not promoted — honest report (no original
990
- // to roll back; the clone is a fresh row the caller can trash and retry).
991
- return {
992
- ...promote,
993
- op,
994
- ...("detail" in promote
1156
+ // COMPOSITE LOCK: clone-as-plain promote-with-the-source's-rule is one verb;
1157
+ // hold one lock across both legs.
1158
+ return runComposite(deps, op, async () => {
1159
+ const startedAt = deps.now?.() ?? new Date();
1160
+ const txnId = newTxnId(startedAt);
1161
+ // 4. Mint the plain clone as an embedded leg — cloneTemplateAsPlain reaches the
1162
+ // clone orchestrator's plain-content path (recurrence + schedule stripped);
1163
+ // --title/--preserve-created ride through the CloneParams.
1164
+ const cloneParams = {
1165
+ uuid: srcUuid,
1166
+ ...(params.title !== undefined && { title: params.title }),
1167
+ ...(params.preserveCreated === true && { preserveCreated: true }),
1168
+ };
1169
+ const cloneOptions = {
1170
+ ...legOptions(options, txnId),
1171
+ cloneTemplateAsPlain: true,
1172
+ };
1173
+ const clone = kind === "project"
1174
+ ? await runCloneProject(deps, cloneParams, cloneOptions)
1175
+ : await runCloneTodo(deps, cloneParams, cloneOptions);
1176
+ if (clone.kind !== "ok" || clone.uuid === null) {
1177
+ // A nested-repeater refusal (a template CONTAINING a nested repeater) or any
1178
+ // clone failure surfaces coherently here — re-label it to the compound op.
1179
+ return clone.kind === "ok"
995
1180
  ? {
996
- detail: `${promote.detail} — the plain clone (uuid ${cloneUuid}) was created but the promote ` +
997
- `did not land; trash the clone with \`things ${kind} delete ${cloneUuid}\` and retry`,
1181
+ kind: "verify-failed",
1182
+ op,
1183
+ reason: "mismatch",
1184
+ expected: {
1185
+ mode: "create",
1186
+ probe: { title, type: expectedType, sinceEpoch: 0 },
1187
+ assert: [],
1188
+ },
1189
+ observed: null,
1190
+ detail: "the plain clone was created but its uuid was not discovered — nothing was promoted",
998
1191
  }
999
- : {}),
1000
- };
1001
- }
1002
- const { templateUuid, instanceUuid } = discoveryOf(promote);
1003
- const warnings = [NEW_SERIES_NOTE, PLACEMENT_NOTE];
1004
- if (params.preserveCreated === true) {
1005
- warnings.push("--preserve-created is best-effort on a template clone: the promote may replace the clone " +
1006
- "row with the new template, whose creation date is the conversion time");
1007
- }
1008
- if (src.repeating.paused === true) {
1009
- warnings.push(`the source template was PAUSED; the new series is created UNPAUSED and begins spawning — ` +
1010
- `pause it with \`things ${kind} pause-repeat\` if you want it suspended`);
1011
- }
1012
- if (promote.warnings !== undefined)
1013
- warnings.push(...promote.warnings);
1014
- // Summary WITHOUT originalUuid → undo is the add-repeating trash-both (remove
1015
- // the minted series; there is no original to restore).
1016
- appendPromoteSummary(deps, {
1017
- startedAt,
1018
- op,
1019
- txnId,
1020
- templateUuid,
1021
- instanceUuid,
1022
- invocation: `${kind}.clone (template) ${srcUuid}: clone promote ${cloneUuid} → template ${templateUuid}`,
1023
- requested: { source: srcUuid, title, ...inverse },
1024
- });
1025
- return promoteOk({
1026
- op,
1027
- templateUuid,
1028
- instanceUuid,
1029
- replacedUuid: cloneUuid,
1030
- title,
1031
- txnId,
1032
- warnings,
1192
+ : { ...clone, op };
1193
+ }
1194
+ const cloneUuid = clone.uuid;
1195
+ // 5. Native-promote the clone with the FULL decoded rule (ruleToInverseParams
1196
+ // carries deadline/start-earlier + the calendar anchors + ends).
1197
+ const ruleParams = { uuid: cloneUuid, ...inverse };
1198
+ const promote = kind === "project"
1199
+ ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
1200
+ : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
1201
+ if (promote.kind !== "ok") {
1202
+ // The plain clone persists but was not promoted honest report (no original
1203
+ // to roll back; the clone is a fresh row the caller can trash and retry).
1204
+ return {
1205
+ ...promote,
1206
+ op,
1207
+ ...("detail" in promote
1208
+ ? {
1209
+ detail: `${promote.detail} — the plain clone (uuid ${cloneUuid}) was created but the promote ` +
1210
+ `did not land; trash the clone with \`things ${kind} delete ${cloneUuid}\` and retry`,
1211
+ }
1212
+ : {}),
1213
+ };
1214
+ }
1215
+ const { templateUuid, instanceUuid } = discoveryOf(promote);
1216
+ const warnings = [NEW_SERIES_NOTE, PLACEMENT_NOTE];
1217
+ if (params.preserveCreated === true) {
1218
+ warnings.push("--preserve-created is best-effort on a template clone: the promote may replace the clone " +
1219
+ "row with the new template, whose creation date is the conversion time");
1220
+ }
1221
+ if (src.repeating.paused === true) {
1222
+ warnings.push(`the source template was PAUSED; the new series is created UNPAUSED and begins spawning — ` +
1223
+ `pause it with \`things ${kind} pause-repeat\` if you want it suspended`);
1224
+ }
1225
+ if (promote.warnings !== undefined)
1226
+ warnings.push(...promote.warnings);
1227
+ // Summary WITHOUT originalUuid → undo is the add-repeating trash-both (remove
1228
+ // the minted series; there is no original to restore).
1229
+ appendPromoteSummary(deps, {
1230
+ startedAt,
1231
+ op,
1232
+ txnId,
1233
+ templateUuid,
1234
+ instanceUuid,
1235
+ invocation: `${kind}.clone (template) ${srcUuid}: clone → promote ${cloneUuid} → template ${templateUuid}`,
1236
+ requested: { source: srcUuid, title, ...inverse },
1237
+ });
1238
+ return promoteOk({
1239
+ op,
1240
+ templateUuid,
1241
+ instanceUuid,
1242
+ replacedUuid: cloneUuid,
1243
+ title,
1244
+ txnId,
1245
+ warnings,
1246
+ });
1033
1247
  });
1034
1248
  }
1035
1249
  //# sourceMappingURL=promote-clone.js.map