things-api 0.18.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 (191) hide show
  1. package/README.md +16 -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 +13 -1
  20. package/dist/audit/schema.js.map +1 -1
  21. package/dist/capability.d.ts +160 -0
  22. package/dist/capability.js +416 -0
  23. package/dist/capability.js.map +1 -0
  24. package/dist/cli/commands/doctor.js +127 -1
  25. package/dist/cli/commands/doctor.js.map +1 -1
  26. package/dist/cli/commands/helpers.js +178 -51
  27. package/dist/cli/commands/helpers.js.map +1 -1
  28. package/dist/cli/commands/op-result.js +6 -0
  29. package/dist/cli/commands/op-result.js.map +1 -1
  30. package/dist/cli/commands/reads.js +47 -1
  31. package/dist/cli/commands/reads.js.map +1 -1
  32. package/dist/cli/commands/setup.d.ts +9 -0
  33. package/dist/cli/commands/setup.js +63 -83
  34. package/dist/cli/commands/setup.js.map +1 -1
  35. package/dist/cli/commands/writes.d.ts +1 -1
  36. package/dist/cli/commands/writes.js +241 -107
  37. package/dist/cli/commands/writes.js.map +1 -1
  38. package/dist/cli/dry-run.d.ts +1 -1
  39. package/dist/cli/dry-run.js +1 -1
  40. package/dist/cli/glyphs.d.ts +23 -4
  41. package/dist/cli/glyphs.js +46 -19
  42. package/dist/cli/glyphs.js.map +1 -1
  43. package/dist/cli/help.d.ts +1 -1
  44. package/dist/cli/help.js +49 -27
  45. package/dist/cli/help.js.map +1 -1
  46. package/dist/cli/period.d.ts +3 -1
  47. package/dist/cli/period.js +1 -1
  48. package/dist/cli/period.js.map +1 -1
  49. package/dist/cli/read-driver.js +26 -1
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/render.d.ts +31 -4
  52. package/dist/cli/render.js +127 -15
  53. package/dist/cli/render.js.map +1 -1
  54. package/dist/client.d.ts +22 -2
  55. package/dist/client.js +36 -13
  56. package/dist/client.js.map +1 -1
  57. package/dist/contracts.d.ts +6 -2
  58. package/dist/contracts.js +1 -1
  59. package/dist/contracts.js.map +1 -1
  60. package/dist/deputy/install.d.ts +219 -5
  61. package/dist/deputy/install.js +803 -31
  62. package/dist/deputy/install.js.map +1 -1
  63. package/dist/deputy/notices.js +2 -2
  64. package/dist/deputy/notices.js.map +1 -1
  65. package/dist/deputy/protocol.d.ts +59 -5
  66. package/dist/deputy/protocol.js +48 -11
  67. package/dist/deputy/protocol.js.map +1 -1
  68. package/dist/deputy/routing.d.ts +21 -0
  69. package/dist/deputy/routing.js +111 -27
  70. package/dist/deputy/routing.js.map +1 -1
  71. package/dist/diagnose.d.ts +14 -1
  72. package/dist/diagnose.js +46 -6
  73. package/dist/diagnose.js.map +1 -1
  74. package/dist/direct-setup.d.ts +78 -0
  75. package/dist/direct-setup.js +484 -0
  76. package/dist/direct-setup.js.map +1 -0
  77. package/dist/host-access.d.ts +63 -0
  78. package/dist/host-access.js +165 -0
  79. package/dist/host-access.js.map +1 -0
  80. package/dist/index.d.ts +20 -5
  81. package/dist/index.js +14 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/mcp/server.d.ts +27 -1
  84. package/dist/mcp/server.js +285 -100
  85. package/dist/mcp/server.js.map +1 -1
  86. package/dist/op-result.d.ts +9 -0
  87. package/dist/op-result.js +5 -0
  88. package/dist/op-result.js.map +1 -1
  89. package/dist/read/filter-contract.d.ts +1 -1
  90. package/dist/read/filter-contract.js +4 -0
  91. package/dist/read/filter-contract.js.map +1 -1
  92. package/dist/read/shape.js +4 -0
  93. package/dist/read/shape.js.map +1 -1
  94. package/dist/read/views.d.ts +29 -0
  95. package/dist/read/views.js +60 -0
  96. package/dist/read/views.js.map +1 -1
  97. package/dist/session-grant.d.ts +48 -0
  98. package/dist/session-grant.js +170 -0
  99. package/dist/session-grant.js.map +1 -0
  100. package/dist/surface-copy.d.ts +8 -0
  101. package/dist/surface-copy.js +8 -0
  102. package/dist/surface-copy.js.map +1 -1
  103. package/dist/wizard.d.ts +58 -0
  104. package/dist/wizard.js +167 -0
  105. package/dist/wizard.js.map +1 -0
  106. package/dist/write/availability.d.ts +1 -1
  107. package/dist/write/availability.js +3 -3
  108. package/dist/write/availability.js.map +1 -1
  109. package/dist/write/batch.d.ts +20 -0
  110. package/dist/write/batch.js +94 -15
  111. package/dist/write/batch.js.map +1 -1
  112. package/dist/write/capabilities.d.ts +9 -0
  113. package/dist/write/capabilities.js +2 -0
  114. package/dist/write/capabilities.js.map +1 -1
  115. package/dist/write/clear-reminder.js +1 -1
  116. package/dist/write/clear-reminder.js.map +1 -1
  117. package/dist/write/commands.d.ts +12 -1
  118. package/dist/write/commands.js +112 -224
  119. package/dist/write/commands.js.map +1 -1
  120. package/dist/write/failure-hints.d.ts +3 -1
  121. package/dist/write/failure-hints.js.map +1 -1
  122. package/dist/write/guards.js +19 -2
  123. package/dist/write/guards.js.map +1 -1
  124. package/dist/write/lock.d.ts +16 -13
  125. package/dist/write/lock.js +55 -0
  126. package/dist/write/lock.js.map +1 -1
  127. package/dist/write/make-repeating-project.js +9 -13
  128. package/dist/write/make-repeating-project.js.map +1 -1
  129. package/dist/write/operations.d.ts +36 -21
  130. package/dist/write/operations.js +7 -0
  131. package/dist/write/operations.js.map +1 -1
  132. package/dist/write/opid.d.ts +14 -0
  133. package/dist/write/opid.js +22 -0
  134. package/dist/write/opid.js.map +1 -1
  135. package/dist/write/param-schema.d.ts +124 -0
  136. package/dist/write/param-schema.js +747 -0
  137. package/dist/write/param-schema.js.map +1 -0
  138. package/dist/write/pipeline.d.ts +76 -2
  139. package/dist/write/pipeline.js +190 -2
  140. package/dist/write/pipeline.js.map +1 -1
  141. package/dist/write/pre-state.d.ts +8 -0
  142. package/dist/write/pre-state.js +29 -2
  143. package/dist/write/pre-state.js.map +1 -1
  144. package/dist/write/promote-clone.js +539 -439
  145. package/dist/write/promote-clone.js.map +1 -1
  146. package/dist/write/reorder.js +25 -8
  147. package/dist/write/reorder.js.map +1 -1
  148. package/dist/write/repeat-rule.d.ts +28 -1
  149. package/dist/write/repeat-rule.js +54 -4
  150. package/dist/write/repeat-rule.js.map +1 -1
  151. package/dist/write/resolution-timestamps.d.ts +11 -0
  152. package/dist/write/resolution-timestamps.js +83 -38
  153. package/dist/write/resolution-timestamps.js.map +1 -1
  154. package/dist/write/reversibility.js +4 -0
  155. package/dist/write/reversibility.js.map +1 -1
  156. package/dist/write/template-mutation.d.ts +137 -0
  157. package/dist/write/template-mutation.js +451 -0
  158. package/dist/write/template-mutation.js.map +1 -0
  159. package/dist/write/undo.js +10 -20
  160. package/dist/write/undo.js.map +1 -1
  161. package/dist/write/update-fields.d.ts +151 -0
  162. package/dist/write/update-fields.js +427 -0
  163. package/dist/write/update-fields.js.map +1 -0
  164. package/dist/write/vectors/applescript.js +3 -0
  165. package/dist/write/vectors/applescript.js.map +1 -1
  166. package/dist/write/vectors/registry.d.ts +1 -1
  167. package/dist/write/vectors/registry.js +10 -2
  168. package/dist/write/vectors/registry.js.map +1 -1
  169. package/dist/write/vectors/shortcuts.js +2 -2
  170. package/dist/write/vectors/shortcuts.js.map +1 -1
  171. package/dist/write/vectors/simulator.js +7 -2
  172. package/dist/write/vectors/simulator.js.map +1 -1
  173. package/dist/write/vectors/types.d.ts +57 -0
  174. package/dist/write/vectors/ui-recipes.d.ts +19 -0
  175. package/dist/write/vectors/ui-recipes.js +30 -0
  176. package/dist/write/vectors/ui-recipes.js.map +1 -1
  177. package/dist/write/vectors/ui.d.ts +13 -0
  178. package/dist/write/vectors/ui.js +62 -13
  179. package/dist/write/vectors/ui.js.map +1 -1
  180. package/dist/write/verify/delta.d.ts +21 -0
  181. package/dist/write/verify/delta.js.map +1 -1
  182. package/package.json +2 -1
  183. package/schema/envelope.schema.json +5 -3
  184. package/scripts/build-helpers.sh +5 -0
  185. package/skills/things-cli/SKILL.md +5 -3
  186. package/skills/things-cli/references/contracts.md +1 -0
  187. package/skills/things-cli/references/data-model.md +3 -0
  188. package/skills/things-cli/references/errors.md +1 -1
  189. package/skills/things-cli/references/gui.md +1 -0
  190. package/skills/things-cli/references/repeating.md +6 -0
  191. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -1,12 +1,13 @@
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 { fingerprintLabel, runComposite, runMutation, } from "./pipeline.js";
8
9
  import { assessOffRuleFirst, daysBetweenIso, deriveFixedAnchor, isIsoDate, } from "./repeat-anchor.js";
