things-api 0.19.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +3 -1
  2. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  3. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  5. package/dist/audit/schema.d.ts +19 -2
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/capability.d.ts +166 -4
  8. package/dist/capability.js +303 -13
  9. package/dist/capability.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +13 -1
  11. package/dist/cli/commands/doctor.js +52 -22
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/helpers.js +14 -3
  14. package/dist/cli/commands/helpers.js.map +1 -1
  15. package/dist/cli/commands/mcp.js +8 -0
  16. package/dist/cli/commands/mcp.js.map +1 -1
  17. package/dist/cli/commands/repeat-flags.js +34 -2
  18. package/dist/cli/commands/repeat-flags.js.map +1 -1
  19. package/dist/cli/commands/setup.js +16 -3
  20. package/dist/cli/commands/setup.js.map +1 -1
  21. package/dist/cli/commands/ui-state.d.ts +11 -0
  22. package/dist/cli/commands/ui-state.js +27 -0
  23. package/dist/cli/commands/ui-state.js.map +1 -0
  24. package/dist/cli/commands/writes.js +102 -67
  25. package/dist/cli/commands/writes.js.map +1 -1
  26. package/dist/cli/help.js +2 -0
  27. package/dist/cli/help.js.map +1 -1
  28. package/dist/cli/interrupt.d.ts +54 -6
  29. package/dist/cli/interrupt.js +78 -12
  30. package/dist/cli/interrupt.js.map +1 -1
  31. package/dist/cli/main.js +10 -5
  32. package/dist/cli/main.js.map +1 -1
  33. package/dist/client.d.ts +1 -1
  34. package/dist/contracts.d.ts +4 -4
  35. package/dist/contracts.js +1 -1
  36. package/dist/contracts.js.map +1 -1
  37. package/dist/deputy/install.d.ts +15 -1
  38. package/dist/deputy/install.js +46 -6
  39. package/dist/deputy/install.js.map +1 -1
  40. package/dist/deputy/osa.d.ts +42 -2
  41. package/dist/deputy/osa.js +79 -5
  42. package/dist/deputy/osa.js.map +1 -1
  43. package/dist/deputy/routing.d.ts +34 -0
  44. package/dist/deputy/routing.js +95 -0
  45. package/dist/deputy/routing.js.map +1 -1
  46. package/dist/deputy/wake.d.ts +61 -0
  47. package/dist/deputy/wake.js +149 -0
  48. package/dist/deputy/wake.js.map +1 -0
  49. package/dist/diagnose.d.ts +6 -4
  50. package/dist/diagnose.js +20 -4
  51. package/dist/diagnose.js.map +1 -1
  52. package/dist/direct-setup.d.ts +31 -5
  53. package/dist/direct-setup.js +200 -26
  54. package/dist/direct-setup.js.map +1 -1
  55. package/dist/host-access.d.ts +12 -0
  56. package/dist/host-access.js +12 -0
  57. package/dist/host-access.js.map +1 -1
  58. package/dist/index.d.ts +5 -3
  59. package/dist/index.js +3 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/mcp/server.js +23 -9
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/op-result.js +8 -1
  64. package/dist/op-result.js.map +1 -1
  65. package/dist/session-grant.js +16 -1
  66. package/dist/session-grant.js.map +1 -1
  67. package/dist/surface-copy.d.ts +11 -3
  68. package/dist/surface-copy.js +11 -3
  69. package/dist/surface-copy.js.map +1 -1
  70. package/dist/ui-state.d.ts +46 -0
  71. package/dist/ui-state.js +82 -0
  72. package/dist/ui-state.js.map +1 -0
  73. package/dist/wizard.js +8 -1
  74. package/dist/wizard.js.map +1 -1
  75. package/dist/write/accessibility-probe.js +4 -1
  76. package/dist/write/accessibility-probe.js.map +1 -1
  77. package/dist/write/automation-probe.js +7 -1
  78. package/dist/write/automation-probe.js.map +1 -1
  79. package/dist/write/availability.d.ts +0 -15
  80. package/dist/write/availability.js +7 -62
  81. package/dist/write/availability.js.map +1 -1
  82. package/dist/write/batch.js +17 -6
  83. package/dist/write/batch.js.map +1 -1
  84. package/dist/write/commands.d.ts +10 -6
  85. package/dist/write/commands.js +120 -31
  86. package/dist/write/commands.js.map +1 -1
  87. package/dist/write/failure-hints.d.ts +36 -7
  88. package/dist/write/failure-hints.js +33 -14
  89. package/dist/write/failure-hints.js.map +1 -1
  90. package/dist/write/field-limits.d.ts +100 -0
  91. package/dist/write/field-limits.js +144 -0
  92. package/dist/write/field-limits.js.map +1 -0
  93. package/dist/write/guards.js +3 -2
  94. package/dist/write/guards.js.map +1 -1
  95. package/dist/write/move.js +9 -12
  96. package/dist/write/move.js.map +1 -1
  97. package/dist/write/operations.d.ts +15 -8
  98. package/dist/write/operations.js +9 -0
  99. package/dist/write/operations.js.map +1 -1
  100. package/dist/write/opid.d.ts +67 -23
  101. package/dist/write/opid.js +141 -19
  102. package/dist/write/opid.js.map +1 -1
  103. package/dist/write/param-schema.d.ts +17 -44
  104. package/dist/write/param-schema.js +74 -7
  105. package/dist/write/param-schema.js.map +1 -1
  106. package/dist/write/pipeline.d.ts +38 -7
  107. package/dist/write/pipeline.js +134 -18
  108. package/dist/write/pipeline.js.map +1 -1
  109. package/dist/write/pre-state.d.ts +30 -16
  110. package/dist/write/pre-state.js +88 -20
  111. package/dist/write/pre-state.js.map +1 -1
  112. package/dist/write/promote-clone.d.ts +26 -0
  113. package/dist/write/promote-clone.js +333 -41
  114. package/dist/write/promote-clone.js.map +1 -1
  115. package/dist/write/repeat-collateral.d.ts +31 -0
  116. package/dist/write/repeat-collateral.js +153 -0
  117. package/dist/write/repeat-collateral.js.map +1 -0
  118. package/dist/write/resolution-timestamps.js +25 -2
  119. package/dist/write/resolution-timestamps.js.map +1 -1
  120. package/dist/write/template-mutation.js +163 -16
  121. package/dist/write/template-mutation.js.map +1 -1
  122. package/dist/write/vectors/applescript.js +3 -10
  123. package/dist/write/vectors/applescript.js.map +1 -1
  124. package/dist/write/vectors/simulator.js +8 -2
  125. package/dist/write/vectors/simulator.js.map +1 -1
  126. package/dist/write/vectors/types.d.ts +236 -4
  127. package/dist/write/vectors/ui-certification.js +63 -13
  128. package/dist/write/vectors/ui-certification.js.map +1 -1
  129. package/dist/write/vectors/ui-chord.d.ts +144 -0
  130. package/dist/write/vectors/ui-chord.js +428 -0
  131. package/dist/write/vectors/ui-chord.js.map +1 -0
  132. package/dist/write/vectors/ui-drag.d.ts +8 -4
  133. package/dist/write/vectors/ui-drag.js +2 -0
  134. package/dist/write/vectors/ui-drag.js.map +1 -1
  135. package/dist/write/vectors/ui-recipes.d.ts +51 -3
  136. package/dist/write/vectors/ui-recipes.js +452 -44
  137. package/dist/write/vectors/ui-recipes.js.map +1 -1
  138. package/dist/write/vectors/ui-state.d.ts +157 -0
  139. package/dist/write/vectors/ui-state.js +237 -0
  140. package/dist/write/vectors/ui-state.js.map +1 -0
  141. package/dist/write/vectors/ui.d.ts +386 -13
  142. package/dist/write/vectors/ui.js +1543 -124
  143. package/dist/write/vectors/ui.js.map +1 -1
  144. package/dist/write/vectors/url-scheme.js +3 -0
  145. package/dist/write/vectors/url-scheme.js.map +1 -1
  146. package/dist/write/verify/delta.d.ts +41 -0
  147. package/dist/write/verify/delta.js +36 -2
  148. package/dist/write/verify/delta.js.map +1 -1
  149. package/dist/write/verify/poller.d.ts +4 -1
  150. package/dist/write/verify/poller.js +14 -0
  151. package/dist/write/verify/poller.js.map +1 -1
  152. package/package.json +1 -1
  153. package/schema/envelope.schema.json +2 -1
  154. package/skills/things-cli/SKILL.md +2 -2
