things-api 0.11.0 → 0.13.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 (208) hide show
  1. package/README.md +16 -5
  2. package/dist/audit/schema.d.ts +7 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/canonical-ref.d.ts +21 -0
  5. package/dist/cli/canonical-ref.js +23 -0
  6. package/dist/cli/canonical-ref.js.map +1 -0
  7. package/dist/cli/commands/area.d.ts +18 -11
  8. package/dist/cli/commands/area.js +81 -77
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -2
  11. package/dist/cli/commands/doctor.js +11 -1
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/install-skill.d.ts +19 -4
  14. package/dist/cli/commands/install-skill.js +130 -52
  15. package/dist/cli/commands/install-skill.js.map +1 -1
  16. package/dist/cli/commands/mcp.js +21 -1
  17. package/dist/cli/commands/mcp.js.map +1 -1
  18. package/dist/cli/commands/project.d.ts +1 -0
  19. package/dist/cli/commands/project.js +92 -48
  20. package/dist/cli/commands/project.js.map +1 -1
  21. package/dist/cli/commands/reads.d.ts +5 -1
  22. package/dist/cli/commands/reads.js +110 -31
  23. package/dist/cli/commands/reads.js.map +1 -1
  24. package/dist/cli/commands/setup.js +11 -4
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.js +75 -36
  27. package/dist/cli/commands/show.js.map +1 -1
  28. package/dist/cli/commands/todo.js +2 -2
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +795 -144
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +14 -3
  33. package/dist/cli/did-you-mean.js +44 -7
  34. package/dist/cli/did-you-mean.js.map +1 -1
  35. package/dist/cli/dry-run.d.ts +55 -0
  36. package/dist/cli/dry-run.js +92 -0
  37. package/dist/cli/dry-run.js.map +1 -0
  38. package/dist/cli/glyphs.d.ts +16 -2
  39. package/dist/cli/glyphs.js +60 -12
  40. package/dist/cli/glyphs.js.map +1 -1
  41. package/dist/cli/help.js +69 -13
  42. package/dist/cli/help.js.map +1 -1
  43. package/dist/cli/main.js +8 -1
  44. package/dist/cli/main.js.map +1 -1
  45. package/dist/cli/move-hint.d.ts +3 -2
  46. package/dist/cli/move-hint.js +2 -2
  47. package/dist/cli/move-hint.js.map +1 -1
  48. package/dist/cli/read-driver.d.ts +53 -5
  49. package/dist/cli/read-driver.js +82 -15
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/ref-render.d.ts +14 -0
  52. package/dist/cli/ref-render.js +9 -0
  53. package/dist/cli/ref-render.js.map +1 -0
  54. package/dist/cli/render.d.ts +40 -14
  55. package/dist/cli/render.js +128 -55
  56. package/dist/cli/render.js.map +1 -1
  57. package/dist/cli/resolve-invocation.d.ts +7 -5
  58. package/dist/cli/resolve-invocation.js +16 -7
  59. package/dist/cli/resolve-invocation.js.map +1 -1
  60. package/dist/cli/skill-check.d.ts +12 -6
  61. package/dist/cli/skill-check.js +31 -14
  62. package/dist/cli/skill-check.js.map +1 -1
  63. package/dist/cli/skill.d.ts +8 -0
  64. package/dist/cli/skill.js +10 -0
  65. package/dist/cli/skill.js.map +1 -1
  66. package/dist/cli/verb-hint.js +1 -1
  67. package/dist/cli/verb-hint.js.map +1 -1
  68. package/dist/client.d.ts +158 -26
  69. package/dist/client.js +254 -38
  70. package/dist/client.js.map +1 -1
  71. package/dist/config.d.ts +66 -2
  72. package/dist/config.js +120 -12
  73. package/dist/config.js.map +1 -1
  74. package/dist/contracts.d.ts +187 -28
  75. package/dist/contracts.js +22 -1
  76. package/dist/contracts.js.map +1 -1
  77. package/dist/db/baselines/db-v26.js +4 -1
  78. package/dist/db/baselines/db-v26.js.map +1 -1
  79. package/dist/diagnose.d.ts +34 -2
  80. package/dist/diagnose.js +36 -1
  81. package/dist/diagnose.js.map +1 -1
  82. package/dist/index.d.ts +21 -8
  83. package/dist/index.js +26 -3
  84. package/dist/index.js.map +1 -1
  85. package/dist/mcp/server.d.ts +9 -0
  86. package/dist/mcp/server.js +790 -322
  87. package/dist/mcp/server.js.map +1 -1
  88. package/dist/model/entities.d.ts +57 -4
  89. package/dist/model/entities.js.map +1 -1
  90. package/dist/model/mappers.d.ts +2 -0
  91. package/dist/model/mappers.js +44 -2
  92. package/dist/model/mappers.js.map +1 -1
  93. package/dist/model/recurrence.d.ts +8 -1
  94. package/dist/model/recurrence.js.map +1 -1
  95. package/dist/model/serialize.js +15 -3
  96. package/dist/model/serialize.js.map +1 -1
  97. package/dist/read/area-filter.d.ts +76 -0
  98. package/dist/read/area-filter.js +58 -0
  99. package/dist/read/area-filter.js.map +1 -0
  100. package/dist/read/area-view.d.ts +22 -11
  101. package/dist/read/area-view.js +54 -28
  102. package/dist/read/area-view.js.map +1 -1
  103. package/dist/read/detail.js +15 -8
  104. package/dist/read/detail.js.map +1 -1
  105. package/dist/read/predicates.d.ts +18 -0
  106. package/dist/read/predicates.js +19 -0
  107. package/dist/read/predicates.js.map +1 -1
  108. package/dist/read/project-view.d.ts +91 -15
  109. package/dist/read/project-view.js +135 -37
  110. package/dist/read/project-view.js.map +1 -1
  111. package/dist/read/pseudo-area.d.ts +29 -0
  112. package/dist/read/pseudo-area.js +27 -0
  113. package/dist/read/pseudo-area.js.map +1 -0
  114. package/dist/read/queries.d.ts +175 -19
  115. package/dist/read/queries.js +376 -32
  116. package/dist/read/queries.js.map +1 -1
  117. package/dist/read/scope.d.ts +126 -0
  118. package/dist/read/scope.js +161 -0
  119. package/dist/read/scope.js.map +1 -0
  120. package/dist/read/search-rank.d.ts +2 -5
  121. package/dist/read/shape.d.ts +181 -0
  122. package/dist/read/shape.js +737 -0
  123. package/dist/read/shape.js.map +1 -0
  124. package/dist/read/show-target.d.ts +9 -1
  125. package/dist/read/show-target.js +77 -17
  126. package/dist/read/show-target.js.map +1 -1
  127. package/dist/read/stage.d.ts +199 -0
  128. package/dist/read/stage.js +125 -0
  129. package/dist/read/stage.js.map +1 -0
  130. package/dist/read/truncation.d.ts +7 -12
  131. package/dist/read/truncation.js +56 -21
  132. package/dist/read/truncation.js.map +1 -1
  133. package/dist/read/views.d.ts +68 -16
  134. package/dist/read/views.js +206 -52
  135. package/dist/read/views.js.map +1 -1
  136. package/dist/surface-copy.d.ts +16 -0
  137. package/dist/surface-copy.js +16 -0
  138. package/dist/surface-copy.js.map +1 -1
  139. package/dist/write/batch.d.ts +49 -10
  140. package/dist/write/batch.js +423 -71
  141. package/dist/write/batch.js.map +1 -1
  142. package/dist/write/commands.js +268 -55
  143. package/dist/write/commands.js.map +1 -1
  144. package/dist/write/guards.d.ts +1 -1
  145. package/dist/write/guards.js +71 -10
  146. package/dist/write/guards.js.map +1 -1
  147. package/dist/write/heading.d.ts +10 -1
  148. package/dist/write/heading.js +35 -5
  149. package/dist/write/heading.js.map +1 -1
  150. package/dist/write/make-repeating-project.d.ts +2 -2
  151. package/dist/write/make-repeating-project.js +9 -9
  152. package/dist/write/make-repeating-project.js.map +1 -1
  153. package/dist/write/move.d.ts +134 -0
  154. package/dist/write/move.js +1874 -0
  155. package/dist/write/move.js.map +1 -0
  156. package/dist/write/operations.d.ts +105 -19
  157. package/dist/write/operations.js +56 -7
  158. package/dist/write/operations.js.map +1 -1
  159. package/dist/write/opid.d.ts +31 -0
  160. package/dist/write/opid.js +30 -0
  161. package/dist/write/opid.js.map +1 -0
  162. package/dist/write/pipeline.d.ts +70 -3
  163. package/dist/write/pipeline.js +198 -33
  164. package/dist/write/pipeline.js.map +1 -1
  165. package/dist/write/pre-state.d.ts +172 -8
  166. package/dist/write/pre-state.js +467 -25
  167. package/dist/write/pre-state.js.map +1 -1
  168. package/dist/write/reorder.d.ts +23 -3
  169. package/dist/write/reorder.js +2106 -86
  170. package/dist/write/reorder.js.map +1 -1
  171. package/dist/write/reversibility.js +19 -7
  172. package/dist/write/reversibility.js.map +1 -1
  173. package/dist/write/scope-guard.d.ts +31 -0
  174. package/dist/write/scope-guard.js +162 -0
  175. package/dist/write/scope-guard.js.map +1 -0
  176. package/dist/write/undo.js +120 -13
  177. package/dist/write/undo.js.map +1 -1
  178. package/dist/write/vectors/applescript.js +16 -7
  179. package/dist/write/vectors/applescript.js.map +1 -1
  180. package/dist/write/vectors/shortcuts.js +1 -1
  181. package/dist/write/vectors/shortcuts.js.map +1 -1
  182. package/dist/write/vectors/simulator.js +83 -27
  183. package/dist/write/vectors/simulator.js.map +1 -1
  184. package/dist/write/vectors/types.d.ts +11 -0
  185. package/dist/write/vectors/ui-certification.d.ts +3 -3
  186. package/dist/write/vectors/ui-certification.js +41 -5
  187. package/dist/write/vectors/ui-certification.js.map +1 -1
  188. package/dist/write/vectors/ui-drag.d.ts +21 -1
  189. package/dist/write/vectors/ui-drag.js +138 -27
  190. package/dist/write/vectors/ui-drag.js.map +1 -1
  191. package/dist/write/vectors/ui-recipes.d.ts +21 -1
  192. package/dist/write/vectors/ui-recipes.js +166 -7
  193. package/dist/write/vectors/ui-recipes.js.map +1 -1
  194. package/dist/write/vectors/ui.d.ts +25 -1
  195. package/dist/write/vectors/ui.js +142 -33
  196. package/dist/write/vectors/ui.js.map +1 -1
  197. package/dist/write/verify/delta.d.ts +17 -1
  198. package/dist/write/verify/delta.js +106 -29
  199. package/dist/write/verify/delta.js.map +1 -1
  200. package/package.json +7 -1
  201. package/schema/envelope.schema.json +397 -0
  202. package/skills/things-cli/SKILL.md +36 -17
  203. package/skills/things-cli/references/banner.md +35 -0
  204. package/skills/things-cli/references/contracts.md +32 -9
  205. package/skills/things-cli/references/errors.md +57 -0
  206. package/skills/things-cli/references/gui.md +1 -1
  207. package/skills/things-cli/references/model.md +18 -7
  208. package/skills/things-cli/references/ordering.md +88 -0
