things-api 0.17.0 → 0.19.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 (277) hide show
  1. package/README.md +16 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -0
  4. package/deputy/helpers-Info.plist +27 -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 +22 -0
  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 +128 -0
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +27 -0
  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 +137 -0
  14. package/deputy/reader/Info.plist +22 -0
  15. package/deputy/reader/entitlements.plist +45 -0
  16. package/deputy/reader/main.swift +580 -0
  17. package/deputy/src/main.swift +133 -0
  18. package/deputy/src/osascript.swift +102 -0
  19. package/deputy/src/server.swift +373 -0
  20. package/deputy/src/sqlite.swift +121 -0
  21. package/deputy/src/tcc.swift +58 -0
  22. package/dist/audit/schema.d.ts +13 -1
  23. package/dist/audit/schema.js.map +1 -1
  24. package/dist/capability.d.ts +160 -0
  25. package/dist/capability.js +416 -0
  26. package/dist/capability.js.map +1 -0
  27. package/dist/cli/commands/doctor.js +181 -2
  28. package/dist/cli/commands/doctor.js.map +1 -1
  29. package/dist/cli/commands/helpers.d.ts +17 -0
  30. package/dist/cli/commands/helpers.js +275 -0
  31. package/dist/cli/commands/helpers.js.map +1 -0
  32. package/dist/cli/commands/op-result.d.ts +15 -0
  33. package/dist/cli/commands/op-result.js +45 -0
  34. package/dist/cli/commands/op-result.js.map +1 -0
  35. package/dist/cli/commands/reads.js +47 -1
  36. package/dist/cli/commands/reads.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +63 -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 +262 -109
  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 +53 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/helpers-check.d.ts +10 -0
  52. package/dist/cli/helpers-check.js +59 -0
  53. package/dist/cli/helpers-check.js.map +1 -0
  54. package/dist/cli/main.js +22 -1
  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 +22 -2
  65. package/dist/client.js +52 -15
  66. package/dist/client.js.map +1 -1
  67. package/dist/config.d.ts +31 -0
  68. package/dist/config.js +9 -0
  69. package/dist/config.js.map +1 -1
  70. package/dist/contracts.d.ts +6 -2
  71. package/dist/contracts.js +1 -1
  72. package/dist/contracts.js.map +1 -1
  73. package/dist/db/baselines/db-v26.js +8 -4
  74. package/dist/db/baselines/db-v26.js.map +1 -1
  75. package/dist/db/baselines/db-v27.d.ts +42 -0
  76. package/dist/db/baselines/db-v27.js +47 -0
  77. package/dist/db/baselines/db-v27.js.map +1 -0
  78. package/dist/db/baselines/index.js +2 -1
  79. package/dist/db/baselines/index.js.map +1 -1
  80. package/dist/db/locate.d.ts +4 -1
  81. package/dist/db/locate.js +3 -1
  82. package/dist/db/locate.js.map +1 -1
  83. package/dist/db/schema.d.ts +1 -1
  84. package/dist/db/schema.js +9 -0
  85. package/dist/db/schema.js.map +1 -1
  86. package/dist/deputy/bridge-worker.d.ts +1 -0
  87. package/dist/deputy/bridge-worker.js +79 -0
  88. package/dist/deputy/bridge-worker.js.map +1 -0
  89. package/dist/deputy/bridge.d.ts +14 -0
  90. package/dist/deputy/bridge.js +70 -0
  91. package/dist/deputy/bridge.js.map +1 -0
  92. package/dist/deputy/client.d.ts +19 -0
  93. package/dist/deputy/client.js +104 -0
  94. package/dist/deputy/client.js.map +1 -0
  95. package/dist/deputy/db-facade.d.ts +18 -0
  96. package/dist/deputy/db-facade.js +71 -0
  97. package/dist/deputy/db-facade.js.map +1 -0
  98. package/dist/deputy/files.d.ts +1 -0
  99. package/dist/deputy/files.js +16 -0
  100. package/dist/deputy/files.js.map +1 -0
  101. package/dist/deputy/install.d.ts +319 -0
  102. package/dist/deputy/install.js +1239 -0
  103. package/dist/deputy/install.js.map +1 -0
  104. package/dist/deputy/notice.d.ts +6 -0
  105. package/dist/deputy/notice.js +28 -0
  106. package/dist/deputy/notice.js.map +1 -0
  107. package/dist/deputy/notices.d.ts +31 -0
  108. package/dist/deputy/notices.js +94 -0
  109. package/dist/deputy/notices.js.map +1 -0
  110. package/dist/deputy/osa.d.ts +21 -0
  111. package/dist/deputy/osa.js +72 -0
  112. package/dist/deputy/osa.js.map +1 -0
  113. package/dist/deputy/protocol.d.ts +140 -0
  114. package/dist/deputy/protocol.js +143 -0
  115. package/dist/deputy/protocol.js.map +1 -0
  116. package/dist/deputy/routing.d.ts +84 -0
  117. package/dist/deputy/routing.js +422 -0
  118. package/dist/deputy/routing.js.map +1 -0
  119. package/dist/deputy/shortcuts-exec.d.ts +10 -0
  120. package/dist/deputy/shortcuts-exec.js +50 -0
  121. package/dist/deputy/shortcuts-exec.js.map +1 -0
  122. package/dist/diagnose.d.ts +57 -2
  123. package/dist/diagnose.js +105 -3
  124. package/dist/diagnose.js.map +1 -1
  125. package/dist/direct-setup.d.ts +78 -0
  126. package/dist/direct-setup.js +484 -0
  127. package/dist/direct-setup.js.map +1 -0
  128. package/dist/host-access.d.ts +63 -0
  129. package/dist/host-access.js +165 -0
  130. package/dist/host-access.js.map +1 -0
  131. package/dist/index.d.ts +31 -5
  132. package/dist/index.js +23 -1
  133. package/dist/index.js.map +1 -1
  134. package/dist/mcp/server.d.ts +27 -1
  135. package/dist/mcp/server.js +300 -97
  136. package/dist/mcp/server.js.map +1 -1
  137. package/dist/model/mappers.d.ts +12 -3
  138. package/dist/model/mappers.js +6 -1
  139. package/dist/model/mappers.js.map +1 -1
  140. package/dist/model/recurrence.d.ts +5 -1
  141. package/dist/model/recurrence.js +19 -1
  142. package/dist/model/recurrence.js.map +1 -1
  143. package/dist/model/template-projection.d.ts +26 -0
  144. package/dist/model/template-projection.js +113 -0
  145. package/dist/model/template-projection.js.map +1 -0
  146. package/dist/op-result.d.ts +55 -0
  147. package/dist/op-result.js +161 -0
  148. package/dist/op-result.js.map +1 -0
  149. package/dist/read/detail.js +7 -1
  150. package/dist/read/detail.js.map +1 -1
  151. package/dist/read/filter-contract.d.ts +1 -1
  152. package/dist/read/filter-contract.js +4 -0
  153. package/dist/read/filter-contract.js.map +1 -1
  154. package/dist/read/queries.js +8 -1
  155. package/dist/read/queries.js.map +1 -1
  156. package/dist/read/shape.js +4 -0
  157. package/dist/read/shape.js.map +1 -1
  158. package/dist/read/views.d.ts +35 -4
  159. package/dist/read/views.js +120 -18
  160. package/dist/read/views.js.map +1 -1
  161. package/dist/session-grant.d.ts +48 -0
  162. package/dist/session-grant.js +170 -0
  163. package/dist/session-grant.js.map +1 -0
  164. package/dist/surface-copy.d.ts +8 -0
  165. package/dist/surface-copy.js +8 -0
  166. package/dist/surface-copy.js.map +1 -1
  167. package/dist/sync-health.js +6 -2
  168. package/dist/sync-health.js.map +1 -1
  169. package/dist/wizard.d.ts +58 -0
  170. package/dist/wizard.js +167 -0
  171. package/dist/wizard.js.map +1 -0
  172. package/dist/write/accessibility-probe.js +4 -2
  173. package/dist/write/accessibility-probe.js.map +1 -1
  174. package/dist/write/automation-probe.js +5 -1
  175. package/dist/write/automation-probe.js.map +1 -1
  176. package/dist/write/availability.d.ts +1 -1
  177. package/dist/write/availability.js +10 -6
  178. package/dist/write/availability.js.map +1 -1
  179. package/dist/write/batch.d.ts +20 -0
  180. package/dist/write/batch.js +94 -15
  181. package/dist/write/batch.js.map +1 -1
  182. package/dist/write/capabilities.d.ts +9 -0
  183. package/dist/write/capabilities.js +2 -0
  184. package/dist/write/capabilities.js.map +1 -1
  185. package/dist/write/clear-reminder.js +1 -1
  186. package/dist/write/clear-reminder.js.map +1 -1
  187. package/dist/write/commands.d.ts +12 -1
  188. package/dist/write/commands.js +170 -237
  189. package/dist/write/commands.js.map +1 -1
  190. package/dist/write/experimental.d.ts +19 -0
  191. package/dist/write/experimental.js +50 -0
  192. package/dist/write/experimental.js.map +1 -1
  193. package/dist/write/failure-hints.d.ts +3 -1
  194. package/dist/write/failure-hints.js.map +1 -1
  195. package/dist/write/guards.js +19 -2
  196. package/dist/write/guards.js.map +1 -1
  197. package/dist/write/lock.d.ts +16 -13
  198. package/dist/write/lock.js +55 -0
  199. package/dist/write/lock.js.map +1 -1
  200. package/dist/write/make-repeating-project.js +9 -13
  201. package/dist/write/make-repeating-project.js.map +1 -1
  202. package/dist/write/move.js +14 -6
  203. package/dist/write/move.js.map +1 -1
  204. package/dist/write/operations.d.ts +64 -25
  205. package/dist/write/operations.js +7 -0
  206. package/dist/write/operations.js.map +1 -1
  207. package/dist/write/opid.d.ts +14 -0
  208. package/dist/write/opid.js +22 -0
  209. package/dist/write/opid.js.map +1 -1
  210. package/dist/write/param-schema.d.ts +124 -0
  211. package/dist/write/param-schema.js +747 -0
  212. package/dist/write/param-schema.js.map +1 -0
  213. package/dist/write/pipeline.d.ts +78 -2
  214. package/dist/write/pipeline.js +249 -4
  215. package/dist/write/pipeline.js.map +1 -1
  216. package/dist/write/pre-state.d.ts +8 -0
  217. package/dist/write/pre-state.js +62 -17
  218. package/dist/write/pre-state.js.map +1 -1
  219. package/dist/write/promote-clone.js +635 -367
  220. package/dist/write/promote-clone.js.map +1 -1
  221. package/dist/write/reorder.js +95 -29
  222. package/dist/write/reorder.js.map +1 -1
  223. package/dist/write/repeat-anchor.d.ts +30 -0
  224. package/dist/write/repeat-anchor.js +166 -1
  225. package/dist/write/repeat-anchor.js.map +1 -1
  226. package/dist/write/repeat-rule.d.ts +28 -1
  227. package/dist/write/repeat-rule.js +64 -4
  228. package/dist/write/repeat-rule.js.map +1 -1
  229. package/dist/write/resolution-timestamps.d.ts +11 -0
  230. package/dist/write/resolution-timestamps.js +83 -38
  231. package/dist/write/resolution-timestamps.js.map +1 -1
  232. package/dist/write/reversibility.js +4 -0
  233. package/dist/write/reversibility.js.map +1 -1
  234. package/dist/write/template-mutation.d.ts +137 -0
  235. package/dist/write/template-mutation.js +451 -0
  236. package/dist/write/template-mutation.js.map +1 -0
  237. package/dist/write/undo.js +10 -20
  238. package/dist/write/undo.js.map +1 -1
  239. package/dist/write/update-fields.d.ts +151 -0
  240. package/dist/write/update-fields.js +427 -0
  241. package/dist/write/update-fields.js.map +1 -0
  242. package/dist/write/vectors/applescript.js +10 -16
  243. package/dist/write/vectors/applescript.js.map +1 -1
  244. package/dist/write/vectors/registry.d.ts +1 -1
  245. package/dist/write/vectors/registry.js +10 -2
  246. package/dist/write/vectors/registry.js.map +1 -1
  247. package/dist/write/vectors/session-reachability.d.ts +55 -0
  248. package/dist/write/vectors/session-reachability.js +56 -8
  249. package/dist/write/vectors/session-reachability.js.map +1 -1
  250. package/dist/write/vectors/shortcuts.js +7 -16
  251. package/dist/write/vectors/shortcuts.js.map +1 -1
  252. package/dist/write/vectors/simulator.d.ts +16 -1
  253. package/dist/write/vectors/simulator.js +68 -12
  254. package/dist/write/vectors/simulator.js.map +1 -1
  255. package/dist/write/vectors/types.d.ts +124 -1
  256. package/dist/write/vectors/ui-recipes.d.ts +19 -0
  257. package/dist/write/vectors/ui-recipes.js +158 -38
  258. package/dist/write/vectors/ui-recipes.js.map +1 -1
  259. package/dist/write/vectors/ui.d.ts +89 -0
  260. package/dist/write/vectors/ui.js +504 -49
  261. package/dist/write/vectors/ui.js.map +1 -1
  262. package/dist/write/verify/delta.d.ts +21 -0
  263. package/dist/write/verify/delta.js.map +1 -1
  264. package/dist/write/verify/poller.d.ts +13 -0
  265. package/dist/write/verify/poller.js +38 -0
  266. package/dist/write/verify/poller.js.map +1 -1
  267. package/package.json +9 -1
  268. package/schema/envelope.schema.json +5 -3
  269. package/scripts/build-helpers.sh +109 -0
  270. package/scripts/deputy-cert-setup.sh +60 -0
  271. package/skills/things-cli/SKILL.md +12 -5
  272. package/skills/things-cli/references/contracts.md +1 -0
  273. package/skills/things-cli/references/data-model.md +3 -0
  274. package/skills/things-cli/references/errors.md +5 -1
  275. package/skills/things-cli/references/gui.md +1 -0
  276. package/skills/things-cli/references/repeating.md +75 -0
  277. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -1,12 +1,13 @@