@@ -5,10 +5,11 @@ import { byUuid } from "../read/detail.js";
5
5
  import { resolveProjectWriteTarget, resolveTaskUuidPrefix } from "../read/queries.js";
6
6
  import { runCloneProject, runCloneTodo } from "./clone.js";
7
7
  import { promoteProjectViaGui } from "./make-repeating-project.js";
8
+ import { replayIfApplied } from "./opid.js";
8
9
  import { fingerprintLabel, runComposite, runMutation, } from "./pipeline.js";
9
10
  import { assessOffRuleFirst, daysBetweenIso, deriveFixedAnchor, isIsoDate, } from "./repeat-anchor.js";
10
11
  import { assertRepeatRule, ruleToInverseParams, splitAddRepeatingRule } from "./repeat-rule.js";
11
- import { createDbReader } from "./verify/delta.js";
12
+ import { createDbReader, } from "./verify/delta.js";
12
13
  import { H_UI_SESSION_UNREACHABLE } from "./vectors/session-reachability.js";
13
14
  const PLACEMENT_NOTE = "the series' current instance lands at its container's default position — its prior slot was " +
14
15
  "not automatically restored (best-effort placement is not yet wired for this container); " +
@@ -98,6 +99,29 @@ async function gateUiPreflight(deps, op) {
98
99
  }
