things-api 0.12.0 → 0.14.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 (141) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/canonical-ref.d.ts +21 -0
  3. package/dist/cli/canonical-ref.js +23 -0
  4. package/dist/cli/canonical-ref.js.map +1 -0
  5. package/dist/cli/commands/area.d.ts +5 -5
  6. package/dist/cli/commands/area.js +39 -18
  7. package/dist/cli/commands/area.js.map +1 -1
  8. package/dist/cli/commands/install-skill.d.ts +2 -2
  9. package/dist/cli/commands/install-skill.js +29 -0
  10. package/dist/cli/commands/install-skill.js.map +1 -1
  11. package/dist/cli/commands/mcp.js +12 -1
  12. package/dist/cli/commands/mcp.js.map +1 -1
  13. package/dist/cli/commands/project.js +74 -22
  14. package/dist/cli/commands/project.js.map +1 -1
  15. package/dist/cli/commands/reads.d.ts +5 -1
  16. package/dist/cli/commands/reads.js +31 -15
  17. package/dist/cli/commands/reads.js.map +1 -1
  18. package/dist/cli/commands/setup.js +11 -4
  19. package/dist/cli/commands/setup.js.map +1 -1
  20. package/dist/cli/commands/show.js +51 -32
  21. package/dist/cli/commands/show.js.map +1 -1
  22. package/dist/cli/commands/todo.js +2 -2
  23. package/dist/cli/commands/todo.js.map +1 -1
  24. package/dist/cli/commands/writes.js +138 -67
  25. package/dist/cli/commands/writes.js.map +1 -1
  26. package/dist/cli/did-you-mean.js +19 -5
  27. package/dist/cli/did-you-mean.js.map +1 -1
  28. package/dist/cli/dry-run.d.ts +55 -0
  29. package/dist/cli/dry-run.js +92 -0
  30. package/dist/cli/dry-run.js.map +1 -0
  31. package/dist/cli/glyphs.d.ts +9 -2
  32. package/dist/cli/glyphs.js +11 -5
  33. package/dist/cli/glyphs.js.map +1 -1
  34. package/dist/cli/help.js +7 -6
  35. package/dist/cli/help.js.map +1 -1
  36. package/dist/cli/main.js +7 -0
  37. package/dist/cli/main.js.map +1 -1
  38. package/dist/cli/read-driver.d.ts +47 -7
  39. package/dist/cli/read-driver.js +43 -21
  40. package/dist/cli/read-driver.js.map +1 -1
  41. package/dist/cli/ref-render.d.ts +14 -0
  42. package/dist/cli/ref-render.js +9 -0
  43. package/dist/cli/ref-render.js.map +1 -0
  44. package/dist/cli/render.d.ts +39 -25
  45. package/dist/cli/render.js +102 -62
  46. package/dist/cli/render.js.map +1 -1
  47. package/dist/cli/resolve-invocation.d.ts +5 -3
  48. package/dist/cli/resolve-invocation.js +13 -4
  49. package/dist/cli/resolve-invocation.js.map +1 -1
  50. package/dist/client.d.ts +100 -32
  51. package/dist/client.js +63 -19
  52. package/dist/client.js.map +1 -1
  53. package/dist/contracts.d.ts +34 -33
  54. package/dist/contracts.js +1 -1
  55. package/dist/contracts.js.map +1 -1
  56. package/dist/db/baselines/db-v26.js +4 -1
  57. package/dist/db/baselines/db-v26.js.map +1 -1
  58. package/dist/index.d.ts +8 -5
  59. package/dist/index.js +9 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/mcp/server.js +192 -139
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/model/dates.d.ts +26 -0
  64. package/dist/model/dates.js +46 -4
  65. package/dist/model/dates.js.map +1 -1
  66. package/dist/model/entities.d.ts +11 -1
  67. package/dist/model/entities.js.map +1 -1
  68. package/dist/model/mappers.js +3 -0
  69. package/dist/model/mappers.js.map +1 -1
  70. package/dist/model/serialize.js +15 -3
  71. package/dist/model/serialize.js.map +1 -1
  72. package/dist/read/area-filter.d.ts +2 -2
  73. package/dist/read/area-filter.js +3 -3
  74. package/dist/read/area-filter.js.map +1 -1
  75. package/dist/read/project-view.d.ts +76 -1
  76. package/dist/read/project-view.js +80 -9
  77. package/dist/read/project-view.js.map +1 -1
  78. package/dist/read/queries.d.ts +71 -2
  79. package/dist/read/queries.js +199 -10
  80. package/dist/read/queries.js.map +1 -1
  81. package/dist/read/scope.d.ts +2 -2
  82. package/dist/read/scope.js +3 -3
  83. package/dist/read/scope.js.map +1 -1
  84. package/dist/read/shape.d.ts +87 -9
  85. package/dist/read/shape.js +483 -124
  86. package/dist/read/shape.js.map +1 -1
  87. package/dist/read/show-target.js +52 -33
  88. package/dist/read/show-target.js.map +1 -1
  89. package/dist/read/truncation.d.ts +73 -7
  90. package/dist/read/truncation.js +62 -29
  91. package/dist/read/truncation.js.map +1 -1
  92. package/dist/read/views.d.ts +20 -7
  93. package/dist/read/views.js +7 -7
  94. package/dist/read/views.js.map +1 -1
  95. package/dist/surface-copy.d.ts +13 -0
  96. package/dist/surface-copy.js +13 -0
  97. package/dist/surface-copy.js.map +1 -1
  98. package/dist/write/batch.js +0 -1
  99. package/dist/write/batch.js.map +1 -1
  100. package/dist/write/commands.d.ts +25 -1
  101. package/dist/write/commands.js +259 -122
  102. package/dist/write/commands.js.map +1 -1
  103. package/dist/write/guards.js +27 -11
  104. package/dist/write/guards.js.map +1 -1
  105. package/dist/write/move.d.ts +34 -9
  106. package/dist/write/move.js +730 -78
  107. package/dist/write/move.js.map +1 -1
  108. package/dist/write/operations.d.ts +50 -20
  109. package/dist/write/operations.js +2 -2
  110. package/dist/write/operations.js.map +1 -1
  111. package/dist/write/pipeline.js +25 -4
  112. package/dist/write/pipeline.js.map +1 -1
  113. package/dist/write/pre-state.d.ts +58 -28
  114. package/dist/write/pre-state.js +247 -49
  115. package/dist/write/pre-state.js.map +1 -1
  116. package/dist/write/reorder.js +266 -32
  117. package/dist/write/reorder.js.map +1 -1
  118. package/dist/write/resolution-timestamps.d.ts +34 -0
  119. package/dist/write/resolution-timestamps.js +286 -0
  120. package/dist/write/resolution-timestamps.js.map +1 -0
  121. package/dist/write/reversibility.js +5 -5
  122. package/dist/write/reversibility.js.map +1 -1
  123. package/dist/write/scope-guard.js +0 -5
  124. package/dist/write/scope-guard.js.map +1 -1
  125. package/dist/write/undo.d.ts +0 -6
  126. package/dist/write/undo.js +44 -4
  127. package/dist/write/undo.js.map +1 -1
  128. package/dist/write/vectors/applescript.js +14 -4
  129. package/dist/write/vectors/applescript.js.map +1 -1
  130. package/dist/write/vectors/url-scheme.js +0 -8
  131. package/dist/write/vectors/url-scheme.js.map +1 -1
  132. package/dist/write/verify/delta.d.ts +16 -2
  133. package/dist/write/verify/delta.js +21 -2
  134. package/dist/write/verify/delta.js.map +1 -1
  135. package/package.json +1 -1
  136. package/schema/envelope.schema.json +16 -78
  137. package/skills/things-cli/SKILL.md +10 -6
  138. package/skills/things-cli/references/contracts.md +3 -2
  139. package/skills/things-cli/references/errors.md +9 -1
  140. package/skills/things-cli/references/model.md +9 -7
  141. package/skills/things-cli/references/ordering.md +36 -10