1
1
  import { undoToken } from "../audit/schema.js";
2
- import { addDaysIso, decodePackedDate } from "../model/dates.js";
2
+ import { uiAllowed, uiCapability as uiCapabilityDefault } from "../capability.js";
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 { deriveFixedAnchor, isIsoDate } from "./repeat-anchor.js";
9
- import { assertRepeatRule, ruleToInverseParams } from "./repeat-rule.js";
8
+ import { fingerprintLabel, runComposite, runMutation, } from "./pipeline.js";
9
+ import { assessOffRuleFirst, daysBetweenIso, deriveFixedAnchor, isIsoDate, } from "./repeat-anchor.js";
10
+ import { assertRepeatRule, ruleToInverseParams, splitAddRepeatingRule } from "./repeat-rule.js";
10
11
  import { createDbReader } from "./verify/delta.js";
11
12
  import { H_UI_SESSION_UNREACHABLE } from "./vectors/session-reachability.js";
12
13
  const PLACEMENT_NOTE = "the series' current instance lands at its container's default position — its prior slot was " +
@@ -50,22 +51,51 @@ function blockedUiDrive(op) {
50
51
  detail: "this operation promotes an item to a repeating series by driving the local Things app " +
51
52
  "through the Accessibility API (the Repeat… dialog) — it may briefly interact with the UI",
52
53
  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)",
54
+ "`things config set ui-enabled true` and `things helpers setup --gui`, which grants " +
55
+ "GUI-driving to the helper pair — the only identity it is granted to " +
56
+ "(docs/design/permissions-doctrine.md, Article IV)",
54
57
  };
55
58
  }
56
59
  /**
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).
60
+ * The pre-seed UI PREFLIGHT (SESSGATE #480 + issue #512). A promote composite is
61
+ * NOT atomic: it SEEDS a row (clone / add) before the GUI promote leg drives the
62
+ * dialog. Anything that will certainly stop that drive must be established BEFORE
63
+ * the seed exists, or the composite mutates, fails, and cleans up after itself for
64
+ * a reason it could have known up front. Two checks, cheapest and most decisive
65
+ * first, both prompt-free:
66
+ *
67
+ * 1. STANDING (permissions doctrine, Article IV). Driving the window needs
68
+ * Accessibility + Automation System Events, held by the helper pair and
69
+ * nothing else. The pipeline already gates this but only on the PROMOTE leg,
70
+ * which runs after the seed, so a machine without the grants used to create
71
+ * the row, refuse, and trash it. Same verdict, same copy, one leg earlier;
72
+ * nothing is created. Keyed on a vector's `drivesGui` DECLARATION exactly as
73
+ * the pipeline's gate is, never on its id, so a fake/simulator substituted
74
+ * under "ui" is not gated on the developer's own host state.
75
+ * 2. REACHABILITY (SESSGATE). If the Mac's session is AX-blind (screen locked /
76
+ * full-screen Space), the dialog would open on an unreachable window. Only
77
+ * "session" scope refuses here: a window merely on another Space is left for
78
+ * the in-drive relocation (the reveal has not run yet, so refusing before the
79
+ * seed would be a false positive). A fail-open probe proceeds — the promote
80
+ * leg's own gate + the seed cleanup remain the backstop.
81
+ *
82
+ * Running the standing check FIRST also keeps the probe itself doctrine-clean: no
83
+ * System Events call is attempted on a host that has not granted one.
67
84
  */