99
100
  if (!deps.config.ui.enabled)
100
101
  return null;
102
+ // 1½. AN OPEN DIALOG, before the seed (MODALX1, issue #620). This is the gap
103
+ // the field incident fell into twice over: the clone leg rides the URL scheme,
104
+ // which an open dialog does not touch, so it LANDS — and then the trash leg,
105
+ // the promote and the cleanup are all AppleScript, all refused with `-1728`,
106
+ // leaving a copy of the user's to-do in their lists. Measured in-lab: with a
107
+ // dialog standing, the compound mutates and then fails for a reason it could
108
+ // have read up front. A census that cannot be read proceeds (the drive's own
109
+ // precondition is the backstop); only a POSITIVE sighting refuses.
110
+ const dialogVector = deps.vectors.find((v) => v.probeUiState !== undefined);
111
+ if (dialogVector?.probeUiState !== undefined) {
112
+ const state = await dialogVector.probeUiState();
113
+ if (state !== null && state.inspectable && state.sheetOpen) {
114
+ return {
115
+ kind: "blocked",
116
+ op,
117
+ reason: "environment",
118
+ detail: "a dialog is already open in Things, and while one is open the app ignores changes " +
119
+ "like this one and stops sending anything to Things Cloud — nothing was created",
120
+ remediation: "dismiss the dialog in Things (click Cancel, or press Escape with Things in front), " +
121
+ "then run the same command again; `things ui-state` shows what is open",
122
+ };
123
+ }
124
+ }
101
125
  const ui = deps.vectors.find((v) => v.probeReachability !== undefined);
102
126
  if (ui?.probeReachability === undefined)
103
127
  return null;
@@ -139,6 +163,126 @@ async function cleanupSeed(deps, kind, createdUuid, promote, options, txnId) {
139
163
  ? { detail: `${promote.detail} — ${cleanupNote}` }
140
164
  : {};
141
165
  }