9
- import { assertRepeatRule, ruleToInverseParams } from "./repeat-rule.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",
@@ -214,6 +232,32 @@ function instanceStartDate(db, instanceUuid) {
214
232
  return null;
215
233
  return decodePackedDate(row.s);
216
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
+ }
217
261
  /**
218
262
  * DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md, golden-v3 / Things 3.22.14):
219
263
  * a promote whose source is PRESERVED (SRCFATE deadline / terminal-element trigger)
@@ -234,7 +278,16 @@ function instanceStartDate(db, instanceUuid) {
234
278
  * promote did not preserve a future instance. Best-effort: a failed trash is reported
235
279
  * in the warning rather than failing the whole compound (the series is already sound).
236
280
  */
237
- async function trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now) {
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;
238
291
  const rep = promote.repeating;
239
292
  // Preserved iff the native promote relinked the source (replacedUuid === null) AND
240
293
  // there is a materialized instance. A DELETE-fate promote reports replacedUuid !==
@@ -424,141 +477,151 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
424
477
  },
425
478
  };
426
479
  }
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);
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);
431
485
  if (gate !== null)
432
486
  return gate;
433
- const startedAt = now;
434
- 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",
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,
446
536
  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",
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);
455
583
  }
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,
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,
476
607
  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,
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({
501
617
  op,
502
- ...("detail" in promote
503
- ? {
504
- detail: `${promote.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
505
- `promote did not land; ${rollNote}. Trash the clone and retry`,
506
- }
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);
521
- }
522
- }
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,
618
+ templateUuid,
619
+ instanceUuid,
620
+ replacedUuid: cloneUuid,
621
+ title: src.title,
622
+ txnId,
623
+ warnings,
624
+ });
562
625
  });
563
626
  }
564
627
  export function runMakeRepeatingTodo(deps, params, options = {}) {
@@ -636,165 +699,185 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
636
699
  },
637
700
  };
638
701
  }
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);
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);
643
707
  if (gate !== null)
644
708
  return gate;
645
- const startedAt = deps.now?.() ?? new Date();
646
- 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 };
665
- }
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 };
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");
684
839
  }
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);
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;
697
854
  }
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;
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;
710
859
  }
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
- });
860
+ // The RULE owns the deadline; the seed carries none.
861
+ return {
862
+ rule: { ...baseRule, deadline: true, startDaysEarlier: startEarlier },
863
+ seedDeadline: undefined,
864
+ };
734
865
  }
735
866
  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.
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).
768
877
  let rule = baseRule;
769
878
  let seedDeadline = add.deadline;
770
879
  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
880
+ ({ rule, seedDeadline } = mapDeadlineOntoRule(baseRule, add.when, add.deadline, startDaysEarlier));
798
881
  }
799
882
  else if (startDaysEarlier !== undefined) {
800
883
  // afterCompletion === true here: an after-completion repeat has no calendar
@@ -802,45 +885,54 @@ export async function runAddRepeatingTodo(deps, params, options = {}) {
802
885
  throw new RangeError("--start-days-earlier applies only to a fixed-schedule deadline — an after-completion repeat " +
803
886
  "has no calendar start to count back from; drop --after-completion or --start-days-earlier");
804
887
  }
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
- };
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;
818
897
  return addRepeatingViaCreate(deps, "todo", addParams, rule, add.title, options);
819
898
  }
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
- };
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
+ }
831
924
  // Seed a pure-AX taxonomy: an area lands a selectable AREA-view row; otherwise
832
925
  // create in Someday (UIC4-f) so the promote skips the anytime-header problem.
833
926
  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
- };
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;
844
936
  return addRepeatingViaCreate(deps, "project", addParams, rule, add.title, options);
845
937
  }
846
938
  // ====================================================== template-direct clone
@@ -941,95 +1033,103 @@ export async function cloneTemplateViaRepromote(deps, kind, src, srcUuid, params
941
1033
  },
942
1034
  };
943
1035
  }
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);
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);
947
1040
  if (gate !== null)
948
1041
  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
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"
995
1066
  ? {
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`,
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",
998
1077
  }
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,
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
+ });
1033
1133
  });
1034
1134
  }
1035
1135
  //# sourceMappingURL=promote-clone.js.map