68
- async function gateSessionReachability(deps, op) {
85
+ async function gateUiPreflight(deps, op) {
86
+ const gui = deps.vectors.find((v) => v.drivesGui === true && v.simulates !== true);
87
+ if (gui !== undefined) {
88
+ const capability = (deps.uiCapability ?? (() => uiCapabilityDefault()))();
89
+ if (!uiAllowed(capability)) {
90
+ return {
91
+ kind: "blocked",
92
+ op,
93
+ reason: "environment",
94
+ detail: `this operation drives the Things window, and ${capability.detail} — nothing was created`,
95
+ remediation: capability.remediation.join("; "),
96
+ };
97
+ }
98
+ }
69
99
  if (!deps.config.ui.enabled)
70
100
  return null;
71
101
  const ui = deps.vectors.find((v) => v.probeReachability !== undefined);
@@ -117,35 +147,23 @@ async function cleanupSeed(deps, kind, createdUuid, promote, options, txnId) {
117
147
  *
118
148
  * The `rule` bag is the SUPERSET the make/add legs actually carry: make-repeating
119
149
  * 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).
150
+ * and the requested `next` included), add-repeating an
151
+ * {@link AddRepeatingRuleFields} plus whichever of the deadline pair its geometry
152
+ * folded in. Every field flows through by SPREAD, never by a hand-copied field
153
+ * list a list here is how the make-repeating promote once stripped
154
+ * `deadline`/`startDaysEarlier` (a deadlined make-repeating produced a
155
+ * NON-deadlined template, YANCH1 #493) and how the project promote later dropped
156
+ * `next` (#549). The ONE field that is deliberately re-derived rather than copied
157
+ * is `next`: the drive date is deadline-shifted by the caller, and an
158
+ * after-completion dialog has no first-occurrence field to drive at all.
126
159
  */
127
160
  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
- };
161
+ const out = { ...rule, uuid };
162
+ // Never the caller's raw `next` — see above; the drive date arrives as `nextIso`.
163
+ delete out.next;
164
+ if (nextIso !== undefined && rule.afterCompletion !== true)
165
+ out.next = nextIso;
166
+ return out;
149
167
  }
150
168
  const UNIT_SINGULAR = {
151
169
  daily: "day",
@@ -182,6 +200,18 @@ function landedRuleEcho(rule, startIso) {
182
200
  : "";
183
201
  return `landed: the series repeats ${cadence}${first}${deadlineNote}`;
184
202
  }
203
+ /**
204
+ * The DACON1 off-rule-first disclosure line, or null when the request is on-rule.
205
+ * When an explicit anchor disagrees with `--when` (deadline-shift-aware) the app
206
+ * lands an OFF-RULE first occurrence (honored for weekly/yearly); this states both
207
+ * halves of the landed pattern so the caller can eyeball the two-phase series.
208
+ * The dishonored monthly shape is refused upstream (assertRepeatRule), so only the
209
+ * honored disclosure ever reaches here.
210
+ */
211
+ function offRuleFirstNote(params) {
212
+ const assessment = assessOffRuleFirst(params);
213
+ return assessment?.kind === "honored" ? assessment.disclosure.message : null;
214
+ }
185
215
  /**
186
216
  * The template's app-materialized first occurrence (`rt1_instanceCreationStartDate`
187
217
  * = the date the current instance is dated at) as an ISO date, for the post-drive
@@ -195,6 +225,95 @@ function firstOccurrenceOf(db, templateUuid) {
195
225
  return null;
196
226
  return decodePackedDate(row.ic);
197
227
  }
228
+ /** A repeating instance's own scheduled start date (`startDate`) as ISO, or null. */
229
+ function instanceStartDate(db, instanceUuid) {
230
+ const row = db.prepare("SELECT startDate AS s FROM TMTask WHERE uuid = ?").get(instanceUuid);
231
+ if (row === undefined || row.s === null)
232
+ return null;
233
+ return decodePackedDate(row.s);
234
+ }
235
+ /**
236
+ * The oracle for the post-drive first-occurrence check, picked by rule KIND
237
+ * (issue #508).
238
+ *
239
+ * A FIXED-schedule series is anchored on the calendar: the dialog's "Next:" field
240
+ * IS driven, and the template's own cursor (`rt1_instanceCreationStartDate`) holds
241
+ * the resulting first occurrence — the right thing to compare against.
242
+ *
243
+ * An AFTER-COMPLETION series has NO calendar anchor, so {@link ruleParamsFor}
244
+ * deliberately leaves "Next:" alone, and the app mints the template with an EMPTY
245
+ * cursor (RSIM2 / RSIM-P P4: no next / reference dates exist until a completion
246
+ * happens). Comparing that empty cursor against `--when` reported a FALSE
247
+ * `verify-failed:mismatch` on a perfectly correct creation (#508, reproduced 6/6
248
+ * on the live host). The requested date lives on the materialized INSTANCE's own
249
+ * `startDate` — the row the promote preserved and relinked — so that is the oracle
250
+ * for an after-completion series.
251
+ *
252
+ * Returns `null` when no oracle is reachable. For an after-completion rule that
253
+ * means "unverifiable" and the caller SKIPS the check (the create delta already
254
+ * proved the series landed); for a fixed rule an absent cursor is a genuine miss.
255
+ */
256
+ function landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion) {
257
+ if (!afterCompletion)
258
+ return firstOccurrenceOf(deps.db, templateUuid);
259
+ return instanceUuid === null ? null : instanceStartDate(deps.db, instanceUuid);
260
+ }
261
+ /**
262
+ * DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md, golden-v3 / Things 3.22.14):
263
+ * a promote whose source is PRESERVED (SRCFATE deadline / terminal-element trigger)
264
+ * relinks that source IN PLACE as the current-occurrence instance. When the first
265
+ * occurrence is FUTURE-dated this double-books: the hidden template's cursor
266
+ * (`rt1_nextInstanceStartDate`) points at the SAME occurrence with
267
+ * `rt1_instanceCreationCount = 0` — the cursor does not know the occurrence is already
268
+ * materialized — so when the date ARRIVES the app spawns a SECOND instance alongside
269
+ * the preserved one (cell C: two rows dated the same day, icCount 0→1). A genuine
270
+ * duplicate factory, not cosmetic.
271
+ *
272
+ * So a promote composite trashes the redundant preserved FUTURE instance inside the
273
+ * txn: its content mirrors the template, and the cursor will mint the single real
274
+ * occurrence when the date arrives (matching a normal future-first series, which holds
275
+ * no materialized instance until then). A today/past-dated preserved instance is the
276
+ * LEGITIMATE current occurrence (the cursor has already advanced past it) — left
277
+ * untouched. Returns the disclosure warning (and the trashed uuid) or null when the
278
+ * promote did not preserve a future instance. Best-effort: a failed trash is reported
279
+ * in the warning rather than failing the whole compound (the series is already sound).
280
+ */
281
+ async function trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now, afterCompletion) {
282
+ // AFTER-COMPLETION series are exempt: the double-book is a CURSOR phenomenon
283
+ // (`rt1_nextInstanceStartDate` pointing at an already-materialized occurrence),
284
+ // and an after-completion template is minted with NO cursor at all — the next
285
+ // occurrence is unknown until a completion happens (RSIM2 / RSIM-P P4). Its
286
+ // preserved instance is therefore the series' ONLY occurrence, whatever its
287
+ // date; trashing it would destroy the series' current occurrence rather than a
288
+ // duplicate. (Unreachable before #508, whose false verify-failed returned first.)
289
+ if (afterCompletion)
290
+ return null;
291
+ const rep = promote.repeating;
292
+ // Preserved iff the native promote relinked the source (replacedUuid === null) AND
293
+ // there is a materialized instance. A DELETE-fate promote reports replacedUuid !==
294
+ // null and never leaves a future instance to double-book.
295
+ if (rep === undefined || rep.replacedUuid !== null || rep.instanceUuid === null)
296
+ return null;
297
+ const instanceUuid = rep.instanceUuid;
298
+ const startIso = instanceStartDate(deps.db, instanceUuid);
299
+ if (startIso === null)
300
+ return null;
301
+ const todayIso = localToday(now, deps.zone);
302
+ if (daysBetweenIso(todayIso, startIso) <= 0)
303
+ return null; // today or past — legitimate
304
+ const trashOp = kind === "project" ? "project.delete" : "todo.delete";
305
+ const trashed = await runMutation(deps, trashOp, { uuid: instanceUuid }, legOptions(options, txnId));
306
+ const kindWord = kind === "project" ? "project" : "to-do";
307
+ const warning = trashed.kind === "ok"
308
+ ? `the source ${kindWord} was kept by the app as a pre-materialized first occurrence dated ` +
309
+ `${startIso}; because that date is in the future the series would have spawned a DUPLICATE ` +
310
+ `there, so the redundant occurrence was moved to the Trash — the series mints a single ` +
311
+ `occurrence when ${startIso} arrives`
312
+ : `the app kept the source ${kindWord} as a pre-materialized first occurrence dated ${startIso} ` +
313
+ `(a future date the series would DUPLICATE), and it could NOT be auto-trashed — remove it ` +
314
+ `with \`things ${kind} delete ${instanceUuid}\``;
315
+ return { warning, trashedUuid: instanceUuid };
316
+ }
198
317
  /**
199
318
  * The verify-failed result when the driven "Next:" first occurrence did not land
200
319
  * (issue #476, ANCH2). The series exists but on the wrong phase — report it
@@ -358,128 +477,151 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
358
477
  },
359
478
  };
360
479
  }
361
- // SESSGATE (#480): refuse a locked / full-screen session BEFORE minting a clone
362
- // otherwise the promote's dialog opens on an unreachable window and the whole
363
- // compound fails, stranding a disposable clone. Zero mutation on refusal.
364
- const gate = await gateSessionReachability(deps, op);
480
+ // PRE-SEED UI PREFLIGHT (#480/#512): refuse a host that cannot drive the window,
481
+ // or a locked / full-screen session, BEFORE minting a clone otherwise the
482
+ // promote's dialog never opens and the whole compound fails, stranding a
483
+ // disposable clone. Zero mutation on refusal.
484
+ const gate = await gateUiPreflight(deps, op);
365
485
  if (gate !== null)
366
486
  return gate;
367
- const startedAt = now;
368
- const txnId = newTxnId(startedAt);
369
- // 1. Clone the source as a disposable, embedded leg (--preserve-created). The
370
- // clone has captured X's full content by the time it returns.
371
- const clone = kind === "project"
372
- ? await runCloneProject(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId))
373
- : await runCloneTodo(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId));
374
- if (clone.kind !== "ok" || clone.uuid === null) {
375
- // A clone refusal (nested repeating template, H-CLONE-SOURCE) surfaces
376
- // coherently here re-label it to the make-repeating op for the caller.
377
- return clone.kind === "ok"
378
- ? {
379
- kind: "verify-failed",
487
+ // COMPOSITE LOCK: everything below is ONE verb executed as several mutations
488
+ // (clone trash → promote → the DBLSPAWN1 clean-up), and they must not
489
+ // interleave with another writer's legs the promote's row selection is by
490
+ // TITLE, so a concurrent clone of the same item makes it ambiguous. One lock,
491
+ // held to the end; each leg's own acquisition is a reentrant no-op.
492
+ return runComposite(deps, op, async () => {
493
+ const startedAt = now;
494
+ const txnId = newTxnId(startedAt);
495
+ // 1. Clone the source as a disposable, embedded leg (--preserve-created). The
496
+ // clone has captured X's full content by the time it returns.
497
+ const clone = kind === "project"
498
+ ? await runCloneProject(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId))
499
+ : await runCloneTodo(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId));
500
+ if (clone.kind !== "ok" || clone.uuid === null) {
501
+ // A clone refusal (nested repeating template, H-CLONE-SOURCE) surfaces
502
+ // coherently here — re-label it to the make-repeating op for the caller.
503
+ return clone.kind === "ok"
504
+ ? {
505
+ kind: "verify-failed",
506
+ op,
507
+ reason: "mismatch",
508
+ expected: {
509
+ mode: "create",
510
+ probe: { title: src.title, type: expectedType, sinceEpoch: 0 },
511
+ assert: [],
512
+ },
513
+ observed: null,
514
+ detail: "the disposable clone was created but its uuid was not discovered — nothing was promoted or trashed",
515
+ }
516
+ : { ...clone, op };
517
+ }
518
+ const cloneUuid = clone.uuid;
519
+ // --preserve-modified: X is the ONLY pre-existing row the compound touches (the
520
+ // clone/promote legs mint fresh rows). Capture its pre-write umd BEFORE the
521
+ // trash bumps it — the trash leg restores it forward, and the value rides the
522
+ // summary record's preModDates so the symmetric undo restore fires on the
523
+ // revived X (undo.ts, 2026-08-13 ruling). The clone leg above reads X but never
524
+ // writes it, so its umd is still pristine here.
525
+ const preserveModified = options.preserveModified === true;
526
+ const preUmd = preserveModified
527
+ ? createDbReader(deps.db, now, deps.zone).modDateOf(srcUuid)
528
+ : null;
529
+ // 2. Trash the original BEFORE promoting — the clone already holds X's content,
530
+ // and a live same-titled X would make the promote's project row-selection
531
+ // ambiguous (H-PROJECT-REPEAT). X survives in the Trash (the recoverable half).
532
+ const trash = await runMutation(deps, `${kind}.delete`, { uuid: srcUuid }, legOptions(options, txnId, undefined, preserveModified ? { preserveModified: true } : undefined));
533
+ if (trash.kind !== "ok") {
534
+ return {
535
+ ...trash,
380
536
  op,
381
- reason: "mismatch",
382
- expected: {
383
- mode: "create",
384
- probe: { title: src.title, type: expectedType, sinceEpoch: 0 },
385
- assert: [],
386
- },
387
- observed: null,
388
- detail: "the disposable clone was created but its uuid was not discovered — nothing was promoted or trashed",
537
+ ...("detail" in trash
538
+ ? {
539
+ detail: `${trash.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
540
+ `original ${srcUuid} could not be moved to the Trash, so it was NOT promoted; trash ` +
541
+ "the clone and retry",
542
+ }
543
+ : {}),
544
+ };
545
+ }
546
+ // 3. Native-promote the clone (with the ANCH2 Next drive + derived weekday).
547
+ const rule = ruleParamsFor(cloneUuid, effParams, nextIso);
548
+ const promote = kind === "project"
549
+ ? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
550
+ : await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
551
+ if (promote.kind !== "ok") {
552
+ // The clone persists but was not promoted; best-effort ROLL BACK the trash so
553
+ // the original is not stranded in the Trash.
554
+ const restoreOp = kind === "project" ? "project.restore" : "todo.restore";
555
+ const rolledBack = await runMutation(deps, restoreOp, { uuid: srcUuid }, legOptions(options, txnId));
556
+ const rollNote = rolledBack.kind === "ok"
557
+ ? `the original ${srcUuid} was restored from the Trash`
558
+ : `the original ${srcUuid} could NOT be restored from the Trash — restore it in the app`;
559
+ return {
560
+ ...promote,
561
+ op,
562
+ ...("detail" in promote
563
+ ? {
564
+ detail: `${promote.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
565
+ `promote did not land; ${rollNote}. Trash the clone and retry`,
566
+ }
567
+ : {}),
568
+ };
569
+ }
570
+ const { templateUuid } = discoveryOf(promote);
571
+ let { instanceUuid } = discoveryOf(promote);
572
+ // Post-drive verify (ANCH2 + YANCH1): the instance START must have landed on the
573
+ // requested `--when` — for a deadlined rule the driven Next is the deadline
574
+ // (when + startDaysEarlier) and the app back-shifts the start to `--when`, so the
575
+ // check is against `expectedStartIso`, not the raw drive date. Fail closed on
576
+ // mismatch rather than report a wrong-phase ok. The ORACLE is rule-kind dependent
577
+ // (#508) — see landedFirstStart; an unverifiable after-completion series skips.
578
+ const afterCompletion = effParams.afterCompletion === true;
579
+ if (expectedStartIso !== undefined) {
580
+ const landed = landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion);
581
+ if (!(afterCompletion && landed === null) && landed !== expectedStartIso) {
582
+ return nextMismatch(op, templateUuid, expectedStartIso, landed);
389
583
  }
390
- : { ...clone, op };
391
- }
392
- const cloneUuid = clone.uuid;
393
- // --preserve-modified: X is the ONLY pre-existing row the compound touches (the
394
- // clone/promote legs mint fresh rows). Capture its pre-write umd BEFORE the
395
- // trash bumps it — the trash leg restores it forward, and the value rides the
396
- // summary record's preModDates so the symmetric undo restore fires on the
397
- // revived X (undo.ts, 2026-08-13 ruling). The clone leg above reads X but never
398
- // writes it, so its umd is still pristine here.
399
- const preserveModified = options.preserveModified === true;
400
- const preUmd = preserveModified
401
- ? createDbReader(deps.db, now, deps.zone).modDateOf(srcUuid)
402
- : null;
403
- // 2. Trash the original BEFORE promoting — the clone already holds X's content,
404
- // and a live same-titled X would make the promote's project row-selection
405
- // ambiguous (H-PROJECT-REPEAT). X survives in the Trash (the recoverable half).
406
- const trash = await runMutation(deps, `${kind}.delete`, { uuid: srcUuid }, legOptions(options, txnId, undefined, preserveModified ? { preserveModified: true } : undefined));
407
- if (trash.kind !== "ok") {
408
- return {
409
- ...trash,
584
+ }
585
+ const warnings = [
586
+ landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
587
+ `the original ${expectedType} (uuid ${srcUuid}) was moved to the Trash; \`things undo\` ` +
588
+ "removes the new series (trash-both) and restores it",
589
+ PLACEMENT_NOTE,
590
+ ];
591
+ // DBLSPAWN1: if the promote PRESERVED the source (deadline / terminal-element
592
+ // trigger) as a FUTURE-dated instance, the app would spawn a duplicate on that date
593
+ // trash the redundant occurrence and disclose (cursor mints the single real one).
594
+ const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now, afterCompletion);
595
+ if (dbl !== null) {
596
+ warnings.push(dbl.warning);
597
+ if (instanceUuid === dbl.trashedUuid)
598
+ instanceUuid = null;
599
+ }
600
+ const offRule = offRuleFirstNote(effParams);
601
+ if (offRule !== null)
602
+ warnings.push(offRule);
603
+ if (promote.warnings !== undefined)
604
+ warnings.push(...promote.warnings);
605
+ appendPromoteSummary(deps, {
606
+ startedAt,
410
607
  op,
411
- ...("detail" in trash
412
- ? {
413
- detail: `${trash.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
414
- `original ${srcUuid} could not be moved to the Trash, so it was NOT promoted; trash ` +
415
- "the clone and retry",
416
- }
417
- : {}),
418
- };
419
- }
420
- // 3. Native-promote the clone (with the ANCH2 Next drive + derived weekday).
421
- const rule = ruleParamsFor(cloneUuid, effParams, nextIso);
422
- const promote = kind === "project"
423
- ? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
424
- : await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
425
- if (promote.kind !== "ok") {
426
- // The clone persists but was not promoted; best-effort ROLL BACK the trash so
427
- // the original is not stranded in the Trash.
428
- const restoreOp = kind === "project" ? "project.restore" : "todo.restore";
429
- const rolledBack = await runMutation(deps, restoreOp, { uuid: srcUuid }, legOptions(options, txnId));
430
- const rollNote = rolledBack.kind === "ok"
431
- ? `the original ${srcUuid} was restored from the Trash`
432
- : `the original ${srcUuid} could NOT be restored from the Trash — restore it in the app`;
433
- return {
434
- ...promote,
608
+ txnId,
609
+ templateUuid,
610
+ instanceUuid,
611
+ originalUuid: srcUuid,
612
+ invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
613
+ requested: effParams,
614
+ ...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
615
+ });
616
+ return promoteOk({
435
617
  op,
436
- ...("detail" in promote
437
- ? {
438
- detail: `${promote.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
439
- `promote did not land; ${rollNote}. Trash the clone and retry`,
440
- }
441
- : {}),
442
- };
443
- }
444
- const { templateUuid, instanceUuid } = discoveryOf(promote);
445
- // Post-drive verify (ANCH2 + YANCH1): the instance START must have landed on the
446
- // requested `--when` — for a deadlined rule the driven Next is the deadline
447
- // (when + startDaysEarlier) and the app back-shifts the start to `--when`, so the
448
- // check is against `expectedStartIso`, not the raw drive date. Fail closed on
449
- // mismatch rather than report a wrong-phase ok.
450
- if (expectedStartIso !== undefined) {
451
- const landed = firstOccurrenceOf(deps.db, templateUuid);
452
- if (landed !== expectedStartIso) {
453
- return nextMismatch(op, templateUuid, expectedStartIso, landed);
454
- }
455
- }
456
- const warnings = [
457
- landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
458
- `the original ${expectedType} (uuid ${srcUuid}) was moved to the Trash; \`things undo\` ` +
459
- "removes the new series (trash-both) and restores it",
460
- PLACEMENT_NOTE,
461
- ];
462
- if (promote.warnings !== undefined)
463
- warnings.push(...promote.warnings);
464
- appendPromoteSummary(deps, {
465
- startedAt,
466
- op,
467
- txnId,
468
- templateUuid,
469
- instanceUuid,
470
- originalUuid: srcUuid,
471
- invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
472
- requested: effParams,
473
- ...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
474
- });
475
- return promoteOk({
476
- op,
477
- templateUuid,
478
- instanceUuid,
479
- replacedUuid: cloneUuid,
480
- title: src.title,
481
- txnId,
482
- warnings,
618
+ templateUuid,
619
+ instanceUuid,
620
+ replacedUuid: cloneUuid,
621
+ title: src.title,
622
+ txnId,
623
+ warnings,
624
+ });
483
625
  });
484
626
  }
485
627
  export function runMakeRepeatingTodo(deps, params, options = {}) {
@@ -504,9 +646,24 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
504
646
  // explicit anchor was given, and drive the anchor pop-ups — otherwise the
505
647
  // recurring rule keeps the dialog's untouched default (weekly Sunday, monthly
506
648
  // 1st, yearly January 1) and only the first occurrence is correct.
507
- const anchorIso = isIsoDate(addParams["when"]) ? addParams["when"] : null;
508
- const nextIso = anchorIso ?? undefined;
509
- const effRule = { ...rule, ...deriveFixedAnchor(rule, anchorIso) };
649
+ //
650
+ // DEADLINE-MODE (DBLSPAWN1): a deadlined rule anchors on the DEADLINE, and each
651
+ // instance's START = deadline startDaysEarlier (DACON1 DC4). `--when` is the
652
+ // scheduled START, so the date the dialog's "Next:" field + anchor pop-ups must
653
+ // carry is when + startDaysEarlier (the deadline); the app back-shifts the start to
654
+ // `--when`. `runAddRepeatingTodo` maps a concrete item-level `--deadline` into the
655
+ // rule's deadline/startDaysEarlier here (and strips it from the seed, so the source
656
+ // is not SRCFATE-preserved as a double-booking future instance). For a non-deadlined
657
+ // rule the shift is 0 and the drive date equals `--when` (unchanged).
658
+ const whenIso = isIsoDate(addParams["when"]) ? addParams["when"] : null;
659
+ const deadlineShift = rule.deadline === true || (rule.startDaysEarlier ?? 0) > 0 ? (rule.startDaysEarlier ?? 0) : 0;
660
+ const driveIso = whenIso !== null ? addDaysIso(whenIso, deadlineShift) : null;
661
+ const nextIso = driveIso ?? undefined;
662
+ const expectedStartIso = whenIso ?? undefined;
663
+ const effRule = {
664
+ ...rule,
665
+ ...deriveFixedAnchor(rule, driveIso),
666
+ };
510
667
  // ADR1 (issue #480, requested behavior #3): carry the base to-do's --reminder
511
668
  // onto the SERIES. The create leg sets a one-off reminderTime on the seed, but
512
669
  // the Repeat-dialog conversion does NOT preserve it — the dialog OWNS the repeat
@@ -542,137 +699,240 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
542
699
  },
543
700
  };
544
701
  }
545
- // SESSGATE (#480): refuse a locked / full-screen session BEFORE seeding the row
546
- // (the two legs are not atomic a doomed promote would strand the seed). Zero
547
- // mutation on refusal; a window merely on another Space is relocated in-drive.
548
- const gate = await gateSessionReachability(deps, op);
702
+ // PRE-SEED UI PREFLIGHT (#480/#512): refuse a host that cannot drive the window,
703
+ // or a locked / full-screen session, BEFORE seeding the row (the two legs are not
704
+ // atomic a doomed promote would strand the seed). Zero mutation on refusal; a
705
+ // window merely on another Space is relocated in-drive.
706
+ const gate = await gateUiPreflight(deps, op);
549
707
  if (gate !== null)
550
708
  return gate;
551
- const startedAt = deps.now?.() ?? new Date();
552
- const txnId = newTxnId(startedAt);
553
- // 1. Create the item (full add vocabulary) as an embedded leg.
554
- const addOp = kind === "project" ? "project.add" : "todo.add";
555
- const add = await runMutation(deps, addOp, addParams, legOptions(options, txnId, "url-scheme"));
556
- if (add.kind !== "ok" || add.uuid === null) {
557
- return add.kind === "ok"
558
- ? {
559
- kind: "verify-failed",
560
- op,
561
- reason: "mismatch",
562
- expected: {
563
- mode: "create",
564
- probe: { title, type: expectedType, sinceEpoch: 0 },
565
- assert: [],
566
- },
567
- observed: null,
568
- detail: `the ${expectedType} was created but its uuid was not discovered — it cannot be promoted to repeating`,
569
- }
570
- : { ...add, op };
709
+ // COMPOSITE LOCK: add → promote ( the seed auto-trash / DBLSPAWN1 clean-up)
710
+ // is one verb, several mutations; hold one lock across all of them so a
711
+ // concurrent composite cannot land its own legs between ours.
712
+ return runComposite(deps, op, async () => {
713
+ const startedAt = deps.now?.() ?? new Date();
714
+ const txnId = newTxnId(startedAt);
715
+ // 1. Create the item (full add vocabulary) as an embedded leg.
716
+ const addOp = kind === "project" ? "project.add" : "todo.add";
717
+ const add = await runMutation(deps, addOp, addParams, legOptions(options, txnId, "url-scheme"));
718
+ if (add.kind !== "ok" || add.uuid === null) {
719
+ return add.kind === "ok"
720
+ ? {
721
+ kind: "verify-failed",
722
+ op,
723
+ reason: "mismatch",
724
+ expected: {
725
+ mode: "create",
726
+ probe: { title, type: expectedType, sinceEpoch: 0 },
727
+ assert: [],
728
+ },
729
+ observed: null,
730
+ detail: `the ${expectedType} was created but its uuid was not discovered — it cannot be promoted to repeating`,
731
+ }
732
+ : { ...add, op };
733
+ }
734
+ const createdUuid = add.uuid;
735
+ // 2. Native-promote the fresh row (ANCH2 Next drive + derived weekday + the
736
+ // base reminder driven onto the series, ADR1).
737
+ const ruleParams = ruleParamsFor(createdUuid, effRuleWithReminder, nextIso);
738
+ const promote = kind === "project"
739
+ ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
740
+ : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
741
+ if (promote.kind !== "ok") {
742
+ // The seed persists (the two legs are not atomic) but the promote did not
743
+ // land. RATIFIED RULING (2026-08-15, issue #480): auto-trash our OWN seed
744
+ // inside the txn — it is our artifact, recreatable verbatim from the command
745
+ // args, and the Trash is recoverable — then disclose it. If the auto-trash
746
+ // itself fails, the result carries the seed's REAL, resolvable uuid with a
747
+ // working `delete` remediation, so cleanup is never ambiguous (the #480
748
+ // second bug: a failed add-repeating left a residue whose reported uuid was
749
+ // not actionable).
750
+ const patch = await cleanupSeed(deps, kind, createdUuid, promote, options, txnId);
751
+ return { ...promote, op, ...patch };
752
+ }
753
+ const { templateUuid } = discoveryOf(promote);
754
+ let { instanceUuid } = discoveryOf(promote);
755
+ // Post-drive verify (ANCH2 + DBLSPAWN1): the instance START must have landed on the
756
+ // requested `--when` — for a deadlined rule the driven Next is the deadline (when +
757
+ // startDaysEarlier) and the app back-shifts the start to `--when`, so the check is
758
+ // against `expectedStartIso`, not the raw drive date. Fail closed on mismatch rather
759
+ // than report a wrong-phase ok. The series EXISTS here (promote landed) but on the
760
+ // wrong phase, so this is a genuine partial success, NOT a seed to trash. The
761
+ // ORACLE is rule-kind dependent (#508) — see landedFirstStart; an after-completion
762
+ // series verifies against its materialized instance, and skips when it has none.
763
+ const afterCompletion = effRuleWithReminder.afterCompletion === true;
764
+ if (expectedStartIso !== undefined) {
765
+ const landed = landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion);
766
+ if (!(afterCompletion && landed === null) && landed !== expectedStartIso)
767
+ return nextMismatch(op, templateUuid, expectedStartIso, landed);
768
+ }
769
+ const warnings = [
770
+ landedRuleEcho(ruleParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
771
+ PLACEMENT_NOTE,
772
+ ];
773
+ // DBLSPAWN1 backstop: the deadline-mapping above keeps the seed deadline-free (no
774
+ // SRCFATE preserve), but any OTHER preserve trigger reaching the seed (defensive)
775
+ // would double-book a future first occurrence — trash the redundant instance.
776
+ const dbl = await trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, startedAt, afterCompletion);
777
+ if (dbl !== null) {
778
+ warnings.push(dbl.warning);
779
+ if (instanceUuid === dbl.trashedUuid)
780
+ instanceUuid = null;
781
+ }
782
+ const offRule = offRuleFirstNote(ruleParams);
783
+ if (offRule !== null)
784
+ warnings.push(offRule);
785
+ if (promote.warnings !== undefined)
786
+ warnings.push(...promote.warnings);
787
+ appendPromoteSummary(deps, {
788
+ startedAt,
789
+ op,
790
+ txnId,
791
+ templateUuid,
792
+ instanceUuid,
793
+ invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
794
+ requested: { title, ...effRule },
795
+ });
796
+ return promoteOk({
797
+ op,
798
+ templateUuid,
799
+ instanceUuid,
800
+ replacedUuid: createdUuid,
801
+ title,
802
+ txnId,
803
+ warnings,
804
+ });
805
+ });
806
+ }
807
+ /**
808
+ * Fold a concrete item-level `deadline` (and/or an explicit `startDaysEarlier`)
809
+ * into the RULE, returning the rule to promote with and the deadline the SEED
810
+ * should keep (none — the rule owns it). Shared by the to-do and project
811
+ * add-repeating verbs so one geometry, and one set of refusals, serves both.
812
+ *
813
+ * DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md): each occurrence is due
814
+ * `deadline − when` days after its start (the "Add deadlines" + "start N days
815
+ * earlier" dialog fields). This is the deadline the series actually wants, and
816
+ * mapping it up front is also what keeps the SEED deadline-free — a to-do seed
817
+ * carrying a deadline is SRCFATE-preserved as a materialized instance, and a
818
+ * future-dated first occurrence then DOUBLE-BOOKS against the template cursor
819
+ * and spawns a duplicate on the date (cell C). A project seed is DELETE-fate, so
820
+ * there the un-mapped deadline was simply LOST rather than duplicated — a
821
+ * quieter bug with the same cause and the same fix. A deadline needs a concrete
822
+ * `when` (the per-occurrence offset is measured from the start) on/after it.
823
+ *
824
+ * DEADLINE/OFFSET AGREEMENT (ruling 2026-08-18): the same rule-global offset can
825
+ * be named two ways — a concrete `deadline` (offset = deadline − when) OR an
826
+ * explicit `startDaysEarlier` N (deadline derived as when + N). The geometry
827
+ * when/deadline/N is OVER-DETERMINED: the dialog's start-offset is rule-global
828
+ * and the first occurrence's start is derived as due − N, so there is no
829
+ * per-first-instance gap to absorb a disagreement (unlike DACON1's off-rule-first
830
+ * calendar freedom). So when BOTH are given they must AGREE — an exact match is
831
+ * harmless redundancy, a mismatch is inexpressible and refused fast (zero
832
+ * mutation). Either input ALONE maps to the rule.
833
+ */
834
+ function mapDeadlineOntoRule(baseRule, when, deadline, startDaysEarlier) {
835
+ if (!isIsoDate(when)) {
836
+ throw new RangeError("a repeating --deadline or --start-days-earlier needs a concrete --when date (the deadline " +
837
+ "offset is measured from each occurrence's start) — schedule the series on a YYYY-MM-DD " +
838
+ "--when, or drop --deadline / --start-days-earlier");
571
839
  }
572
- const createdUuid = add.uuid;
573
- // 2. Native-promote the fresh row (ANCH2 Next drive + derived weekday + the
574
- // base reminder driven onto the series, ADR1).
575
- const ruleParams = ruleParamsFor(createdUuid, effRuleWithReminder, nextIso);
576
- const promote = kind === "project"
577
- ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
578
- : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
579
- if (promote.kind !== "ok") {
580
- // The seed persists (the two legs are not atomic) but the promote did not
581
- // land. RATIFIED RULING (2026-08-15, issue #480): auto-trash our OWN seed
582
- // inside the txn it is our artifact, recreatable verbatim from the command
583
- // args, and the Trash is recoverable — then disclose it. If the auto-trash
584
- // itself fails, the result carries the seed's REAL, resolvable uuid with a
585
- // working `delete` remediation, so cleanup is never ambiguous (the #480
586
- // second bug: a failed add-repeating left a residue whose reported uuid was
587
- // not actionable).
588
- const patch = await cleanupSeed(deps, kind, createdUuid, promote, options, txnId);
589
- return { ...promote, op, ...patch };
840
+ let startEarlier;
841
+ if (deadline !== undefined) {
842
+ const derived = daysBetweenIso(when, deadline);
843
+ if (derived < 0) {
844
+ throw new RangeError(`--deadline (${deadline}) must be on or after --when (${when}) — a deadline cannot ` +
845
+ "precede the occurrence's own start");
846
+ }
847
+ if (startDaysEarlier !== undefined && startDaysEarlier !== derived) {
848
+ throw new RangeError(`--deadline (${deadline}) puts each occurrence's due date ${derived} day` +
849
+ `${derived === 1 ? "" : "s"} after its start (--when ${when}), but ` +
850
+ `--start-days-earlier says ${startDaysEarlier}these disagree. Drop one, or make them ` +
851
+ `agree (--start-days-earlier ${derived}, or --deadline ${addDaysIso(when, startDaysEarlier)}).`);
852
+ }
853
+ startEarlier = derived;
590
854
  }
591
- const { templateUuid, instanceUuid } = discoveryOf(promote);
592
- // Post-drive verify (ANCH2): the driven Next must have landed as the first
593
- // occurrence fail closed on mismatch rather than report a wrong-phase ok.
594
- // The series EXISTS here (promote landed) but on the wrong phase, so this is a
595
- // genuine partial success, NOT a seed to trash — reported for correction.
596
- if (nextIso !== undefined) {
597
- const landed = firstOccurrenceOf(deps.db, templateUuid);
598
- if (landed !== nextIso)
599
- return nextMismatch(op, templateUuid, nextIso, landed);
855
+ else {
856
+ // `--start-days-earlier N` alone: the deadline is derived as when + N. A bad N
857
+ // (non-integer / negative) is refused by assertRepeatRule once mapped.
858
+ startEarlier = startDaysEarlier;
600
859
  }
601
- const warnings = [
602
- landedRuleEcho(ruleParams, nextIso ?? firstOccurrenceOf(deps.db, templateUuid)),
603
- PLACEMENT_NOTE,
604
- ];
605
- if (promote.warnings !== undefined)
606
- warnings.push(...promote.warnings);
607
- appendPromoteSummary(deps, {
608
- startedAt,
609
- op,
610
- txnId,
611
- templateUuid,
612
- instanceUuid,
613
- invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
614
- requested: { title, ...effRule },
615
- });
616
- return promoteOk({
617
- op,
618
- templateUuid,
619
- instanceUuid,
620
- replacedUuid: createdUuid,
621
- title,
622
- txnId,
623
- warnings,
624
- });
625
- }
626
- export function runAddRepeatingTodo(deps, params, options = {}) {
627
- const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, ...add } = params;
628
- const rule = {
629
- frequency,
630
- interval,
631
- ...(afterCompletion !== undefined && { afterCompletion }),
632
- ...(weekdays !== undefined && { weekdays }),
633
- ...(monthly !== undefined && { monthly }),
634
- ...(yearly !== undefined && { yearly }),
635
- ...(ends !== undefined && { ends }),
636
- };
637
- const addParams = {
638
- title: add.title,
639
- ...(add.notes !== undefined && { notes: add.notes }),
640
- ...(add.when !== undefined && { when: add.when }),
641
- ...(add.reminder !== undefined && { reminder: add.reminder }),
642
- ...(add.deadline !== undefined && { deadline: add.deadline }),
643
- ...(add.tags !== undefined && { tags: add.tags }),
644
- ...(add.checklistItems !== undefined && { checklistItems: add.checklistItems }),
645
- ...(add.project !== undefined && { project: add.project }),
646
- ...(add.area !== undefined && { area: add.area }),
647
- ...(add.heading !== undefined && { heading: add.heading }),
648
- ...(add.createdAt !== undefined && { createdAt: add.createdAt }),
860
+ // The RULE owns the deadline; the seed carries none.
861
+ return {
862
+ rule: { ...baseRule, deadline: true, startDaysEarlier: startEarlier },
863
+ seedDeadline: undefined,
649
864
  };
865
+ }
866
+ export async function runAddRepeatingTodo(deps, params, options = {}) {
867
+ // The rule/add split is driven by the exhaustive key map (splitAddRepeatingRule),
868
+ // not by a hand-written destructure — a field added to either vocabulary lands on
869
+ // the right leg instead of falling between them (#549 / YANCH1 #493).
870
+ const { rule: baseRule, add } = splitAddRepeatingRule(params);
871
+ const { afterCompletion } = params;
872
+ const startDaysEarlier = add.startDaysEarlier;
873
+ // A concrete `--deadline` (or `--start-days-earlier`) belongs to the RULE, not
874
+ // the seed — see mapDeadlineOntoRule for the geometry and the refusals. The
875
+ // to-do stakes: an un-mapped seed deadline makes the app preserve the seed as a
876
+ // future-dated instance that double-books the cursor (DBLSPAWN1 cell C).
877
+ let rule = baseRule;
878
+ let seedDeadline = add.deadline;
879
+ if ((add.deadline !== undefined || startDaysEarlier !== undefined) && afterCompletion !== true) {
880
+ ({ rule, seedDeadline } = mapDeadlineOntoRule(baseRule, add.when, add.deadline, startDaysEarlier));
881
+ }
882
+ else if (startDaysEarlier !== undefined) {
883
+ // afterCompletion === true here: an after-completion repeat has no calendar
884
+ // start to count a deadline back from — refuse rather than silently drop it.
885
+ throw new RangeError("--start-days-earlier applies only to a fixed-schedule deadline — an after-completion repeat " +
886
+ "has no calendar start to count back from; drop --after-completion or --start-days-earlier");
887
+ }
888
+ // The seed carries the WHOLE add vocabulary by spread (a field added to
889
+ // TodoAddRepeatingParams reaches the create leg without a line here), minus the
890
+ // two the rule took: `startDaysEarlier` is rule-only, and the deadline is
891
+ // whatever the geometry left for the seed (none, once mapped).
892
+ const addParams = { ...add };
893
+ delete addParams["startDaysEarlier"];
894
+ delete addParams["deadline"];
895
+ if (seedDeadline !== undefined)
896
+ addParams["deadline"] = seedDeadline;
650
897
  return addRepeatingViaCreate(deps, "todo", addParams, rule, add.title, options);
651
898
  }
652
- export function runAddRepeatingProject(deps, params, options = {}) {
653
- const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, ...add } = params;
654
- const rule = {
655
- frequency,
656
- interval,
657
- ...(afterCompletion !== undefined && { afterCompletion }),
658
- ...(weekdays !== undefined && { weekdays }),
659
- ...(monthly !== undefined && { monthly }),
660
- ...(yearly !== undefined && { yearly }),
661
- ...(ends !== undefined && { ends }),
662
- };
899
+ export async function runAddRepeatingProject(deps, params, options = {}) {
900
+ // Same exhaustive rule/add split as the to-do verb (one key map, both verbs).
901
+ const { rule: baseRule, add } = splitAddRepeatingRule(params);
902
+ const { afterCompletion } = params;
903
+ // A concrete `--deadline` maps to the RULE here exactly as it does on the to-do
904
+ // verb (DBLSPAWN1 residual). The project stakes are quieter than the to-do's but
905
+ // no more acceptable: a project seed is DELETE-fate, so the app does not preserve
906
+ // it into a double-booked instance — it simply DROPS the deadline, and the series
907
+ // came out undeadlined with nothing said. Mapped, every occurrence is due
908
+ // `deadline when` days after its start.
909
+ let rule = baseRule;
910
+ let seedDeadline = add.deadline;
911
+ if (add.deadline !== undefined) {
912
+ if (afterCompletion === true) {
913
+ // The to-do verb can leave a deadline on an after-completion seed because
914
+ // that seed SURVIVES as the series' instance. An after-completion project's
915
+ // seed is deleted and its instance is minted deadline-free (RSIM-P P4), so
916
+ // the deadline would vanish either way — refuse rather than drop it quietly.
917
+ throw new RangeError("an after-completion repeating project cannot carry a --deadline: the rule has no calendar " +
918
+ "start to measure a per-occurrence deadline from, and the created project is replaced by " +
919
+ "the series' own instance, which is created without one — drop --after-completion to " +
920
+ "deadline every occurrence, or drop --deadline");
921
+ }
922
+ ({ rule, seedDeadline } = mapDeadlineOntoRule(baseRule, add.when, add.deadline, undefined));
923
+ }
663
924
  // Seed a pure-AX taxonomy: an area lands a selectable AREA-view row; otherwise
664
925
  // create in Someday (UIC4-f) so the promote skips the anytime-header problem.
665
926
  const seedWhen = add.when ?? (add.area === undefined ? "someday" : undefined);
666
- const addParams = {
667
- title: add.title,
668
- ...(add.notes !== undefined && { notes: add.notes }),
669
- ...(add.area !== undefined && { area: add.area }),
670
- ...(seedWhen !== undefined && { when: seedWhen }),
671
- ...(add.deadline !== undefined && { deadline: add.deadline }),
672
- ...(add.todos !== undefined && { todos: add.todos }),
673
- ...(add.items !== undefined && { items: add.items }),
674
- ...(add.createdAt !== undefined && { createdAt: add.createdAt }),
675
- };
927
+ // The whole add vocabulary by spread (see the to-do verb), with the two the
928
+ // promote owns re-derived: the seed taxonomy `when` and the mapped deadline.
929
+ const addParams = { ...add };
930
+ delete addParams["when"];
931
+ delete addParams["deadline"];
932
+ if (seedWhen !== undefined)
933
+ addParams["when"] = seedWhen;
934
+ if (seedDeadline !== undefined)
935
+ addParams["deadline"] = seedDeadline;
676
936
  return addRepeatingViaCreate(deps, "project", addParams, rule, add.title, options);
677
937
  }
678
938
  // ====================================================== template-direct clone
@@ -773,95 +1033,103 @@ export async function cloneTemplateViaRepromote(deps, kind, src, srcUuid, params
773
1033
  },
774
1034
  };
775
1035
  }