166
+ /**
167
+ * Put a restored to-do back where it was (issue #620, field report M1).
168
+ *
169
+ * The ONLY scriptable restore-from-Trash is `move <to-do> to list "Inbox"`
170
+ * (E15) — and it does exactly that: the row lands in the INBOX, DE-SCHEDULED
171
+ * (`start=0, startDate=NULL`), with its project/heading link severed. That is
172
+ * the GUI's "Put Back" affordance's poorer scripted cousin, and it is why a
173
+ * failed promote handed back a to-do sitting unscheduled in the Inbox instead
174
+ * of where its owner left it. Everything else about the row survives (title,
175
+ * notes, tags, deadline, reminder byte), so the repair is exactly two writes:
176
+ * its container, then its schedule.
177
+ *
178
+ * Best-effort and fully disclosed: each half reports whether it landed, and a
179
+ * to-do that lived under a HEADING is returned to the owning project with the
180
+ * heading placement named as not restored — there is nothing to guess at.
181
+ * Returns the sentence describing what happened.
182
+ */
183
+ async function reassertTodoPlacement(deps, src, options, txnId) {
184
+ const landed = [];
185
+ const missed = [];
186
+ const projectUuid = src.project?.uuid ?? src.headingProject?.uuid ?? null;
187
+ const areaUuid = src.area?.uuid ?? null;
188
+ const move = projectUuid !== null
189
+ ? { uuid: src.uuid, project: { uuid: projectUuid } }
190
+ : areaUuid !== null
191
+ ? { uuid: src.uuid, area: { uuid: areaUuid } }
192
+ : null;
193
+ if (move !== null) {
194
+ const res = await runMutation(deps, "todo.move", move, legOptions(options, txnId));
195
+ const where = projectUuid !== null ? "its project" : "its area";
196
+ (res.kind === "ok" ? landed : missed).push(where);
197
+ }
198
+ if (src.heading !== null)
199
+ missed.push("its position under a heading");
200
+ // The schedule. `evening` is only expressible for TODAY (the app has no other
201
+ // evening slot), so a stale evening marker degrades to its date.
202
+ const todayIso = localToday(deps.now?.() ?? new Date(), deps.zone);
203
+ const when = src.derived.evening === true && src.startDate === todayIso
204
+ ? "evening"
205
+ : src.startDate !== null
206
+ ? src.startDate
207
+ : src.derived.start === "someday"
208
+ ? "someday"
209
+ : src.derived.start === "active"
210
+ ? "anytime"
211
+ : null;
212
+ if (when !== null) {
213
+ const res = await runMutation(deps, "todo.update", { uuid: src.uuid, when }, legOptions(options, txnId));
214
+ (res.kind === "ok" ? landed : missed).push(`its "when" (${when})`);
215
+ }
216
+ if (landed.length === 0 && missed.length === 0)
217
+ return "it was already in the Inbox, unscheduled";
218
+ const ok = landed.length > 0 ? `${landed.join(" and ")} restored` : "";
219
+ const bad = missed.length > 0
220
+ ? `${ok === "" ? "" : ", but "}${missed.join(" and ")} could not be restored — set ${missed.length > 1 ? "them" : "it"} yourself`
221
+ : "";
222
+ return `a scripted restore returns a to-do to the Inbox with no schedule, so ${ok}${bad}`;
223
+ }
224
+ /**
225
+ * Undo a failed make-repeating's OWN two mutations (issue #620).
226
+ *
227
+ * The compound trashes the original and mints a disposable copy before the
228
+ * promote runs, so a promote that does not land leaves two artifacts. Both are
229
+ * ours, so both are cleaned up here rather than described to the caller as
230
+ * homework: the original is restored AND put back where it was
231
+ * ({@link reassertTodoPlacement}), and the disposable copy is moved to the
232
+ * Trash — the same ratified reasoning as the add-repeating seed auto-trash
233
+ * (2026-08-15, issue #480): our artifact, recreatable verbatim, and the Trash
234
+ * is recoverable.
235
+ *
236
+ * When a step does NOT land, the report names the row and the command that
237
+ * finishes the job — and names the likeliest reason, because there is exactly
238
+ * one that matters here: a dialog still open in Things makes the app ignore
239
+ * scripted changes app-wide (docs/things-app-oddities.md §9cc) and holds Things
240
+ * Cloud sync with them. That is the mechanism behind the field report's
241
+ * "AppleScript could not get that to-do ID" on a row the database showed
242
+ * perfectly present.
243
+ */
244
+ async function rollBackFailedPromote(deps, kind, ids, options, txnId) {
245
+ const expectedType = kind === "project" ? "project" : "to-do";
246
+ const restoreOp = kind === "project" ? "project.restore" : "todo.restore";
247
+ const restored = await runMutation(deps, restoreOp, { uuid: ids.srcUuid }, legOptions(options, txnId));
248
+ let originalNote;
249
+ if (restored.kind === "ok") {
250
+ // A project restore flips the row in place and keeps its area, schedule and
251
+ // children exactly as they were (P06) — only the to-do restore relocates.
252
+ const placement = ids.src.type === "to-do" ? await reassertTodoPlacement(deps, ids.src, options, txnId) : null;
253
+ originalNote =
254
+ `the original ${expectedType} (uuid ${ids.srcUuid}) was restored from the Trash` +
255
+ (placement === null ? "" : ` — ${placement}`);
256
+ }
257
+ else {
258
+ originalNote =
259
+ `the original ${expectedType} (uuid ${ids.srcUuid}) could NOT be restored from the Trash — ` +
260
+ `restore it with \`things ${kind} restore ${ids.srcUuid}\`${DISMISS_FIRST}`;
261
+ }
262
+ const cloneNote = await trashDisposableCopy(deps, kind, ids.cloneUuid, options, txnId);
263
+ return `the promote did not land. ${originalNote}; ${cloneNote}`;
264
+ }
265
+ /**
266
+ * Move the compound's own disposable copy to the Trash and say what happened.
267
+ * Shared by every failure path that has minted one, so a caller is never left
268
+ * holding a duplicate of their own to-do with no instruction.
269
+ */
270
+ async function trashDisposableCopy(deps, kind, cloneUuid, options, txnId) {
271
+ const trashOp = kind === "project" ? "project.delete" : "todo.delete";
272
+ const trashed = await runMutation(deps, trashOp, { uuid: cloneUuid }, legOptions(options, txnId));
273
+ return trashed.kind === "ok"
274
+ ? `the disposable copy this command made (uuid ${cloneUuid}) was moved to the Trash`
275
+ : `the disposable copy this command made (uuid ${cloneUuid}) is still in your lists and ` +
276
+ `could NOT be removed — remove it with \`things ${kind} delete ${cloneUuid}\`${DISMISS_FIRST}`;
277
+ }
278
+ /**
279
+ * The one remediation a stranded dialog needs, appended wherever a cleanup
280
+ * mutation fails: while a dialog is open, Things ignores scripted changes
281
+ * app-wide AND stops sending anything to Things Cloud.
282
+ */
283
+ const DISMISS_FIRST = ". If a dialog is still open in Things, dismiss it first (click Cancel, or press Escape with " +
284
+ "Things in front): while one is open the app ignores changes like this and stops sending " +
285
+ "changes to Things Cloud";
142
286
  /**
143
287
  * Pick the rule fields (frequency/interval + calendar anchors + deadline offset)
144
288
  * as a RepeatRuleParams, plus the requested first-occurrence date to drive into
@@ -258,6 +402,33 @@ function landedFirstStart(deps, templateUuid, instanceUuid, afterCompletion) {
258
402
  return firstOccurrenceOf(deps.db, templateUuid);
259
403
  return instanceUuid === null ? null : instanceStartDate(deps.db, instanceUuid);
260
404
  }
405
+ /**
406
+ * Was the requested first occurrence honored? (#508's oracle, extended for the
407
+ * SAME-DAY case — issue #625.)
408
+ *
409
+ * A fixed series is normally read off the template's cursor. But when the
410
+ * requested first occurrence is TODAY, the app materializes that occurrence
411
+ * immediately on commit and ADVANCES the cursor to the next slot — measured on
412
+ * 3.23 (FGRD1 §8: `--when <today>` on a weekly rule left the template's cursor
413
+ * and next-date on the FOLLOWING week with `instanceCreationCount = 1`, while a
414
+ * live instance sat on today). Reading only the cursor there reports a
415
+ * `verify-failed:mismatch` on a series that landed exactly as asked — the same
416
+ * false-negative shape as #508, one case over. So the check accepts EITHER
417
+ * oracle: the cursor naming the requested date, or a materialized instance
418
+ * SITTING on it. An instance dated the requested day is proof by construction;
419
+ * nothing else can put one there.
420
+ */
421
+ export function firstOccurrenceHonored(deps, args) {
422
+ const landed = landedFirstStart(deps, args.templateUuid, args.instanceUuid, args.afterCompletion);
423
+ // An after-completion series with no materialized instance is UNVERIFIABLE
424
+ // (the create delta already proved the series landed) — never a mismatch.
425
+ if (args.afterCompletion && landed === null)
426
+ return { honored: true, landed };
427
+ if (landed === args.expectedIso)
428
+ return { honored: true, landed };
429
+ const instanceIso = args.instanceUuid === null ? null : instanceStartDate(deps.db, args.instanceUuid);
430
+ return { honored: instanceIso === args.expectedIso, landed };
431
+ }
261
432
  /**
262
433
  * DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md, golden-v3 / Things 3.22.14):
263
434
  * a promote whose source is PRESERVED (SRCFATE deadline / terminal-element trigger)
@@ -340,6 +511,36 @@ function discoveryOf(promote) {
340
511
  };
341
512
  }
342
513
  // -------------------------------------------------------------- audit summary
514
+ /**
515
+ * The promote compound's presence oracle: "a repeating TEMPLATE with this title,
516
+ * created since this call started, exists". It is the same create-probe the
517
+ * compound's own dry-run plan states, TIME-BOUNDED to the call — the app mints a
518
+ * template with a fresh write-time creationDate even when the promoted row was
519
+ * created earlier (`--preserve-created`), so the bound is safe and it keeps an
520
+ * older namesake series from reading as this call's work. Recorded on an
521
+ * AMBIGUOUS summary so a resubmission can settle it (see {@link appendPromoteSummary}).
522
+ */
523
+ function promotePresenceDelta(title, type, startedAt) {
524
+ return {
525
+ mode: "create",
526
+ probe: { title, type, sinceEpoch: Math.floor(startedAt.getTime() / 1000) },
527
+ assert: [{ field: "repeating.isTemplate", equals: true }],
528
+ };
529
+ }
530
+ /**
531
+ * Append the promote compound's SUMMARY record — the ONE record that stands for
532
+ * the whole verb (its legs are `leg`-role records, excluded from direct undo),
533
+ * and the one record the caller's `opId` rides, so a resubmission matches the
534
+ * whole promote exactly once instead of matching a leg or nothing at all.
535
+ *
536
+ * Two shapes, one record either way:
537
+ * - the SUCCESS summary (default) — result `ok`, the single undoable unit;
538
+ * - an AMBIGUOUS summary (`ambiguous`) — result `verify-failed:timeout` with the
539
+ * presence oracle attached, written when a leg dispatched and never confirmed.
540
+ * It is not an undo target (undo reads `ok` records only); it exists so a
541
+ * resubmission carrying the same key re-reads state and decides instead of
542
+ * minting a second series.
543
+ */
343
544
  function appendPromoteSummary(deps, args) {
344
545
  const fp = deps.fingerprint();
345
546
  const observed = {
@@ -350,7 +551,7 @@ function appendPromoteSummary(deps, args) {
350
551
  const record = {
351
552
  v: 1,
352
553
  ts: args.startedAt.toISOString(),
353
- actor: deps.config.actor,
554
+ actor: args.options?.actor ?? deps.config.actor,
354
555
  host: deps.config.host,
355
556
  op: args.op,
356
557
  uuid: args.templateUuid,
@@ -359,9 +560,11 @@ function appendPromoteSummary(deps, args) {
359
560
  invocation: args.invocation,
360
561
  requested: args.requested,
361
562
  txn: { id: args.txnId, role: "summary" },
563
+ ...(args.options?.opId !== undefined && { opId: args.options.opId }),
564
+ ...(args.ambiguous !== undefined && { expected: args.ambiguous }),
362
565
  pre: null,
363
- observed,
364
- result: "ok",
566
+ observed: args.ambiguous === undefined ? observed : null,
567
+ result: args.ambiguous === undefined ? "ok" : "verify-failed:timeout",
365
568
  ...(args.preModDates !== undefined && { preModDates: args.preModDates }),
366
569
  verify: null,
367
570
  durationMs: (deps.now?.() ?? new Date()).getTime() - args.startedAt.getTime(),
@@ -373,6 +576,31 @@ function appendPromoteSummary(deps, args) {
373
576
  };
374
577
  deps.audit.append(record);
375
578
  }
579
+ /**
580
+ * Record the AMBIGUOUS summary when a promote compound's own outcome is a
581
+ * timeout — the one verdict where the series may or may not exist. Returns the
582
+ * outcome unchanged, so it wraps a composite body without re-shaping it. Only a
583
+ * keyed call records one: without an `opId` there is nothing to reconcile
584
+ * against later, and the leg records already carry the failure.
585
+ */
586
+ function recordAmbiguousPromote(deps, outcome, args) {
587
+ if (args.options.opId === undefined)
588
+ return outcome;
589
+ if (outcome.kind !== "verify-failed" || outcome.reason !== "timeout")
590
+ return outcome;
591
+ appendPromoteSummary(deps, {
592
+ startedAt: args.startedAt,
593
+ op: args.op,
594
+ txnId: args.txnId,
595
+ templateUuid: null,
596
+ instanceUuid: null,
597
+ invocation: args.invocation,
598
+ requested: args.requested,
599
+ options: args.options,
600
+ ambiguous: promotePresenceDelta(args.title, args.type, args.startedAt),
601
+ });
602
+ return outcome;
603
+ }
376
604
  /** Build the ok result for a promote (make/add-repeating). */
377
605
  function promoteOk(args) {
378
606
  const repeating = {
@@ -409,6 +637,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
409
637
  const op = kind === "project" ? "project.make-repeating" : "todo.make-repeating";
410
638
  // Validate the rule BEFORE anything (a bad rule must never mint a clone).
411
639
  assertRepeatRule(params);
640
+ // Idempotency FIRST, before the target is even resolved: a successful promote
641
+ // moves the original to the Trash and hands back a different uuid, so a
642
+ // resubmission of the same key must be answered from the trail rather than
643
+ // re-driven against a source that is no longer the item it names. The key
644
+ // rides the compound's single summary record, so a match is the whole verb.
645
+ const replay = replayIfApplied(deps, options);
646
+ if (replay !== null)
647
+ return replay;
412
648
  const now = deps.now?.() ?? new Date();
413
649
  const srcUuid = kind === "project"
414
650
  ? resolveProjectWriteTarget(deps.db, params.uuid)
@@ -427,6 +663,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
427
663
  : "verify the uuid with `things show <uuid>`, or use `things project make-repeating` for a project",
428
664
  };
429
665
  }
666
+ // Bound after the existence guard so the composite body (a hoisted function,
667
+ // where TypeScript cannot carry the narrowing) still sees a plain string.
668
+ const srcTitle = src.title;
669
+ // Same reason as srcTitle: the type guard above proved this is the to-do or
670
+ // project the caller named, but the narrowing does not survive into the
671
+ // hoisted composite body. The failure rollback reads its placement from here
672
+ // (captured BEFORE the trash, which is the only moment it is knowable).
673
+ const srcEntity = src;
430
674
  // ANCH2 (issue #476): the app's Repeat dialog HAS a "Next:" first-occurrence
431
675
  // field; its default is the today-anchored next match, but it is editable and
432
676
  // honored (docs/lab/anch2-next-field.md). Drive it with the requested first
@@ -449,10 +693,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
449
693
  ? (params.startDaysEarlier ?? 0)
450
694
  : 0;
451
695
  const driveIso = isIsoDate(whenIso) ? addDaysIso(whenIso, deadlineShift) : undefined;
452
- // The dialog is driven with the deadline-adjusted date; the verify below expects
453
- // the START to land back on the requested `--when`.
454
- const nextIso = driveIso;
455
- const expectedStartIso = isIsoDate(whenIso) ? whenIso : undefined;
696
+ // The ANCHOR is derived from the deadline-adjusted date (the anchor names the
697
+ // DUE date), but `next` is passed through as the requested START: the promote
698
+ // leg's own compile applies the "Next:" shift, and it is the ONLY place that
699
+ // does (NEXTPOP1 shifting here as well made every downstream consumer that
700
+ // shifts, `assessOffRuleFirst` among them, shift a second time). The verify
701
+ // below expects the START to land on the requested `--when` either way.
702
+ const nextIso = isIsoDate(whenIso) ? whenIso : undefined;
703
+ const expectedStartIso = nextIso;
456
704
  const effParams = { ...params, ...deriveFixedAnchor(params, driveIso) };
457
705
  // The promote leg drives the GUI — block before minting a clone if the ack is missing.
458
706
  if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
@@ -484,14 +732,29 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
484
732
  const gate = await gateUiPreflight(deps, op);
485
733
  if (gate !== null)
486
734
  return gate;
735
+ const startedAt = now;
736
+ const txnId = newTxnId(startedAt);
487
737
  // COMPOSITE LOCK: everything below is ONE verb executed as several mutations
488
738
  // (clone → trash → promote → the DBLSPAWN1 clean-up), and they must not
489
739
  // interleave with another writer's legs — the promote's row selection is by
490
740
  // TITLE, so a concurrent clone of the same item makes it ambiguous. One lock,
491
741
  // held to the end; each leg's own acquisition is a reentrant no-op.
492
742
  return runComposite(deps, op, async () => {
493
- const startedAt = now;
494
- const txnId = newTxnId(startedAt);
743
+ const outcome = await promoteBody();
744
+ // An unconfirmed outcome gets the compound's AMBIGUOUS summary, so a
745
+ // resubmission of the same key reconciles instead of minting a second series.
746
+ return recordAmbiguousPromote(deps, outcome, {
747
+ startedAt,
748
+ op,
749
+ txnId,
750
+ title: srcTitle,
751
+ type: expectedType,
752
+ invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote (unconfirmed)`,
753
+ requested: effParams,
754
+ options,
755
+ });
756
+ });
757
+ async function promoteBody() {
495
758
  // 1. Clone the source as a disposable, embedded leg (--preserve-created). The
496
759
  // clone has captured X's full content by the time it returns.
497
760
  const clone = kind === "project"
@@ -507,7 +770,7 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
507
770
  reason: "mismatch",
508
771
  expected: {
509
772
  mode: "create",
510
- probe: { title: src.title, type: expectedType, sinceEpoch: 0 },
773
+ probe: { title: srcTitle, type: expectedType, sinceEpoch: 0 },
511
774
  assert: [],
512
775
  },
513
776
  observed: null,
@@ -531,14 +794,16 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
531
794
  // ambiguous (H-PROJECT-REPEAT). X survives in the Trash (the recoverable half).
532
795
  const trash = await runMutation(deps, `${kind}.delete`, { uuid: srcUuid }, legOptions(options, txnId, undefined, preserveModified ? { preserveModified: true } : undefined));
533
796
  if (trash.kind !== "ok") {
797
+ // The original never moved, so there is nothing to restore — but the
798
+ // disposable copy exists and is ours to clean up (issue #620).
799
+ const cloneNote = await trashDisposableCopy(deps, kind, cloneUuid, options, txnId);
534
800
  return {
535
801
  ...trash,
536
802
  op,
537
803
  ...("detail" in trash
538
804
  ? {
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",
805
+ detail: `${trash.detail} — the original ${srcUuid} could not be moved to the Trash, so it ` +
806
+ `was NOT promoted and is unchanged; ${cloneNote}`,
542
807
  }
543
808
  : {}),
544
809
  };
@@ -549,22 +814,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
549
814
  ? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
550
815
  : await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
551
816
  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`;
817
+ // The clone persists but was not promoted. Undo our own two mutations, in
818
+ // the order that leaves the least behind (issue #620) — see
819
+ // {@link rollBackFailedPromote}.
820
+ const rollNote = await rollBackFailedPromote(deps, kind, { srcUuid, cloneUuid, src: srcEntity }, options, txnId);
559
821
  return {
560
822
  ...promote,
561
823
  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
- : {}),
824
+ ...("detail" in promote ? { detail: `${promote.detail} — ${rollNote}` } : {}),
568
825
  };
569
826
  }
570
827
  const { templateUuid } = discoveryOf(promote);
@@ -577,10 +834,14 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
577
834
  // (#508) — see landedFirstStart; an unverifiable after-completion series skips.
578
835
  const afterCompletion = effParams.afterCompletion === true;
579
836
  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);
583
- }
837
+ const first = firstOccurrenceHonored(deps, {
838
+ templateUuid,
839
+ instanceUuid,
840
+ expectedIso: expectedStartIso,
841
+ afterCompletion,
842
+ });
843
+ if (!first.honored)
844
+ return nextMismatch(op, templateUuid, expectedStartIso, first.landed);
584
845
  }
585
846
  const warnings = [
586
847
  landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
@@ -611,6 +872,7 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
611
872
  originalUuid: srcUuid,
612
873
  invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
613
874
  requested: effParams,
875
+ options,
614
876
  ...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
615
877
  });
616
878
  return promoteOk({
@@ -618,11 +880,11 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
618
880
  templateUuid,
619
881
  instanceUuid,
620
882
  replacedUuid: cloneUuid,
621
- title: src.title,
883
+ title: srcTitle,
622
884
  txnId,
623
885
  warnings,
624
886
  });
625
- });
887
+ }
626
888
  }