@@ -2,19 +2,21 @@ import { addDaysIso, decodePackedDate, encodePackedDate, localToday } from "../m
2
2
  import { ReferenceResolutionError, resolveTaskUuidPrefix } from "../read/queries.js";
3
3
  import { taskMembershipClause } from "../read/scope.js";
4
4
  import { isLooseRef, LOOSE_TO_AREA_REFUSAL } from "../read/pseudo-area.js";
5
- import { computeReorderPre, resolveArea, resolveHeading, resolveProject } from "./pre-state.js";
5
+ import { computeHeadingMovePre, computeReorderPre, resolveArea, resolveHeading, resolveProject, } from "./pre-state.js";
6
6
  import {} from "./pipeline.js";
7
7
  import { runMutation } from "./pipeline.js";
8
8
  import { runReorder } from "./reorder.js";
9
9
  function loadRow(db, uuid) {
10
10
  const row = db
11
- .prepare("SELECT uuid, title, type, project, area, heading, start, startDate, startBucket, " +
12
- "rt1_recurrenceRule AS rule, repeater FROM TMTask WHERE uuid = ?")
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 = ?")
13
14
  .get(uuid);
14
15
  if (row === undefined)
15
16
  return undefined;
16
- const { rule, repeater, ...rest } = row;
17
- return { ...rest, isTemplate: rule !== null || repeater !== null };
17
+ const { rule, repeater, projectionDay, ...rest } = row;
18
+ const isTemplate = rule !== null || repeater !== null;
19
+ return { ...rest, isTemplate, templateProjectionDay: isTemplate ? projectionDay : null };
18
20
  }
19
21
  const KIND_LABEL = { 0: "to-do", 1: "project", 2: "heading" };
20
22
  /**
@@ -58,6 +60,33 @@ function scheduleBucket(row, packedToday) {
58
60
  return "someday";
59
61
  return "anytime";
60
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
+ }
61
90
  /** An app-default target for a bounce-dependent placement while bounce is off. */