776
- // SESSGATE (#480): refuse a locked / full-screen session BEFORE minting the
777
- // plain clone (a doomed promote would strand it). Zero mutation on refusal.
778
- const gate = await gateSessionReachability(deps, op);
1036
+ // PRE-SEED UI PREFLIGHT (#480/#512): refuse a host that cannot drive the window,
1037
+ // or a locked / full-screen session, BEFORE minting the plain clone (a doomed
1038
+ // promote would strand it). Zero mutation on refusal.
1039
+ const gate = await gateUiPreflight(deps, op);
779
1040
  if (gate !== null)
780
1041
  return gate;
781
- const startedAt = deps.now?.() ?? new Date();
782
- const txnId = newTxnId(startedAt);
783
- // 4. Mint the plain clone as an embedded leg — cloneTemplateAsPlain reaches the
784
- // clone orchestrator's plain-content path (recurrence + schedule stripped);
785
- // --title/--preserve-created ride through the CloneParams.
786
- const cloneParams = {
787
- uuid: srcUuid,
788
- ...(params.title !== undefined && { title: params.title }),
789
- ...(params.preserveCreated === true && { preserveCreated: true }),
790
- };
791
- const cloneOptions = { ...legOptions(options, txnId), cloneTemplateAsPlain: true };
792
- const clone = kind === "project"
793
- ? await runCloneProject(deps, cloneParams, cloneOptions)
794
- : await runCloneTodo(deps, cloneParams, cloneOptions);
795
- if (clone.kind !== "ok" || clone.uuid === null) {
796
- // A nested-repeater refusal (a template CONTAINING a nested repeater) or any
797
- // clone failure surfaces coherently here — re-label it to the compound op.
798
- return clone.kind === "ok"
799
- ? {
800
- kind: "verify-failed",
801
- op,
802
- reason: "mismatch",
803
- expected: {
804
- mode: "create",
805
- probe: { title, type: expectedType, sinceEpoch: 0 },
806
- assert: [],
807
- },
808
- observed: null,
809
- detail: "the plain clone was created but its uuid was not discovered — nothing was promoted",
810
- }
811
- : { ...clone, op };
812
- }
813
- const cloneUuid = clone.uuid;
814
- // 5. Native-promote the clone with the FULL decoded rule (ruleToInverseParams
815
- // carries deadline/start-earlier + the calendar anchors + ends).
816
- const ruleParams = { uuid: cloneUuid, ...inverse };
817
- const promote = kind === "project"
818
- ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
819
- : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
820
- if (promote.kind !== "ok") {
821
- // The plain clone persists but was not promoted — honest report (no original
822
- // to roll back; the clone is a fresh row the caller can trash and retry).
823
- return {
824
- ...promote,
825
- op,
826
- ...("detail" in promote
1042
+ // COMPOSITE LOCK: clone-as-plain promote-with-the-source's-rule is one verb;
1043
+ // hold one lock across both legs.
1044
+ return runComposite(deps, op, async () => {
1045
+ const startedAt = deps.now?.() ?? new Date();
1046
+ const txnId = newTxnId(startedAt);
1047
+ // 4. Mint the plain clone as an embedded leg — cloneTemplateAsPlain reaches the
1048
+ // clone orchestrator's plain-content path (recurrence + schedule stripped);
1049
+ // --title/--preserve-created ride through the CloneParams.
1050
+ const cloneParams = {
1051
+ uuid: srcUuid,
1052
+ ...(params.title !== undefined && { title: params.title }),
1053
+ ...(params.preserveCreated === true && { preserveCreated: true }),
1054
+ };
1055
+ const cloneOptions = {
1056
+ ...legOptions(options, txnId),
1057
+ cloneTemplateAsPlain: true,
1058
+ };
1059
+ const clone = kind === "project"
1060
+ ? await runCloneProject(deps, cloneParams, cloneOptions)
1061
+ : await runCloneTodo(deps, cloneParams, cloneOptions);
1062
+ if (clone.kind !== "ok" || clone.uuid === null) {
1063
+ // A nested-repeater refusal (a template CONTAINING a nested repeater) or any
1064
+ // clone failure surfaces coherently here — re-label it to the compound op.
1065
+ return clone.kind === "ok"
827
1066
  ? {
828
- detail: `${promote.detail} — the plain clone (uuid ${cloneUuid}) was created but the promote ` +
829
- `did not land; trash the clone with \`things ${kind} delete ${cloneUuid}\` and retry`,
1067
+ kind: "verify-failed",
1068
+ op,
1069
+ reason: "mismatch",
1070
+ expected: {
1071
+ mode: "create",
1072
+ probe: { title, type: expectedType, sinceEpoch: 0 },
1073
+ assert: [],
1074
+ },
1075
+ observed: null,
1076
+ detail: "the plain clone was created but its uuid was not discovered — nothing was promoted",
830
1077
  }
831
- : {}),
832
- };
833
- }
834
- const { templateUuid, instanceUuid } = discoveryOf(promote);
835
- const warnings = [NEW_SERIES_NOTE, PLACEMENT_NOTE];
836
- if (params.preserveCreated === true) {
837
- warnings.push("--preserve-created is best-effort on a template clone: the promote may replace the clone " +
838
- "row with the new template, whose creation date is the conversion time");
839
- }
840
- if (src.repeating.paused === true) {
841
- warnings.push(`the source template was PAUSED; the new series is created UNPAUSED and begins spawning — ` +
842
- `pause it with \`things ${kind} pause-repeat\` if you want it suspended`);
843
- }
844
- if (promote.warnings !== undefined)
845
- warnings.push(...promote.warnings);
846
- // Summary WITHOUT originalUuid → undo is the add-repeating trash-both (remove
847
- // the minted series; there is no original to restore).
848
- appendPromoteSummary(deps, {
849
- startedAt,
850
- op,
851
- txnId,
852
- templateUuid,
853
- instanceUuid,
854
- invocation: `${kind}.clone (template) ${srcUuid}: clone promote ${cloneUuid} → template ${templateUuid}`,
855
- requested: { source: srcUuid, title, ...inverse },
856
- });
857
- return promoteOk({
858
- op,
859
- templateUuid,
860
- instanceUuid,
861
- replacedUuid: cloneUuid,
862
- title,
863
- txnId,
864
- warnings,
1078
+ : { ...clone, op };
1079
+ }
1080
+ const cloneUuid = clone.uuid;
1081
+ // 5. Native-promote the clone with the FULL decoded rule (ruleToInverseParams
1082
+ // carries deadline/start-earlier + the calendar anchors + ends).
1083
+ const ruleParams = { uuid: cloneUuid, ...inverse };
1084
+ const promote = kind === "project"
1085
+ ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
1086
+ : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
1087
+ if (promote.kind !== "ok") {
1088
+ // The plain clone persists but was not promoted honest report (no original
1089
+ // to roll back; the clone is a fresh row the caller can trash and retry).
1090
+ return {
1091
+ ...promote,
1092
+ op,
1093
+ ...("detail" in promote
1094
+ ? {
1095
+ detail: `${promote.detail} — the plain clone (uuid ${cloneUuid}) was created but the promote ` +
1096
+ `did not land; trash the clone with \`things ${kind} delete ${cloneUuid}\` and retry`,
1097
+ }
1098
+ : {}),
1099
+ };
1100
+ }
1101
+ const { templateUuid, instanceUuid } = discoveryOf(promote);
1102
+ const warnings = [NEW_SERIES_NOTE, PLACEMENT_NOTE];
1103
+ if (params.preserveCreated === true) {
1104
+ warnings.push("--preserve-created is best-effort on a template clone: the promote may replace the clone " +
1105
+ "row with the new template, whose creation date is the conversion time");
1106
+ }
1107
+ if (src.repeating.paused === true) {
1108
+ warnings.push(`the source template was PAUSED; the new series is created UNPAUSED and begins spawning — ` +
1109
+ `pause it with \`things ${kind} pause-repeat\` if you want it suspended`);
1110
+ }
1111
+ if (promote.warnings !== undefined)
1112
+ warnings.push(...promote.warnings);
1113
+ // Summary WITHOUT originalUuid → undo is the add-repeating trash-both (remove
1114
+ // the minted series; there is no original to restore).
1115
+ appendPromoteSummary(deps, {
1116
+ startedAt,
1117
+ op,
1118
+ txnId,
1119
+ templateUuid,
1120
+ instanceUuid,
1121
+ invocation: `${kind}.clone (template) ${srcUuid}: clone → promote ${cloneUuid} → template ${templateUuid}`,
1122
+ requested: { source: srcUuid, title, ...inverse },
1123
+ });
1124
+ return promoteOk({
1125
+ op,
1126
+ templateUuid,
1127
+ instanceUuid,
1128
+ replacedUuid: cloneUuid,
1129
+ title,
1130
+ txnId,
1131
+ warnings,
1132
+ });
865
1133
  });
866
1134
  }
867
1135
  //# sourceMappingURL=promote-clone.js.map