things-api 0.15.0 → 0.17.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 (145) hide show
  1. package/README.md +4 -4
  2. package/dist/audit/schema.d.ts +8 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/commands/install-skill.js +9 -2
  5. package/dist/cli/commands/install-skill.js.map +1 -1
  6. package/dist/cli/commands/repeat-flags.d.ts +14 -4
  7. package/dist/cli/commands/repeat-flags.js +113 -33
  8. package/dist/cli/commands/repeat-flags.js.map +1 -1
  9. package/dist/cli/commands/todo.js +49 -2
  10. package/dist/cli/commands/todo.js.map +1 -1
  11. package/dist/cli/commands/writes.js +241 -66
  12. package/dist/cli/commands/writes.js.map +1 -1
  13. package/dist/cli/help.js +23 -13
  14. package/dist/cli/help.js.map +1 -1
  15. package/dist/cli/interrupt.d.ts +42 -0
  16. package/dist/cli/interrupt.js +105 -0
  17. package/dist/cli/interrupt.js.map +1 -0
  18. package/dist/cli/main.js +5 -0
  19. package/dist/cli/main.js.map +1 -1
  20. package/dist/cli/read-driver.d.ts +2 -2
  21. package/dist/cli/skill.d.ts +24 -0
  22. package/dist/cli/skill.js +40 -0
  23. package/dist/cli/skill.js.map +1 -1
  24. package/dist/client.d.ts +53 -12
  25. package/dist/client.js +9 -1
  26. package/dist/client.js.map +1 -1
  27. package/dist/config.d.ts +39 -0
  28. package/dist/config.js +16 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/contracts.d.ts +9 -2
  31. package/dist/contracts.js +1 -1
  32. package/dist/contracts.js.map +1 -1
  33. package/dist/index.d.ts +5 -2
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/mcp/server.js +132 -31
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/model/entities.d.ts +35 -0
  39. package/dist/model/entities.js.map +1 -1
  40. package/dist/model/occurrences.d.ts +14 -1
  41. package/dist/model/occurrences.js +20 -4
  42. package/dist/model/occurrences.js.map +1 -1
  43. package/dist/model/recurrence.d.ts +60 -9
  44. package/dist/model/recurrence.js +76 -19
  45. package/dist/model/recurrence.js.map +1 -1
  46. package/dist/paths.d.ts +8 -0
  47. package/dist/paths.js +10 -0
  48. package/dist/paths.js.map +1 -1
  49. package/dist/read/detail.js +49 -1
  50. package/dist/read/detail.js.map +1 -1
  51. package/dist/read/queries.d.ts +12 -0
  52. package/dist/read/queries.js +14 -0
  53. package/dist/read/queries.js.map +1 -1
  54. package/dist/read/shape.d.ts +25 -22
  55. package/dist/read/shape.js +81 -55
  56. package/dist/read/shape.js.map +1 -1
  57. package/dist/read/stage.d.ts +5 -5
  58. package/dist/read/stage.js +2 -2
  59. package/dist/read/views.d.ts +1 -1
  60. package/dist/read/views.js +4 -4
  61. package/dist/surface-copy.d.ts +2 -2
  62. package/dist/surface-copy.js +2 -2
  63. package/dist/sync-health.js +2 -1
  64. package/dist/sync-health.js.map +1 -1
  65. package/dist/trace/tracer.d.ts +87 -0
  66. package/dist/trace/tracer.js +167 -0
  67. package/dist/trace/tracer.js.map +1 -0
  68. package/dist/write/batch.d.ts +28 -3
  69. package/dist/write/batch.js +162 -46
  70. package/dist/write/batch.js.map +1 -1
  71. package/dist/write/clear-reminder.js +24 -21
  72. package/dist/write/clear-reminder.js.map +1 -1
  73. package/dist/write/clone.d.ts +4 -0
  74. package/dist/write/clone.js +531 -0
  75. package/dist/write/clone.js.map +1 -0
  76. package/dist/write/commands.js +140 -49
  77. package/dist/write/commands.js.map +1 -1
  78. package/dist/write/completion-context.d.ts +57 -0
  79. package/dist/write/completion-context.js +49 -0
  80. package/dist/write/completion-context.js.map +1 -0
  81. package/dist/write/failure-hints.d.ts +10 -1
  82. package/dist/write/failure-hints.js +8 -0
  83. package/dist/write/failure-hints.js.map +1 -1
  84. package/dist/write/guards.d.ts +1 -1
  85. package/dist/write/guards.js +43 -5
  86. package/dist/write/guards.js.map +1 -1
  87. package/dist/write/make-repeating-project.d.ts +2 -3
  88. package/dist/write/make-repeating-project.js +2 -73
  89. package/dist/write/make-repeating-project.js.map +1 -1
  90. package/dist/write/move.js +8 -1
  91. package/dist/write/move.js.map +1 -1
  92. package/dist/write/operations.d.ts +140 -9
  93. package/dist/write/operations.js +4 -1
  94. package/dist/write/operations.js.map +1 -1
  95. package/dist/write/pipeline.d.ts +77 -0
  96. package/dist/write/pipeline.js +298 -20
  97. package/dist/write/pipeline.js.map +1 -1
  98. package/dist/write/pre-state.d.ts +1 -1
  99. package/dist/write/pre-state.js +9 -3
  100. package/dist/write/pre-state.js.map +1 -1
  101. package/dist/write/preserve-modified.d.ts +55 -0
  102. package/dist/write/preserve-modified.js +106 -0
  103. package/dist/write/preserve-modified.js.map +1 -0
  104. package/dist/write/promote-clone.d.ts +32 -0
  105. package/dist/write/promote-clone.js +867 -0
  106. package/dist/write/promote-clone.js.map +1 -0
  107. package/dist/write/repeat-anchor.d.ts +110 -0
  108. package/dist/write/repeat-anchor.js +238 -0
  109. package/dist/write/repeat-anchor.js.map +1 -0
  110. package/dist/write/repeat-asserts.d.ts +28 -0
  111. package/dist/write/repeat-asserts.js +169 -0
  112. package/dist/write/repeat-asserts.js.map +1 -0
  113. package/dist/write/repeat-rule.js +27 -12
  114. package/dist/write/repeat-rule.js.map +1 -1
  115. package/dist/write/resolution-timestamps.js +22 -0
  116. package/dist/write/resolution-timestamps.js.map +1 -1
  117. package/dist/write/reversibility.js +27 -14
  118. package/dist/write/reversibility.js.map +1 -1
  119. package/dist/write/undo.d.ts +11 -1
  120. package/dist/write/undo.js +126 -10
  121. package/dist/write/undo.js.map +1 -1
  122. package/dist/write/vectors/session-reachability.d.ts +78 -0
  123. package/dist/write/vectors/session-reachability.js +103 -0
  124. package/dist/write/vectors/session-reachability.js.map +1 -0
  125. package/dist/write/vectors/simulator.js +100 -30
  126. package/dist/write/vectors/simulator.js.map +1 -1
  127. package/dist/write/vectors/types.d.ts +117 -10
  128. package/dist/write/vectors/ui-certification.d.ts +1 -1
  129. package/dist/write/vectors/ui-certification.js +10 -3
  130. package/dist/write/vectors/ui-certification.js.map +1 -1
  131. package/dist/write/vectors/ui-recipes.d.ts +8 -0
  132. package/dist/write/vectors/ui-recipes.js +120 -22
  133. package/dist/write/vectors/ui-recipes.js.map +1 -1
  134. package/dist/write/vectors/ui.d.ts +79 -13
  135. package/dist/write/vectors/ui.js +415 -59
  136. package/dist/write/vectors/ui.js.map +1 -1
  137. package/dist/write/verify/delta.d.ts +12 -14
  138. package/dist/write/verify/delta.js +35 -34
  139. package/dist/write/verify/delta.js.map +1 -1
  140. package/package.json +1 -1
  141. package/schema/envelope.schema.json +22 -0
  142. package/skills/things-cli/SKILL.md +7 -3
  143. package/skills/things-cli/references/banner.md +1 -1
  144. package/skills/things-cli/references/contracts.md +17 -9
  145. package/skills/things-cli/references/data-model.md +2 -1