627
889
  export function runMakeRepeatingTodo(deps, params, options = {}) {
628
890
  return makeRepeatingViaClone(deps, "todo", params, options);
@@ -639,6 +901,12 @@ export function runMakeRepeatingProject(deps, params, options = {}) {
639
901
  async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options) {
640
902
  const op = kind === "project" ? "project.add-repeating" : "todo.add-repeating";
641
903
  assertRepeatRule(rule);
904
+ // Idempotency FIRST: this verb CREATES, so a blind resubmission is a duplicate
905
+ // series. The key rides the compound's single summary record, so a match is the
906
+ // whole verb — the add leg and the promote leg together — not one of them.
907
+ const replay = replayIfApplied(deps, options);
908
+ if (replay !== null)
909
+ return replay;
642
910
  // ANCH2 (issue #476): drive the Repeat dialog's "Next:" field with --when so the
643
911
  // series starts on the requested date (the field's default is today-anchored but
644
912
  // it is editable and honored). YANCH1 (issue #493): also DERIVE the calendar
@@ -658,8 +926,11 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
658
926
  const whenIso = isIsoDate(addParams["when"]) ? addParams["when"] : null;
659
927
  const deadlineShift = rule.deadline === true || (rule.startDaysEarlier ?? 0) > 0 ? (rule.startDaysEarlier ?? 0) : 0;
660
928
  const driveIso = whenIso !== null ? addDaysIso(whenIso, deadlineShift) : null;
661
- const nextIso = driveIso ?? undefined;
662
- const expectedStartIso = whenIso ?? undefined;
929
+ // The ANCHOR is derived from the deadline-adjusted date; `next` passes through
930
+ // as the requested START and the promote leg's compile applies the "Next:"
931
+ // shift — the one place that does (NEXTPOP1; see promoteViaCloneAndMakeRepeating).
932
+ const nextIso = whenIso ?? undefined;
933
+ const expectedStartIso = nextIso;
663
934
  const effRule = {
664
935
  ...rule,
665
936
  ...deriveFixedAnchor(rule, driveIso),
@@ -706,12 +977,27 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
706
977
  const gate = await gateUiPreflight(deps, op);
707
978
  if (gate !== null)
708
979
  return gate;
980
+ const startedAt = deps.now?.() ?? new Date();
981
+ const txnId = newTxnId(startedAt);
709
982
  // COMPOSITE LOCK: add → promote (→ the seed auto-trash / DBLSPAWN1 clean-up)
710
983
  // is one verb, several mutations; hold one lock across all of them so a
711
984
  // concurrent composite cannot land its own legs between ours.
712
985
  return runComposite(deps, op, async () => {
713
- const startedAt = deps.now?.() ?? new Date();
714
- const txnId = newTxnId(startedAt);
986
+ const outcome = await addBody();
987
+ // An unconfirmed outcome gets the compound's AMBIGUOUS summary, so a
988
+ // resubmission of the same key reconciles instead of creating a second series.
989
+ return recordAmbiguousPromote(deps, outcome, {
990
+ startedAt,
991
+ op,
992
+ txnId,
993
+ title,
994
+ type: expectedType,
995
+ invocation: `${op}: add "${title}" → promote (unconfirmed)`,
996
+ requested: { title, ...effRule },
997
+ options,
998
+ });
999
+ });
1000
+ async function addBody() {
715
1001
  // 1. Create the item (full add vocabulary) as an embedded leg.
716
1002
  const addOp = kind === "project" ? "project.add" : "todo.add";
717
1003
  const add = await runMutation(deps, addOp, addParams, legOptions(options, txnId, "url-scheme"));
@@ -762,9 +1048,14 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
762
1048
  // series verifies against its materialized instance, and skips when it has none.
763
1049
  const afterCompletion = effRuleWithReminder.afterCompletion === true;
764
1050
  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);
1051
+ const first = firstOccurrenceHonored(deps, {
1052
+ templateUuid,
1053
+ instanceUuid,
1054
+ expectedIso: expectedStartIso,
1055
+ afterCompletion,
1056
+ });
1057
+ if (!first.honored)
1058
+ return nextMismatch(op, templateUuid, expectedStartIso, first.landed);
768
1059
  }
769
1060
  const warnings = [
770
1061
  landedRuleEcho(ruleParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
@@ -792,6 +1083,7 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
792
1083
  instanceUuid,
793
1084
  invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
794
1085
  requested: { title, ...effRule },
1086
+ options,
795
1087
  });
796
1088
  return promoteOk({
797
1089
  op,
@@ -802,7 +1094,7 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
802
1094
  txnId,
803
1095
  warnings,
804
1096
  });
805
- });
1097
+ }
806
1098
  }
807
1099
  /**
808
1100
  * Fold a concrete item-level `deadline` (and/or an explicit `startDaysEarlier`)