things-api 0.16.0 → 0.18.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.
- package/README.md +1 -1
- package/deputy/VERSION +1 -0
- package/deputy/helpers-Info.plist +23 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +20 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +115 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +23 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +125 -0
- package/deputy/reader/Info.plist +20 -0
- package/deputy/reader/entitlements.plist +15 -0
- package/deputy/reader/main.swift +491 -0
- package/deputy/src/main.swift +133 -0
- package/deputy/src/osascript.swift +102 -0
- package/deputy/src/server.swift +350 -0
- package/deputy/src/sqlite.swift +121 -0
- package/dist/cli/commands/doctor.js +55 -2
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.d.ts +17 -0
- package/dist/cli/commands/helpers.js +148 -0
- package/dist/cli/commands/helpers.js.map +1 -0
- package/dist/cli/commands/op-result.d.ts +15 -0
- package/dist/cli/commands/op-result.js +39 -0
- package/dist/cli/commands/op-result.js.map +1 -0
- package/dist/cli/commands/repeat-flags.d.ts +3 -3
- package/dist/cli/commands/repeat-flags.js +90 -33
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/writes.js +79 -6
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/help.js +13 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/helpers-check.d.ts +10 -0
- package/dist/cli/helpers-check.js +59 -0
- package/dist/cli/helpers-check.js.map +1 -0
- package/dist/cli/interrupt.d.ts +42 -0
- package/dist/cli/interrupt.js +105 -0
- package/dist/cli/interrupt.js.map +1 -0
- package/dist/cli/main.js +27 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/client.js +19 -2
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +70 -0
- package/dist/config.js +25 -1
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +9 -2
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/baselines/db-v26.js +8 -4
- package/dist/db/baselines/db-v26.js.map +1 -1
- package/dist/db/baselines/db-v27.d.ts +42 -0
- package/dist/db/baselines/db-v27.js +47 -0
- package/dist/db/baselines/db-v27.js.map +1 -0
- package/dist/db/baselines/index.js +2 -1
- package/dist/db/baselines/index.js.map +1 -1
- package/dist/db/locate.d.ts +4 -1
- package/dist/db/locate.js +3 -1
- package/dist/db/locate.js.map +1 -1
- package/dist/db/schema.d.ts +1 -1
- package/dist/db/schema.js +9 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/deputy/bridge-worker.d.ts +1 -0
- package/dist/deputy/bridge-worker.js +79 -0
- package/dist/deputy/bridge-worker.js.map +1 -0
- package/dist/deputy/bridge.d.ts +14 -0
- package/dist/deputy/bridge.js +70 -0
- package/dist/deputy/bridge.js.map +1 -0
- package/dist/deputy/client.d.ts +19 -0
- package/dist/deputy/client.js +104 -0
- package/dist/deputy/client.js.map +1 -0
- package/dist/deputy/db-facade.d.ts +18 -0
- package/dist/deputy/db-facade.js +71 -0
- package/dist/deputy/db-facade.js.map +1 -0
- package/dist/deputy/files.d.ts +1 -0
- package/dist/deputy/files.js +16 -0
- package/dist/deputy/files.js.map +1 -0
- package/dist/deputy/install.d.ts +105 -0
- package/dist/deputy/install.js +467 -0
- package/dist/deputy/install.js.map +1 -0
- package/dist/deputy/notice.d.ts +6 -0
- package/dist/deputy/notice.js +28 -0
- package/dist/deputy/notice.js.map +1 -0
- package/dist/deputy/notices.d.ts +31 -0
- package/dist/deputy/notices.js +94 -0
- package/dist/deputy/notices.js.map +1 -0
- package/dist/deputy/osa.d.ts +21 -0
- package/dist/deputy/osa.js +72 -0
- package/dist/deputy/osa.js.map +1 -0
- package/dist/deputy/protocol.d.ts +86 -0
- package/dist/deputy/protocol.js +106 -0
- package/dist/deputy/protocol.js.map +1 -0
- package/dist/deputy/routing.d.ts +63 -0
- package/dist/deputy/routing.js +338 -0
- package/dist/deputy/routing.js.map +1 -0
- package/dist/deputy/shortcuts-exec.d.ts +10 -0
- package/dist/deputy/shortcuts-exec.js +50 -0
- package/dist/deputy/shortcuts-exec.js.map +1 -0
- package/dist/diagnose.d.ts +43 -1
- package/dist/diagnose.js +64 -2
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +25 -7
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/mappers.d.ts +12 -3
- package/dist/model/mappers.js +6 -1
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/occurrences.d.ts +14 -1
- package/dist/model/occurrences.js +20 -4
- package/dist/model/occurrences.js.map +1 -1
- package/dist/model/recurrence.d.ts +64 -9
- package/dist/model/recurrence.js +94 -19
- package/dist/model/recurrence.js.map +1 -1
- package/dist/model/template-projection.d.ts +26 -0
- package/dist/model/template-projection.js +113 -0
- package/dist/model/template-projection.js.map +1 -0
- package/dist/op-result.d.ts +46 -0
- package/dist/op-result.js +156 -0
- package/dist/op-result.js.map +1 -0
- package/dist/paths.d.ts +8 -0
- package/dist/paths.js +10 -0
- package/dist/paths.js.map +1 -1
- package/dist/read/detail.js +7 -1
- package/dist/read/detail.js.map +1 -1
- package/dist/read/queries.js +8 -1
- package/dist/read/queries.js.map +1 -1
- package/dist/read/views.d.ts +6 -4
- package/dist/read/views.js +60 -18
- package/dist/read/views.js.map +1 -1
- package/dist/sync-health.js +8 -3
- package/dist/sync-health.js.map +1 -1
- package/dist/trace/tracer.d.ts +87 -0
- package/dist/trace/tracer.js +167 -0
- package/dist/trace/tracer.js.map +1 -0
- package/dist/write/accessibility-probe.js +4 -2
- package/dist/write/accessibility-probe.js.map +1 -1
- package/dist/write/automation-probe.js +5 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/availability.js +7 -3
- package/dist/write/availability.js.map +1 -1
- package/dist/write/clear-reminder.js +24 -21
- package/dist/write/clear-reminder.js.map +1 -1
- package/dist/write/commands.js +95 -45
- package/dist/write/commands.js.map +1 -1
- package/dist/write/completion-context.d.ts +57 -0
- package/dist/write/completion-context.js +49 -0
- package/dist/write/completion-context.js.map +1 -0
- package/dist/write/experimental.d.ts +19 -0
- package/dist/write/experimental.js +50 -0
- package/dist/write/experimental.js.map +1 -1
- package/dist/write/failure-hints.d.ts +10 -1
- package/dist/write/failure-hints.js +8 -0
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +10 -0
- package/dist/write/guards.js.map +1 -1
- package/dist/write/move.js +14 -6
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +40 -7
- package/dist/write/pipeline.d.ts +41 -0
- package/dist/write/pipeline.js +289 -19
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +1 -1
- package/dist/write/pre-state.js +35 -17
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.js +434 -19
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/reorder.js +76 -27
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-anchor.d.ts +140 -0
- package/dist/write/repeat-anchor.js +403 -0
- package/dist/write/repeat-anchor.js.map +1 -0
- package/dist/write/repeat-asserts.d.ts +28 -0
- package/dist/write/repeat-asserts.js +169 -0
- package/dist/write/repeat-asserts.js.map +1 -0
- package/dist/write/repeat-rule.js +37 -12
- package/dist/write/repeat-rule.js.map +1 -1
- package/dist/write/vectors/applescript.js +7 -16
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/session-reachability.d.ts +133 -0
- package/dist/write/vectors/session-reachability.js +151 -0
- package/dist/write/vectors/session-reachability.js.map +1 -0
- package/dist/write/vectors/shortcuts.js +5 -14
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.d.ts +16 -1
- package/dist/write/vectors/simulator.js +122 -32
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +183 -10
- package/dist/write/vectors/ui-recipes.d.ts +8 -0
- package/dist/write/vectors/ui-recipes.js +246 -58
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +156 -14
- package/dist/write/vectors/ui.js +846 -84
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +12 -14
- package/dist/write/verify/delta.js +35 -34
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +13 -0
- package/dist/write/verify/poller.js +38 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +8 -1
- package/schema/envelope.schema.json +22 -0
- package/scripts/build-helpers.sh +104 -0
- package/scripts/deputy-cert-setup.sh +60 -0
- package/skills/things-cli/SKILL.md +11 -3
- package/skills/things-cli/references/errors.md +4 -0
- package/skills/things-cli/references/repeating.md +69 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { undoToken } from "../audit/schema.js";
|
|
2
|
+
import { addDaysIso, decodePackedDate, localToday } from "../model/dates.js";
|
|
2
3
|
import { byUuid } from "../read/detail.js";
|
|
3
4
|
import { resolveProjectWriteTarget, resolveTaskUuidPrefix } from "../read/queries.js";
|
|
4
5
|
import { runCloneProject, runCloneTodo } from "./clone.js";
|
|
5
6
|
import { promoteProjectViaGui } from "./make-repeating-project.js";
|
|
6
7
|
import { fingerprintLabel, runMutation, } from "./pipeline.js";
|
|
8
|
+
import { assessOffRuleFirst, daysBetweenIso, deriveFixedAnchor, isIsoDate, } from "./repeat-anchor.js";
|
|
7
9
|
import { assertRepeatRule, ruleToInverseParams } from "./repeat-rule.js";
|
|
8
10
|
import { createDbReader } from "./verify/delta.js";
|
|
11
|
+
import { H_UI_SESSION_UNREACHABLE } from "./vectors/session-reachability.js";
|
|
9
12
|
const PLACEMENT_NOTE = "the series' current instance lands at its container's default position — its prior slot was " +
|
|
10
13
|
"not automatically restored (best-effort placement is not yet wired for this container); " +
|
|
11
14
|
"reposition it with `things reorder` if the order matters";
|
|
@@ -50,8 +53,78 @@ function blockedUiDrive(op) {
|
|
|
50
53
|
"`things config set ui-enabled true` and Accessibility granted to this process (see docs/setup.md)",
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
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) {
|
|
55
128
|
return {
|
|
56
129
|
uuid,
|
|
57
130
|
frequency: rule.frequency,
|
|
@@ -61,6 +134,148 @@ function ruleParamsFor(uuid, rule) {
|
|
|
61
134
|
...(rule.monthly !== undefined && { monthly: rule.monthly }),
|
|
62
135
|
...(rule.yearly !== undefined && { yearly: rule.yearly }),
|
|
63
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 DACON1 off-rule-first disclosure line, or null when the request is on-rule.
|
|
187
|
+
* When an explicit anchor disagrees with `--when` (deadline-shift-aware) the app
|
|
188
|
+
* lands an OFF-RULE first occurrence (honored for weekly/yearly); this states both
|
|
189
|
+
* halves of the landed pattern so the caller can eyeball the two-phase series.
|
|
190
|
+
* The dishonored monthly shape is refused upstream (assertRepeatRule), so only the
|
|
191
|
+
* honored disclosure ever reaches here.
|
|
192
|
+
*/
|
|
193
|
+
function offRuleFirstNote(params) {
|
|
194
|
+
const assessment = assessOffRuleFirst(params);
|
|
195
|
+
return assessment?.kind === "honored" ? assessment.disclosure.message : null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The template's app-materialized first occurrence (`rt1_instanceCreationStartDate`
|
|
199
|
+
* = the date the current instance is dated at) as an ISO date, for the post-drive
|
|
200
|
+
* Next-honored check. `null` when the row/date is absent.
|
|
201
|
+
*/
|
|
202
|
+
function firstOccurrenceOf(db, templateUuid) {
|
|
203
|
+
const row = db
|
|
204
|
+
.prepare("SELECT rt1_instanceCreationStartDate AS ic FROM TMTask WHERE uuid = ?")
|
|
205
|
+
.get(templateUuid);
|
|
206
|
+
if (row === undefined || row.ic === null)
|
|
207
|
+
return null;
|
|
208
|
+
return decodePackedDate(row.ic);
|
|
209
|
+
}
|
|
210
|
+
/** A repeating instance's own scheduled start date (`startDate`) as ISO, or null. */
|
|
211
|
+
function instanceStartDate(db, instanceUuid) {
|
|
212
|
+
const row = db.prepare("SELECT startDate AS s FROM TMTask WHERE uuid = ?").get(instanceUuid);
|
|
213
|
+
if (row === undefined || row.s === null)
|
|
214
|
+
return null;
|
|
215
|
+
return decodePackedDate(row.s);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* DBLSPAWN1 (docs/lab/dblspawn1-preserved-instance.md, golden-v3 / Things 3.22.14):
|
|
219
|
+
* a promote whose source is PRESERVED (SRCFATE deadline / terminal-element trigger)
|
|
220
|
+
* relinks that source IN PLACE as the current-occurrence instance. When the first
|
|
221
|
+
* occurrence is FUTURE-dated this double-books: the hidden template's cursor
|
|
222
|
+
* (`rt1_nextInstanceStartDate`) points at the SAME occurrence with
|
|
223
|
+
* `rt1_instanceCreationCount = 0` — the cursor does not know the occurrence is already
|
|
224
|
+
* materialized — so when the date ARRIVES the app spawns a SECOND instance alongside
|
|
225
|
+
* the preserved one (cell C: two rows dated the same day, icCount 0→1). A genuine
|
|
226
|
+
* duplicate factory, not cosmetic.
|
|
227
|
+
*
|
|
228
|
+
* So a promote composite trashes the redundant preserved FUTURE instance inside the
|
|
229
|
+
* txn: its content mirrors the template, and the cursor will mint the single real
|
|
230
|
+
* occurrence when the date arrives (matching a normal future-first series, which holds
|
|
231
|
+
* no materialized instance until then). A today/past-dated preserved instance is the
|
|
232
|
+
* LEGITIMATE current occurrence (the cursor has already advanced past it) — left
|
|
233
|
+
* untouched. Returns the disclosure warning (and the trashed uuid) or null when the
|
|
234
|
+
* promote did not preserve a future instance. Best-effort: a failed trash is reported
|
|
235
|
+
* in the warning rather than failing the whole compound (the series is already sound).
|
|
236
|
+
*/
|
|
237
|
+
async function trashRedundantFuturePreservedInstance(deps, kind, promote, options, txnId, now) {
|
|
238
|
+
const rep = promote.repeating;
|
|
239
|
+
// Preserved iff the native promote relinked the source (replacedUuid === null) AND
|
|
240
|
+
// there is a materialized instance. A DELETE-fate promote reports replacedUuid !==
|
|
241
|
+
// null and never leaves a future instance to double-book.
|
|
242
|
+
if (rep === undefined || rep.replacedUuid !== null || rep.instanceUuid === null)
|
|
243
|
+
return null;
|
|
244
|
+
const instanceUuid = rep.instanceUuid;
|
|
245
|
+
const startIso = instanceStartDate(deps.db, instanceUuid);
|
|
246
|
+
if (startIso === null)
|
|
247
|
+
return null;
|
|
248
|
+
const todayIso = localToday(now, deps.zone);
|
|
249
|
+
if (daysBetweenIso(todayIso, startIso) <= 0)
|
|
250
|
+
return null; // today or past — legitimate
|
|
251
|
+
const trashOp = kind === "project" ? "project.delete" : "todo.delete";
|
|
252
|
+
const trashed = await runMutation(deps, trashOp, { uuid: instanceUuid }, legOptions(options, txnId));
|
|
253
|
+
const kindWord = kind === "project" ? "project" : "to-do";
|
|
254
|
+
const warning = trashed.kind === "ok"
|
|
255
|
+
? `the source ${kindWord} was kept by the app as a pre-materialized first occurrence dated ` +
|
|
256
|
+
`${startIso}; because that date is in the future the series would have spawned a DUPLICATE ` +
|
|
257
|
+
`there, so the redundant occurrence was moved to the Trash — the series mints a single ` +
|
|
258
|
+
`occurrence when ${startIso} arrives`
|
|
259
|
+
: `the app kept the source ${kindWord} as a pre-materialized first occurrence dated ${startIso} ` +
|
|
260
|
+
`(a future date the series would DUPLICATE), and it could NOT be auto-trashed — remove it ` +
|
|
261
|
+
`with \`things ${kind} delete ${instanceUuid}\``;
|
|
262
|
+
return { warning, trashedUuid: instanceUuid };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The verify-failed result when the driven "Next:" first occurrence did not land
|
|
266
|
+
* (issue #476, ANCH2). The series exists but on the wrong phase — report it
|
|
267
|
+
* fail-closed rather than as a silent ok.
|
|
268
|
+
*/
|
|
269
|
+
function nextMismatch(op, templateUuid, requestedIso, landedIso) {
|
|
270
|
+
return {
|
|
271
|
+
kind: "verify-failed",
|
|
272
|
+
op,
|
|
273
|
+
reason: "mismatch",
|
|
274
|
+
expected: { mode: "update", uuid: templateUuid, assert: [] },
|
|
275
|
+
observed: null,
|
|
276
|
+
detail: `the repeating series was created but its first occurrence landed on ` +
|
|
277
|
+
`${landedIso ?? "an undetermined date"}, not the requested ${requestedIso} — the Next-field ` +
|
|
278
|
+
"drive did not take; the series exists and can be corrected with `things reschedule-repeat`",
|
|
64
279
|
};
|
|
65
280
|
}
|
|
66
281
|
/** The discovered template/instance from a promote leg's result. */
|
|
@@ -159,6 +374,33 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
159
374
|
: "verify the uuid with `things show <uuid>`, or use `things project make-repeating` for a project",
|
|
160
375
|
};
|
|
161
376
|
}
|
|
377
|
+
// ANCH2 (issue #476): the app's Repeat dialog HAS a "Next:" first-occurrence
|
|
378
|
+
// field; its default is the today-anchored next match, but it is editable and
|
|
379
|
+
// honored (docs/lab/anch2-next-field.md). Drive it with the requested first
|
|
380
|
+
// occurrence — an explicit `--when` if given, else the item's own scheduled
|
|
381
|
+
// date. YANCH1 (issue #493): also DERIVE the calendar anchor (weekly weekday /
|
|
382
|
+
// monthly day / yearly month+day) from that date when no explicit anchor was
|
|
383
|
+
// given, and drive the anchor pop-ups — otherwise the recurring rule keeps the
|
|
384
|
+
// dialog's untouched default (weekly Sunday, monthly 1st, yearly January 1) and
|
|
385
|
+
// only the first occurrence is correct (the #493 anchor-drop).
|
|
386
|
+
//
|
|
387
|
+
// DEADLINE-MODE ANCHORING (YANCH1 #493, in-lab golden-v3): a deadlined rule
|
|
388
|
+
// anchors on the DEADLINE, and each instance's START = anchor − startDaysEarlier
|
|
389
|
+
// (probe: anchor+Next driven to Oct-16 with start-14 → of=[Oct-16], instance
|
|
390
|
+
// start = Oct-02). `--when` is the scheduled START, so the date the dialog's
|
|
391
|
+
// anchor pop-ups + "Next:" field must carry is when + startDaysEarlier (the
|
|
392
|
+
// deadline); the app then back-shifts the start to `when`. For a non-deadlined
|
|
393
|
+
// rule the shift is 0 and the drive date equals `--when` (unchanged).
|
|
394
|
+
const whenIso = isIsoDate(params.next) ? params.next : src.startDate;
|
|
395
|
+
const deadlineShift = params.deadline === true || (params.startDaysEarlier ?? 0) > 0
|
|
396
|
+
? (params.startDaysEarlier ?? 0)
|
|
397
|
+
: 0;
|
|
398
|
+
const driveIso = isIsoDate(whenIso) ? addDaysIso(whenIso, deadlineShift) : undefined;
|
|
399
|
+
// The dialog is driven with the deadline-adjusted date; the verify below expects
|
|
400
|
+
// the START to land back on the requested `--when`.
|
|
401
|
+
const nextIso = driveIso;
|
|
402
|
+
const expectedStartIso = isIsoDate(whenIso) ? whenIso : undefined;
|
|
403
|
+
const effParams = { ...params, ...deriveFixedAnchor(params, driveIso) };
|
|
162
404
|
// The promote leg drives the GUI — block before minting a clone if the ack is missing.
|
|
163
405
|
if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
|
|
164
406
|
return blockedUiDrive(op);
|
|
@@ -172,7 +414,7 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
172
414
|
vector: "ui",
|
|
173
415
|
tier: 3,
|
|
174
416
|
invocation: `clone ${srcUuid} (--preserve-created) → make-repeating the clone (Repeat… → ` +
|
|
175
|
-
`frequency=${
|
|
417
|
+
`frequency=${effParams.frequency}, interval=${effParams.interval}) → trash the original ${srcUuid}`,
|
|
176
418
|
expectedDelta: {
|
|
177
419
|
mode: "create",
|
|
178
420
|
probe: { title: src.title, type: expectedType, sinceEpoch: 0 },
|
|
@@ -182,6 +424,12 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
182
424
|
},
|
|
183
425
|
};
|
|
184
426
|
}
|
|
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);
|
|
431
|
+
if (gate !== null)
|
|
432
|
+
return gate;
|
|
185
433
|
const startedAt = now;
|
|
186
434
|
const txnId = newTxnId(startedAt);
|
|
187
435
|
// 1. Clone the source as a disposable, embedded leg (--preserve-created). The
|
|
@@ -235,8 +483,8 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
235
483
|
: {}),
|
|
236
484
|
};
|
|
237
485
|
}
|
|
238
|
-
// 3. Native-promote the clone.
|
|
239
|
-
const rule = ruleParamsFor(cloneUuid,
|
|
486
|
+
// 3. Native-promote the clone (with the ANCH2 Next drive + derived weekday).
|
|
487
|
+
const rule = ruleParamsFor(cloneUuid, effParams, nextIso);
|
|
240
488
|
const promote = kind === "project"
|
|
241
489
|
? await promoteProjectViaGui(deps, rule, legOptions(options, txnId, "ui"))
|
|
242
490
|
: await runMutation(deps, "todo.make-repeating", rule, legOptions(options, txnId, "ui"));
|
|
@@ -259,12 +507,37 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
259
507
|
: {}),
|
|
260
508
|
};
|
|
261
509
|
}
|
|
262
|
-
const { templateUuid
|
|
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
|
+
}
|
|
263
523
|
const warnings = [
|
|
524
|
+
landedRuleEcho(effParams, expectedStartIso ?? firstOccurrenceOf(deps.db, templateUuid)),
|
|
264
525
|
`the original ${expectedType} (uuid ${srcUuid}) was moved to the Trash; \`things undo\` ` +
|
|
265
526
|
"removes the new series (trash-both) and restores it",
|
|
266
527
|
PLACEMENT_NOTE,
|
|
267
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);
|
|
268
541
|
if (promote.warnings !== undefined)
|
|
269
542
|
warnings.push(...promote.warnings);
|
|
270
543
|
appendPromoteSummary(deps, {
|
|
@@ -275,7 +548,7 @@ async function makeRepeatingViaClone(deps, kind, params, options) {
|
|
|
275
548
|
instanceUuid,
|
|
276
549
|
originalUuid: srcUuid,
|
|
277
550
|
invocation: `${op}: clone ${srcUuid} → trash ${srcUuid} → promote ${cloneUuid} → template ${templateUuid}`,
|
|
278
|
-
requested:
|
|
551
|
+
requested: effParams,
|
|
279
552
|
...(preserveModified && preUmd !== null && { preModDates: { [srcUuid]: preUmd } }),
|
|
280
553
|
});
|
|
281
554
|
return promoteOk({
|
|
@@ -303,6 +576,42 @@ export function runMakeRepeatingProject(deps, params, options = {}) {
|
|
|
303
576
|
async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options) {
|
|
304
577
|
const op = kind === "project" ? "project.add-repeating" : "todo.add-repeating";
|
|
305
578
|
assertRepeatRule(rule);
|
|
579
|
+
// ANCH2 (issue #476): drive the Repeat dialog's "Next:" field with --when so the
|
|
580
|
+
// series starts on the requested date (the field's default is today-anchored but
|
|
581
|
+
// it is editable and honored). YANCH1 (issue #493): also DERIVE the calendar
|
|
582
|
+
// anchor (weekly weekday / monthly day / yearly month+day) from --when when no
|
|
583
|
+
// explicit anchor was given, and drive the anchor pop-ups — otherwise the
|
|
584
|
+
// recurring rule keeps the dialog's untouched default (weekly Sunday, monthly
|
|
585
|
+
// 1st, yearly January 1) and only the first occurrence is correct.
|
|
586
|
+
//
|
|
587
|
+
// DEADLINE-MODE (DBLSPAWN1): a deadlined rule anchors on the DEADLINE, and each
|
|
588
|
+
// instance's START = deadline − startDaysEarlier (DACON1 DC4). `--when` is the
|
|
589
|
+
// scheduled START, so the date the dialog's "Next:" field + anchor pop-ups must
|
|
590
|
+
// carry is when + startDaysEarlier (the deadline); the app back-shifts the start to
|
|
591
|
+
// `--when`. `runAddRepeatingTodo` maps a concrete item-level `--deadline` into the
|
|
592
|
+
// rule's deadline/startDaysEarlier here (and strips it from the seed, so the source
|
|
593
|
+
// is not SRCFATE-preserved as a double-booking future instance). For a non-deadlined
|
|
594
|
+
// rule the shift is 0 and the drive date equals `--when` (unchanged).
|
|
595
|
+
const whenIso = isIsoDate(addParams["when"]) ? addParams["when"] : null;
|
|
596
|
+
const deadlineShift = rule.deadline === true || (rule.startDaysEarlier ?? 0) > 0 ? (rule.startDaysEarlier ?? 0) : 0;
|
|
597
|
+
const driveIso = whenIso !== null ? addDaysIso(whenIso, deadlineShift) : null;
|
|
598
|
+
const nextIso = driveIso ?? undefined;
|
|
599
|
+
const expectedStartIso = whenIso ?? undefined;
|
|
600
|
+
const effRule = {
|
|
601
|
+
...rule,
|
|
602
|
+
...deriveFixedAnchor(rule, driveIso),
|
|
603
|
+
};
|
|
604
|
+
// ADR1 (issue #480, requested behavior #3): carry the base to-do's --reminder
|
|
605
|
+
// onto the SERIES. The create leg sets a one-off reminderTime on the seed, but
|
|
606
|
+
// the Repeat-dialog conversion does NOT preserve it — the dialog OWNS the repeat
|
|
607
|
+
// reminder via its "Add reminders" control — so a base reminder was silently
|
|
608
|
+
// dropped from the template (verified empty on golden-v2,
|
|
609
|
+
// docs/lab/adr1-add-repeating-reveal.md). Drive the dialog's reminder with the
|
|
610
|
+
// base time so every spawned occurrence carries it (ANCH2: the reminder picker
|
|
611
|
+
// commits reminderTime deterministically). Projects have no reminder vocabulary,
|
|
612
|
+
// so addParams never carries one there.
|
|
613
|
+
const baseReminder = typeof addParams["reminder"] === "string" ? addParams["reminder"] : undefined;
|
|
614
|
+
const effRuleWithReminder = baseReminder !== undefined ? { ...effRule, reminder: baseReminder } : effRule;
|
|
306
615
|
// The promote leg drives the GUI — block before creating anything if the ack is missing.
|
|
307
616
|
if (options.dangerouslyDriveGui !== true && options.dryRun !== true) {
|
|
308
617
|
return blockedUiDrive(op);
|
|
@@ -327,6 +636,12 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
327
636
|
},
|
|
328
637
|
};
|
|
329
638
|
}
|
|
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);
|
|
643
|
+
if (gate !== null)
|
|
644
|
+
return gate;
|
|
330
645
|
const startedAt = deps.now?.() ?? new Date();
|
|
331
646
|
const txnId = newTxnId(startedAt);
|
|
332
647
|
// 1. Create the item (full add vocabulary) as an embedded leg.
|
|
@@ -349,17 +664,53 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
349
664
|
: { ...add, op };
|
|
350
665
|
}
|
|
351
666
|
const createdUuid = add.uuid;
|
|
352
|
-
// 2. Native-promote the fresh row
|
|
353
|
-
|
|
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);
|
|
354
670
|
const promote = kind === "project"
|
|
355
671
|
? await promoteProjectViaGui(deps, ruleParams, legOptions(options, txnId, "ui"))
|
|
356
672
|
: await runMutation(deps, "todo.make-repeating", ruleParams, legOptions(options, txnId, "ui"));
|
|
357
673
|
if (promote.kind !== "ok") {
|
|
358
|
-
//
|
|
359
|
-
|
|
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 };
|
|
360
684
|
}
|
|
361
|
-
const { templateUuid
|
|
362
|
-
|
|
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);
|
|
697
|
+
}
|
|
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;
|
|
710
|
+
}
|
|
711
|
+
const offRule = offRuleFirstNote(ruleParams);
|
|
712
|
+
if (offRule !== null)
|
|
713
|
+
warnings.push(offRule);
|
|
363
714
|
if (promote.warnings !== undefined)
|
|
364
715
|
warnings.push(...promote.warnings);
|
|
365
716
|
appendPromoteSummary(deps, {
|
|
@@ -369,7 +720,7 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
369
720
|
templateUuid,
|
|
370
721
|
instanceUuid,
|
|
371
722
|
invocation: `${op}: add "${title}" ${createdUuid} → template ${templateUuid}`,
|
|
372
|
-
requested: { title, ...
|
|
723
|
+
requested: { title, ...effRule },
|
|
373
724
|
});
|
|
374
725
|
return promoteOk({
|
|
375
726
|
op,
|
|
@@ -381,9 +732,9 @@ async function addRepeatingViaCreate(deps, kind, addParams, rule, title, options
|
|
|
381
732
|
warnings,
|
|
382
733
|
});
|
|
383
734
|
}
|
|
384
|
-
export function runAddRepeatingTodo(deps, params, options = {}) {
|
|
385
|
-
const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, ...add } = params;
|
|
386
|
-
const
|
|
735
|
+
export async function runAddRepeatingTodo(deps, params, options = {}) {
|
|
736
|
+
const { frequency, interval, afterCompletion, weekdays, monthly, yearly, ends, startDaysEarlier, ...add } = params;
|
|
737
|
+
const baseRule = {
|
|
387
738
|
frequency,
|
|
388
739
|
interval,
|
|
389
740
|
...(afterCompletion !== undefined && { afterCompletion }),
|
|
@@ -392,12 +743,71 @@ export function runAddRepeatingTodo(deps, params, options = {}) {
|
|
|
392
743
|
...(yearly !== undefined && { yearly }),
|
|
393
744
|
...(ends !== undefined && { ends }),
|
|
394
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.
|
|
768
|
+
let rule = baseRule;
|
|
769
|
+
let seedDeadline = add.deadline;
|
|
770
|
+
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
|
|
798
|
+
}
|
|
799
|
+
else if (startDaysEarlier !== undefined) {
|
|
800
|
+
// afterCompletion === true here: an after-completion repeat has no calendar
|
|
801
|
+
// start to count a deadline back from — refuse rather than silently drop it.
|
|
802
|
+
throw new RangeError("--start-days-earlier applies only to a fixed-schedule deadline — an after-completion repeat " +
|
|
803
|
+
"has no calendar start to count back from; drop --after-completion or --start-days-earlier");
|
|
804
|
+
}
|
|
395
805
|
const addParams = {
|
|
396
806
|
title: add.title,
|
|
397
807
|
...(add.notes !== undefined && { notes: add.notes }),
|
|
398
808
|
...(add.when !== undefined && { when: add.when }),
|
|
399
809
|
...(add.reminder !== undefined && { reminder: add.reminder }),
|
|
400
|
-
...(
|
|
810
|
+
...(seedDeadline !== undefined && { deadline: seedDeadline }),
|
|
401
811
|
...(add.tags !== undefined && { tags: add.tags }),
|
|
402
812
|
...(add.checklistItems !== undefined && { checklistItems: add.checklistItems }),
|
|
403
813
|
...(add.project !== undefined && { project: add.project }),
|
|
@@ -453,7 +863,7 @@ function blockedCloneSource(op, detail, remediation) {
|
|
|
453
863
|
* produce: two simultaneous end bounds, and a multi-anchor month/year rule.
|
|
454
864
|
*/
|
|
455
865
|
function inexpressibleReason(rule) {
|
|
456
|
-
if (rule.endDate !== null && rule.
|
|
866
|
+
if (rule.endDate !== null && rule.occurrenceCount !== null) {
|
|
457
867
|
return ("the source rule ends on BOTH a date and an occurrence count, which the repeat vocabulary " +
|
|
458
868
|
"cannot express (its Ends bound is a single choice)");
|
|
459
869
|
}
|
|
@@ -531,6 +941,11 @@ export async function cloneTemplateViaRepromote(deps, kind, src, srcUuid, params
|
|
|
531
941
|
},
|
|
532
942
|
};
|
|
533
943
|
}
|
|
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);
|
|
947
|
+
if (gate !== null)
|
|
948
|
+
return gate;
|
|
534
949
|
const startedAt = deps.now?.() ?? new Date();
|
|
535
950
|
const txnId = newTxnId(startedAt);
|
|
536
951
|
// 4. Mint the plain clone as an embedded leg — cloneTemplateAsPlain reaches the
|