@@ -0,0 +1,867 @@
1
+ import { undoToken } from "../audit/schema.js";
2
+ import { addDaysIso, decodePackedDate } from "../model/dates.js";
3
+ import { byUuid } from "../read/detail.js";
4
+ import { resolveProjectWriteTarget, resolveTaskUuidPrefix } from "../read/queries.js";
5
+ import { runCloneProject, runCloneTodo } from "./clone.js";
6
+ import { promoteProjectViaGui } from "./make-repeating-project.js";
7
+ import { fingerprintLabel, runMutation, } from "./pipeline.js";
8
+ import { deriveFixedAnchor, isIsoDate } from "./repeat-anchor.js";
9
+ import { assertRepeatRule, ruleToInverseParams } from "./repeat-rule.js";
10
+ import { createDbReader } from "./verify/delta.js";
11
+ import { H_UI_SESSION_UNREACHABLE } from "./vectors/session-reachability.js";
12
+ const PLACEMENT_NOTE = "the series' current instance lands at its container's default position — its prior slot was " +
13
+ "not automatically restored (best-effort placement is not yet wired for this container); " +
14
+ "reposition it with `things reorder` if the order matters";
15
+ // --------------------------------------------------------------- small helpers
16
+ function newTxnId(now) {
17
+ return `txn-${now.getTime().toString(36)}-${process.pid.toString(36)}`;
18
+ }
19
+ /**
20
+ * Forward the caller's audit/timeout/GUI knobs onto a delegated leg. `extra`
21
+ * carries per-leg additions — `preserveModified` is threaded ONLY onto the legs
22
+ * that touch a PRE-EXISTING row (the trash-X leg); the clone/promote legs mint
23
+ * fresh rows, where the flag would be a no-op.
24
+ */
25
+ function legOptions(base, txnId, vector, extra) {
26
+ const out = { txn: { id: txnId, role: "leg" } };
27
+ if (vector !== undefined)
28
+ out.vector = vector;
29
+ if (base.actor !== undefined)
30
+ out.actor = base.actor;
31
+ if (base.verifyTimeoutMs !== undefined)
32
+ out.verifyTimeoutMs = base.verifyTimeoutMs;
33
+ if (base.dangerouslyDriveGui !== undefined)
34
+ out.dangerouslyDriveGui = base.dangerouslyDriveGui;
35
+ if (base.maxDisruption !== undefined)
36
+ out.maxDisruption = base.maxDisruption;
37
+ if (base.zone !== undefined)
38
+ out.zone = base.zone;
39
+ if (extra?.preserveModified === true)
40
+ out.preserveModified = true;
41
+ return out;
42
+ }
43
+ /** The two-key GUI-drive block (mirrors H-UI-DRIVE) — the promote leg drives the app. */
44
+ function blockedUiDrive(op) {
45
+ return {
46
+ kind: "blocked",
47
+ op,
48
+ reason: "hazard",
49
+ hazard: "H-UI-DRIVE",
50
+ detail: "this operation promotes an item to a repeating series by driving the local Things app " +
51
+ "through the Accessibility API (the Repeat… dialog) — it may briefly interact with the UI",
52
+ 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
+ };
55
+ }
56
+ /**
57
+ * SESSGATE (#480) pre-seed reachability gate. A promote composite is NOT atomic:
58
+ * it SEEDS a row (clone / add) before the GUI promote leg drives the dialog. If
59
+ * the Mac's session is AX-blind (screen locked / full-screen Space), the dialog
60
+ * would open on an unreachable window and the drive would fail — leaving an
61
+ * orphan seed. So probe the live session BEFORE seeding and refuse fast (zero
62
+ * mutation) on the certain-failure LOCKED signature. Only "session" scope refuses
63
+ * here: a window merely on another Space is left for the in-drive relocation (the
64
+ * reveal has not run yet, so refusing before the seed would be a false positive).
65
+ * No ui vector (simulator / bench), ui disabled, or a fail-open probe → proceed
66
+ * (the promote leg's own gate + cleanup remain the backstop).
67
+ */
68
+ async function gateSessionReachability(deps, op) {
69
+ if (!deps.config.ui.enabled)
70
+ return null;
71
+ const ui = deps.vectors.find((v) => v.probeReachability !== undefined);
72
+ if (ui?.probeReachability === undefined)
73
+ return null;
74
+ const verdict = await ui.probeReachability();
75
+ if (verdict.reachable || verdict.scope !== "session")
76
+ return null;
77
+ return {
78
+ kind: "blocked",
79
+ op,
80
+ reason: "hazard",
81
+ hazard: H_UI_SESSION_UNREACHABLE,
82
+ detail: verdict.detail,
83
+ remediation: verdict.remediation,
84
+ };
85
+ }
86
+ /**
87
+ * Auto-trash a promote composite's own seeded item after its promote leg failed
88
+ * (RATIFIED RULING 2026-08-15, issue #480). The add/add-repeating legs are NOT
89
+ * atomic: the seed persists even when the promote no-ops. The seed is OUR
90
+ * artifact — recreatable verbatim from the command args — and the Trash is
91
+ * recoverable, so we trash it inside the same txn and disclose it. The
92
+ * distinction the failure MUST make honest: an auto-trash that SUCCEEDS points
93
+ * the caller at `restore` (the row is in the Trash); one that FAILS points at
94
+ * `delete` with the seed's REAL, resolvable uuid (never a buried, non-actionable
95
+ * uuid — the #480 second bug). Returns a `detail` patch appended to the promote
96
+ * result's own message (best-effort: a non-`detail` result shape is left as-is).
97
+ */
98
+ async function cleanupSeed(deps, kind, createdUuid, promote, options, txnId) {
99
+ const expectedType = kind === "project" ? "project" : "to-do";
100
+ const trashOp = kind === "project" ? "project.delete" : "todo.delete";
101
+ const trashed = await runMutation(deps, trashOp, { uuid: createdUuid }, legOptions(options, txnId));
102
+ const cleanupNote = trashed.kind === "ok"
103
+ ? `the seeded ${expectedType} (uuid ${createdUuid}) was created but the promote did not land, ` +
104
+ `so it was moved to the Trash — recreate it from the command args, or restore it with ` +
105
+ `\`things ${kind} restore ${createdUuid}\``
106
+ : `the seeded ${expectedType} (uuid ${createdUuid}) was created but the promote did not land, ` +
107
+ `and it could NOT be auto-trashed — remove it with \`things ${kind} delete ${createdUuid}\``;
108
+ return "detail" in promote
109
+ ? { detail: `${promote.detail} — ${cleanupNote}` }
110
+ : {};
111
+ }
112
+ /**
113
+ * Pick the rule fields (frequency/interval + calendar anchors + deadline offset)
114
+ * as a RepeatRuleParams, plus the requested first-occurrence date to drive into
115
+ * the dialog's "Next:" field (ANCH2, issue #476). `nextIso` is the item's
116
+ * scheduled date; omitted (or after-completion) leaves Next at the app default.
117
+ *
118
+ * The `rule` bag is the SUPERSET the make/add legs actually carry: make-repeating
119
+ * passes a full {@link RepeatRuleParams} (rule-level `deadline`/`startDaysEarlier`
120
+ * included), add-repeating an {@link AddRepeatingRuleFields} (which OMITS those —
121
+ * the base add owns the item's own deadline). Every field is copied THROUGH so
122
+ * neither is silently dropped: an earlier version keyed the param type to
123
+ * AddRepeatingRuleFields and stripped `deadline`/`startDaysEarlier` from the
124
+ * make-repeating promote entirely (the same class as the RRX1 reminder drop —
125
+ * a deadlined make-repeating produced a NON-deadlined template, YANCH1 #493).
126
+ */
127
+ function ruleParamsFor(uuid, rule, nextIso) {
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
+ };
149
+ }
150
+ const UNIT_SINGULAR = {
151
+ daily: "day",
152
+ weekly: "week",
153
+ monthly: "month",
154
+ yearly: "year",
155
+ };
156
+ const UNIT_PLURAL = {
157
+ daily: "days",
158
+ weekly: "weeks",
159
+ monthly: "months",
160
+ yearly: "years",
161
+ };
162
+ /**
163
+ * A one-line echo of the LANDED series so a caller can eyeball what actually
164
+ * committed — the cadence, the verified first-occurrence START, and the deadline
165
+ * offset (YANCH1 #493 item 5). It rides the verified data (the anchor is asserted
166
+ * == requested by the post-drive verify), so it describes the START date rather
167
+ * than the internal deadline anchor, which for a deadlined rule is N days later.
168
+ */
169
+ function landedRuleEcho(rule, startIso) {
170
+ const cadence = rule.interval === 1
171
+ ? `every ${UNIT_SINGULAR[rule.frequency]}`
172
+ : `every ${rule.interval} ${UNIT_PLURAL[rule.frequency]}`;
173
+ if (rule.afterCompletion === true) {
174
+ return `landed: the series repeats ${cadence} after each occurrence is completed`;
175
+ }
176
+ const first = startIso !== null ? `; the first occurrence is ${startIso}` : "";
177
+ const offset = rule.startDaysEarlier ?? 0;
178
+ const deadlineNote = offset > 0
179
+ ? `, with a deadline ${offset} day${offset === 1 ? "" : "s"} later`
180
+ : rule.deadline === true
181
+ ? ", with a deadline on each occurrence"
182
+ : "";
183
+ return `landed: the series repeats ${cadence}${first}${deadlineNote}`;
184
+ }
185
+ /**
186
+ * The template's app-materialized first occurrence (`rt1_instanceCreationStartDate`
187
+ * = the date the current instance is dated at) as an ISO date, for the post-drive
188
+ * Next-honored check. `null` when the row/date is absent.
189
+ */
190
+ function firstOccurrenceOf(db, templateUuid) {
191
+ const row = db
192
+ .prepare("SELECT rt1_instanceCreationStartDate AS ic FROM TMTask WHERE uuid = ?")
193
+ .get(templateUuid);
194
+ if (row === undefined || row.ic === null)
195
+ return null;
196
+ return decodePackedDate(row.ic);
197
+ }
198
+ /**
199
+ * The verify-failed result when the driven "Next:" first occurrence did not land
200
+ * (issue #476, ANCH2). The series exists but on the wrong phase — report it
201
+ * fail-closed rather than as a silent ok.
202
+ */
203
+ function nextMismatch(op, templateUuid, requestedIso, landedIso) {
204
+ return {
205
+ kind: "verify-failed",
206
+ op,
207
+ reason: "mismatch",
208
+ expected: { mode: "update", uuid: templateUuid, assert: [] },
209
+ observed: null,
210
+ detail: `the repeating series was created but its first occurrence landed on ` +
211
+ `${landedIso ?? "an undetermined date"}, not the requested ${requestedIso} — the Next-field ` +
212
+ "drive did not take; the series exists and can be corrected with `things reschedule-repeat`",
213
+ };
214
+ }
215
+ /** The discovered template/instance from a promote leg's result. */
216
+ function discoveryOf(promote) {
217
+ const rep = promote.repeating;
218
+ return {
219
+ templateUuid: rep?.templateUuid ?? promote.uuid ?? "",
220
+ instanceUuid: rep?.instanceUuid ?? null,
221
+ };
222
+ }
223
+ // -------------------------------------------------------------- audit summary
224
+ function appendPromoteSummary(deps, args) {
225
+ const fp = deps.fingerprint();
226
+ const observed = {
227
+ templateUuid: args.templateUuid,
228
+ instanceUuid: args.instanceUuid,
229
+ ...(args.originalUuid !== undefined && { originalUuid: args.originalUuid }),
230
+ };
231
+ const record = {
232
+ v: 1,
233
+ ts: args.startedAt.toISOString(),
234
+ actor: deps.config.actor,
235
+ host: deps.config.host,
236
+ op: args.op,
237
+ uuid: args.templateUuid,
238
+ vector: "ui",
239
+ disruption: 3,
240
+ invocation: args.invocation,
241
+ requested: args.requested,
242
+ txn: { id: args.txnId, role: "summary" },
243
+ pre: null,
244
+ observed,
245
+ result: "ok",
246
+ ...(args.preModDates !== undefined && { preModDates: args.preModDates }),
247
+ verify: null,
248
+ durationMs: (deps.now?.() ?? new Date()).getTime() - args.startedAt.getTime(),
249
+ env: {
250
+ pkg: deps.pkgVersion ?? "0.0.1",
251
+ dbVersion: fp.observation.databaseVersion,
252
+ fingerprint: fingerprintLabel(fp, deps.config),
253
+ },
254
+ };
255
+ deps.audit.append(record);
256
+ }
257
+ /** Build the ok result for a promote (make/add-repeating). */
258
+ function promoteOk(args) {
259
+ const repeating = {
260
+ templateUuid: args.templateUuid,
261
+ instanceUuid: args.instanceUuid,
262
+ replacedUuid: args.replacedUuid,
263
+ };
264
+ return {
265
+ kind: "ok",
266
+ op: args.op,
267
+ uuid: args.templateUuid,
268
+ title: args.title,
269
+ observed: { templateUuid: args.templateUuid, instanceUuid: args.instanceUuid },
270
+ vector: "ui",
271
+ tier: 3,
272
+ undoToken: undoToken({
273
+ ts: "",
274
+ op: args.op,
275
+ actor: "",
276
+ host: "",
277
+ uuid: args.templateUuid,
278
+ txn: { id: args.txnId, role: "summary" },
279
+ }),
280
+ repeating,
281
+ ...(args.warnings.length > 0 && { warnings: args.warnings }),
282
+ };
283
+ }
284
+ // ============================================================ make-repeating
285
+ /**
286
+ * Shared clone → native-promote → trash(X) for `todo.make-repeating` /
287
+ * `project.make-repeating`. `promoteLeg` runs the native promote on the clone.
288
+ */
289
+ async function makeRepeatingViaClone(deps, kind, params, options) {
290
+ const op = kind === "project" ? "project.make-repeating" : "todo.make-repeating";
291
+ // Validate the rule BEFORE anything (a bad rule must never mint a clone).
292
+ assertRepeatRule(params);
293
+ const now = deps.now?.() ?? new Date();
294
+ const srcUuid = kind === "project"
295
+ ? resolveProjectWriteTarget(deps.db, params.uuid)
296
+ : resolveTaskUuidPrefix(deps.db, params.uuid, "to-do");
297
+ const src = byUuid(deps.db, srcUuid, now, deps.zone);
298
+ const expectedType = kind === "project" ? "project" : "to-do";
299
+ if (src === null || src.type !== expectedType) {
300
+ return {
301
+ kind: "blocked",
302
+ op,
303
+ reason: "hazard",
304
+ hazard: "H-UNKNOWN-DESTINATION",
305
+ detail: `the target is not a ${expectedType} (make-repeating needs an existing ${expectedType})`,
306
+ remediation: kind === "project"
307
+ ? "verify the uuid with `things projects`, or use `things todo make-repeating` for a to-do"
308
+ : "verify the uuid with `things show <uuid>`, or use `things project make-repeating` for a project",
309
+ };
310
+ }
311
+ // ANCH2 (issue #476): the app's Repeat dialog HAS a "Next:" first-occurrence
312
+ // field; its default is the today-anchored next match, but it is editable and
313
+ // honored (docs/lab/anch2-next-field.md). Drive it with the requested first
314
+ // occurrence — an explicit `--when` if given, else the item's own scheduled
315
+ // date. YANCH1 (issue #493): also DERIVE the calendar anchor (weekly weekday /
316
+ // monthly day / yearly month+day) from that date when no explicit anchor was
317
+ // given, and drive the anchor pop-ups — otherwise the recurring rule keeps the
318
+ // dialog's untouched default (weekly Sunday, monthly 1st, yearly January 1) and
319
+ // only the first occurrence is correct (the #493 anchor-drop).
320
+ //
321
+ // DEADLINE-MODE ANCHORING (YANCH1 #493, in-lab golden-v3): a deadlined rule
322
+ // anchors on the DEADLINE, and each instance's START = anchor − startDaysEarlier
323
+ // (probe: anchor+Next driven to Oct-16 with start-14 → of=[Oct-16], instance
324
+ // start = Oct-02). `--when` is the scheduled START, so the date the dialog's
325
+ // anchor pop-ups + "Next:" field must carry is when + startDaysEarlier (the
326
+ // deadline); the app then back-shifts the start to `when`. For a non-deadlined
327
+ // rule the shift is 0 and the drive date equals `--when` (unchanged).
328
+ const whenIso = isIsoDate(params.next) ? params.next : src.startDate;
329
+ const deadlineShift = params.deadline === true || (params.startDaysEarlier ?? 0) > 0
330
+ ? (params.startDaysEarlier ?? 0)
331
+ : 0;
332
+ const driveIso = isIsoDate(whenIso) ? addDaysIso(whenIso, deadlineShift) : undefined;
333
+ // The dialog is driven with the deadline-adjusted date; the verify below expects
334
+ // the START to land back on the requested `--when`.
335
+ const nextIso = driveIso;
336
+ const expectedStartIso = isIsoDate(whenIso) ? whenIso : undefined;
337
+ const effParams = { ...params, ...deriveFixedAnchor(params, driveIso) };
338
+ // The promote leg drives the GUI — block before minting a clone if the ack is missing.
339
+ if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
340
+ return blockedUiDrive(op);
341
+ }
342
+ if (options.dryRun === true) {
343
+ return {
344
+ kind: "dry-run",
345
+ op,
346
+ plan: {
347
+ op,
348
+ vector: "ui",
349
+ tier: 3,
350
+ invocation: `clone ${srcUuid} (--preserve-created) → make-repeating the clone (Repeat… → ` +
351
+ `frequency=${effParams.frequency}, interval=${effParams.interval}) → trash the original ${srcUuid}`,
352
+ expectedDelta: {
353
+ mode: "create",
354
+ probe: { title: src.title, type: expectedType, sinceEpoch: 0 },
355
+ assert: [{ field: "repeating.isTemplate", equals: true }],
356
+ },
357
+ hazardsChecked: ["H-UNKNOWN-DESTINATION", "H-CLONE-SOURCE", "H-UI-DRIVE"],
358
+ },
359
+ };
360
+ }
361
+ // SESSGATE (#480): refuse a locked / full-screen session BEFORE minting a clone
362
+ // — otherwise the promote's dialog opens on an unreachable window and the whole
363
+ // compound fails, stranding a disposable clone. Zero mutation on refusal.
364
+ const gate = await gateSessionReachability(deps, op);
365
+ if (gate !== null)
366
+ return gate;
367
+ const startedAt = now;
368
+ const txnId = newTxnId(startedAt);
369
+ // 1. Clone the source as a disposable, embedded leg (--preserve-created). The
370
+ // clone has captured X's full content by the time it returns.
371
+ const clone = kind === "project"
372
+ ? await runCloneProject(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId))
373
+ : await runCloneTodo(deps, { uuid: srcUuid, preserveCreated: true }, legOptions(options, txnId));
374
+ if (clone.kind !== "ok" || clone.uuid === null) {
375
+ // A clone refusal (nested repeating template, H-CLONE-SOURCE) surfaces
376
+ // coherently here — re-label it to the make-repeating op for the caller.
377
+ return clone.kind === "ok"
378
+ ? {
379
+ kind: "verify-failed",
380
+ op,
381
+ reason: "mismatch",
382
+ expected: {
383
+ mode: "create",
384
+ probe: { title: src.title, type: expectedType, sinceEpoch: 0 },
385
+ assert: [],
386
+ },
387
+ observed: null,
388
+ detail: "the disposable clone was created but its uuid was not discovered — nothing was promoted or trashed",
389
+ }
390
+ : { ...clone, op };
391
+ }
392
+ const cloneUuid = clone.uuid;
393
+ // --preserve-modified: X is the ONLY pre-existing row the compound touches (the
394
+ // clone/promote legs mint fresh rows). Capture its pre-write umd BEFORE the
395
+ // trash bumps it — the trash leg restores it forward, and the value rides the
396
+ // summary record's preModDates so the symmetric undo restore fires on the
397
+ // revived X (undo.ts, 2026-08-13 ruling). The clone leg above reads X but never
398
+ // writes it, so its umd is still pristine here.
399
+ const preserveModified = options.preserveModified === true;
400
+ const preUmd = preserveModified
401
+ ? createDbReader(deps.db, now, deps.zone).modDateOf(srcUuid)
402
+ : null;
403
+ // 2. Trash the original BEFORE promoting — the clone already holds X's content,
404
+ // and a live same-titled X would make the promote's project row-selection
405
+ // ambiguous (H-PROJECT-REPEAT). X survives in the Trash (the recoverable half).
406
+ const trash = await runMutation(deps, `${kind}.delete`, { uuid: srcUuid }, legOptions(options, txnId, undefined, preserveModified ? { preserveModified: true } : undefined));
407
+ if (trash.kind !== "ok") {
408
+ return {
409
+ ...trash,
410
+ op,
411
+ ...("detail" in trash
412
+ ? {
413
+ detail: `${trash.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
414
+ `original ${srcUuid} could not be moved to the Trash, so it was NOT promoted; trash ` +
415
+ "the clone and retry",
416
+ }
417
+ : {}),
418
+ };
419
+ }
420
+ // 3. Native-promote the clone (with the ANCH2 Next drive + derived weekday).
421
+ const rule = ruleParamsFor(cloneUuid, effParams, nextIso);
422
+ const promote = kind === "project"
423
+ ? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
424
+ : await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
425
+ if (promote.kind !== "ok") {
426
+ // The clone persists but was not promoted; best-effort ROLL BACK the trash so
427
+ // the original is not stranded in the Trash.
428
+ const restoreOp = kind === "project" ? "project.restore" : "todo.restore";
429
+ const rolledBack = await runMutation(deps, restoreOp, { uuid: srcUuid }, legOptions(options, txnId));
430
+ const rollNote = rolledBack.kind === "ok"
431
+ ? `the original ${srcUuid} was restored from the Trash`
432
+ : `the original ${srcUuid} could NOT be restored from the Trash — restore it in the app`;
433
+ return {
434
+ ...promote,
435
+ op,
436
+ ...("detail" in promote
437
+ ? {
438
+ detail: `${promote.detail} — the disposable clone (uuid ${cloneUuid}) was created but the ` +
439
+ `promote did not land; ${rollNote}. Trash the clone and retry`,
440
+ }
441
+ : {}),
442
+ };
443
+ }
444
+ const { templateUuid, instanceUuid } = discoveryOf(promote);
445
+ // Post-drive verify (ANCH2 + YANCH1): the instance START must have landed on the
446
+ // requested `--when` — for a deadlined rule the driven Next is the deadline
447
+ // (when + startDaysEarlier) and the app back-shifts the start to `--when`, so the
448
+ // check is against `expectedStartIso`, not the raw drive date. Fail closed on
449
+ // mismatch rather than report a wrong-phase ok.
450
+ if (expectedStartIso !== undefined) {
451
+ const landed = firstOccurrenceOf(deps.db, templateUuid);
452
+ if (landed !== expectedStartIso) {
453
+ return nextMismatch(op, templateUuid, expectedStartIso, landed);
454
+ }
455
+ }
456
+ const warnings = [
457
+ landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
458
+ `the original ${expectedType} (uuid ${srcUuid}) was moved to the Trash; \`things undo\` ` +
459
+ "removes the new series (trash-both) and restores it",
460
+ PLACEMENT_NOTE,
461
+ ];
462
+ if (promote.warnings !== undefined)
463
+ warnings.push(...promote.warnings);
464
+ appendPromoteSummary(deps, {
465
+ startedAt,
466
+ op,
467
+ txnId,
468
+ templateUuid,
469
+ instanceUuid,
470
+ originalUuid: srcUuid,
471
+ invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
472
+ requested: effParams,
473
+ ...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
474
+ });
475
+ return promoteOk({
476
+ op,
477
+ templateUuid,
478
+ instanceUuid,
479
+ replacedUuid: cloneUuid,
480
+ title: src.title,
481
+ txnId,
482
+ warnings,
483
+ });
484
+ }
485
+ export function runMakeRepeatingTodo(deps, params, options = {}) {
486
+ return makeRepeatingViaClone(deps, "todo", params, options);
487
+ }
488
+ export function runMakeRepeatingProject(deps, params, options = {}) {
489
+ return makeRepeatingViaClone(deps, "project", params, options);
490
+ }
491
+ // ============================================================== add-repeating
492
+ /**
493
+ * Shared add(full vocabulary) → native-promote for `todo.add-repeating` /
494
+ * `project.add-repeating`. No trash leg — there is no original. The two legs are
495
+ * NOT atomic: the created item persists even if the promote refuses.
496
+ */
497
+ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options) {
498
+ const op = kind === "project" ? "project.add-repeating" : "todo.add-repeating";
499
+ assertRepeatRule(rule);
500
+ // ANCH2 (issue #476): drive the Repeat dialog's "Next:" field with --when so the
501
+ // series starts on the requested date (the field's default is today-anchored but
502
+ // it is editable and honored). YANCH1 (issue #493): also DERIVE the calendar
503
+ // anchor (weekly weekday / monthly day / yearly month+day) from --when when no
504
+ // explicit anchor was given, and drive the anchor pop-ups — otherwise the
505
+ // recurring rule keeps the dialog's untouched default (weekly Sunday, monthly
506
+ // 1st, yearly January 1) and only the first occurrence is correct.
507
+ const anchorIso = isIsoDate(addParams["when"]) ? addParams["when"] : null;
508
+ const nextIso = anchorIso ?? undefined;
509
+ const effRule = { ...rule, ...deriveFixedAnchor(rule, anchorIso) };
510
+ // ADR1 (issue #480, requested behavior #3): carry the base to-do's --reminder
511
+ // onto the SERIES. The create leg sets a one-off reminderTime on the seed, but
512
+ // the Repeat-dialog conversion does NOT preserve it — the dialog OWNS the repeat
513
+ // reminder via its "Add reminders" control — so a base reminder was silently
514
+ // dropped from the template (verified empty on golden-v2,
515
+ // docs/lab/adr1-add-repeating-reveal.md). Drive the dialog's reminder with the
516
+ // base time so every spawned occurrence carries it (ANCH2: the reminder picker
517
+ // commits reminderTime deterministically). Projects have no reminder vocabulary,
518
+ // so addParams never carries one there.
519
+ const baseReminder = typeof addParams["reminder"] === "string" ? addParams["reminder"] : undefined;
520
+ const effRuleWithReminder = baseReminder !== undefined ? { ...effRule, reminder: baseReminder } : effRule;
521
+ // The promote leg drives the GUI — block before creating anything if the ack is missing.
522
+ if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
523
+ return blockedUiDrive(op);
524
+ }
525
+ const expectedType = kind === "project" ? "project" : "to-do";
526
+ if (options.dryRun === true) {
527
+ return {
528
+ kind: "dry-run",
529
+ op,
530
+ plan: {
531
+ op,
532
+ vector: "ui",
533
+ tier: 3,
534
+ invocation: `create ${expectedType} "${title}" (persists on its own) → then make-repeating ` +
535
+ `(Repeat… → frequency=${rule.frequency}, interval=${rule.interval})`,
536
+ expectedDelta: {
537
+ mode: "create",
538
+ probe: { title, type: expectedType, sinceEpoch: 0 },
539
+ assert: [{ field: "repeating.isTemplate", equals: true }],
540
+ },
541
+ hazardsChecked: ["H-UI-DRIVE"],
542
+ },
543
+ };
544
+ }
545
+ // SESSGATE (#480): refuse a locked / full-screen session BEFORE seeding the row
546
+ // (the two legs are not atomic — a doomed promote would strand the seed). Zero
547
+ // mutation on refusal; a window merely on another Space is relocated in-drive.
548
+ const gate = await gateSessionReachability(deps, op);
549
+ if (gate !== null)
550
+ return gate;
551
+ const startedAt = deps.now?.() ?? new Date();
552
+ const txnId = newTxnId(startedAt);
553
+ // 1. Create the item (full add vocabulary) as an embedded leg.
554
+ const addOp = kind === "project" ? "project.add" : "todo.add";
555
+ const add = await runMutation(deps, addOp, addParams, legOptions(options, txnId, "url-scheme"));
556
+ if (add.kind !== "ok" || add.uuid === null) {
557
+ return add.kind === "ok"
558
+ ? {
559
+ kind: "verify-failed",
560
+ op,
561
+ reason: "mismatch",
562
+ expected: {
563
+ mode: "create",
564
+ probe: { title, type: expectedType, sinceEpoch: 0 },
565
+ assert: [],
566
+ },
567
+ observed: null,
568
+ detail: `the ${expectedType} was created but its uuid was not discovered — it cannot be promoted to repeating`,
569
+ }
570
+ : { ...add, op };
571
+ }
572
+ const createdUuid = add.uuid;
573
+ // 2. Native-promote the fresh row (ANCH2 Next drive + derived weekday + the
574
+ // base reminder driven onto the series, ADR1).
575
+ const ruleParams = ruleParamsFor(createdUuid, effRuleWithReminder, nextIso);
576
+ const promote = kind === "project"
577
+ ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
578
+ : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
579
+ if (promote.kind !== "ok") {
580
+ // The seed persists (the two legs are not atomic) but the promote did not
581
+ // land. RATIFIED RULING (2026-08-15, issue #480): auto-trash our OWN seed
582
+ // inside the txn — it is our artifact, recreatable verbatim from the command
583
+ // args, and the Trash is recoverable — then disclose it. If the auto-trash
584
+ // itself fails, the result carries the seed's REAL, resolvable uuid with a
585
+ // working `delete` remediation, so cleanup is never ambiguous (the #480
586
+ // second bug: a failed add-repeating left a residue whose reported uuid was
587
+ // not actionable).
588
+ const patch = await cleanupSeed(deps, kind, createdUuid, promote, options, txnId);
589
+ return { ...promote, op, ...patch };
590
+ }
591
+ const { templateUuid, instanceUuid } = discoveryOf(promote);
592
+ // Post-drive verify (ANCH2): the driven Next must have landed as the first
593
+ // occurrence — fail closed on mismatch rather than report a wrong-phase ok.
594
+ // The series EXISTS here (promote landed) but on the wrong phase, so this is a
595
+ // genuine partial success, NOT a seed to trash — reported for correction.
596
+ if (nextIso !== undefined) {
597
+ const landed = firstOccurrenceOf(deps.db, templateUuid);
598
+ if (landed !== nextIso)
599
+ return nextMismatch(op, templateUuid, nextIso, landed);
600
+ }
601
+ const warnings = [
602
+ landedRuleEcho(ruleParams, nextIso ?? firstOccurrenceOf(deps.db, templateUuid)),
603
+ PLACEMENT_NOTE,
604
+ ];
605
+ if (promote.warnings !== undefined)
606
+ warnings.push(...promote.warnings);
607
+ appendPromoteSummary(deps, {
608
+ startedAt,
609
+ op,
610
+ txnId,
611
+ templateUuid,
612
+ instanceUuid,
613
+ invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
614
+ requested: { title, ...effRule },
615
+ });
616
+ return promoteOk({
617
+ op,
618
+ templateUuid,
619
+ instanceUuid,
620
+ replacedUuid: createdUuid,
621
+ title,
622
+ txnId,
623
+ warnings,
624
+ });
625
+ }
626
+ export function runAddRepeatingTodo(deps, params, options = {}) {
627
+ const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, ...add } = params;
628
+ const rule = {
629
+ frequency,
630
+ interval,
631
+ ...(afterCompletion !== undefined && { afterCompletion }),
632
+ ...(weekdays !== undefined && { weekdays }),
633
+ ...(monthly !== undefined && { monthly }),
634
+ ...(yearly !== undefined && { yearly }),
635
+ ...(ends !== undefined && { ends }),
636
+ };
637
+ const addParams = {
638
+ title: add.title,
639
+ ...(add.notes !== undefined && { notes: add.notes }),
640
+ ...(add.when !== undefined && { when: add.when }),
641
+ ...(add.reminder !== undefined && { reminder: add.reminder }),
642
+ ...(add.deadline !== undefined && { deadline: add.deadline }),
643
+ ...(add.tags !== undefined && { tags: add.tags }),
644
+ ...(add.checklistItems !== undefined && { checklistItems: add.checklistItems }),
645
+ ...(add.project !== undefined && { project: add.project }),
646
+ ...(add.area !== undefined && { area: add.area }),
647
+ ...(add.heading !== undefined && { heading: add.heading }),
648
+ ...(add.createdAt !== undefined && { createdAt: add.createdAt }),
649
+ };
650
+ return addRepeatingViaCreate(deps, "todo", addParams, rule, add.title, options);
651
+ }
652
+ export function runAddRepeatingProject(deps, params, options = {}) {
653
+ const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, ...add } = params;
654
+ const rule = {
655
+ frequency,
656
+ interval,
657
+ ...(afterCompletion !== undefined && { afterCompletion }),
658
+ ...(weekdays !== undefined && { weekdays }),
659
+ ...(monthly !== undefined && { monthly }),
660
+ ...(yearly !== undefined && { yearly }),
661
+ ...(ends !== undefined && { ends }),
662
+ };
663
+ // Seed a pure-AX taxonomy: an area lands a selectable AREA-view row; otherwise
664
+ // create in Someday (UIC4-f) so the promote skips the anytime-header problem.
665
+ const seedWhen = add.when ?? (add.area === undefined ? "someday" : undefined);
666
+ const addParams = {
667
+ title: add.title,
668
+ ...(add.notes !== undefined && { notes: add.notes }),
669
+ ...(add.area !== undefined && { area: add.area }),
670
+ ...(seedWhen !== undefined && { when: seedWhen }),
671
+ ...(add.deadline !== undefined && { deadline: add.deadline }),
672
+ ...(add.todos !== undefined && { todos: add.todos }),
673
+ ...(add.items !== undefined && { items: add.items }),
674
+ ...(add.createdAt !== undefined && { createdAt: add.createdAt }),
675
+ };
676
+ return addRepeatingViaCreate(deps, "project", addParams, rule, add.title, options);
677
+ }
678
+ // ====================================================== template-direct clone
679
+ /**
680
+ * The new-series-identity disclosure: a template clone is NOT linked to the
681
+ * source — it is a fresh series with its own uuid, and references to the source
682
+ * (its instances, its uuid) do not transfer.
683
+ */
684
+ const NEW_SERIES_NOTE = "cloning a repeating template mints a NEW repeating series with its own identity — it is not " +
685
+ "linked to the source template, and references to the source (its instances, its uuid) do not " +
686
+ "transfer to the clone";
687
+ /** A fail-closed H-CLONE-SOURCE refusal for a template that cannot be cloned. */
688
+ function blockedCloneSource(op, detail, remediation) {
689
+ return { kind: "blocked", op, reason: "hazard", hazard: "H-CLONE-SOURCE", detail, remediation };
690
+ }
691
+ /**
692
+ * Name the specific feature that puts a decoded rule OUTSIDE the promote
693
+ * vocabulary (used only when {@link ruleToInverseParams} returns null — the SAME
694
+ * boundary the reschedule undo rides). Two shapes the Repeat dialog cannot
695
+ * produce: two simultaneous end bounds, and a multi-anchor month/year rule.
696
+ */
697
+ function inexpressibleReason(rule) {
698
+ if (rule.endDate !== null && rule.occurrenceCount !== null) {
699
+ return ("the source rule ends on BOTH a date and an occurrence count, which the repeat vocabulary " +
700
+ "cannot express (its Ends bound is a single choice)");
701
+ }
702
+ const anchors = rule.offsets.filter((o) => o.day !== undefined || o.weekday !== undefined || o.month !== undefined);
703
+ if ((rule.unit === "monthly" || rule.unit === "yearly") && anchors.length > 1) {
704
+ return (`the source ${rule.unit} rule fires on multiple calendar anchors, which the repeat ` +
705
+ `vocabulary cannot express (it sets exactly one ${rule.unit} anchor)`);
706
+ }
707
+ return "the source recurrence rule uses a shape the repeat vocabulary cannot express";
708
+ }
709
+ /**
710
+ * TEMPLATE-DIRECT clone (ruling 2026-08-13(d)): cloning a repeating TEMPLATE =
711
+ * clone its content as a PLAIN item, then native-promote the clone with the
712
+ * SOURCE's decoded rule — a NEW series identity, NO instances cloned, one instance
713
+ * spawns immediately per the create law (identical to a from-scratch
714
+ * add-repeating). Delegated to from `runCloneTodo`/`runCloneProject`'s
715
+ * template-source branch (clone.ts).
716
+ *
717
+ * The compound:
718
+ * 1. decode the source's rule (`repeating.rule`) — undecodable ⇒ refuse;
719
+ * 2. map it onto the promote vocabulary (`ruleToInverseParams` + the template's
720
+ * deadline flag) — inexpressible (two end bounds / multi-anchor month-year) ⇒
721
+ * refuse, naming the feature (the SAME boundary the reschedule undo rides);
722
+ * 3. gate on the GUI-drive ack (the promote leg drives the app) BEFORE minting;
723
+ * 4. mint the plain clone (embedded leg, recurrence stripped — `cloneTemplateAsPlain`);
724
+ * 5. native-promote the clone with the FULL decoded rule (incl. deadline/start-earlier).
725
+ *
726
+ * Result = the add-repeating contract (template uuid + `repeating{templateUuid,
727
+ * instanceUuid|null}`); undo = trash-both (no original to restore). A PAUSED
728
+ * source mints the new series UNPAUSED (pause is not part of the rule vocabulary),
729
+ * disclosed. `--title`/`--preserve-created` behave as in ordinary clone (a
730
+ * delete-fate promote may replace the clone row, so preserve-created is
731
+ * best-effort on the surviving series — disclosed).
732
+ */
733
+ export async function cloneTemplateViaRepromote(deps, kind, src, srcUuid, params, options) {
734
+ const op = kind === "project" ? "project.add-repeating" : "todo.add-repeating";
735
+ const expectedType = kind === "project" ? "project" : "to-do";
736
+ const title = params.title ?? src.title;
737
+ // 1. Decode the source template's rule (detail reads populate repeating.rule;
738
+ // an undecodable rule — a future Things schema — is omitted).
739
+ const rule = src.repeating.rule;
740
+ if (rule === undefined) {
741
+ return blockedCloneSource(op, `the source ${expectedType} is a repeating template whose recurrence rule could not be ` +
742
+ "decoded (an unrecognized rule format), so the series cannot be reproduced", "re-create the repeat in the Things app on a fresh " + expectedType);
743
+ }
744
+ // 2. Map the decoded rule onto the promote vocabulary — refuse fail-closed when
745
+ // it falls outside what the Repeat dialog can express (name the feature).
746
+ const inverse = ruleToInverseParams(rule, src.repeating.deadlined === true);
747
+ if (inverse === null) {
748
+ return blockedCloneSource(op, `${inexpressibleReason(rule)} — so this template cannot be cloned faithfully`, "re-create the repeat in the Things app on a fresh " + expectedType);
749
+ }
750
+ // 3. The promote leg drives the GUI — block before minting a clone if the ack
751
+ // is missing (nothing created). An expressibility refusal above takes
752
+ // precedence (more informative than the drive block).
753
+ if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
754
+ return blockedUiDrive(op);
755
+ }
756
+ if (options.dryRun === true) {
757
+ return {
758
+ kind: "dry-run",
759
+ op,
760
+ plan: {
761
+ op,
762
+ vector: "ui",
763
+ tier: 3,
764
+ invocation: `clone the template ${srcUuid} as a plain ${expectedType} (content only, recurrence ` +
765
+ `stripped) → make-repeating the clone with the source's rule (Repeat… → ` +
766
+ `frequency=${inverse.frequency}, interval=${inverse.interval})`,
767
+ expectedDelta: {
768
+ mode: "create",
769
+ probe: { title, type: expectedType, sinceEpoch: 0 },
770
+ assert: [{ field: "repeating.isTemplate", equals: true }],
771
+ },
772
+ hazardsChecked: ["H-CLONE-SOURCE", "H-UI-DRIVE"],
773
+ },
774
+ };
775
+ }
776
+ // SESSGATE (#480): refuse a locked / full-screen session BEFORE minting the
777
+ // plain clone (a doomed promote would strand it). Zero mutation on refusal.
778
+ const gate = await gateSessionReachability(deps, op);
779
+ if (gate !== null)
780
+ return gate;
781
+ const startedAt = deps.now?.() ?? new Date();
782
+ const txnId = newTxnId(startedAt);
783
+ // 4. Mint the plain clone as an embedded leg — cloneTemplateAsPlain reaches the
784
+ // clone orchestrator's plain-content path (recurrence + schedule stripped);
785
+ // --title/--preserve-created ride through the CloneParams.
786
+ const cloneParams = {
787
+ uuid: srcUuid,
788
+ ...(params.title !== undefined && { title: params.title }),
789
+ ...(params.preserveCreated === true && { preserveCreated: true }),
790
+ };
791
+ const cloneOptions = { ...legOptions(options, txnId), cloneTemplateAsPlain: true };
792
+ const clone = kind === "project"
793
+ ? await runCloneProject(deps, cloneParams, cloneOptions)
794
+ : await runCloneTodo(deps, cloneParams, cloneOptions);
795
+ if (clone.kind !== "ok" || clone.uuid === null) {
796
+ // A nested-repeater refusal (a template CONTAINING a nested repeater) or any
797
+ // clone failure surfaces coherently here — re-label it to the compound op.
798
+ return clone.kind === "ok"
799
+ ? {
800
+ kind: "verify-failed",
801
+ op,
802
+ reason: "mismatch",
803
+ expected: {
804
+ mode: "create",
805
+ probe: { title, type: expectedType, sinceEpoch: 0 },
806
+ assert: [],
807
+ },
808
+ observed: null,
809
+ detail: "the plain clone was created but its uuid was not discovered — nothing was promoted",
810
+ }
811
+ : { ...clone, op };
812
+ }
813
+ const cloneUuid = clone.uuid;
814
+ // 5. Native-promote the clone with the FULL decoded rule (ruleToInverseParams
815
+ // carries deadline/start-earlier + the calendar anchors + ends).
816
+ const ruleParams = { uuid: cloneUuid, ...inverse };
817
+ const promote = kind === "project"
818
+ ? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
819
+ : await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
820
+ if (promote.kind !== "ok") {
821
+ // The plain clone persists but was not promoted — honest report (no original
822
+ // to roll back; the clone is a fresh row the caller can trash and retry).
823
+ return {
824
+ ...promote,
825
+ op,
826
+ ...("detail" in promote
827
+ ? {
828
+ detail: `${promote.detail} — the plain clone (uuid ${cloneUuid}) was created but the promote ` +
829
+ `did not land; trash the clone with \`things ${kind} delete ${cloneUuid}\` and retry`,
830
+ }
831
+ : {}),
832
+ };
833
+ }
834
+ const { templateUuid, instanceUuid } = discoveryOf(promote);
835
+ const warnings = [NEW_SERIES_NOTE, PLACEMENT_NOTE];
836
+ if (params.preserveCreated === true) {
837
+ warnings.push("--preserve-created is best-effort on a template clone: the promote may replace the clone " +
838
+ "row with the new template, whose creation date is the conversion time");
839
+ }
840
+ if (src.repeating.paused === true) {
841
+ warnings.push(`the source template was PAUSED; the new series is created UNPAUSED and begins spawning — ` +
842
+ `pause it with \`things ${kind} pause-repeat\` if you want it suspended`);
843
+ }
844
+ if (promote.warnings !== undefined)
845
+ warnings.push(...promote.warnings);
846
+ // Summary WITHOUT originalUuid → undo is the add-repeating trash-both (remove
847
+ // the minted series; there is no original to restore).
848
+ appendPromoteSummary(deps, {
849
+ startedAt,
850
+ op,
851
+ txnId,
852
+ templateUuid,
853
+ instanceUuid,
854
+ invocation: `${kind}.clone (template) ${srcUuid}: clone → promote ${cloneUuid} → template ${templateUuid}`,
855
+ requested: { source: srcUuid, title, ...inverse },
856
+ });
857
+ return promoteOk({
858
+ op,
859
+ templateUuid,
860
+ instanceUuid,
861
+ replacedUuid: cloneUuid,
862
+ title,
863
+ txnId,
864
+ warnings,
865
+ });
866
+ }
867
+ //# sourceMappingURL=promote-clone.js.map