62
91
  function bounceDisabledTarget(what) {
63
92
  return {
@@ -90,7 +119,24 @@ function bounceDisabledTarget(what) {
90
119
  */
91
120
  function reorderTargetOf(row, isTodo, packedToday, bounceEnabled) {
92
121
  if (row.isTemplate) {
93
- return { scope: null, reason: "a repeating template (unreorderable oddity §9e)" };
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 };
94
140
  }
95
141
  const bucket = scheduleBucket(row, packedToday);
96
142
  // A same-day (today-proper) or future scheduled day, startBucket=0 — the
@@ -114,6 +160,17 @@ function reorderTargetOf(row, isTodo, packedToday, bounceEnabled) {
114
160
  // so they take the dated bounce even on tomorrow.
115
161
  const isTomorrow = row.startBucket === 0 && row.startDate === packedTomorrowOf(packedToday);
116
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 };
117
174
  // ARRIVED Today-view members route DATE-FIRST, before any container branch.
118
175
  // A dated row whose day has landed (startDate <= today) is a Today member —
119
176
  // the GUI renders arrived/today-dated rows in the TODAY view; the Upcoming
@@ -262,6 +319,20 @@ function indexAxisTargetOf(row, bounceEnabled) {
262
319
  // shared loose index bucket and spuriously "dual-axis ambiguous".
263
320
  return reorderTargetOf(asAnytime, row.type === 0, 0, bounceEnabled);
264
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
+ }
265
336
  /**
266
337
  * True when a row's INDEX-axis target preserves the Today/Evening flag. TWO
267
338
  * flag-safe families:
@@ -311,11 +382,38 @@ function describeSetLocation(rows, packedToday) {
311
382
  const buckets = [...new Set(rows.map((r) => scheduleBucket(r, packedToday)))];
312
383
  return `in the ${buckets.join(" / ")} bucket`;
313
384
  }
314
- /** Parse a raw `--in <target>` into a list axis or a resolved container ref. */
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. */
315
400
  function parseInTarget(deps, raw, rows) {
316
401
  const norm = raw.trim().toLowerCase();
317
402
  if (IN_AXES.includes(norm))
318
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
+ }
319
417
  if (norm === "loose") {
320
418
  return {
321
419
  error: '`--in loose` is not valid — "loose" is a read view, not a reorder bucket; ' +
@@ -345,7 +443,7 @@ function parseInTarget(deps, raw, rows) {
345
443
  }
346
444
  return {
347
445
  error: `--in "${raw}" did not resolve to a project, area, or heading ` +
348
- "(or one of today | evening | anytime | someday | inbox)",
446
+ "(or one of today | evening | anytime | someday | inbox | upcoming | a YYYY-MM-DD day)",
349
447
  };
350
448
  }
351
449
  /**
@@ -375,20 +473,76 @@ function resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, positio
375
473
  // single-bucket guard; view=null means "not a clean view set".)
376
474
  const views = new Set(coherence.map((r) => viewOf(r, packedToday)));
377
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;
378
486
  // The shared index-axis container target, if the whole coherence set has one
379
487
  // (classifying each row as though it sat in its container's anytime bucket).
380
488
  const indexTargets = coherence.map((r) => indexAxisTargetOf(r, bounceEnabled));
381
489
  const indexKeys = new Set(indexTargets.map(containerKey));
382
- const indexTarget = indexKeys.size === 1 && indexTargets[0]?.scope != null
490
+ const indexTarget = sameKind && indexKeys.size === 1 && indexTargets[0]?.scope != null
383
491
  ? indexTargets[0]
384
492
  : null;
385
- // Force the CONTAINER index axis for view members, leaving non-view members on
386
- // their natural target (used when --in names a container).
387
- const indexClassifier = (r) => viewOf(r, packedToday) !== null ? indexAxisTargetOf(r, bounceEnabled) : base(r);
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
+ };
388
506
  if (inTarget !== undefined) {
389
507
  const parsed = parseInTarget(deps, inTarget, rows);
390
508
  if ("error" in parsed)
391
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
+ }
392
546
  if ("axis" in parsed) {
393
547
  const axis = parsed.axis;
394
548
  if (axis === "today" || axis === "evening") {
@@ -421,19 +575,34 @@ function resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, positio
421
575
  : ""), `reorder them in the view with --in ${view}, or reschedule them off Today first`),
422
576
  };
423
577
  }
424
- const wrong = rows.filter((r) => scheduleBucket(r, packedToday) !== axis ||
425
- r.project !== null ||
426
- r.area !== null ||
427
- r.heading !== null);
428
- if (wrong.length > 0) {
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) {
429
584
  return {
430
- refused: refused(op, "usage", `--in ${axis} but the items are ${describeSetLocation(rows, packedToday)}` +
431
- (rows.some((r) => r.project !== null || r.area !== null || r.heading !== null)
432
- ? " (and some are in a container, not loose)"
433
- : ""), "omit --in and reorder the items where they are, or move them first"),
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"),
434
588
  };
435
589
  }
436
- return { targetOf: base };
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 };
437
606
  }
438
607
  // A container ref (project/area/heading).
439
608
  const container = parsed.container;
@@ -472,6 +641,32 @@ function resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, positio
472
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`),
473
642
  };
474
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
+ }
475
670
  // A MIXED dual-axis set (some Today/Evening-flagged, some plain, all sharing one
476
671
  // flag-safe container index) is NOT ambiguous: the plain members are not view
477
672
  // members, so there is no shared view to reorder them in — the container index is
@@ -529,6 +724,37 @@ function refused(op, refusal, detail, remediation) {
529
724
  ...(remediation !== undefined && { remediation }),
530
725
  };
531
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
+ }
532
758
  // The ratified teaching errors (spec §4 bare-invocation block, §5, §7).
533
759
  const BARE_TODO_MOVE = "`todo move` needs a destination or a position. To change what a to-do belongs to, name " +
534
760
  "one of --to-project / --to-heading / --to-area (or --no-heading / --loose to detach). To " +
@@ -905,6 +1131,36 @@ export async function runProjectMove(deps, request, options = {}) {
905
1131
  * a contiguous block at the EARLIEST movee's current slot, in argument order
906
1132
  * (spec §4 — `--first` is NOT implied). Cross-container operands fail closed.
907
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
+ }
908
1164
  export async function runInPlaceReorder(deps, op, request, options = {}) {
909
1165
  const now = deps.now?.() ?? new Date();
910
1166
  const packedToday = encodePackedDate(localToday(now));
@@ -933,61 +1189,446 @@ export async function runInPlaceReorder(deps, op, request, options = {}) {
933
1189
  wrongKind.push({ ref: r.uuid, kind: KIND_LABEL[row.type] ?? "item" });
934
1190
  rows.push(row);
935
1191
  }
936
- // The GLOBAL todayIndex buckets accept PROJECT rows INTERMIXED with to-dos in one
937
- // reorder: `today` (O12 — `list "Today"` takes projects inline), `evening` (SIT4
938
- // EVEORD — projects share the evening axis, per-type bounce legs), and the future
939
- // day-group scopes (`day`/`tomorrow` — SIT4 DAYBNC + TOMORROWLIST accept area-
940
- // less project rows). So on the `todo reorder` path a project movee is legal WHEN
941
- // every movee sits in a today/evening/scheduled bucket; the mixed set routes to
942
- // one of those scopes (whose wire list is type IN (0,1)). Every OTHER bucket
943
- // keeps the homogeneous-kinds refusal, and an ineligible member still fails the
944
- // downstream scope guard, so the relaxation never reaches an unvalidated scope.
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);
945
1217
  const globalAxisIntermix = isTodo &&
946
1218
  rows.length > 0 &&
1219
+ !inForcesIndex &&
947
1220
  rows.every((r) => {
948
1221
  const b = scheduleBucket(r, packedToday);
949
- return b === "today" || b === "evening" || b.startsWith("scheduled:");
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));
950
1236
  });
951
1237
  if (wrongKind.length > 0 && !globalAxisIntermix) {
952
- const list = wrongKind.map((w) => `${w.ref} (${w.kind})`).join(", ");
953
- const allProjects = isTodo && rows.length > 0 && rows.every((r) => r.type === 1);
954
- return refused(op, "usage", `homogeneous kinds required — \`reorder\` rearranges to-dos ` +
955
- "(the Today, This Evening, and future day-group lists also accept project rows " +
956
- `intermixed with to-dos); not: ${list}`, allProjects
957
- ? "to rearrange projects in their sidebar/area/someday order use `things project move " +
958
- "<refs…> --first/--last/--before/--after`"
959
- : undefined);
1238
+ return indexKindRefusal(op, rows);
960
1239
  }
961
1240
  return repositionInPlace(deps, op, rows, request.position, packedToday, options, "reorder", request.in);
962
1241
  }
1242
+ /**
1243
+ * The ONE reorder verb (`things reorder <refs…>`) — kind-aware dispatch across
1244
+ * to-dos, projects, headings, and sidebar areas (spec §7). Every operand is
1245
+ * resolved (task first, then area) and classified; a homogeneous set routes to
1246
+ * its engine, and any illegal MIX gets one precise refusal:
1247
+ * - to-dos and/or projects → the in-place index/day/view engine (a to-do+project
1248
+ * set is legal only on the shared Today/Evening/day axes — that engine enforces
1249
+ * it); a pure-project set re-ranks its siblings;
1250
+ * - headings (one project) → the certified heading-block wire (#V11);
1251
+ * - areas → the sidebar-rank drag engine.
1252
+ * A heading or area may not share a call with any other kind.
1253
+ */
1254
+ export async function runUniversalReorder(deps, request, options = {}) {
1255
+ if (request.uuids.length === 0) {
1256
+ return refused("todo.move", "usage", "no items given — name at least one to reorder");
1257
+ }
1258
+ const classified = [];
1259
+ for (const ref of request.uuids) {
1260
+ const t = resolveMovee(deps, ref);
1261
+ if (!(t instanceof ReferenceResolutionError)) {
1262
+ const row = loadRow(deps.db, t.uuid);
1263
+ if (row !== undefined) {
1264
+ const kind = row.type === 1 ? "project" : row.type === 2 ? "heading" : "todo";
1265
+ classified.push({ kind, uuid: t.uuid, ref, row });
1266
+ continue;
1267
+ }
1268
+ }
1269
+ // Not a task ref — try a sidebar area (areas live in TMArea, not TMTask).
1270
+ const a = resolveArea(deps.db, { uuid: ref, title: ref });
1271
+ if (a.resolved !== null) {
1272
+ classified.push({ kind: "area", uuid: a.resolved.uuid, ref, title: a.resolved.title });
1273
+ continue;
1274
+ }
1275
+ // Neither a task nor an area — surface an AMBIGUOUS task match (its candidates
1276
+ // are the useful signal); a clean not-found names all four kinds.
1277
+ if (t instanceof ReferenceResolutionError &&
1278
+ t.candidates !== undefined &&
1279
+ t.candidates.length > 0) {
1280
+ return {
1281
+ kind: "move-refused",
1282
+ op: "todo.move",
1283
+ refusal: "usage",
1284
+ detail: t.message,
1285
+ candidates: t.candidates,
1286
+ };
1287
+ }
1288
+ return refused("todo.move", "usage", `no to-do, project, heading, or area matches "${ref}"`);
1289
+ }
1290
+ const kinds = new Set(classified.map((c) => c.kind));
1291
+ const hasHeading = kinds.has("heading");
1292
+ const hasArea = kinds.has("area");
1293
+ // Mixed-kind refusal (item 2): only a to-do+project set may share a call (and
1294
+ // only on a global axis, enforced downstream). A heading or an area never mixes.
1295
+ if ((hasHeading || hasArea) && kinds.size > 1) {
1296
+ const listed = classified.map((c) => `${c.ref} (${c.kind})`).join(", ");
1297
+ return refused("todo.move", "usage", "one kind at a time — `reorder` rearranges a set of to-dos, a set of projects, a set of " +
1298
+ `headings, OR a set of areas, but this set mixes kinds: ${listed}. Only to-dos and ` +
1299
+ "projects intermix, and only on the shared Today/Evening/day axes.", "reorder each kind in its own call");
1300
+ }
1301
+ if (hasArea) {
1302
+ if (request.in !== undefined) {
1303
+ return refused("area.reorder", "usage", "`--in` names a to-do/project reorder axis — areas have a single sidebar-rank order, so " +
1304
+ "it does not apply here", "drop --in; position the area(s) with --start / --end / --before / --after");
1305
+ }
1306
+ return runAreaReorderUniversal(deps, classified.filter((c) => c.kind === "area"), request.position, options);
1307
+ }
1308
+ if (hasHeading) {
1309
+ if (request.in !== undefined) {
1310
+ return refused("project.move-heading", "usage", "`--in` names a to-do/project reorder axis — a heading's order is the project's heading " +
1311
+ "axis, so it does not apply here", "drop --in; position the heading(s) with --start / --end / --before / --after");
1312
+ }
1313
+ return runHeadingReorder(deps, classified.filter((c) => c.kind === "heading"), request.position, options);
1314
+ }
1315
+ // to-dos and/or projects: the existing in-place engine. A pure-project set routes
1316
+ // through project.move; any to-do present routes through todo.move (which handles
1317
+ // the to-do+project global-axis intermix and refuses a non-global mixed set).
1318
+ const op = kinds.has("todo") ? "todo.move" : "project.move";
1319
+ return runInPlaceReorder(deps, op, request, options);
1320
+ }
1321
+ /** The project's heading uuids in current display (index) order. */
1322
+ function currentHeadingOrder(deps, projectUuid) {
1323
+ return deps.db
1324
+ .prepare(`SELECT uuid FROM TMTask WHERE type = 2 AND trashed = 0 AND project = ? ORDER BY "index"`)
1325
+ .all(projectUuid).map((r) => r.uuid);
1326
+ }
1327
+ /**
1328
+ * Same-project heading re-ranking via the universal verb — dispatches onto the
1329
+ * certified heading-block wire (`project.move-heading`), honoring #V11 (archived
1330
+ * headings reorderable unguarded, with reopens disclosed). `project move-heading`
1331
+ * remains the placement verb (cross-project / demotion); THIS is pure re-rank.
1332
+ */
1333
+ async function runHeadingReorder(deps, headings, position, options) {
1334
+ const op = "project.move-heading";
1335
+ // One shared project (cross-container refusal, item 2).
1336
+ const projectUuids = new Set(headings.map((h) => h.row.project));
1337
+ if (projectUuids.size !== 1 || projectUuids.has(null)) {
1338
+ const where = headings
1339
+ .map((h) => `${h.uuid} in ${h.row.project === null ? "no project" : `project ${h.row.project}`}`)
1340
+ .join("; ");
1341
+ return refused(op, "blocked", `these headings span projects (${where}) — a heading reorder rearranges the headings of ONE project`, "reorder the headings of one project at a time");
1342
+ }
1343
+ const projectUuid = [...projectUuids][0];
1344
+ const movees = headings.map((h) => h.uuid);
1345
+ // Map the anchor grammar onto a HeadingPlacement. --start/--end → first/last;
1346
+ // --before/--after resolve their ref to a heading of the SAME project (an anchor
1347
+ // positions, never migrates); bare → the block at the earliest movee's slot.
1348
+ let placement;
1349
+ if (position !== undefined && ("before" in position || "after" in position)) {
1350
+ const anchorRef = "before" in position ? position.before : position.after;
1351
+ const ar = resolveMovee(deps, anchorRef);
1352
+ if (ar instanceof ReferenceResolutionError) {
1353
+ return {
1354
+ kind: "move-refused",
1355
+ op,
1356
+ refusal: "usage",
1357
+ detail: ar.message,
1358
+ ...(ar.candidates !== undefined && { candidates: ar.candidates }),
1359
+ };
1360
+ }
1361
+ const arow = loadRow(deps.db, ar.uuid);
1362
+ if (arow === undefined || arow.type !== 2 || arow.project !== projectUuid) {
1363
+ return refused(op, "blocked", `the anchor ${ar.uuid} is not a heading of the movees' project — an anchor positions, it never migrates`, "pick a heading anchor in that project, or use --start / --end");
1364
+ }
1365
+ placement = "before" in position ? { before: ar.uuid } : { after: ar.uuid };
1366
+ }
1367
+ else if (position !== undefined && "at" in position) {
1368
+ placement = { position: position.at };
1369
+ }
1370
+ else {
1371
+ const current = currentHeadingOrder(deps, projectUuid);
1372
+ const moveeSet = new Set(movees);
1373
+ const earliest = current.findIndex((u) => moveeSet.has(u));
1374
+ const before = current.slice(0, Math.max(earliest, 0)).filter((u) => !moveeSet.has(u));
1375
+ placement =
1376
+ before.length > 0 ? { after: before[before.length - 1] } : { position: "first" };
1377
+ }
1378
+ // Compute the wire/reopened disclosure up front (both the dry-run plan and the ok
1379
+ // note surface it) — computeHeadingMovePre also validates membership/anchor.
1380
+ const pre = computeHeadingMovePre(deps.db, resolveProject(deps.db, { uuid: projectUuid }), movees, placement);
1381
+ if (pre.problems.length > 0) {
1382
+ return refused(op, "blocked", `heading reorder rejected: ${pre.problems.join("; ")}`, "reorder the headings of one project (read the project first)");
1383
+ }
1384
+ const touched = pre.wire.filter((u) => !movees.includes(u) && !pre.reopened.includes(u));
1385
+ const reopenNote = pre.reopened.length > 0
1386
+ ? `; re-ranking archived heading(s) ${pre.reopened.join(", ")} brought them back to open ` +
1387
+ "(their children stay resolved)"
1388
+ : "";
1389
+ const result = await runMutation(deps, op, { project: { uuid: projectUuid }, headings: movees, placement }, { ...legOptions(options), ...(options.dryRun === true && { dryRun: true }) });
1390
+ if (options.dryRun === true) {
1391
+ return {
1392
+ kind: "move-dry-run",
1393
+ op,
1394
+ plan: {
1395
+ movees,
1396
+ membership: "none (heading re-rank)",
1397
+ placement: `move-heading → ${describePosition(position)}`,
1398
+ placementClass: "guaranteed",
1399
+ note: dryRunNote(result, "heading re-rank via the native heading-block wire") + reopenNote,
1400
+ },
1401
+ };
1402
+ }
1403
+ if (result.kind === "blocked") {
1404
+ return {
1405
+ kind: "move-refused",
1406
+ op,
1407
+ refusal: "blocked",
1408
+ detail: result.detail,
1409
+ ...(result.remediation !== undefined && { remediation: result.remediation }),
1410
+ ...(result.hazard !== undefined && { hazard: result.hazard }),
1411
+ };
1412
+ }
1413
+ if (result.kind !== "ok") {
1414
+ return {
1415
+ kind: "move-leg-failed",
1416
+ op,
1417
+ detail: `the heading re-rank did not complete (${result.kind})`,
1418
+ failed: result,
1419
+ completed: [],
1420
+ };
1421
+ }
1422
+ return {
1423
+ kind: "move-ok",
1424
+ op,
1425
+ movees: headings.map((h) => ({ uuid: h.uuid, title: h.row.title })),
1426
+ membership: [],
1427
+ placement: result,
1428
+ placementClass: "guaranteed",
1429
+ note: `reordered ${movees.length} heading(s) within project ${projectUuid}` +
1430
+ reopenNote +
1431
+ (touched.length > 0
1432
+ ? `; also re-inserted ${touched.length} unnamed heading(s) to honor the order: ${touched.join(", ")}`
1433
+ : ""),
1434
+ };
1435
+ }
1436
+ /** Build the ordered single-area drag legs that realize a multi-area placement. */
1437
+ function planAreaLegs(areaUuids, position, anchorUuid) {
1438
+ if ("at" in position) {
1439
+ // first: drag last→first so the block ends up top-in-order; last: forward.
1440
+ return position.at === "first"
1441
+ ? areaUuids.toReversed().map((target) => ({ target, position: "first" }))
1442
+ : areaUuids.map((target) => ({ target, position: "last" }));
1443
+ }
1444
+ if ("before" in position) {
1445
+ // Each area dragged before the anchor, forward, lands them in order above it.
1446
+ return areaUuids.map((target) => ({ target, before: anchorUuid }));
1447
+ }
1448
+ // after: chain each area after the previous (anchor → a → b → c).
1449
+ const legs = [];
1450
+ let prev = anchorUuid;
1451
+ for (const target of areaUuids) {
1452
+ legs.push({ target, after: prev });
1453
+ prev = target;
1454
+ }
1455
+ return legs;
1456
+ }
1457
+ /**
1458
+ * Sidebar-area re-ranking via the universal verb — dispatches onto `area.reorder`
1459
+ * (the GUI sidebar-drag driver, the ONLY area-order surface, P6/O13). A single
1460
+ * area is one drag; a set composes sequential drags (non-atomic, disclosed). The
1461
+ * discriminating `things area reorder` alias reaches the same path.
1462
+ */
1463
+ async function runAreaReorderUniversal(deps, areas, position, options) {
1464
+ const op = "area.reorder";
1465
+ if (position === undefined) {
1466
+ return refused(op, "usage", "an area reorder needs a position — areas have no per-item bucket to assemble a block in", "use --start / --end, or --before / --after another area");
1467
+ }
1468
+ const movees = areas.map((a) => a.uuid);
1469
+ let anchorUuid = null;
1470
+ if ("before" in position || "after" in position) {
1471
+ const anchorRef = "before" in position ? position.before : position.after;
1472
+ const ar = resolveArea(deps.db, { uuid: anchorRef, title: anchorRef });
1473
+ if (ar.resolved === null) {
1474
+ return refused(op, "usage", `the anchor "${anchorRef}" did not resolve to an area`, "name an area (uuid or unique title) to anchor against");
1475
+ }
1476
+ anchorUuid = ar.resolved.uuid;
1477
+ if (movees.includes(anchorUuid)) {
1478
+ return refused(op, "blocked", "the anchor area cannot also be one of the moved areas — an anchor positions, it never migrates", "pick a different anchor area, or use --start / --end");
1479
+ }
1480
+ }
1481
+ const legs = planAreaLegs(movees, position, anchorUuid);
1482
+ const areaTitles = areas.map((a) => ({ uuid: a.uuid, title: a.title }));
1483
+ if (options.dryRun === true) {
1484
+ return {
1485
+ kind: "move-dry-run",
1486
+ op,
1487
+ plan: {
1488
+ movees,
1489
+ membership: "none (sidebar drag)",
1490
+ placement: `area re-rank → ${describePosition(position)}`,
1491
+ placementClass: "guaranteed",
1492
+ note: `${legs.length} sidebar-drag leg(s) — drives the local Things app, one drag per area`,
1493
+ },
1494
+ };
1495
+ }
1496
+ // Each leg is a full GUI-drive mutation; forward the drive acknowledgement.
1497
+ const legOpts = {
1498
+ ...legOptions(options),
1499
+ ...(options.dangerouslyDriveGui !== undefined && {
1500
+ dangerouslyDriveGui: options.dangerouslyDriveGui,
1501
+ }),
1502
+ };
1503
+ const placed = [];
1504
+ let last = null;
1505
+ for (const leg of legs) {
1506
+ const res = await runMutation(deps, op, leg, legOpts);
1507
+ last = res;
1508
+ if (res.kind === "blocked") {
1509
+ // A blocked FIRST leg (nothing moved yet) hoists to a clean refusal; a
1510
+ // blocked later leg is a partial-progress leg failure.
1511
+ if (placed.length === 0) {
1512
+ return {
1513
+ kind: "move-refused",
1514
+ op,
1515
+ refusal: "blocked",
1516
+ detail: res.detail,
1517
+ ...(res.remediation !== undefined && { remediation: res.remediation }),
1518
+ ...(res.hazard !== undefined && { hazard: res.hazard }),
1519
+ };
1520
+ }
1521
+ return {
1522
+ kind: "move-leg-failed",
1523
+ op,
1524
+ detail: `positioning ${leg.target} was blocked (${res.detail}) — ${placed.length} area(s) already moved`,
1525
+ failed: res,
1526
+ completed: [],
1527
+ };
1528
+ }
1529
+ if (res.kind !== "ok") {
1530
+ return {
1531
+ kind: "move-leg-failed",
1532
+ op,
1533
+ detail: `positioning ${leg.target} did not complete (${res.kind}) — ${placed.length} area(s) already moved`,
1534
+ failed: res,
1535
+ completed: [],
1536
+ };
1537
+ }
1538
+ placed.push(leg.target);
1539
+ }
1540
+ return {
1541
+ kind: "move-ok",
1542
+ op,
1543
+ movees: areaTitles,
1544
+ membership: [],
1545
+ placement: last,
1546
+ placementClass: "guaranteed",
1547
+ note: `re-ranked ${areas.length} area(s) via ${legs.length} sidebar drag(s)` +
1548
+ (legs.length > 1 ? " (sequential — non-atomic)" : ""),
1549
+ };
1550
+ }
963
1551
  // ---------------------------------------------------------------- shared core
964
1552
  /**
965
- * The single STRICTLY-FUTURE day every movee shares (packed startDate), or null
966
- * the precondition for the SIT4 dated `day` bounce (and the one-call `tomorrow`
967
- * sort). Members are to-dos in ANY container AND scheduled PROJECT rows (area-less
968
- * OR area-directSIT5 AREAPROJDAY proved the update-project when= legs preserve
969
- * the area FK), all on the SAME future day in the Today-bucket axis (startBucket=0).
970
- * A row off the day, a template, or an undated/arrived row breaks the group and
971
- * falls through to the normal single-container guard. (Templates never reach here:
972
- * startDate is NULL on a resting template, so `startDate === day` fails for them.)
1553
+ * The packed day a single row contributes to a shared future day-group, or null.
1554
+ * A SCHEDULED row (to-do or project) on a strictly-future startBucket=0 day
1555
+ * contributes its `startDate`; a DEADLINE-FORECAST to-do (startDate NULL, future
1556
+ * `deadline`, start IN (1,2) DLBNC/§9o) contributes its `deadline` (they share the
1557
+ * one block todayIndex axis). Any other row (arrived, undated non-forecast, off the
1558
+ * Today axis) contributes null.
1559
+ */
1560
+ function rowDayKey(row, packedToday) {
1561
+ // A repeating TEMPLATE contributes its PROJECTION day (rt1_nextInstanceStartDate),
1562
+ // the Upcoming day-block its rendered projection row sits in (TMPLSORT/PTMPL). Its
1563
+ // startDate/deadline are NULL (or non-block), so it would otherwise contribute null;
1564
+ // strictly-future gate mirrors the scheduled/forecast branches (an arrived/absent
1565
+ // projection is not a future day-group member).
1566
+ if (row.isTemplate) {
1567
+ return row.templateProjectionDay !== null && row.templateProjectionDay > packedToday
1568
+ ? row.templateProjectionDay
1569
+ : null;
1570
+ }
1571
+ if (row.startDate !== null &&
1572
+ row.startBucket === 0 &&
1573
+ row.startDate > packedToday &&
1574
+ (row.type === 0 || row.type === 1)) {
1575
+ return row.startDate;
1576
+ }
1577
+ return forecastDeadlineDay(row, packedToday);
1578
+ }
1579
+ /**
1580
+ * The single STRICTLY-FUTURE day every movee shares, or null — the precondition for
1581
+ * the SIT4 dated `day` bounce (and the one-call `tomorrow` sort). Members are
1582
+ * SCHEDULED to-dos in ANY container AND scheduled PROJECT rows (area-less OR area-
1583
+ * direct — SIT5 AREAPROJDAY proved the update-project when= legs preserve the area
1584
+ * FK) sharing a future startBucket=0 `startDate`, PLUS DEADLINE-FORECAST to-dos
1585
+ * (DLBNC/§9o) whose future `deadline` equals that same day — all on the ONE Upcoming
1586
+ * day-block todayIndex axis, so a scheduled+forecast mix is one group. A row off the
1587
+ * day, a template, or an undated/arrived non-forecast row breaks the group and falls
1588
+ * through to the normal single-container guard. (Templates never reach here: both
1589
+ * `startDate` and `deadline` fail the strictly-future key for a resting template.)
973
1590
  */
974
1591
  function sharedFutureDay(rows, packedToday) {
975
1592
  const first = rows[0];
976
1593
  if (first === undefined)
977
1594
  return null;
978
- const day = first.startDate;
979
- if (day === null || day <= packedToday || first.startBucket !== 0)
1595
+ const day = rowDayKey(first, packedToday);
1596
+ if (day === null)
980
1597
  return null;
981
1598
  for (const r of rows) {
982
- if (r.startBucket !== 0 || r.startDate !== day)
983
- return null;
984
- if (r.type !== 0 && r.type !== 1)
985
- return null;
986
- if (r.isTemplate)
1599
+ // Templates NO LONGER break the group: a strictly-future template projection is
1600
+ // a first-class day-block member (TMPLSORT-3c-Tomorrow / PTMPL-B5), reached on the
1601
+ // `tomorrow` native wire or the `day` per-class leg family (reorder.ts). rowDayKey
1602
+ // returns its projection day, so an on-day template shares the group.
1603
+ if (rowDayKey(r, packedToday) !== day)
987
1604
  return null;
988
1605
  }
989
1606
  return day;
990
1607
  }
1608
+ /** Whether any row in the group is a deadline-forecast member (DLBNC/§9o). */
1609
+ function hasForecastMember(rows, packedToday) {
1610
+ return rows.some((r) => forecastDeadlineDay(r, packedToday) !== null);
1611
+ }
1612
+ /**
1613
+ * The single shared FORECAST deadline day when EVERY row is a deadline-forecast
1614
+ * member (§9o) of that one day, else null. Stricter than {@link sharedFutureDay}:
1615
+ * a scheduled row (or a forecast row on a different day) breaks it — so a
1616
+ * scheduled-only or scheduled+forecast-mixed set returns null (single-axis, day-
1617
+ * only). Gates the forecast dual-axis refusal: only an all-forecast, one-day set
1618
+ * has a coherent container-index alternative to the day-block.
1619
+ */
1620
+ function sharedForecastDay(rows, packedToday) {
1621
+ const first = rows[0];
1622
+ if (first === undefined)
1623
+ return null;
1624
+ const day = forecastDeadlineDay(first, packedToday);
1625
+ if (day === null)
1626
+ return null;
1627
+ for (const r of rows)
1628
+ if (forecastDeadlineDay(r, packedToday) !== day)
1629
+ return null;
1630
+ return day;
1631
+ }
991
1632
  /**
992
1633
  * Reposition a shared FUTURE day-group on the global todayIndex axis — the SIT4
993
1634
  * dated `day` bounce (loose/direct-area/headed/cross-container to-dos + area-less
@@ -1040,13 +1681,7 @@ async function runDayGroupReposition(deps, op, rows, position, options, scope, d
1040
1681
  };
1041
1682
  }
1042
1683
  if (placement.kind !== "ok") {
1043
- return {
1044
- kind: "move-leg-failed",
1045
- op,
1046
- detail: `the reorder leg did not complete (${placement.kind})`,
1047
- failed: placement,
1048
- completed: [],
1049
- };
1684
+ return repositionFailed(op, placement);
1050
1685
  }
1051
1686
  return {
1052
1687
  kind: "move-ok",
@@ -1073,6 +1708,18 @@ async function repositionInPlace(deps, op, rows, position, packedToday, options,
1073
1708
  const axis = resolveReorderAxis(deps, op, rows, inTarget, packedToday, verb, position);
1074
1709
  if ("refused" in axis)
1075
1710
  return axis.refused;
1711
+ // An explicit day-axis token (`--in <YYYY-MM-DD>` / `--in upcoming`) routes straight
1712
+ // to the cross-container day-group reposition — membership + future-ness were
1713
+ // validated in resolveReorderAxis. Mirror the auto-route's scope pick: the native
1714
+ // one-call `tomorrow` sort only for a non-forecast group whose day is tomorrow (that
1715
+ // surface re-dates a forecast row), else the dated `day` bounce.
1716
+ if ("dayAxis" in axis) {
1717
+ const day = axis.dayAxis;
1718
+ const scope = !hasForecastMember(rows, packedToday) && day === packedTomorrowOf(packedToday)
1719
+ ? "tomorrow"
1720
+ : "day";
1721
+ return runDayGroupReposition(deps, op, rows, position, options, scope, day);
1722
+ }
1076
1723
  const targetOf = axis.targetOf;
1077
1724
  // On a forced/auto INDEX axis (a dual-axis set reordered within its container),
1078
1725
  // the display-bucket coherence guards below are the WRONG check: the members
@@ -1084,18 +1731,27 @@ async function repositionInPlace(deps, op, rows, position, packedToday, options,
1084
1731
  // A shared FUTURE day-group (single- OR cross-container) rides the global
1085
1732
  // todayIndex axis, not the normal single-container reorder: the SIT4 dated `day`
1086
1733
  // bounce (loose/direct-area/headed/cross-container to-dos + area-less project
1087
- // rows, any mix), or the one-call `list "Tomorrow"` sort when the day is
1088
- // tomorrow. The ONLY exception is a single UNHEADED PROJECT container its same-
1089
- // day children ride the cheaper atomic native container-day re-rank, so it falls
1090
- // through to the normal path below (reorderTargetOf → container-day). Templates
1091
- // and area-direct project rows are excluded by sharedFutureDay.
1734
+ // rows + DEADLINE-FORECAST to-dos, any mix), or the one-call `list "Tomorrow"`
1735
+ // sort when the day is tomorrow. Two exceptions force the `day` scope (never the
1736
+ // native short-cuts): a group CONTAINING a forecast row NEVER rides `list
1737
+ // "Tomorrow"` (that surface re-dates a forecast row, UPCDL-5) NOR the single-
1738
+ // project container-day native re-rank (a forecast row has no startDate, so it is
1739
+ // not a container-day scheduled child) — the deadline-cycle `day` bounce is the
1740
+ // one surface that serves the mixed group. Otherwise a single UNHEADED PROJECT
1741
+ // container's same-day scheduled children ride the cheaper atomic native
1742
+ // container-day re-rank (fall through to reorderTargetOf → container-day).
1743
+ // Templates are excluded by sharedFutureDay. The auto-route NEVER fires on a
1744
+ // forced/auto INDEX axis (indexAxis === true): an explicit `--in someday` /
1745
+ // `--in anytime` / `--in <container>` on a same-day forecast set is the certified
1746
+ // container `index` re-rank, and must not be overridden by the day bounce.
1092
1747
  const structKeys = new Set(rows.map((r) => structuralKey(r, packedToday)));
1093
1748
  const sharedDay = sharedFutureDay(rows, packedToday);
1094
- if (sharedDay !== null) {
1749
+ if (sharedDay !== null && !indexAxis) {
1750
+ const forecastInGroup = hasForecastMember(rows, packedToday);
1095
1751
  const soleStruct = structKeys.size === 1 ? structKeys.values().next().value : null;
1096
- const singleProjectContainer = soleStruct?.startsWith("project:") ?? false;
1752
+ const singleProjectContainer = !forecastInGroup && (soleStruct?.startsWith("project:") ?? false);
1097
1753
  if (!singleProjectContainer) {
1098
- const scope = sharedDay === packedTomorrowOf(packedToday) ? "tomorrow" : "day";
1754
+ const scope = !forecastInGroup && sharedDay === packedTomorrowOf(packedToday) ? "tomorrow" : "day";
1099
1755
  return runDayGroupReposition(deps, op, rows, position, options, scope, sharedDay);
1100
1756
  }
1101
1757
  }
@@ -1204,13 +1860,7 @@ async function repositionInPlace(deps, op, rows, position, packedToday, options,
1204
1860
  };
1205
1861
  }
1206
1862
  if (placement.kind !== "ok") {
1207
- return {
1208
- kind: "move-leg-failed",
1209
- op,
1210
- detail: `the reorder leg did not complete (${placement.kind})`,
1211
- failed: placement,
1212
- completed: [],
1213
- };
1863
+ return repositionFailed(op, placement);
1214
1864
  }
1215
1865
  return {
1216
1866
  kind: "move-ok",
@@ -1484,7 +2134,9 @@ function bucketMembers(deps, target, dayAnchor) {
1484
2134
  ...(target.container !== undefined && { container: { uuid: target.container } }),
1485
2135
  };
1486
2136
  const containerUuid = target.container ?? null;
1487
- const pre = computeReorderPre(deps.db, params, containerUuid, deps.now?.() ?? new Date());
2137
+ const pre = computeReorderPre(deps.db, params, containerUuid, deps.now?.() ?? new Date(), {
2138
+ zone: deps.zone,
2139
+ });
1488
2140
  return pre.members.map((m) => m.uuid);
1489
2141
  }
1490
2142
  function legOptions(options) {