@@ -0,0 +1,1874 @@
1
+ import { addDaysIso, decodePackedDate, encodePackedDate, localToday } from "../model/dates.js";
2
+ import { ReferenceResolutionError, resolveTaskUuidPrefix } from "../read/queries.js";
3
+ import { taskMembershipClause } from "../read/scope.js";
4
+ import { isLooseRef, LOOSE_TO_AREA_REFUSAL } from "../read/pseudo-area.js";
5
+ import { computeReorderPre, resolveArea, resolveHeading, resolveProject } from "./pre-state.js";
6
+ import {} from "./pipeline.js";
7
+ import { runMutation } from "./pipeline.js";
8
+ import { runReorder } from "./reorder.js";
9
+ function loadRow(db, uuid) {
10
+ const row = db
11
+ .prepare("SELECT uuid, title, type, project, area, heading, start, startDate, startBucket, deadline, " +
12
+ "rt1_recurrenceRule AS rule, repeater, rt1_nextInstanceStartDate AS projectionDay " +
13
+ "FROM TMTask WHERE uuid = ?")
14
+ .get(uuid);
15
+ if (row === undefined)
16
+ return undefined;
17
+ const { rule, repeater, projectionDay, ...rest } = row;
18
+ const isTemplate = rule !== null || repeater !== null;
19
+ return { ...rest, isTemplate, templateProjectionDay: isTemplate ? projectionDay : null };
20
+ }
21
+ const KIND_LABEL = { 0: "to-do", 1: "project", 2: "heading" };
22
+ /**
23
+ * The display bucket a row sits in — the anchor single-bucket rule keys on it.
24
+ *
25
+ * DATE-FIRST precedence, mirroring the read layer's settled semantics
26
+ * (src/read/stage.ts deriveStage, post-R10.2/R12): a `startDate` is classified
27
+ * by its date BEFORE `start` is consulted, because the app's ONLY representation
28
+ * of a future-scheduled item is `start=2` + a future `startDate` (UPC1 upcoming
29
+ * cohort, BANNER1 scheduled arrivals; live prod scan 2026-07-30: 4/4
30
+ * future-scheduled to-dos are start=2, zero are start=1+future). Classifying
31
+ * `start===2 → someday` BEFORE the date check would mislabel every real
32
+ * future-scheduled row as `someday` and route it to the someday protocols —
33
+ * whose someday↔anytime bounce legs would CLEAR the item's date (a de-schedule).
34
+ * So: inbox → dated (arrived → today/evening; future → scheduled:<date>) → then
35
+ * `start===2` correctly means an UNDATED someday only → else anytime. An arrived
36
+ * `start=2` (someday-scheduled, `startDate <= today`) is a Today+Anytime member,
37
+ * so it buckets today/evening here, exactly as deriveStage derives `anytime`
38
+ * with the Today marker.
39
+ */
40
+ /**
41
+ * The packed startDate of TOMORROW (the day after `packedToday`). Packed dates
42
+ * are bit-fields, not integers, so tomorrow is NOT packedToday+1 across a month/
43
+ * year boundary — decode → +1 calendar day → re-encode. Feeds the ORDFIN2
44
+ * TOMORROWLIST fast path (a future day-group whose day == tomorrow rides the
45
+ * native `list "Tomorrow"` one-call sort, not the scratch-park compound).
46
+ */
47
+ function packedTomorrowOf(packedToday) {
48
+ const iso = decodePackedDate(packedToday);
49
+ return iso === null ? packedToday : encodePackedDate(addDaysIso(iso, 1));
50
+ }
51
+ function scheduleBucket(row, packedToday) {
52
+ if (row.start === 0)
53
+ return "inbox";
54
+ if (row.startDate !== null) {
55
+ if (row.startDate <= packedToday)
56
+ return row.startBucket === 1 ? "evening" : "today";
57
+ return `scheduled:${row.startDate}`;
58
+ }
59
+ if (row.start === 2)
60
+ return "someday";
61
+ return "anytime";
62
+ }
63
+ /**
64
+ * The packed future-deadline day of a DEADLINE-FORECAST row (DLBNC / #383, #385),
65
+ * or null. The §9o forecast cohort: a to-do (type=0) OR project (type=1) with NO
66
+ * `startDate`, someday/anytime stage (`start IN (1,2)`), and a strictly-FUTURE
67
+ * `deadline` rests on that deadline day's ROOT Upcoming day-block on the shared
68
+ * `todayIndex` axis (UPCDL-1a/§9o, GUI-confirmed DLBNC-1d; forecast PROJECTS carry
69
+ * the axis identically — PROJDL-2a/2b/2c, #385). It reorders there via the deadline-
70
+ * cycle (URL `deadline=` clear + re-set — `update` for a to-do, `update-project`
71
+ * for a project), NOT the someday/anytime `index` lever — so the planner routes it
72
+ * to the `day` scope keyed on the deadline. INBOX-stage rows (`start=0`) rest OFF
73
+ * the axis (todayIndex=0) — excluded here; a today/past deadline is NOT a future
74
+ * day-block (the row renders in its someday/anytime bucket with an overdue badge) —
75
+ * excluded, matching the strictly-future gate scheduled rows use. Headings (type=2)
76
+ * are excluded. PROJSTAR-safe: the project deadline-cycle never flips `start` to 1
77
+ * (no accidental Today star) and preserves `index`/area-FK/tags (PROJDL-2b/2b').
78
+ */
79
+ function forecastDeadlineDay(row, packedToday) {
80
+ if (row.type !== 0 && row.type !== 1)
81
+ return null;
82
+ if (row.startDate !== null)
83
+ return null;
84
+ if (row.start !== 1 && row.start !== 2)
85
+ return null;
86
+ if (row.deadline === null || row.deadline <= packedToday)
87
+ return null;
88
+ return row.deadline;
89
+ }
90
+ /** An app-default target for a bounce-dependent placement while bounce is off. */
91
+ function bounceDisabledTarget(what) {
92
+ return {
93
+ scope: null,
94
+ reason: `${what} needs the when= bounce, which is disabled (bounce-enabled=false) — ` +
95
+ "re-enable it with `things config set bounce-enabled true`",
96
+ };
97
+ }
98
+ /**
99
+ * The reorder protocol for a row's container × bucket, per the REORDGAPS +
100
+ * BOUNCE2 verdicts (docs/lab/reordgaps-results.md, spec §4 rule 5). GUARANTEED:
101
+ * loose inbox/today/evening/someday/anytime (ANYBNC bounce); a project's
102
+ * unheaded anytime OR someday children (SOMEORD-b, native `index`); an area's
103
+ * anytime members; an area's someday members (SOMEBNC-area bounce — was §9f-
104
+ * prohibited); a heading's anytime children (BOUNCE2-h forward-order bounce); a
105
+ * container's UNHEADED same-day scheduled children (DAYORD-b native todayIndex re-
106
+ * rank — the single-project degenerate case); ANY OTHER future day-group — loose,
107
+ * direct-area, headed, cross-container, or any mix incl. AREA-LESS scheduled
108
+ * PROJECT rows — via the SIT4 dated `day` bounce (cross-date re-when round-trip,
109
+ * per-type legs, reminder/deadline/heading-FK preserving; the one-call `tomorrow`
110
+ * sort when the day is tomorrow); a heading's SOMEDAY children (HEADSUB1 heading-
111
+ * someday re-head-in-order back-insert); a container child's EVENING sub-bucket,
112
+ * to-do OR project (HEADSUB1 Arm D + ORDFIN1 Arm 2b + SIT4 EVEORD — the shipped
113
+ * `evening` bounce accepts project/area/HEADED movees, heading FK preserved,
114
+ * projects share the evening axis); area-less someday projects; top-level anytime
115
+ * projects. APP-DEFAULT: an AREA project's future-day cell (only area-less project
116
+ * rows are proven — SIT4 DAYBNC); repeating TEMPLATE rows (§9e). When bounce is
117
+ * DISABLED the bounce-dependent classes degrade to app-default naming the flag —
118
+ * never a destructive or unverified fallback.
119
+ */
120
+ function reorderTargetOf(row, isTodo, packedToday, bounceEnabled) {
121
+ if (row.isTemplate) {
122
+ // A repeating template's Upcoming-day-block projection is a first-class todayIndex
123
+ // member of its projection day (TMPLSORT/PTMPL): route it to the day-group scope so
124
+ // it rides the template-aware leg family in reorder.ts — the native one-call `list
125
+ // "Tomorrow"` wire when the projection == tomorrow (both to-do AND project
126
+ // templates: TMPLSORT-3c-Tomorrow / PTMPL-B5), else the `day` scope (a TO-DO
127
+ // template front-inserts via a single-id `list "Upcoming"` leg; a PROJECT template
128
+ // is byte-untouched under the suffix rule). A template with no strictly-future
129
+ // projection has no wired day-block surface — app-default, honest.
130
+ const day = rowDayKey(row, packedToday);
131
+ if (day === null) {
132
+ return {
133
+ scope: null,
134
+ reason: "a repeating template with no strictly-future projection (§9e)",
135
+ };
136
+ }
137
+ return day === packedTomorrowOf(packedToday)
138
+ ? { scope: "tomorrow", day }
139
+ : { scope: "day", day };
140
+ }
141
+ const bucket = scheduleBucket(row, packedToday);
142
+ // A same-day (today-proper) or future scheduled day, startBucket=0 — the
143
+ // DAYORD-b container todayIndex surface. The evening sub-bucket is distinct.
144
+ // NB: for a to-do an ARRIVED day (today-proper) is intercepted at the top of
145
+ // the isTodo block and routed to the today scope (it is a Today-view member,
146
+ // not a day-group member); the today-proper disjunct here only bears on the
147
+ // projects path below (where an arrived scheduled project stays app-default).
148
+ const containerDay = bucket === "today" || bucket.startsWith("scheduled:");
149
+ // The packed scheduled day, threaded into the future day-group targets so their
150
+ // refusal / disclosure copy names the DATE — a day-group's identity IS its day
151
+ // (two rows on different days are different groups). Reached only for strictly-
152
+ // future scheduled rows below, so startDate is non-null there.
153
+ const dayField = row.startDate !== null ? { day: row.startDate } : {};
154
+ // ORDFIN2 TOMORROWLIST: a row scheduled for TOMORROW (startBucket=0) rides the
155
+ // native one-call `list "Tomorrow"` day-sort instead of the SIT4 dated `day`
156
+ // bounce. It re-ranks the whole cross-container tomorrow group on todayIndex,
157
+ // projects included, preserving startDate/FKs (no §9g re-date) — cheaper than
158
+ // the 2N-leg bounce. The single-project container-day path keeps its own native
159
+ // re-rank; HEADED children never ride the native sort (it RIPS a heading, §9k),
160
+ // so they take the dated bounce even on tomorrow.
161
+ const isTomorrow = row.startBucket === 0 && row.startDate === packedTomorrowOf(packedToday);
162
+ if (isTodo) {
163
+ // DEADLINE-FORECAST members route FIRST (DLBNC / #383). A someday/anytime-stage
164
+ // to-do (no startDate) with a future deadline is a first-class member of that
165
+ // deadline day's Upcoming block — it reorders on the block's todayIndex axis via
166
+ // the deadline-cycle (URL deadline= clear + re-set), never the someday/anytime
167
+ // index lever. ALWAYS the `day` scope keyed on the deadline — NEVER the native
168
+ // `list "Tomorrow"` sort even when the deadline is tomorrow, because that surface
169
+ // RE-DATES a forecast row (stamps a startDate, UPCDL-5), ejecting it from the
170
+ // forecast cohort. The deadline-cycle is public-URL-only (no experimental gate).
171
+ const fDay = forecastDeadlineDay(row, packedToday);
172
+ if (fDay !== null)
173
+ return { scope: "day", day: fDay };
174
+ // ARRIVED Today-view members route DATE-FIRST, before any container branch.
175
+ // A dated row whose day has landed (startDate <= today) is a Today member —
176
+ // the GUI renders arrived/today-dated rows in the TODAY view; the Upcoming
177
+ // day-groups hold STRICTLY FUTURE dates only. So it reorders via the shipped
178
+ // cross-container today/evening scopes exactly like an undated Today member,
179
+ // regardless of its container (loose, project-, heading-, or area-child).
180
+ // `scheduleBucket` already classifies arrived rows date-first (#325 — arrived
181
+ // → today/evening, future → scheduled:<date>), so route on its verdict
182
+ // (single-source, no re-derive). Only strictly-future dates fall through to
183
+ // the per-container day-groups below. This is the same class of bug #325 fixed
184
+ // in `scheduleBucket`: classifying by date-group before checking arrived-ness
185
+ // misrouted an arrived member into a future day-group compound.
186
+ if (bucket === "today")
187
+ return { scope: "today" };
188
+ if (bucket === "evening") {
189
+ // Evening flag is live (startBucket=1) only while startDate == today (§9n);
190
+ // scheduleBucket already gates this — an arrived evening member front-inserts
191
+ // via the shipped `evening` bounce (container FK + startBucket=1 preserved,
192
+ // R07 reminder-loss caveat inherited). Same scope for loose and every child.
193
+ return bounceEnabled ? { scope: "evening" } : bounceDisabledTarget("evening-section order");
194
+ }
195
+ if (row.heading !== null) {
196
+ // Within-heading order (HEADSUB1). anytime → the forward-order bounce
197
+ // (BOUNCE2-h). someday → the re-head-in-order back-insert (heading-someday,
198
+ // Arm B/C — pure URL move legs, no gate). FUTURE same-day scheduled → the
199
+ // SIT4 dated `day` bounce (it preserves the heading FK, §2e/R21 — no unhead/
200
+ // re-head round-trip needed). --before/--after against an unmoved sibling
201
+ // rides these scopes' co-touch (handled by the anchor path), not here.
202
+ if (bucket === "anytime") {
203
+ return bounceEnabled
204
+ ? { scope: "heading", container: row.heading }
205
+ : bounceDisabledTarget("within-heading order");
206
+ }
207
+ if (bucket === "someday")
208
+ return { scope: "heading-someday", container: row.heading };
209
+ // A heading's same-day SCHEDULED children ride the `day` dated bounce — SIT4
210
+ // DAYBNC proved the cross-date round-trip preserves the heading FK byte-
211
+ // identical and lands a headed child exactly, so the former heading-day
212
+ // unhead→container-day→re-head round-trip is gone. Container-less: `day` is a
213
+ // GLOBAL cross-container axis. Headed rows NEVER ride the native tomorrow
214
+ // sort (the private reorder RIPS a headed child, §9k/O06), so even on tomorrow
215
+ // a headed child bounces.
216
+ if (containerDay)
217
+ return { scope: "day", ...dayField };
218
+ return {
219
+ scope: null,
220
+ reason: `a heading's ${bucket} sub-bucket (no wired order surface for it)`,
221
+ };
222
+ }
223
+ if (row.project !== null) {
224
+ // Project unheaded: a FUTURE same-day scheduled bucket re-ranks todayIndex via
225
+ // the container specifier, date-preserving (DAYORD-b); everything else
226
+ // (anytime / someday) re-ranks cleanly by index through the native project
227
+ // reorder (O04, SOMEORD-b).
228
+ if (containerDay)
229
+ return { scope: "container-day", container: row.project, ...dayField };
230
+ return { scope: "project", container: row.project };
231
+ }
232
+ if (row.area !== null) {
233
+ // Area someday members: the SOMEBNC-area bounce (was §9f-prohibited via
234
+ // the destructive area reorder command — the planner NEVER uses that).
235
+ if (bucket === "someday") {
236
+ return bounceEnabled
237
+ ? { scope: "area-someday", container: row.area }
238
+ : bounceDisabledTarget("an area's someday order");
239
+ }
240
+ if (bucket === "anytime")
241
+ return { scope: "area", container: row.area };
242
+ // A direct-area to-do's FUTURE scheduled DAY rides the `day` dated bounce
243
+ // (SIT4 DAYBNC — the when= round-trip preserves the area FK and needs no
244
+ // scratch project; container-less GLOBAL axis), or the one-call `list
245
+ // "Tomorrow"` sort when the day is tomorrow.
246
+ if (containerDay)
247
+ return isTomorrow ? { scope: "tomorrow", ...dayField } : { scope: "day", ...dayField };
248
+ return { scope: null, reason: "a direct-area to-do's scheduled bucket (app-default)" };
249
+ }
250
+ // loose:
251
+ if (bucket === "inbox")
252
+ return { scope: "inbox" };
253
+ if (bucket === "someday")
254
+ return { scope: "someday" };
255
+ if (bucket === "anytime") {
256
+ // ANYBNC reverse-order bounce for area-less loose anytime to-dos.
257
+ return bounceEnabled
258
+ ? { scope: "anytime" }
259
+ : bounceDisabledTarget("area-less loose anytime order");
260
+ }
261
+ // A loose FUTURE Upcoming day rides the `day` dated bounce (SIT4 DAYBNC — the
262
+ // reverse-target when= round-trip front-inserts on the global todayIndex axis,
263
+ // no scratch project, no experimental gate), or the one-call `list "Tomorrow"`
264
+ // sort when the day is tomorrow.
265
+ return isTomorrow ? { scope: "tomorrow", ...dayField } : { scope: "day", ...dayField };
266
+ }
267
+ // projects:
268
+ // ARRIVED Today-view members route DATE-FIRST (mirrors the to-do path, #341). An
269
+ // arrived today-dated project is a Today member — the native Today reorder
270
+ // accepts projects intermixed (O12); an arrived This-Evening project rides the
271
+ // shipped `evening` bounce (SIT4 EVEORD — projects share the evening todayIndex
272
+ // axis, R07 reminder-loss caveat inherited). Only STRICTLY-FUTURE project rows
273
+ // fall through to the day-group / app-default routing below.
274
+ if (bucket === "today")
275
+ return { scope: "today" };
276
+ if (bucket === "evening") {
277
+ return bounceEnabled ? { scope: "evening" } : bounceDisabledTarget("evening-section order");
278
+ }
279
+ if (row.area !== null) {
280
+ // A project INSIDE an area on a strictly-future day now rides the dated `day`
281
+ // bounce (SIT5 AREAPROJDAY — the update-project when= legs preserve the area FK
282
+ // and re-enter at the day's global todayIndex min), or the one-call Tomorrow
283
+ // sort when the day is tomorrow. Someday stays app-default; anytime rides the
284
+ // area's native index order.
285
+ if (containerDay)
286
+ return isTomorrow ? { scope: "tomorrow", ...dayField } : { scope: "day", ...dayField };
287
+ return bucket === "someday"
288
+ ? { scope: null, reason: "a someday project inside an area (app-default)" }
289
+ : { scope: "area", container: row.area };
290
+ }
291
+ if (bucket === "someday")
292
+ return { scope: "someday" };
293
+ if (bucket === "anytime") {
294
+ // Top-level sidebar order is bounce-only (P8e).
295
+ return bounceEnabled ? { scope: "projects" } : bounceDisabledTarget("top-level projects order");
296
+ }
297
+ // An AREA-LESS scheduled PROJECT row on a strictly-future day: the dated `day`
298
+ // bounce (SIT4 DAYBNC — DP rows front-insert on the shared todayIndex axis via
299
+ // update-project), or the one-call `list "Tomorrow"` sort when the day is
300
+ // tomorrow. This is the loose-scheduled-PROJECT-row cell, formerly app-default.
301
+ if (containerDay)
302
+ return isTomorrow ? { scope: "tomorrow", ...dayField } : { scope: "day", ...dayField };
303
+ return { scope: null, reason: "a scheduled day bucket (app-default)" };
304
+ }
305
+ const IN_AXES = ["today", "evening", "anytime", "someday", "inbox"];
306
+ /**
307
+ * The INDEX-axis reorder target of a row — the target it has through its CONTAINER
308
+ * (a project/area/heading `index`, or the loose Anytime bucket), IGNORING any
309
+ * Today/Evening membership. For a dual-axis row this is the alternative to its
310
+ * view-axis (today/evening) target; computed by classifying the row as if it sat
311
+ * in its container's ANYTIME bucket (start=1, no startDate — date-independent, so
312
+ * the packedToday argument is irrelevant here).
313
+ */
314
+ function indexAxisTargetOf(row, bounceEnabled) {
315
+ const asAnytime = { ...row, start: 1, startDate: null, startBucket: 0 };
316
+ // Classify by the row's REAL kind — a project's index axis is its area / the
317
+ // sidebar (`projects`), NOT the loose Anytime to-do list. Conflating them (an
318
+ // isTodo=true hardcode) makes a mixed to-do+project Today block look like ONE
319
+ // shared loose index bucket and spuriously "dual-axis ambiguous".
320
+ return reorderTargetOf(asAnytime, row.type === 0, 0, bounceEnabled);
321
+ }
322
+ /**
323
+ * The INDEX-axis reorder target of a DEADLINE-FORECAST row (§9o dual-citizen) —
324
+ * its container's someday/anytime `index` order, the alternative to its Upcoming
325
+ * day-block todayIndex axis. Classified by STRIPPING the deadline so
326
+ * reorderTargetOf routes to the container index (someday/anytime/project/area/
327
+ * heading-someday), never the `day` scope the forecast day-block routes to. Used
328
+ * both to name the container spelling in the dual-axis refusal and to classify a
329
+ * forecast row that an explicit `--in <container>` / `--in someday|anytime` forces
330
+ * onto its index axis (the bug-fix: `--in` must never be overridden by the day
331
+ * auto-route).
332
+ */
333
+ function forecastIndexTargetOf(row, packedToday, bounceEnabled) {
334
+ return reorderTargetOf({ ...row, deadline: null }, row.type === 0, packedToday, bounceEnabled);
335
+ }
336
+ /**
337
+ * True when a row's INDEX-axis target preserves the Today/Evening flag. TWO
338
+ * flag-safe families:
339
+ * - the NATIVE project/area `index` re-rank writes only `index` (startBucket/
340
+ * startDate kept — sit3 EVEPROJ / DAYORD-b);
341
+ * - the SIT6 flag-safe MOVE protocols route a FLAGGED touched set off the
342
+ * de-Today bounce onto the URL move family (heading→HEADMOVE, loose anytime→
343
+ * LOOSEPARK, area-less sidebar projects→PROJPARK), which preserve the flag +
344
+ * reminder + deadline. So the heading / anytime / projects index axes are now
345
+ * honest alternatives for a Today/Evening member too.
346
+ * This is consulted only where the index target's scope is non-null; when bounce
347
+ * is disabled those scopes degrade to `{ scope: null }` upstream, so no unsafe
348
+ * bounce is ever offered.
349
+ */
350
+ function indexAxisTodaySafe(target) {
351
+ return (target.scope === "project" ||
352
+ target.scope === "area" ||
353
+ target.scope === "heading" ||
354
+ target.scope === "anytime" ||
355
+ target.scope === "projects");
356
+ }
357
+ /** A row's Today/Evening view, or null when it is not an arrived view member. */
358
+ function viewOf(row, packedToday) {
359
+ const b = scheduleBucket(row, packedToday);
360
+ return b === "today" || b === "evening" ? b : null;
361
+ }
362
+ /** The container uuid a row's INDEX axis lives in (most-specific), or null (loose). */
363
+ function indexAxisContainerOf(row) {
364
+ return row.heading ?? row.project ?? row.area ?? null;
365
+ }
366
+ /** A container's display title (for the `--in <title>` spelling), else its uuid. */
367
+ function containerLabel(deps, uuid) {
368
+ const r = deps.db.prepare("SELECT title FROM TMTask WHERE uuid = ?").get(uuid);
369
+ return r?.title ?? uuid;
370
+ }
371
+ /** The kind of a resolved `--in` container uuid. */
372
+ function containerKindOf(deps, uuid) {
373
+ const t = deps.db.prepare("SELECT type FROM TMTask WHERE uuid = ?").get(uuid);
374
+ if (t?.type === 1)
375
+ return "project";
376
+ if (t?.type === 2)
377
+ return "heading";
378
+ return "area";
379
+ }
380
+ /** A short phrase naming the display bucket(s) the set currently sits in. */
381
+ function describeSetLocation(rows, packedToday) {
382
+ const buckets = [...new Set(rows.map((r) => scheduleBucket(r, packedToday)))];
383
+ return `in the ${buckets.join(" / ")} bucket`;
384
+ }
385
+ /** The DIRECT container a row sits in, named for a refusal (its title, or "the loose list"). */
386
+ function describeDirectContainer(deps, row) {
387
+ const c = indexAxisContainerOf(row);
388
+ return c === null ? "the loose list" : `"${containerLabel(deps, c)}"`;
389
+ }
390
+ /** Per-movee day membership (`uuid on YYYY-MM-DD`, or off-day), for the `--in upcoming` spread refusal. */
391
+ function describeDayMembership(rows, packedToday) {
392
+ return rows
393
+ .map((r) => {
394
+ const k = rowDayKey(r, packedToday);
395
+ return k === null ? `${r.uuid} (not on a future day)` : `${r.uuid} on ${decodePackedDate(k)}`;
396
+ })
397
+ .join("; ");
398
+ }
399
+ /** Parse a raw `--in <target>` into a list axis, a day-axis token, or a container ref. */
400
+ function parseInTarget(deps, raw, rows) {
401
+ const norm = raw.trim().toLowerCase();
402
+ if (IN_AXES.includes(norm))
403
+ return { axis: norm };
404
+ // Day-axis tokens (checked before container resolution so an ISO date is never
405
+ // read as a container title). `upcoming` is the shared-future-day proxy; a
406
+ // YYYY-MM-DD names one exact Upcoming day-block.
407
+ if (norm === "upcoming")
408
+ return { upcoming: true };
409
+ if (/^\d{4}-\d{2}-\d{2}$/.test(norm)) {
410
+ try {
411
+ return { day: encodePackedDate(norm) };
412
+ }
413
+ catch {
414
+ return { error: `--in "${raw}" is not a valid calendar date (expected YYYY-MM-DD)` };
415
+ }
416
+ }
417
+ if (norm === "loose") {
418
+ return {
419
+ error: '`--in loose` is not valid — "loose" is a read view, not a reorder bucket; ' +
420
+ "use --in anytime / --in someday, or a project/area/heading ref",
421
+ };
422
+ }
423
+ // A raw uuid that IS a movee's own container (project/area/heading).
424
+ const ownContainers = new Set(rows.map(indexAxisContainerOf).filter((u) => u !== null));
425
+ if (ownContainers.has(raw)) {
426
+ return { container: { uuid: raw, kind: containerKindOf(deps, raw) } };
427
+ }
428
+ const p = resolveProject(deps.db, { title: raw });
429
+ if (p.resolved?.uuid !== undefined) {
430
+ return { container: { uuid: p.resolved.uuid, kind: "project" } };
431
+ }
432
+ const a = resolveArea(deps.db, { title: raw });
433
+ if (a.resolved?.uuid !== undefined) {
434
+ return { container: { uuid: a.resolved.uuid, kind: "area" } };
435
+ }
436
+ // A heading, resolved within the movees' shared project.
437
+ const proj = rows[0]?.project ?? headingProjectOf(deps.db, rows[0]?.heading ?? null);
438
+ if (proj !== null && proj !== undefined) {
439
+ const h = resolveHeading(deps.db, proj, raw);
440
+ if (h.resolved?.uuid !== undefined) {
441
+ return { container: { uuid: h.resolved.uuid, kind: "heading" } };
442
+ }
443
+ }
444
+ return {
445
+ error: `--in "${raw}" did not resolve to a project, area, or heading ` +
446
+ "(or one of today | evening | anytime | someday | inbox | upcoming | a YYYY-MM-DD day)",
447
+ };
448
+ }
449
+ /**
450
+ * Resolve the reorder AXIS for a `things reorder` set per `--in` (the ratified
451
+ * axis-disambiguation contract). Returns a per-row target classifier, or a
452
+ * refusal. The `todo move` anchor-implied reposition (verb !== "reorder") keeps
453
+ * its prior single-axis behavior — no `--in`, no ambiguity gate.
454
+ */
455
+ function resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, position) {
456
+ const isTodo = op === "todo.move";
457
+ const bounceEnabled = deps.config.bounceEnabled;
458
+ const base = (r) => reorderTargetOf(r, isTodo, packedToday, bounceEnabled);
459
+ if (verb !== "reorder")
460
+ return { targetOf: base };
461
+ // The coherence set is the movees PLUS the anchor (spec: "the movee set AND
462
+ // anchor is coherent on both axes"). A cross-container anchor breaks the index
463
+ // axis (so the reorder is unambiguously the view axis); an unresolved anchor is
464
+ // left to the downstream anchor validation.
465
+ const anchorRow = position !== undefined && ("before" in position || "after" in position)
466
+ ? (() => {
467
+ const ar = resolveMovee(deps, "before" in position ? position.before : position.after);
468
+ return ar instanceof ReferenceResolutionError ? undefined : loadRow(deps.db, ar.uuid);
469
+ })()
470
+ : undefined;
471
+ const coherence = anchorRow !== undefined ? [...rows, anchorRow] : rows;
472
+ // One shared Today/Evening view? (Mixed today+evening is caught downstream by the
473
+ // single-bucket guard; view=null means "not a clean view set".)
474
+ const views = new Set(coherence.map((r) => viewOf(r, packedToday)));
475
+ const view = views.size === 1 ? [...views][0] : null;
476
+ // An INDEX bucket sorts each object KIND in its OWN rank space (an area's someday
477
+ // to-dos and someday projects are DIFFERENT index buckets — spec axis-isolation),
478
+ // so a MIXED to-do+project movee set has NO shared container index: only its
479
+ // GLOBAL axis (the Today/Evening view or the Upcoming day-block, both of which
480
+ // intermix kinds) is coherent. Nulling the index target for a cross-kind set keeps
481
+ // the dual-axis refusals (view + forecast) and the mixed auto-route from treating
482
+ // it as container-sortable; it falls through to its global axis instead. (A wrong-
483
+ // kind movee that ISN'T bound for a global axis was already refused upstream in
484
+ // runInPlaceReorder, so the only cross-kind sets that reach here are global ones.)
485
+ const sameKind = new Set(rows.map((r) => r.type)).size === 1;
486
+ // The shared index-axis container target, if the whole coherence set has one
487
+ // (classifying each row as though it sat in its container's anytime bucket).
488
+ const indexTargets = coherence.map((r) => indexAxisTargetOf(r, bounceEnabled));
489
+ const indexKeys = new Set(indexTargets.map(containerKey));
490
+ const indexTarget = sameKind && indexKeys.size === 1 && indexTargets[0]?.scope != null
491
+ ? indexTargets[0]
492
+ : null;
493
+ // Force the CONTAINER index axis for a dual-axis row, leaving single-axis rows on
494
+ // their natural target (used when --in names a container or a loose stage list).
495
+ // A Today/Evening member forces onto its container index (view axis stripped); a
496
+ // DEADLINE-FORECAST row forces onto its someday/anytime container index (day-block
497
+ // axis stripped, §9o) — without this a forecast row would classify to the `day`
498
+ // scope and the explicit `--in` would be overridden by the day route.
499
+ const indexClassifier = (r) => {
500
+ if (viewOf(r, packedToday) !== null)
501
+ return indexAxisTargetOf(r, bounceEnabled);
502
+ if (forecastDeadlineDay(r, packedToday) !== null)
503
+ return forecastIndexTargetOf(r, packedToday, bounceEnabled);
504
+ return base(r);
505
+ };
506
+ if (inTarget !== undefined) {
507
+ const parsed = parseInTarget(deps, inTarget, rows);
508
+ if ("error" in parsed)
509
+ return { refused: refused(op, "usage", parsed.error) };
510
+ // `--in <YYYY-MM-DD>` — one exact Upcoming day-block (the day axis). The date
511
+ // must be strictly future, and every movee must be a member of that day
512
+ // (scheduled startDate == date, or a forecast deadline == date per §9o). No
513
+ // shared-container requirement — a day-block is ONE cross-container axis.
514
+ if ("day" in parsed) {
515
+ const day = parsed.day;
516
+ if (day <= packedToday) {
517
+ return {
518
+ refused: refused(op, "usage", `--in ${decodePackedDate(day)} is not a future day — that date is today or in the past; ` +
519
+ "use --in today to reorder the Today view", "name a strictly-future YYYY-MM-DD day, or --in today for the Today view"),
520
+ };
521
+ }
522
+ const notMembers = rows.filter((r) => rowDayKey(r, packedToday) !== day);
523
+ if (notMembers.length > 0) {
524
+ return {
525
+ refused: refused(op, "usage", `--in ${decodePackedDate(day)} but these items are not on that day: ` +
526
+ notMembers.map((r) => r.uuid).join(", ") +
527
+ " — a movee must be scheduled for it, or carry it as a deadline", "name the day the items actually share, or omit --in if they share one"),
528
+ };
529
+ }
530
+ return { dayAxis: day };
531
+ }
532
+ // `--in upcoming` — the single future day the whole set shares (the day axis),
533
+ // derived via sharedFutureDay. Stage-agnostic across scheduled + forecast, no
534
+ // container requirement; a set spanning days (or with an off-day member) refuses
535
+ // with the per-item days listed.
536
+ if ("upcoming" in parsed) {
537
+ const day = sharedFutureDay(rows, packedToday);
538
+ if (day === null) {
539
+ return {
540
+ refused: refused(op, "blocked", "--in upcoming needs every item on ONE shared future day, but they are not: " +
541
+ describeDayMembership(rows, packedToday), "name the exact day with --in YYYY-MM-DD, or reorder one day at a time"),
542
+ };
543
+ }
544
+ return { dayAxis: day };
545
+ }
546
+ if ("axis" in parsed) {
547
+ const axis = parsed.axis;
548
+ if (axis === "today" || axis === "evening") {
549
+ if (view === axis)
550
+ return { targetOf: base };
551
+ return {
552
+ refused: refused(op, "blocked", `--in ${axis} but the items are ${describeSetLocation(rows, packedToday)} — they are ` +
553
+ `not ${axis === "today" ? "Today" : "This Evening"} members`, "name the axis the items actually share, or omit --in if it is unambiguous"),
554
+ };
555
+ }
556
+ // anytime | someday | inbox — a loose list axis.
557
+ // `--in anytime` on genuinely-LOOSE Today/Evening members is now HONEST: the
558
+ // SIT6 LOOSEPARK protocol reorders the loose Anytime `index` axis via URL
559
+ // move legs, preserving the flag (no de-Today). Route the whole (possibly
560
+ // mixed flagged+plain) loose set to the anytime index scope. someday/inbox
561
+ // have NO flag-safe twin, so a view member on those still de-Todays → refused.
562
+ if (axis === "anytime" &&
563
+ bounceEnabled &&
564
+ rows.every((r) => indexAxisContainerOf(r) === null) &&
565
+ rows.some((r) => viewOf(r, packedToday) !== null)) {
566
+ return { targetOf: indexClassifier, indexAxis: true };
567
+ }
568
+ if (view !== null) {
569
+ return {
570
+ refused: refused(op, "blocked", `--in ${axis} would order ${view === "today" ? "Today" : "This Evening"} members on ` +
571
+ `the loose ${axis} axis, whose when= legs OVERWRITE the Today/Evening flag ` +
572
+ "(de-Today hazard) — refused rather than silently stripping it" +
573
+ (axis === "anytime"
574
+ ? " (the flag-safe LOOSEPARK path needs every item genuinely loose and bounce-enabled)"
575
+ : ""), `reorder them in the view with --in ${view}, or reschedule them off Today first`),
576
+ };
577
+ }
578
+ // Sanity + membership (spec rule 4). Every movee must BE that stage; and for
579
+ // anytime/someday every movee must share ONE direct container (same project,
580
+ // heading, area, or all loose — the index bucket they re-rank in). inbox needs
581
+ // only the stage check (inbox items are container-less by nature).
582
+ const wrongStage = rows.filter((r) => scheduleBucket(r, packedToday) !== axis);
583
+ if (wrongStage.length > 0) {
584
+ return {
585
+ refused: refused(op, "usage", `--in ${axis} but not every item is ${axis}-stage — ` +
586
+ `${describeSetLocation(wrongStage, packedToday)}: ` +
587
+ wrongStage.map((r) => r.uuid).join(", "), "omit --in and reorder the items where they are, or reschedule them first"),
588
+ };
589
+ }
590
+ if (axis !== "inbox") {
591
+ const containers = new Set(rows.map((r) => structuralKey(r, packedToday)));
592
+ if (containers.size > 1) {
593
+ return {
594
+ refused: refused(op, "usage", `--in ${axis} but the items are in different containers (` +
595
+ rows.map((r) => `${r.uuid} in ${describeDirectContainer(deps, r)}`).join("; ") +
596
+ `) — a single --in ${axis} reorder needs them all in one project, heading, or ` +
597
+ "area, or all loose", "reorder within one container at a time, or move them together first"),
598
+ };
599
+ }
600
+ }
601
+ // The stage list axis is an INDEX axis — it suppresses the day auto-route so a
602
+ // forecast someday/anytime row re-ranks its container index here (the bug-fix:
603
+ // `--in someday` on a same-day forecast set compiles the index re-rank, not the
604
+ // deadline day bounce); indexClassifier strips the deadline for that.
605
+ return { targetOf: indexClassifier, indexAxis: true };
606
+ }
607
+ // A container ref (project/area/heading).
608
+ const container = parsed.container;
609
+ const notIn = rows.filter((r) => indexAxisContainerOf(r) !== container.uuid);
610
+ if (notIn.length > 0) {
611
+ return {
612
+ refused: refused(op, "usage", `--in "${inTarget}" (${container.kind} ${container.uuid}) but these items are not in it: ` +
613
+ notIn.map((r) => r.uuid).join(", "), "name the container the items actually share, or omit --in"),
614
+ };
615
+ }
616
+ if (view !== null) {
617
+ const forced = indexAxisTargetOf(rows[0], bounceEnabled);
618
+ if (!indexAxisTodaySafe(forced)) {
619
+ return {
620
+ refused: refused(op, "blocked", `--in "${inTarget}" would order these ${view === "today" ? "Today" : "This Evening"} ` +
621
+ `members on ${describeScope(forced)}, a when= bounce whose legs OVERWRITE the ` +
622
+ "Today/Evening flag (de-Today hazard); no flag-safe protocol reaches that bucket, so it " +
623
+ "is refused rather than silently stripping the flag", `reorder them in the view with --in ${view}, or reschedule them off Today first`),
624
+ };
625
+ }
626
+ }
627
+ return { targetOf: indexClassifier, indexAxis: true };
628
+ }
629
+ // No --in: refuse only when BOTH axes are honest — a shared view AND a flag-safe
630
+ // container index (native project/area re-rank OR a SIT6 flag-safe MOVE protocol,
631
+ // heading/anytime/projects). The two readings are genuinely ambiguous, so name
632
+ // one with --in. This replaces the old silent always-Today resolution.
633
+ if (rows.length >= 2 &&
634
+ view !== null &&
635
+ indexTarget !== null &&
636
+ indexTarget.scope !== null &&
637
+ indexAxisTodaySafe(indexTarget)) {
638
+ return {
639
+ refused: refused(op, "blocked", `these items are ${view === "today" ? "Today" : "This Evening"} members that also share ` +
640
+ `${describeScope(indexTarget)} — the reorder is ambiguous between the ${view} view ` +
641
+ "(todayIndex slots) and the container (index slots); say which with --in", `--in ${view} to reorder the ${view} view, or ${inSpellingFor(deps, indexTarget)} to reorder within the container`),
642
+ };
643
+ }
644
+ // A FORECAST set coherent on BOTH axes (§9o dual-citizen): every movee a deadline-
645
+ // forecast member of ONE shared day AND all sharing one container index bucket.
646
+ // Ambiguous between the day-block (todayIndex) and the container (index) — refuse,
647
+ // echoing the REAL date and the container/list spelling. Forecast rows are never
648
+ // Today/Evening members, so this is the forecast twin of the view-member refusal
649
+ // above. (A forecast set spanning containers, or mixed with scheduled rows, is
650
+ // coherent on only the day axis → it auto-routes to `day` below, no refusal.)
651
+ const forecastDay = sharedForecastDay(rows, packedToday);
652
+ if (rows.length >= 2 && view === null && forecastDay !== null) {
653
+ const fTargets = rows.map((r) => forecastIndexTargetOf(r, packedToday, bounceEnabled));
654
+ const fKeys = new Set(fTargets.map(containerKey));
655
+ // A cross-kind forecast set has no shared container index (kinds isolate), so it
656
+ // is coherent on ONLY the day-block axis — not dual-axis. Auto-route it there
657
+ // (below) rather than offering a container spelling that would then kind-refuse.
658
+ const fIndexTarget = sameKind && fKeys.size === 1 && fTargets[0]?.scope != null
659
+ ? fTargets[0]
660
+ : null;
661
+ if (fIndexTarget !== null) {
662
+ const date = decodePackedDate(forecastDay);
663
+ return {
664
+ refused: refused(op, "blocked", `these items are forecast members of the ${date} Upcoming day-block that also share ` +
665
+ `${describeScope(fIndexTarget)} — the reorder is ambiguous between the day-block ` +
666
+ "(todayIndex slots) and the container (index slots); say which with --in", `--in ${date} to reorder the day-block, or ${inSpellingFor(deps, fIndexTarget)} to reorder within the container`),
667
+ };
668
+ }
669
+ }
670
+ // A MIXED dual-axis set (some Today/Evening-flagged, some plain, all sharing one
671
+ // flag-safe container index) is NOT ambiguous: the plain members are not view
672
+ // members, so there is no shared view to reorder them in — the container index is
673
+ // the only coherent reading. Auto-route it there (a flag-safe MOVE protocol keeps
674
+ // the flagged members' flag). Without this the flagged members classify to the
675
+ // today view while the plain ones classify to the container, and the set would
676
+ // spuriously "span containers".
677
+ if (rows.length >= 2 &&
678
+ view === null &&
679
+ indexTarget !== null &&
680
+ indexTarget.scope !== null &&
681
+ indexAxisTodaySafe(indexTarget) &&
682
+ coherence.some((r) => viewOf(r, packedToday) !== null)) {
683
+ return { targetOf: indexClassifier, indexAxis: true };
684
+ }
685
+ return { targetOf: base };
686
+ }
687
+ /** The `--in` spelling that names an index target (a loose axis, or a container). */
688
+ function inSpellingFor(deps, target) {
689
+ if (target.scope === null)
690
+ return "--in <container>"; // unreachable: only real index targets reach here
691
+ if (target.scope === "anytime")
692
+ return "--in anytime";
693
+ if (target.container !== undefined)
694
+ return `--in "${containerLabel(deps, target.container)}"`;
695
+ return `--in ${target.scope}`;
696
+ }
697
+ /**
698
+ * The STRUCTURAL container a row shares with its siblings — the rule-2 span key.
699
+ * Distinct from the reorder target: two in-project to-dos with different
700
+ * schedules share ONE structural container (their project) but different display
701
+ * buckets (a rule-4 concern, not a rule-2 span).
702
+ */
703
+ function structuralKey(row, packedToday) {
704
+ if (row.heading !== null)
705
+ return `heading:${row.heading}`;
706
+ if (row.project !== null)
707
+ return `project:${row.project}`;
708
+ if (row.area !== null)
709
+ return `area:${row.area}`;
710
+ return `list:${scheduleBucket(row, packedToday)}`;
711
+ }
712
+ /** A stable key for a placement target (guaranteed scope, or its app/prohibited reason). */
713
+ function containerKey(target) {
714
+ if (target.scope === null)
715
+ return `app:${target.reason}`;
716
+ return target.container !== undefined ? `${target.scope}:${target.container}` : target.scope;
717
+ }
718
+ function refused(op, refusal, detail, remediation) {
719
+ return {
720
+ kind: "move-refused",
721
+ op,
722
+ refusal,
723
+ detail,
724
+ ...(remediation !== undefined && { remediation }),
725
+ };
726
+ }
727
+ /**
728
+ * Map a non-ok reorder PLACEMENT from a pure in-place reposition (no membership
729
+ * leg ran, so NOTHING landed) to a terminal MoveResult. A `blocked` placement is
730
+ * a genuine refusal — the reorder never touched the app (an H-REORDER-SCOPE
731
+ * template suffix / experimental-off block) — so it HOISTS to a canonical
732
+ * top-level `move-refused` carrying the hazard: exit 4 (Blocked) + the
733
+ * `blocked:<hazard>` code + `BLOCKED (<hazard>)` copy, the SAME surface a direct
734
+ * `things reorder` block gets, instead of being buried under a generic
735
+ * leg-failed with a verify-failed (exit 3) code. Every other non-ok kind (a
736
+ * mid-bounce `bounce-aborted`, a `verify-failed`) is a leg that DID run and did
737
+ * not complete, so it stays `move-leg-failed`.
738
+ */
739
+ function repositionFailed(op, placement) {
740
+ if (placement.kind === "blocked") {
741
+ return {
742
+ kind: "move-refused",
743
+ op,
744
+ refusal: "blocked",
745
+ detail: placement.detail,
746
+ ...(placement.remediation !== undefined && { remediation: placement.remediation }),
747
+ ...(placement.hazard !== undefined && { hazard: placement.hazard }),
748
+ };
749
+ }
750
+ return {
751
+ kind: "move-leg-failed",
752
+ op,
753
+ detail: `the reorder leg did not complete (${placement.kind})`,
754
+ failed: placement,
755
+ completed: [],
756
+ };
757
+ }
758
+ // The ratified teaching errors (spec §4 bare-invocation block, §5, §7).
759
+ const BARE_TODO_MOVE = "`todo move` needs a destination or a position. To change what a to-do belongs to, name " +
760
+ "one of --to-project / --to-heading / --to-area (or --no-heading / --loose to detach). To " +
761
+ "rearrange to-dos that already share a container, use `todo reorder` (or pass --first / " +
762
+ "--last / --before / --after here). A bare `todo move` has no meaning — it would move " +
763
+ "nothing.";
764
+ const BARE_PROJECT_MOVE = "`project move` needs a destination or a position. Name --to-area (or --no-area to leave " +
765
+ "the area) to change the project's area, or pass --first / --last / --before / --after to " +
766
+ "reorder it among its siblings. A bare `project move` has no meaning.";
767
+ // ------------------------------------------------------------ scope-aware resolve
768
+ function resolveMovee(deps, ref) {
769
+ try {
770
+ // Scope-aware (the no-oracle guarantee): an out-of-scope ref resolves to
771
+ // not-found through the identical path a nonexistent one does (#276).
772
+ const taskScope = deps.scope !== undefined ? taskMembershipClause(deps.scope) : undefined;
773
+ return { uuid: resolveTaskUuidPrefix(deps.db, ref, "item", taskScope) };
774
+ }
775
+ catch (err) {
776
+ if (err instanceof ReferenceResolutionError)
777
+ return err;
778
+ // A short/invalid prefix is a plain RangeError — wrap as not-found.
779
+ return new ReferenceResolutionError(err instanceof Error ? err.message : String(err), {
780
+ code: "not-found",
781
+ ref,
782
+ });
783
+ }
784
+ }
785
+ // --------------------------------------------------------------- todo move
786
+ export async function runTodoMove(deps, request, options = {}) {
787
+ const op = "todo.move";
788
+ const now = deps.now?.() ?? new Date();
789
+ const packedToday = encodePackedDate(localToday(now));
790
+ if (request.uuids.length === 0) {
791
+ return refused(op, "usage", "no to-dos given — name at least one to-do to move");
792
+ }
793
+ // Resolve movees + homogeneity (rule 3).
794
+ const rows = [];
795
+ const wrongKind = [];
796
+ for (const ref of request.uuids) {
797
+ const r = resolveMovee(deps, ref);
798
+ if (r instanceof ReferenceResolutionError) {
799
+ return {
800
+ kind: "move-refused",
801
+ op,
802
+ refusal: "usage",
803
+ detail: r.message,
804
+ candidates: r.candidates,
805
+ };
806
+ }
807
+ const row = loadRow(deps.db, r.uuid);
808
+ if (row === undefined) {
809
+ return refused(op, "usage", `no to-do matches "${ref}"`);
810
+ }
811
+ if (row.type !== 0)
812
+ wrongKind.push({ ref: r.uuid, kind: KIND_LABEL[row.type] ?? "item" });
813
+ rows.push(row);
814
+ }
815
+ if (wrongKind.length > 0) {
816
+ const list = wrongKind.map((w) => `${w.ref} (${w.kind})`).join(", ");
817
+ return refused(op, "usage", `homogeneous movee kinds required — \`todo move\` moves to-dos only, but these are not to-dos: ${list}`, "move projects with `project move` and headings with `project move-heading`");
818
+ }
819
+ const dest = request.destination;
820
+ const position = request.position;
821
+ // Detach-family teaching errors (§5/§7) before any wire leg.
822
+ if (dest?.kind === "detach") {
823
+ return refused(op, "usage", "`--detach` was removed. To leave the heading but stay in the project use --no-heading; " +
824
+ "to leave the project, area, AND heading use --loose.");
825
+ }
826
+ if (dest?.kind === "no-area") {
827
+ // A to-do's area is inherited from its project (project child) or direct
828
+ // (loose direct-area to-do). Neither takes --no-area (§5).
829
+ const child = rows.some((r) => r.project !== null || r.heading !== null);
830
+ return refused(op, "usage", child
831
+ ? "`--no-area` does not apply to a to-do: its area comes from its project. Use --loose to " +
832
+ "leave the project (and its area), or move the project with `project move --no-area`."
833
+ : "`--no-area` does not apply to a to-do — for a to-do this is `--loose` (leaving a " +
834
+ "direct-area to-do's area severs its only container). No duplicate spellings.");
835
+ }
836
+ // Bare invocation (§4): the ratified teaching error.
837
+ if (dest === undefined && position === undefined) {
838
+ return refused(op, "usage", BARE_TODO_MOVE);
839
+ }
840
+ // No destination + a position → an anchor-implied reposition (rule 2): pure
841
+ // positioning within the movees' current shared container. Delegate to the
842
+ // reorder machinery, but with move's own cross-container teaching copy.
843
+ if (dest === undefined) {
844
+ return repositionInPlace(deps, op, rows, position, packedToday, options, "move");
845
+ }
846
+ // --no-heading / --loose validation against the movees' current containment.
847
+ if (dest.kind === "no-heading") {
848
+ const noProject = rows.filter((r) => r.project === null && r.heading === null);
849
+ if (noProject.length > 0) {
850
+ return refused(op, "usage", "--no-heading needs the to-do to be in a project — these are not in any project: " +
851
+ noProject.map((r) => r.uuid).join(", "), "use --loose to detach a loose to-do's schedule-only state, or --to-project to place it");
852
+ }
853
+ }
854
+ // Where the movees will LAND (computed pre-membership from the destination +
855
+ // each movee's kept schedule) — drives the placement class and the pre-flight
856
+ // anchor check.
857
+ const landing = uniformLanding(rows.map((r) => todoLandedRow(deps, dest, r)), true, packedToday, deps.config.bounceEnabled);
858
+ // Fail-closed BEFORE any membership leg: an explicit --before/--after into a
859
+ // destination bucket with no guaranteed protocol cannot be honored, so refuse
860
+ // the whole move rather than move-then-drop-the-position (rule 5 honesty).
861
+ const anchorRefusal = preflightAnchor(op, position, landing);
862
+ if (anchorRefusal !== null)
863
+ return anchorRefusal;
864
+ // Membership legs (rule 1: selection order). Each movee gets one todo.move.
865
+ const legParams = membershipLeg(deps, dest, rows, options);
866
+ if ("refused" in legParams)
867
+ return legParams.refused;
868
+ const membership = [];
869
+ for (const leg of legParams.legs) {
870
+ const res = await runMutation(deps, "todo.move", leg.params, {
871
+ // A leg-pinned vector is the fallback transport (inbox → applescript); an
872
+ // explicit caller --vector still wins because legOptions spreads after it.
873
+ ...(leg.vector !== undefined && { vector: leg.vector }),
874
+ ...legOptions(options),
875
+ ...(options.dryRun === true && { dryRun: true }),
876
+ });
877
+ membership.push(res);
878
+ if (res.kind !== "ok" && res.kind !== "dry-run") {
879
+ return {
880
+ kind: "move-leg-failed",
881
+ op,
882
+ detail: `moving ${leg.uuid} into the destination failed (${res.kind})`,
883
+ failed: res,
884
+ completed: membership.slice(0, -1),
885
+ };
886
+ }
887
+ }
888
+ return finishPlacement(deps, op, rows, landing, position, packedToday, options, membership);
889
+ }
890
+ /** Pre-flight refusal for an explicit anchor into a no-protocol destination bucket. */
891
+ function preflightAnchor(op, position, landing) {
892
+ if (position === undefined || !("before" in position || "after" in position))
893
+ return null;
894
+ // The ONLY remaining anchor refusal is a bucket with no reorder protocol at
895
+ // all. Within-heading --before/--after IS supported now — the extended bounce
896
+ // co-bounces the members between the block and the anchor (disclosed).
897
+ if (landing.scope !== null)
898
+ return null;
899
+ // A mixed-stage selection (movees span the destination's stage sub-buckets) is
900
+ // the per-bucket anchor refusal (§4 rule 4): an anchor has no honest cross-bucket
901
+ // meaning. Distinguished from a single bucket that simply has no protocol.
902
+ const spansBuckets = landing.reason.includes("span display buckets");
903
+ return refused(op, landing.prohibited === true ? "blocked" : "unsupported", spansBuckets
904
+ ? "--before/--after cannot anchor a selection that spans stage sub-buckets in the " +
905
+ "destination — every movee must share the anchor's sub-bucket"
906
+ : `--before/--after cannot be honored in the destination (${describeScope(landing)}) — ` +
907
+ "no reorder protocol positions within that bucket", spansBuckets
908
+ ? "split the move by bucket, or drop the anchor (use --first/--last — they apply per sub-bucket)"
909
+ : "use --first/--last, or omit the position (membership still lands)");
910
+ }
911
+ /** Build one todo.move leg per movee for the requested destination. */
912
+ function membershipLeg(deps, dest, rows, _options) {
913
+ const op = "todo.move";
914
+ const legs = [];
915
+ // Resolve a shared project for a heading destination (rule: within
916
+ // --to-project's project, else the movees' shared project).
917
+ if (dest.kind === "heading") {
918
+ let projectUuid = null;
919
+ if (dest.project !== undefined) {
920
+ const p = resolveProject(deps.db, dest.project);
921
+ if (p.resolved === null) {
922
+ return {
923
+ refused: refused(op, "usage", "the --to-project for this heading did not resolve"),
924
+ };
925
+ }
926
+ projectUuid = p.resolved.uuid;
927
+ }
928
+ else {
929
+ // The movees' shared project (direct, or the heading's project).
930
+ const projSet = new Set(rows.map((r) => r.project ?? headingProjectOf(deps.db, r.heading)));
931
+ projSet.delete(null);
932
+ if (projSet.size !== 1) {
933
+ return {
934
+ refused: refused(op, "usage", "the movees span projects (or none), so a --to-heading selector is ambiguous — " +
935
+ "name --to-project <ref> to say which project's heading you mean"),
936
+ };
937
+ }
938
+ projectUuid = [...projSet][0];
939
+ }
940
+ const h = resolveHeading(deps.db, projectUuid, dest.sel);
941
+ if (h.resolved === null) {
942
+ return {
943
+ refused: {
944
+ kind: "move-refused",
945
+ op,
946
+ refusal: "usage",
947
+ detail: h.matches > 1
948
+ ? `the heading selector "${dest.sel}" matches ${h.matches} headings — disambiguate with a uuid`
949
+ : `no heading matching "${dest.sel}" in the destination project`,
950
+ },
951
+ };
952
+ }
953
+ for (const r of rows) {
954
+ legs.push({
955
+ uuid: r.uuid,
956
+ params: { uuid: r.uuid, project: { uuid: projectUuid }, heading: h.resolved.uuid },
957
+ });
958
+ }
959
+ return { legs };
960
+ }
961
+ // `loose` is the reserved READ-only pseudo-area — never a move destination.
962
+ // (Detaching a to-do to area-less is `--loose`, not `--to-area loose`.)
963
+ if (dest.kind === "area" && isLooseRef(dest.ref.uuid ?? dest.ref.title ?? "")) {
964
+ return { refused: refused(op, "usage", LOOSE_TO_AREA_REFUSAL) };
965
+ }
966
+ for (const r of rows) {
967
+ switch (dest.kind) {
968
+ case "project":
969
+ legs.push({
970
+ uuid: r.uuid,
971
+ params: {
972
+ uuid: r.uuid,
973
+ project: dest.ref,
974
+ ...(dest.heading !== undefined && { heading: dest.heading }),
975
+ },
976
+ });
977
+ break;
978
+ case "area":
979
+ legs.push({ uuid: r.uuid, params: { uuid: r.uuid, area: dest.ref } });
980
+ break;
981
+ case "inbox":
982
+ // Inbox return compiles only to AppleScript — pin the transport so the
983
+ // planner does not route it to url-scheme (the tier-0 registry-tie winner).
984
+ legs.push({ uuid: r.uuid, params: { uuid: r.uuid, inbox: true }, vector: "applescript" });
985
+ break;
986
+ case "no-heading":
987
+ legs.push({ uuid: r.uuid, params: { uuid: r.uuid, noHeading: true } });
988
+ break;
989
+ case "loose":
990
+ legs.push({ uuid: r.uuid, params: { uuid: r.uuid, loose: true } });
991
+ break;
992
+ default:
993
+ break;
994
+ }
995
+ }
996
+ return { legs };
997
+ }
998
+ /** The row a to-do BECOMES after a membership move (schedule kept unless it resets). */
999
+ function todoLandedRow(deps, dest, row) {
1000
+ switch (dest.kind) {
1001
+ case "project": {
1002
+ const p = resolveProject(deps.db, dest.ref);
1003
+ return {
1004
+ ...row,
1005
+ project: p.resolved?.uuid ?? null,
1006
+ area: null,
1007
+ heading: dest.heading !== undefined ? "landed-heading" : null,
1008
+ };
1009
+ }
1010
+ case "heading":
1011
+ // Under a heading — the exact heading uuid does not matter to the target
1012
+ // classifier (a heading bucket is app-default regardless); mark it headed.
1013
+ return { ...row, heading: "landed-heading", area: null };
1014
+ case "area": {
1015
+ const a = resolveArea(deps.db, dest.ref);
1016
+ return { ...row, area: a.resolved?.uuid ?? null, project: null, heading: null };
1017
+ }
1018
+ case "inbox":
1019
+ return { ...row, start: 0, startDate: null, project: null, area: null, heading: null };
1020
+ case "no-heading": {
1021
+ const container = row.project ?? headingProjectOf(deps.db, row.heading);
1022
+ return { ...row, project: container, area: null, heading: null };
1023
+ }
1024
+ case "loose":
1025
+ return { ...row, project: null, area: null, heading: null };
1026
+ default:
1027
+ return row;
1028
+ }
1029
+ }
1030
+ /** The uniform landing target across the movees (app-default when they diverge). */
1031
+ function uniformLanding(landed, isTodo, packedToday, bounceEnabled) {
1032
+ const targets = landed.map((r) => reorderTargetOf(r, isTodo, packedToday, bounceEnabled));
1033
+ const keys = new Set(targets.map(containerKey));
1034
+ return keys.size === 1
1035
+ ? targets[0]
1036
+ : {
1037
+ scope: null,
1038
+ reason: "the movees span display buckets in the destination (per-bucket app-default)",
1039
+ };
1040
+ }
1041
+ function headingProjectOf(db, headingUuid) {
1042
+ if (headingUuid === null)
1043
+ return null;
1044
+ const r = db.prepare("SELECT project FROM TMTask WHERE uuid = ?").get(headingUuid);
1045
+ return r?.project ?? null;
1046
+ }
1047
+ // --------------------------------------------------------------- project move
1048
+ export async function runProjectMove(deps, request, options = {}) {
1049
+ const op = "project.move";
1050
+ const now = deps.now?.() ?? new Date();
1051
+ const packedToday = encodePackedDate(localToday(now));
1052
+ if (request.uuids.length === 0) {
1053
+ return refused(op, "usage", "no projects given — name at least one project to move");
1054
+ }
1055
+ const rows = [];
1056
+ const wrongKind = [];
1057
+ for (const ref of request.uuids) {
1058
+ const r = resolveMovee(deps, ref);
1059
+ if (r instanceof ReferenceResolutionError) {
1060
+ return {
1061
+ kind: "move-refused",
1062
+ op,
1063
+ refusal: "usage",
1064
+ detail: r.message,
1065
+ candidates: r.candidates,
1066
+ };
1067
+ }
1068
+ const row = loadRow(deps.db, r.uuid);
1069
+ if (row === undefined)
1070
+ return refused(op, "usage", `no project matches "${ref}"`);
1071
+ if (row.type !== 1)
1072
+ wrongKind.push({ ref: r.uuid, kind: KIND_LABEL[row.type] ?? "item" });
1073
+ rows.push(row);
1074
+ }
1075
+ if (wrongKind.length > 0) {
1076
+ const list = wrongKind.map((w) => `${w.ref} (${w.kind})`).join(", ");
1077
+ return refused(op, "usage", `homogeneous movee kinds required — \`project move\` moves projects only, but these are not projects: ${list}`, "move to-dos with `todo move`");
1078
+ }
1079
+ const dest = request.destination;
1080
+ const position = request.position;
1081
+ if (dest?.kind === "detach") {
1082
+ return refused(op, "usage", "`--detach` was removed. A project's detach is `--no-area` (a project has one containment level).");
1083
+ }
1084
+ if (dest?.kind === "loose") {
1085
+ return refused(op, "usage", "`--loose` does not apply to a project — a project has a single containment level, so its " +
1086
+ "detach is `--no-area`. (`--loose` is a to-do's total sever.)");
1087
+ }
1088
+ if (dest === undefined && position === undefined) {
1089
+ return refused(op, "usage", BARE_PROJECT_MOVE);
1090
+ }
1091
+ if (dest === undefined) {
1092
+ return repositionInPlace(deps, op, rows, position, packedToday, options, "move");
1093
+ }
1094
+ // `loose` is the reserved READ-only pseudo-area — never a move destination.
1095
+ // (A project leaves its area with `--no-area`, not `--to-area loose`.)
1096
+ if (dest.kind === "area" && isLooseRef(dest.ref.uuid ?? dest.ref.title ?? "")) {
1097
+ return refused(op, "usage", LOOSE_TO_AREA_REFUSAL);
1098
+ }
1099
+ const landedArea = dest.kind === "area" ? (resolveArea(deps.db, dest.ref).resolved?.uuid ?? null) : null;
1100
+ const landing = uniformLanding(rows.map((r) => ({ ...r, area: landedArea })), false, packedToday, deps.config.bounceEnabled);
1101
+ const anchorRefusal = preflightAnchor(op, position, landing);
1102
+ if (anchorRefusal !== null)
1103
+ return anchorRefusal;
1104
+ // Membership legs.
1105
+ const membership = [];
1106
+ for (const r of rows) {
1107
+ const params = dest.kind === "area"
1108
+ ? { uuid: r.uuid, area: dest.ref }
1109
+ : { uuid: r.uuid, noArea: true };
1110
+ const res = await runMutation(deps, "project.move", params, {
1111
+ ...legOptions(options),
1112
+ ...(options.dryRun === true && { dryRun: true }),
1113
+ });
1114
+ membership.push(res);
1115
+ if (res.kind !== "ok" && res.kind !== "dry-run") {
1116
+ return {
1117
+ kind: "move-leg-failed",
1118
+ op,
1119
+ detail: `moving ${r.uuid} failed (${res.kind})`,
1120
+ failed: res,
1121
+ completed: membership.slice(0, -1),
1122
+ };
1123
+ }
1124
+ }
1125
+ return finishPlacement(deps, op, rows, landing, position, packedToday, options, membership);
1126
+ }
1127
+ // ----------------------------------------------------------------- reorder verb
1128
+ /**
1129
+ * `todo reorder` / `project reorder` — pure positioning within the movees'
1130
+ * CURRENT shared container+bucket. Bare (no position) assembles the movees as
1131
+ * a contiguous block at the EARLIEST movee's current slot, in argument order
1132
+ * (spec §4 — `--first` is NOT implied). Cross-container operands fail closed.
1133
+ */
1134
+ /**
1135
+ * The single-KIND refusal for an INDEX-axis reorder (a stage list, or a project/
1136
+ * area/heading container `index`). An index bucket sorts each object KIND in its
1137
+ * OWN rank space, so a to-do+project mix cannot re-rank in one call — even when the
1138
+ * kinds share a container. A HEADING is never an index/day/view member at all: its
1139
+ * order is the project's heading axis. The GLOBAL day/Today/Evening axes intermix
1140
+ * kinds and never reach here. Copy is tailored per case, naming each movee's kind.
1141
+ */
1142
+ function indexKindRefusal(op, rows) {
1143
+ const headings = rows.filter((r) => r.type === 2);
1144
+ if (headings.length > 0) {
1145
+ return refused(op, "usage", "a heading has no stage, schedule, or day order of its own — it is never a Today/Evening, " +
1146
+ "day-block, or stage-list member; a heading's order is the project's heading axis, not a " +
1147
+ `to-do/project index bucket: ${headings.map((r) => r.uuid).join(", ")}`, "reorder a project's headings with `things project move-heading <project> <headings…>`");
1148
+ }
1149
+ const want = op === "todo.move" ? 0 : 1;
1150
+ const named = rows.map((r) => `${r.uuid} (${KIND_LABEL[r.type] ?? "item"})`).join(", ");
1151
+ // A homogeneous set of the OTHER kind (e.g. all projects handed to `todo reorder`):
1152
+ // point at that kind's own reorder verb rather than the mixed-axis message.
1153
+ if (rows.every((r) => r.type !== want)) {
1154
+ return refused(op, "usage", `\`reorder\` on this path rearranges ${want === 0 ? "to-dos" : "projects"}, but every item ` +
1155
+ `is a ${want === 0 ? "project" : "to-do"}: ${named}`, want === 0
1156
+ ? "to rearrange projects use `things project move <refs…> --first/--last/--before/--after`"
1157
+ : "to rearrange to-dos use `things todo reorder <refs…>`");
1158
+ }
1159
+ // A genuine to-do + project mix bound for an index bucket.
1160
+ return refused(op, "usage", "one kind at a time — an index bucket sorts to-dos and projects in separate order-spaces (a " +
1161
+ `shared container does not merge them), so a mixed set cannot re-rank in one call: ${named}`, "reorder the to-dos and the projects in separate calls; only the Today/Evening and day-block " +
1162
+ "axes (--in today | evening | upcoming | a YYYY-MM-DD day) sort both kinds together");
1163
+ }
1164
+ export async function runInPlaceReorder(deps, op, request, options = {}) {
1165
+ const now = deps.now?.() ?? new Date();
1166
+ const packedToday = encodePackedDate(localToday(now));
1167
+ const isTodo = op === "todo.move";
1168
+ if (request.uuids.length === 0) {
1169
+ return refused(op, "usage", "no items given — name at least one to reorder");
1170
+ }
1171
+ const rows = [];
1172
+ const wrongKind = [];
1173
+ for (const ref of request.uuids) {
1174
+ const r = resolveMovee(deps, ref);
1175
+ if (r instanceof ReferenceResolutionError) {
1176
+ return {
1177
+ kind: "move-refused",
1178
+ op,
1179
+ refusal: "usage",
1180
+ detail: r.message,
1181
+ candidates: r.candidates,
1182
+ };
1183
+ }
1184
+ const row = loadRow(deps.db, r.uuid);
1185
+ if (row === undefined)
1186
+ return refused(op, "usage", `no item matches "${ref}"`);
1187
+ const want = isTodo ? 0 : 1;
1188
+ if (row.type !== want)
1189
+ wrongKind.push({ ref: r.uuid, kind: KIND_LABEL[row.type] ?? "item" });
1190
+ rows.push(row);
1191
+ }
1192
+ // Kind rule (spec: index-axis isolation). The GLOBAL todayIndex axes INTERMIX
1193
+ // object kinds in one reorder: `today` (O12 — `list "Today"` takes projects
1194
+ // inline), `evening` (SIT4 EVEORD — projects share the evening axis, per-type
1195
+ // bounce legs), and the future day-group scopes (`day`/`tomorrow`/`upcoming`/an
1196
+ // `<ISO>` day — SIT4 DAYBNC + TOMORROWLIST accept area-less project rows; DEADLINE-
1197
+ // FORECAST projects join that block via the update-project deadline-cycle, PROJDL-
1198
+ // 2a/2c #385). An INDEX axis (a stage list `anytime`/`someday`/`inbox`, or a
1199
+ // project/area/heading container `index`) sorts each KIND in its OWN rank space, so
1200
+ // it takes ONE kind only — an area's someday to-dos and someday projects are
1201
+ // DIFFERENT index buckets, so a shared container does not merge them.
1202
+ //
1203
+ // A movee kind mismatched to the reorder verb (a project/heading on `todo reorder`)
1204
+ // is therefore legal ONLY when the whole set lands on a GLOBAL axis. It lands there
1205
+ // when NO `--in` forces an index axis AND every movee is a today/evening/scheduled/
1206
+ // forecast member (the bare set then auto-routes to a day/today scope whose wire
1207
+ // list is type IN (0,1)). An explicit `--in anytime|someday|inbox|<container>`
1208
+ // forces the index axis regardless of bucket, so it NEVER intermixes — this is what
1209
+ // closes the #387-opened trap where a same-day forecast set's index token slipped
1210
+ // the bucket-only relaxation.
1211
+ const inNorm = request.in?.trim().toLowerCase();
1212
+ const inForcesIndex = inNorm !== undefined &&
1213
+ inNorm !== "today" &&
1214
+ inNorm !== "evening" &&
1215
+ inNorm !== "upcoming" &&
1216
+ !/^\d{4}-\d{2}-\d{2}$/.test(inNorm);
1217
+ const globalAxisIntermix = isTodo &&
1218
+ rows.length > 0 &&
1219
+ !inForcesIndex &&
1220
+ rows.every((r) => {
1221
+ const b = scheduleBucket(r, packedToday);
1222
+ return (b === "today" ||
1223
+ b === "evening" ||
1224
+ b.startsWith("scheduled:") ||
1225
+ forecastDeadlineDay(r, packedToday) !== null ||
1226
+ // A repeating TEMPLATE with a strictly-future projection is a first-class
1227
+ // day-block todayIndex member too (mirrors rowDayKey's template branch,
1228
+ // #393). Its startDate is NULL and it carries no deadline, so it satisfies
1229
+ // NEITHER branch above — without this disjunct a MIXED-kind day set that
1230
+ // also holds a template fails `.every()`, `globalAxisIntermix` goes false,
1231
+ // and the upstream indexKindRefusal blocks the set before the day-axis
1232
+ // resolver (which #393 taught to admit templates) ever runs. That is Mike's
1233
+ // "both kinds + template, one op" interleave, so the template must count as
1234
+ // a day-group member here exactly as it does downstream.
1235
+ (r.isTemplate && r.templateProjectionDay !== null && r.templateProjectionDay > packedToday));
1236
+ });
1237
+ if (wrongKind.length > 0 && !globalAxisIntermix) {
1238
+ return indexKindRefusal(op, rows);
1239
+ }
1240
+ return repositionInPlace(deps, op, rows, request.position, packedToday, options, "reorder", request.in);
1241
+ }
1242
+ // ---------------------------------------------------------------- shared core
1243
+ /**
1244
+ * The packed day a single row contributes to a shared future day-group, or null.
1245
+ * A SCHEDULED row (to-do or project) on a strictly-future startBucket=0 day
1246
+ * contributes its `startDate`; a DEADLINE-FORECAST to-do (startDate NULL, future
1247
+ * `deadline`, start IN (1,2) — DLBNC/§9o) contributes its `deadline` (they share the
1248
+ * one block todayIndex axis). Any other row (arrived, undated non-forecast, off the
1249
+ * Today axis) contributes null.
1250
+ */
1251
+ function rowDayKey(row, packedToday) {
1252
+ // A repeating TEMPLATE contributes its PROJECTION day (rt1_nextInstanceStartDate),
1253
+ // the Upcoming day-block its rendered projection row sits in (TMPLSORT/PTMPL). Its
1254
+ // startDate/deadline are NULL (or non-block), so it would otherwise contribute null;
1255
+ // strictly-future gate mirrors the scheduled/forecast branches (an arrived/absent
1256
+ // projection is not a future day-group member).
1257
+ if (row.isTemplate) {
1258
+ return row.templateProjectionDay !== null && row.templateProjectionDay > packedToday
1259
+ ? row.templateProjectionDay
1260
+ : null;
1261
+ }
1262
+ if (row.startDate !== null &&
1263
+ row.startBucket === 0 &&
1264
+ row.startDate > packedToday &&
1265
+ (row.type === 0 || row.type === 1)) {
1266
+ return row.startDate;
1267
+ }
1268
+ return forecastDeadlineDay(row, packedToday);
1269
+ }
1270
+ /**
1271
+ * The single STRICTLY-FUTURE day every movee shares, or null — the precondition for
1272
+ * the SIT4 dated `day` bounce (and the one-call `tomorrow` sort). Members are
1273
+ * SCHEDULED to-dos in ANY container AND scheduled PROJECT rows (area-less OR area-
1274
+ * direct — SIT5 AREAPROJDAY proved the update-project when= legs preserve the area
1275
+ * FK) sharing a future startBucket=0 `startDate`, PLUS DEADLINE-FORECAST to-dos
1276
+ * (DLBNC/§9o) whose future `deadline` equals that same day — all on the ONE Upcoming
1277
+ * day-block todayIndex axis, so a scheduled+forecast mix is one group. A row off the
1278
+ * day, a template, or an undated/arrived non-forecast row breaks the group and falls
1279
+ * through to the normal single-container guard. (Templates never reach here: both
1280
+ * `startDate` and `deadline` fail the strictly-future key for a resting template.)
1281
+ */
1282
+ function sharedFutureDay(rows, packedToday) {
1283
+ const first = rows[0];
1284
+ if (first === undefined)
1285
+ return null;
1286
+ const day = rowDayKey(first, packedToday);
1287
+ if (day === null)
1288
+ return null;
1289
+ for (const r of rows) {
1290
+ // Templates NO LONGER break the group: a strictly-future template projection is
1291
+ // a first-class day-block member (TMPLSORT-3c-Tomorrow / PTMPL-B5), reached on the
1292
+ // `tomorrow` native wire or the `day` per-class leg family (reorder.ts). rowDayKey
1293
+ // returns its projection day, so an on-day template shares the group.
1294
+ if (rowDayKey(r, packedToday) !== day)
1295
+ return null;
1296
+ }
1297
+ return day;
1298
+ }
1299
+ /** Whether any row in the group is a deadline-forecast member (DLBNC/§9o). */
1300
+ function hasForecastMember(rows, packedToday) {
1301
+ return rows.some((r) => forecastDeadlineDay(r, packedToday) !== null);
1302
+ }
1303
+ /**
1304
+ * The single shared FORECAST deadline day when EVERY row is a deadline-forecast
1305
+ * member (§9o) of that one day, else null. Stricter than {@link sharedFutureDay}:
1306
+ * a scheduled row (or a forecast row on a different day) breaks it — so a
1307
+ * scheduled-only or scheduled+forecast-mixed set returns null (single-axis, day-
1308
+ * only). Gates the forecast dual-axis refusal: only an all-forecast, one-day set
1309
+ * has a coherent container-index alternative to the day-block.
1310
+ */
1311
+ function sharedForecastDay(rows, packedToday) {
1312
+ const first = rows[0];
1313
+ if (first === undefined)
1314
+ return null;
1315
+ const day = forecastDeadlineDay(first, packedToday);
1316
+ if (day === null)
1317
+ return null;
1318
+ for (const r of rows)
1319
+ if (forecastDeadlineDay(r, packedToday) !== day)
1320
+ return null;
1321
+ return day;
1322
+ }
1323
+ /**
1324
+ * Reposition a shared FUTURE day-group on the global todayIndex axis — the SIT4
1325
+ * dated `day` bounce (loose/direct-area/headed/cross-container to-dos + area-less
1326
+ * project rows, any mix), or the one-call native `list "Tomorrow"` sort when the
1327
+ * day is tomorrow. Both order the WHOLE cross-container day-group on ONE shared
1328
+ * axis, so the anchor (if any) is validated by DAY-GROUP membership (not the
1329
+ * structural container) — an anchor positions within the group, it never migrates.
1330
+ */
1331
+ async function runDayGroupReposition(deps, op, rows, position, options, scope, day) {
1332
+ const target = { scope, day };
1333
+ const movees = rows.map((r) => r.uuid);
1334
+ const members = bucketMembers(deps, target, movees[0]);
1335
+ // Anchor must share the movees' day-group (positions, never migrates) — an
1336
+ // AXIS-aware check (the day-group is one cross-container todayIndex axis, so a
1337
+ // different structural container is NOT a migration).
1338
+ if (position !== undefined && ("before" in position || "after" in position)) {
1339
+ const anchorRef = "before" in position ? position.before : position.after;
1340
+ const ar = resolveMovee(deps, anchorRef);
1341
+ if (ar instanceof ReferenceResolutionError) {
1342
+ return {
1343
+ kind: "move-refused",
1344
+ op,
1345
+ refusal: "usage",
1346
+ detail: ar.message,
1347
+ candidates: ar.candidates,
1348
+ };
1349
+ }
1350
+ if (!members.includes(ar.uuid)) {
1351
+ return refused(op, "blocked", `the anchor ${ar.uuid} is not in ${describeScope(target)} — an anchor positions, it never migrates`, "pick an anchor that shares the movees' day, or use --first/--last");
1352
+ }
1353
+ }
1354
+ const reorderUuids = position === undefined
1355
+ ? earliestSlotOrder(deps, target, movees)
1356
+ : buildReorderOrder(deps, target, movees, position);
1357
+ const placement = await runReorder(deps, { scope, uuids: reorderUuids, named: movees }, { ...legOptions(options), ...(options.dryRun === true && { dryRun: true }) });
1358
+ const mechanism = scope === "tomorrow"
1359
+ ? 'cross-container Tomorrow day-group reorder (one-call `list "Tomorrow"` sort)'
1360
+ : "cross-container future day-group reorder (SIT4 dated when= bounce)";
1361
+ if (options.dryRun === true) {
1362
+ return {
1363
+ kind: "move-dry-run",
1364
+ op,
1365
+ plan: {
1366
+ movees,
1367
+ membership: "none (in-place reposition)",
1368
+ placement: `reorder scope=${scope} → ${describePosition(position)}`,
1369
+ placementClass: "guaranteed",
1370
+ note: dryRunNote(placement, mechanism),
1371
+ },
1372
+ };
1373
+ }
1374
+ if (placement.kind !== "ok") {
1375
+ return repositionFailed(op, placement);
1376
+ }
1377
+ return {
1378
+ kind: "move-ok",
1379
+ op,
1380
+ movees: rows.map((r) => ({ uuid: r.uuid, title: r.title })),
1381
+ membership: [],
1382
+ placement,
1383
+ placementClass: "guaranteed",
1384
+ note: `reordered within ${describeScope(target)} (${scope} scope — placement guaranteed)` +
1385
+ touchedSuffix(placement),
1386
+ };
1387
+ }
1388
+ /**
1389
+ * Reorder the given rows within their CURRENT shared container. Fails closed
1390
+ * when the rows span containers or (for --before/--after) the anchor lives
1391
+ * elsewhere or in another bucket.
1392
+ */
1393
+ async function repositionInPlace(deps, op, rows, position, packedToday, options, verb, inTarget) {
1394
+ // Resolve the reorder AXIS (`reorder --in` disambiguation). The classifier maps
1395
+ // each row to its effective reorder target for the chosen axis: the view axis
1396
+ // (today/evening) leaves reorderTargetOf unchanged; a container index axis
1397
+ // reclassifies Today/Evening members by their container. A dual-axis set with no
1398
+ // --in is refused here (naming both --in spellings).
1399
+ const axis = resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, position);
1400
+ if ("refused" in axis)
1401
+ return axis.refused;
1402
+ // An explicit day-axis token (`--in <YYYY-MM-DD>` / `--in upcoming`) routes straight
1403
+ // to the cross-container day-group reposition — membership + future-ness were
1404
+ // validated in resolveReorderAxis. Mirror the auto-route's scope pick: the native
1405
+ // one-call `tomorrow` sort only for a non-forecast group whose day is tomorrow (that
1406
+ // surface re-dates a forecast row), else the dated `day` bounce.
1407
+ if ("dayAxis" in axis) {
1408
+ const day = axis.dayAxis;
1409
+ const scope = !hasForecastMember(rows, packedToday) && day === packedTomorrowOf(packedToday)
1410
+ ? "tomorrow"
1411
+ : "day";
1412
+ return runDayGroupReposition(deps, op, rows, position, options, scope, day);
1413
+ }
1414
+ const targetOf = axis.targetOf;
1415
+ // On a forced/auto INDEX axis (a dual-axis set reordered within its container),
1416
+ // the display-bucket coherence guards below are the WRONG check: the members
1417
+ // share ONE container `index` order even though a flagged member renders in the
1418
+ // Today/Evening view. The target-span guard still enforces the single shared
1419
+ // container; only the per-display-bucket splits are relaxed (a SIT6 flag-safe
1420
+ // protocol reorders the whole run, flag preserved).
1421
+ const indexAxis = axis.indexAxis === true;
1422
+ // A shared FUTURE day-group (single- OR cross-container) rides the global
1423
+ // todayIndex axis, not the normal single-container reorder: the SIT4 dated `day`
1424
+ // bounce (loose/direct-area/headed/cross-container to-dos + area-less project
1425
+ // rows + DEADLINE-FORECAST to-dos, any mix), or the one-call `list "Tomorrow"`
1426
+ // sort when the day is tomorrow. Two exceptions force the `day` scope (never the
1427
+ // native short-cuts): a group CONTAINING a forecast row NEVER rides `list
1428
+ // "Tomorrow"` (that surface re-dates a forecast row, UPCDL-5) NOR the single-
1429
+ // project container-day native re-rank (a forecast row has no startDate, so it is
1430
+ // not a container-day scheduled child) — the deadline-cycle `day` bounce is the
1431
+ // one surface that serves the mixed group. Otherwise a single UNHEADED PROJECT
1432
+ // container's same-day scheduled children ride the cheaper atomic native
1433
+ // container-day re-rank (fall through to reorderTargetOf → container-day).
1434
+ // Templates are excluded by sharedFutureDay. The auto-route NEVER fires on a
1435
+ // forced/auto INDEX axis (indexAxis === true): an explicit `--in someday` /
1436
+ // `--in anytime` / `--in <container>` on a same-day forecast set is the certified
1437
+ // container `index` re-rank, and must not be overridden by the day bounce.
1438
+ const structKeys = new Set(rows.map((r) => structuralKey(r, packedToday)));
1439
+ const sharedDay = sharedFutureDay(rows, packedToday);
1440
+ if (sharedDay !== null && !indexAxis) {
1441
+ const forecastInGroup = hasForecastMember(rows, packedToday);
1442
+ const soleStruct = structKeys.size === 1 ? structKeys.values().next().value : null;
1443
+ const singleProjectContainer = !forecastInGroup && (soleStruct?.startsWith("project:") ?? false);
1444
+ if (!singleProjectContainer) {
1445
+ const scope = !forecastInGroup && sharedDay === packedTomorrowOf(packedToday) ? "tomorrow" : "day";
1446
+ return runDayGroupReposition(deps, op, rows, position, options, scope, sharedDay);
1447
+ }
1448
+ }
1449
+ // One shared reorder TARGET (rule 2 span). Keyed by the reorder target, NOT the
1450
+ // structural container: a GLOBAL todayIndex bucket (today / evening) collapses
1451
+ // cross-container rows to ONE target, so they reorder together on that shared
1452
+ // axis (the GUI permits exactly that drag — nothing migrates); an INDEX-axis
1453
+ // bucket keeps its per-container key, so a genuine cross-container span still
1454
+ // refuses. The comparator and the describer read the SAME key, so an identical-
1455
+ // label refusal is structurally impossible.
1456
+ const targetKeys = new Set(rows.map((r) => containerKey(targetOf(r))));
1457
+ if (targetKeys.size !== 1) {
1458
+ const where = rows.map((r) => `${r.uuid} in ${describeScope(targetOf(r))}`).join("; ");
1459
+ return refused(op, "blocked", `the items span different containers (${where}), so they cannot be repositioned together`, verb === "move"
1460
+ ? "name an explicit destination (--to-project / --to-area / --no-heading / --loose) if you mean to MOVE them"
1461
+ : "reorder items that already share one container and bucket, or move them together first");
1462
+ }
1463
+ const target = targetOf(rows[0]);
1464
+ if (target.scope === null) {
1465
+ // A prohibited bucket has a destructive protocol we NEVER attempt (§9f); an
1466
+ // app-default bucket simply has no wired protocol. Both refuse a reorder
1467
+ // (an explicit rearrange we cannot honor honestly), but with distinct copy.
1468
+ return refused(op, target.prohibited === true ? "blocked" : "unsupported", target.prohibited === true
1469
+ ? `these items are ${describeScope(target)} — reordering them there is destructive, so it is refused`
1470
+ : `these items are in ${describeScope(target)} — no reorder protocol addresses that bucket yet`, "see the placement-honesty note in `things help move`");
1471
+ }
1472
+ // All movees must share ONE bucket (rule 4 single-bucket-strict) — EXCEPT on a
1473
+ // forced/auto index axis, where a dual-axis set legitimately spans display
1474
+ // buckets (a flagged member in Today + plain members in Anytime) yet shares one
1475
+ // container index order.
1476
+ const buckets = new Map();
1477
+ for (const r of rows) {
1478
+ const b = scheduleBucket(r, packedToday);
1479
+ buckets.set(b, [...(buckets.get(b) ?? []), r.uuid]);
1480
+ }
1481
+ if (buckets.size > 1 && !indexAxis) {
1482
+ const listed = [...buckets.entries()].map(([b, u]) => `${b}: ${u.join(", ")}`).join("; ");
1483
+ return refused(op, "blocked", `the items are in different display buckets (${listed}) — a single reorder cannot span buckets`, "reorder within one bucket at a time, or use per-bucket --first/--last");
1484
+ }
1485
+ // Anchor validation for --before/--after (rule 2/4).
1486
+ let anchorUuid = null;
1487
+ if (position !== undefined && ("before" in position || "after" in position)) {
1488
+ const anchorRef = "before" in position ? position.before : position.after;
1489
+ const ar = resolveMovee(deps, anchorRef);
1490
+ if (ar instanceof ReferenceResolutionError) {
1491
+ return {
1492
+ kind: "move-refused",
1493
+ op,
1494
+ refusal: "usage",
1495
+ detail: ar.message,
1496
+ candidates: ar.candidates,
1497
+ };
1498
+ }
1499
+ anchorUuid = ar.uuid;
1500
+ const anchorRow = loadRow(deps.db, anchorUuid);
1501
+ if (anchorRow === undefined)
1502
+ return refused(op, "usage", `the anchor "${anchorRef}" was not found`);
1503
+ // AXIS-aware anchor validity: the anchor must share the movees' reorder TARGET,
1504
+ // not its structural container. For a GLOBAL todayIndex bucket (today/evening)
1505
+ // a different structural container is NOT a migration — both collapse to the
1506
+ // same target — so the anchor is valid; for an INDEX-axis bucket a cross-
1507
+ // container anchor genuinely implies migration and still refuses. Comparator
1508
+ // and describer read the SAME key (containerKey ⇔ describeScope), so the two
1509
+ // rendered labels can never be identical when the comparator refuses.
1510
+ const anchorTarget = targetOf(anchorRow);
1511
+ if (containerKey(anchorTarget) !== containerKey(target)) {
1512
+ return refused(op, "blocked", `the anchor ${anchorUuid} is in ${describeScope(anchorTarget)}, not ${describeScope(target)} — an anchor positions, it never migrates`, "move the items into the anchor's container explicitly, or pick an anchor that shares it");
1513
+ }
1514
+ // On the index axis the anchor need only share the container (checked above via
1515
+ // containerKey) — display buckets legitimately differ for a dual-axis set.
1516
+ const anchorBucket = scheduleBucket(anchorRow, packedToday);
1517
+ const movBucket = [...buckets.keys()][0];
1518
+ if (!indexAxis && anchorBucket !== movBucket) {
1519
+ return refused(op, "blocked", `the anchor is in the "${anchorBucket}" bucket but the movees are in "${movBucket}" — ` +
1520
+ "--before/--after require every movee to share the anchor's bucket", "use --first/--last (bucket-relative), or reorder within one bucket");
1521
+ }
1522
+ }
1523
+ const movees = rows.map((r) => r.uuid);
1524
+ // Bare form (no position): assemble the block at the EARLIEST movee's current
1525
+ // slot in argument order (`--first` is NOT implied) — spec §4. A position
1526
+ // realizes first/last/before/after instead.
1527
+ const reorderUuids = position === undefined
1528
+ ? earliestSlotOrder(deps, target, movees)
1529
+ : buildReorderOrder(deps, target, movees, position);
1530
+ const reorderParams = {
1531
+ scope: target.scope,
1532
+ uuids: reorderUuids,
1533
+ named: movees,
1534
+ ...(target.container !== undefined && { container: { uuid: target.container } }),
1535
+ };
1536
+ const placement = await runReorder(deps, reorderParams, {
1537
+ ...legOptions(options),
1538
+ ...(options.dryRun === true && { dryRun: true }),
1539
+ });
1540
+ if (options.dryRun === true) {
1541
+ return {
1542
+ kind: "move-dry-run",
1543
+ op,
1544
+ plan: {
1545
+ movees,
1546
+ membership: "none (in-place reposition)",
1547
+ placement: `reorder scope=${target.scope}${target.container !== undefined ? ` container=${target.container}` : ""} → ${describePosition(position)}`,
1548
+ placementClass: "guaranteed",
1549
+ note: dryRunNote(placement, "in-place reorder within the shared container/bucket"),
1550
+ },
1551
+ };
1552
+ }
1553
+ if (placement.kind !== "ok") {
1554
+ return repositionFailed(op, placement);
1555
+ }
1556
+ return {
1557
+ kind: "move-ok",
1558
+ op,
1559
+ movees: rows.map((r) => ({ uuid: r.uuid, title: r.title })),
1560
+ membership: [],
1561
+ placement,
1562
+ placementClass: "guaranteed",
1563
+ note: `reordered within ${describeScope(target)} (${target.scope} scope — placement guaranteed)` +
1564
+ touchedSuffix(placement),
1565
+ };
1566
+ }
1567
+ /** Honest disclosure suffix naming co-bounced siblings an anchor placement touched. */
1568
+ function touchedSuffix(placement) {
1569
+ if (placement.kind !== "ok" ||
1570
+ placement.touched === undefined ||
1571
+ placement.touched.length === 0) {
1572
+ return "";
1573
+ }
1574
+ return `; also re-inserted ${placement.touched.length} unnamed sibling(s) to honor the anchor: ${placement.touched.join(", ")}`;
1575
+ }
1576
+ /** Dry-run note that surfaces the planned co-bounce touch count. */
1577
+ function dryRunNote(placement, base) {
1578
+ if (placement.kind === "dry-run")
1579
+ return `${base} — ${placement.plan.invocation}`;
1580
+ return base;
1581
+ }
1582
+ /**
1583
+ * The final placement step for a MEMBERSHIP move: reorder the movees within the
1584
+ * destination bucket when a protocol exists, else report app-default.
1585
+ */
1586
+ async function finishPlacement(deps, op, rows, landing, position, packedToday, options, membership) {
1587
+ const movees = rows.map((r) => r.uuid);
1588
+ const moveeTitles = rows.map((r) => ({ uuid: r.uuid, title: r.title }));
1589
+ // Anchor with a membership move: the anchor must be in the destination
1590
+ // (rule 2) and share the movees' bucket (rule 4). Validated against post-move
1591
+ // truth. When the destination bucket has no guaranteed protocol we now REFUSE
1592
+ // an explicit --before/--after (we KNOW no honest spelling exists there —
1593
+ // HEADORD-b/§9f) rather than silently app-default it.
1594
+ if (position !== undefined && ("before" in position || "after" in position)) {
1595
+ if (landing.scope === null) {
1596
+ return refused(op, landing.prohibited === true ? "blocked" : "unsupported", `moved into ${describeScope(landing)} — --before/--after cannot be honored there; ` +
1597
+ "no reorder protocol positions within that bucket", "use --first/--last, or omit the position (membership still lands)");
1598
+ }
1599
+ const destStructural = landing.container !== undefined
1600
+ ? `${landing.scope}:${landing.container}`
1601
+ : `list:${landing.scope}`;
1602
+ const anchorRef = "before" in position ? position.before : position.after;
1603
+ const ar = resolveMovee(deps, anchorRef);
1604
+ if (ar instanceof ReferenceResolutionError) {
1605
+ return {
1606
+ kind: "move-refused",
1607
+ op,
1608
+ refusal: "usage",
1609
+ detail: `after moving, the anchor "${anchorRef}" did not resolve`,
1610
+ candidates: ar.candidates,
1611
+ };
1612
+ }
1613
+ const anchorRow = loadRow(deps.db, ar.uuid);
1614
+ if (anchorRow === undefined || structuralKey(anchorRow, packedToday) !== destStructural) {
1615
+ return refused(op, "blocked", `the anchor ${ar.uuid} is not in the destination container — an anchor positions, it never migrates`, "pick an anchor that lives in the destination, or use --first/--last");
1616
+ }
1617
+ const anchorBucket = scheduleBucket(anchorRow, packedToday);
1618
+ const badBucket = rows.filter((r) => scheduleBucket(r, packedToday) !== anchorBucket);
1619
+ if (badBucket.length > 0) {
1620
+ const listed = rows.map((r) => `${r.uuid}: ${scheduleBucket(r, packedToday)}`).join("; ");
1621
+ return refused(op, "blocked", `--before/--after require every movee in the anchor's "${anchorBucket}" bucket, but: ${listed}`, "use --first/--last (per-bucket), or split the move by bucket");
1622
+ }
1623
+ }
1624
+ // Mixed-stage --first/--last (spec §4 rule 4): a selection that lands across
1625
+ // stage sub-buckets (anytime + scheduled + someday) places each stage-group at
1626
+ // the top/bottom of ITS bucket in the destination. Grouped off the RELOADED
1627
+ // post-move rows (real container uuids + true buckets), so it needs no dest
1628
+ // context and is correct for a heading landing too. Dry-run keeps the generic
1629
+ // path (no DB truth to reload).
1630
+ if (options.dryRun !== true && position !== undefined && "at" in position) {
1631
+ const reloaded = rows.map((r) => loadRow(deps.db, r.uuid) ?? r);
1632
+ const groups = groupByReorderTarget(deps, reloaded, packedToday);
1633
+ if (groups.length > 1) {
1634
+ return placePerBucket(deps, op, groups, position, moveeTitles, membership, options);
1635
+ }
1636
+ }
1637
+ if (landing.scope === null) {
1638
+ // Membership landed; the destination bucket has no guaranteed protocol (or a
1639
+ // prohibited/destructive one we never attempt) — app-default placement, honest.
1640
+ return {
1641
+ kind: "move-ok",
1642
+ op,
1643
+ movees: moveeTitles,
1644
+ membership,
1645
+ placement: null,
1646
+ placementClass: "app-default",
1647
+ note: `membership moved; landed in ${describeScope(landing)} — ` +
1648
+ (landing.prohibited === true
1649
+ ? "ordering there is destructive so it was NOT attempted (the app placed it); "
1650
+ : "no reorder protocol addresses that bucket yet; ") +
1651
+ "app-default placement (spec rule 5)",
1652
+ };
1653
+ }
1654
+ if (options.dryRun === true) {
1655
+ return {
1656
+ kind: "move-dry-run",
1657
+ op,
1658
+ plan: {
1659
+ movees,
1660
+ membership: `${membership.length} membership leg(s)`,
1661
+ placement: `reorder scope=${landing.scope}${landing.container !== undefined ? ` container=${landing.container}` : ""} → ${describePosition(position)}`,
1662
+ placementClass: "guaranteed",
1663
+ note: "membership + top-of-bucket placement",
1664
+ },
1665
+ };
1666
+ }
1667
+ const reorderUuids = buildReorderOrder(deps, landing, movees, position);
1668
+ const placement = await runReorder(deps, {
1669
+ scope: landing.scope,
1670
+ uuids: reorderUuids,
1671
+ named: movees,
1672
+ ...(landing.container !== undefined && { container: { uuid: landing.container } }),
1673
+ }, legOptions(options));
1674
+ if (placement.kind === "ok") {
1675
+ return {
1676
+ kind: "move-ok",
1677
+ op,
1678
+ movees: moveeTitles,
1679
+ membership,
1680
+ placement,
1681
+ placementClass: "guaranteed",
1682
+ note: `membership moved and placed top-of-bucket in ${describeScope(landing)} (guaranteed via the ${landing.scope} reorder protocol)` +
1683
+ touchedSuffix(placement),
1684
+ };
1685
+ }
1686
+ // The membership already landed; the placement protocol was unavailable
1687
+ // (experimental off, or an app-side refusal). Honest degrade to app-default.
1688
+ return {
1689
+ kind: "move-ok",
1690
+ op,
1691
+ movees: moveeTitles,
1692
+ membership,
1693
+ placement,
1694
+ placementClass: "app-default",
1695
+ note: `membership moved into ${describeScope(landing)}, but the ${landing.scope} reorder protocol ` +
1696
+ `was unavailable (${placement.kind}${placement.kind === "blocked" ? `: ${placement.detail}` : ""}) — ` +
1697
+ "placement fell back to app-default; enable it with `things config set allow-experimental true`",
1698
+ };
1699
+ }
1700
+ /**
1701
+ * Partition rows by their reorder TARGET (the distinct placement protocol each
1702
+ * stage sub-bucket needs), preserving selection order within each group. Used for
1703
+ * per-sub-bucket --first/--last on a mixed-stage membership move.
1704
+ */
1705
+ function groupByReorderTarget(deps, rows, packedToday) {
1706
+ const groups = new Map();
1707
+ for (const r of rows) {
1708
+ const target = reorderTargetOf(r, r.type === 0, packedToday, deps.config.bounceEnabled);
1709
+ const key = containerKey(target);
1710
+ const g = groups.get(key);
1711
+ if (g !== undefined)
1712
+ g.rows.push(r);
1713
+ else
1714
+ groups.set(key, { target, rows: [r] });
1715
+ }
1716
+ return [...groups.values()];
1717
+ }
1718
+ /**
1719
+ * Place each stage-group of a mixed-stage membership move at the top/bottom of ITS
1720
+ * sub-bucket (spec §4 rule 4). Each group with a wired protocol runs its own
1721
+ * --first/--last reorder; a protocol-less group is honest app-default. The note
1722
+ * states every group's outcome (the rule-5 honesty surface).
1723
+ */
1724
+ async function placePerBucket(deps, op, groups, position, moveeTitles, membership, options) {
1725
+ const notes = [];
1726
+ const placements = [];
1727
+ let anyGuaranteed = false;
1728
+ let anyDefault = false;
1729
+ for (const g of groups) {
1730
+ const count = g.rows.length;
1731
+ if (g.target.scope === null) {
1732
+ anyDefault = true;
1733
+ notes.push(`${describeScope(g.target)}: app-default (${count})`);
1734
+ continue;
1735
+ }
1736
+ const movees = g.rows.map((r) => r.uuid);
1737
+ const reorderUuids = buildReorderOrder(deps, g.target, movees, position);
1738
+ const placement = await runReorder(deps, {
1739
+ scope: g.target.scope,
1740
+ uuids: reorderUuids,
1741
+ named: movees,
1742
+ ...(g.target.container !== undefined && { container: { uuid: g.target.container } }),
1743
+ }, legOptions(options));
1744
+ placements.push(placement);
1745
+ if (placement.kind === "ok") {
1746
+ anyGuaranteed = true;
1747
+ notes.push(`${describeScope(g.target)}: ${position.at} (${count})${touchedSuffix(placement)}`);
1748
+ }
1749
+ else {
1750
+ anyDefault = true;
1751
+ notes.push(`${describeScope(g.target)}: placement unavailable (${placement.kind}) — app-default`);
1752
+ }
1753
+ }
1754
+ return {
1755
+ kind: "move-ok",
1756
+ op,
1757
+ movees: moveeTitles,
1758
+ membership,
1759
+ placement: placements[0] ?? null,
1760
+ placementClass: anyGuaranteed && !anyDefault ? "guaranteed" : "app-default",
1761
+ note: `membership moved; mixed-stage placement applied PER sub-bucket (--${position.at}): ` +
1762
+ notes.join("; "),
1763
+ };
1764
+ }
1765
+ /**
1766
+ * The full bucket order with the movees assembled as a contiguous block at the
1767
+ * EARLIEST movee's current slot (bare-reorder semantics, spec §4).
1768
+ */
1769
+ function earliestSlotOrder(deps, target, movees) {
1770
+ const members = bucketMembers(deps, target, movees[0]);
1771
+ const moveeSet = new Set(movees);
1772
+ const earliest = members.findIndex((u) => moveeSet.has(u));
1773
+ if (earliest < 0)
1774
+ return movees;
1775
+ const before = members.slice(0, earliest).filter((u) => !moveeSet.has(u));
1776
+ const after = members.slice(earliest + 1).filter((u) => !moveeSet.has(u));
1777
+ return [...before, ...movees, ...after];
1778
+ }
1779
+ /** Build the reorder uuid list realizing the requested position. */
1780
+ function buildReorderOrder(deps, target, movees, position) {
1781
+ if (target.scope === null)
1782
+ return movees;
1783
+ // first / default → partial top-placement (reorder puts these at the top).
1784
+ if (position === undefined || ("at" in position && position.at === "first")) {
1785
+ return movees;
1786
+ }
1787
+ // last / before / after → a FULL re-rank: read the bucket order and splice.
1788
+ const members = bucketMembers(deps, target, movees[0]);
1789
+ const others = members.filter((u) => !movees.includes(u));
1790
+ if ("at" in position && position.at === "last")
1791
+ return [...others, ...movees];
1792
+ if ("before" in position || "after" in position) {
1793
+ const anchor = resolveMoveeUuid(deps, "before" in position ? position.before : position.after);
1794
+ const idx = others.indexOf(anchor);
1795
+ if (idx < 0)
1796
+ return [...others, ...movees];
1797
+ const insertAt = "before" in position ? idx : idx + 1;
1798
+ return [...others.slice(0, insertAt), ...movees, ...others.slice(insertAt)];
1799
+ }
1800
+ return movees;
1801
+ }
1802
+ function resolveMoveeUuid(deps, ref) {
1803
+ try {
1804
+ return resolveTaskUuidPrefix(deps.db, ref, "item");
1805
+ }
1806
+ catch {
1807
+ return ref;
1808
+ }
1809
+ }
1810
+ /**
1811
+ * The current member order of a reorder bucket (for full re-rank builds). The
1812
+ * day-group scopes (container-day, `day`, tomorrow) read their day off the FIRST
1813
+ * requested uuid (a day-group is keyed by a movee's startDate, not by a
1814
+ * container), so `dayAnchor` (a movee) seeds the enumeration for them — without it
1815
+ * those scopes enumerate nothing and an anchored (--last/--before/--after)
1816
+ * placement cannot splice against the real day order.
1817
+ */
1818
+ function bucketMembers(deps, target, dayAnchor) {
1819
+ if (target.scope === null)
1820
+ return [];
1821
+ const seedsDay = target.scope === "container-day" || target.scope === "day" || target.scope === "tomorrow";
1822
+ const params = {
1823
+ scope: target.scope,
1824
+ uuids: seedsDay && dayAnchor !== undefined ? [dayAnchor] : [],
1825
+ ...(target.container !== undefined && { container: { uuid: target.container } }),
1826
+ };
1827
+ const containerUuid = target.container ?? null;
1828
+ const pre = computeReorderPre(deps.db, params, containerUuid, deps.now?.() ?? new Date());
1829
+ return pre.members.map((m) => m.uuid);
1830
+ }
1831
+ function legOptions(options) {
1832
+ const legs = {};
1833
+ if (options.maxDisruption !== undefined)
1834
+ legs.maxDisruption = options.maxDisruption;
1835
+ if (options.verifyTimeoutMs !== undefined)
1836
+ legs.verifyTimeoutMs = options.verifyTimeoutMs;
1837
+ if (options.actor !== undefined)
1838
+ legs.actor = options.actor;
1839
+ if (options.vector !== undefined)
1840
+ legs.vector = options.vector;
1841
+ if (options.acknowledgeProjectReopen !== undefined) {
1842
+ legs.acknowledgeProjectReopen = options.acknowledgeProjectReopen;
1843
+ }
1844
+ return legs;
1845
+ }
1846
+ function describeScope(target) {
1847
+ if (target.scope === null)
1848
+ return target.reason;
1849
+ // A day-group's identity IS its date — name it so cross-day refusals read
1850
+ // coherently (e.g. "the loose 2026-08-05 day-group" vs "…2026-08-07…"), never
1851
+ // the self-contradictory date-less "the loose future-day group" on both sides.
1852
+ const day = target.day !== undefined ? decodePackedDate(target.day) : null;
1853
+ if (target.scope === "day") {
1854
+ return day !== null ? `the ${day} day-group` : "the future day-group";
1855
+ }
1856
+ if (target.scope === "tomorrow")
1857
+ return "the Tomorrow day-group";
1858
+ if (target.scope === "container-day" && day !== null) {
1859
+ return `the container-day ${target.container} day-group (${day})`;
1860
+ }
1861
+ return target.container !== undefined
1862
+ ? `the ${target.scope} ${target.container}`
1863
+ : `the ${target.scope} list`;
1864
+ }
1865
+ function describePosition(position) {
1866
+ if (position === undefined)
1867
+ return "top of bucket (default)";
1868
+ if ("at" in position)
1869
+ return position.at;
1870
+ if ("before" in position)
1871
+ return `before ${position.before}`;
1872
+ return `after ${position.after}`;
1873
+ }
1874
+ //# sourceMappingURL=move.js.map