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
@@ -302,8 +302,19 @@ function bounceFallbackOk(deps, what, kind, protocol) {
302
302
  return d;
303
303
  return { ...d, fallbackNote: fallbackNoteFor(deps, protocol) };
304
304
  }
305
+ /**
306
+ * Whether the native `_private_experimental_ reorder` command is available — the
307
+ * config gate is on AND the app still declares it in its sdef (the canary). The
308
+ * `day`-scope template leg family gates on this too: a repeating TO-DO template's
309
+ * only safe day-block placement is the native single-id `list "Upcoming"` front-
310
+ * insert (a dated when= leg CRASHES a template, §1), so a day-group carrying any
311
+ * template requires the native surface (else it refuses honestly, naming them).
312
+ */
313
+ function nativeReorderAvailable(deps) {
314
+ return deps.config.allowExperimental && (deps.sdefProbe ?? sdefDeclaresPrivateReorder)();
315
+ }
305
316
  function resolveStrategy(deps, params) {
306
- const nativeAvailable = deps.config.allowExperimental && (deps.sdefProbe ?? sdefDeclaresPrivateReorder)();
317
+ const nativeAvailable = nativeReorderAvailable(deps);
307
318
  // Scopes whose ONLY surface is the bounce (no native command reaches them).
308
319
  const bounceOnly = {
309
320
  evening: { kind: "evening", what: "evening-section order" },
@@ -398,6 +409,11 @@ function resolveStrategy(deps, params) {
398
409
  // this case exists only for switch exhaustiveness.
399
410
  case "heading-someday":
400
411
  return { kind: "ok", strategy: "native" };
412
+ // `upcoming` is an INTERNAL per-template front-insert leg (the `day` bounce
413
+ // dispatches it via runMutation directly — TMPLSORT-1); it never reaches the
414
+ // strategy resolver. Blocked here for exhaustiveness / defence in depth.
415
+ case "upcoming":
416
+ return blocked("the `upcoming` scope is an internal per-template front-insert leg, not a user reorder scope", "reorder a template's day-block via `things todo reorder` on its day (the planner routes it)");
401
417
  }
402
418
  }
403
419
  // (The SOMEBNC-project someday-only bounce fallback for the `project` scope is
@@ -483,7 +499,7 @@ async function runHeadingSomeday(deps, params, options) {
483
499
  const headingUuid = params.container?.uuid ?? null;
484
500
  const projectUuid = headingProjectUuid(deps, headingUuid);
485
501
  const txnId = `txn-${startedAt.getTime().toString(36)}-${process.pid.toString(36)}`;
486
- const pre = computeReorderPre(deps.db, params, headingUuid, now());
502
+ const pre = computeReorderPre(deps.db, params, headingUuid, now(), { zone: deps.zone });
487
503
  const preRanks = {};
488
504
  for (const m of pre.members)
489
505
  preRanks[m.uuid] = m.rank;
@@ -620,19 +636,38 @@ async function runBounce(deps, params, bounceKind, options) {
620
636
  // The two `when=` leg values. A fixed-bucket bounce uses the spec's keywords; the
621
637
  // DATED `day` bounce (SIT4 DAYBNC) derives them from the movees' shared day —
622
638
  // back = the day D, away = the neighbour day D+1 (a strictly-future staging day
623
- // ≠ D that keeps the transient visit out of Today). The day is read off the first
624
- // requested uuid (the planner guarantees every movee shares it); an absent/
625
- // malformed day yields an empty member set and is rejected below before any
626
- // dispatch, so the today fallback here only keeps the WhenValue well-typed.
639
+ // ≠ D that keeps the transient visit out of Today). The day D is read off the
640
+ // first requested uuid its `startDate` when scheduled, else its `deadline` when
641
+ // it is a deadline-forecast row (startDate NULL, start IN (1,2) DLBNC/§9o). The
642
+ // planner guarantees every movee shares D; an absent/malformed day yields an empty
643
+ // member set and is rejected below before any dispatch, so the today fallback here
644
+ // only keeps the WhenValue well-typed. (The `when=` values drive the SCHEDULED
645
+ // rows' bounce legs; a forecast row ignores them — it rides the deadline-cycle.)
627
646
  let dayPacked = null;
628
647
  let awayValue;
629
648
  let backValue;
630
649
  if (spec.dated) {
631
650
  const firstUuid = params.uuids[0];
651
+ const firstRow = firstUuid !== undefined
652
+ ? deps.db
653
+ .prepare("SELECT startDate, startBucket, deadline, start, rt1_nextInstanceStartDate AS proj, " +
654
+ "(rt1_recurrenceRule IS NOT NULL OR repeater IS NOT NULL) AS isTemplate " +
655
+ "FROM TMTask WHERE uuid = ?")
656
+ .get(firstUuid)
657
+ : undefined;
658
+ // The day D: a template's PROJECTION day (rt1_nextInstanceStartDate), else a
659
+ // scheduled row's startDate, else a forecast row's deadline (mirrors
660
+ // computeReorderPre so the when= legs and the member set agree on D).
632
661
  dayPacked =
633
- firstUuid !== undefined
634
- ? (deps.db.prepare("SELECT startDate FROM TMTask WHERE uuid = ?").get(firstUuid)?.startDate ?? null)
635
- : null;
662
+ firstRow === undefined
663
+ ? null
664
+ : firstRow.isTemplate === 1
665
+ ? firstRow.proj
666
+ : firstRow.startDate !== null && firstRow.startBucket === 0
667
+ ? firstRow.startDate
668
+ : firstRow.startDate === null && (firstRow.start === 1 || firstRow.start === 2)
669
+ ? firstRow.deadline
670
+ : null;
636
671
  const iso = decodePackedDate(dayPacked);
637
672
  backValue = iso ?? localToday(now());
638
673
  awayValue = iso !== null ? addDaysIso(iso, 1) : localToday(now());
@@ -641,6 +676,21 @@ async function runBounce(deps, params, bounceKind, options) {
641
676
  awayValue = spec.away;
642
677
  backValue = spec.back;
643
678
  }
679
+ // A day-scope DEADLINE-FORECAST row (startDate NULL, deadline set) reorders by the
680
+ // DLBNC deadline-cycle (URL `deadline=` clear + re-set of the SAME deadline —
681
+ // byte-identical, front-inserts on the block's todayIndex axis) instead of the
682
+ // scheduled `when=` bounce. Returns the decoded ISO to re-set, or null for a
683
+ // scheduled row (which takes the `when=` legs). AppleScript `due date` is
684
+ // certified-wrong here (lazy todayIndex, cannot clear) — URL only (DLBNC §9q).
685
+ const forecastLegOf = (uuid) => {
686
+ if (bounceKind !== "day")
687
+ return null;
688
+ const r = deps.db.prepare("SELECT startDate, deadline FROM TMTask WHERE uuid = ?").get(uuid);
689
+ if (r === undefined || r.startDate !== null || r.deadline === null)
690
+ return null;
691
+ const iso = decodePackedDate(r.deadline);
692
+ return iso === null ? null : { iso };
693
+ };
644
694
  // Per-item leg op: the mixed-kind `day`/`evening` bounces pick update-project for
645
695
  // a project row (type=1) and todo.update for a to-do (type=0); every other bounce
646
696
  // uses one fixed op. (NEVER send a dated when= leg to a template — §1 crash — but
@@ -654,7 +704,7 @@ async function runBounce(deps, params, bounceKind, options) {
654
704
  };
655
705
  const txnId = `txn-${startedAt.getTime().toString(36)}-${process.pid.toString(36)}`;
656
706
  // Scope/membership guard — same data the native path's guard uses.
657
- const pre = computeReorderPre(deps.db, params, containerUuid, now());
707
+ const pre = computeReorderPre(deps.db, params, containerUuid, now(), { zone: deps.zone });
658
708
  // Pre-ranks make the SUMMARY record the undoable unit (a single inverse
659
709
  // reorder restores the old relative order); legs are excluded from undo.
660
710
  const preRanks = {};
@@ -764,14 +814,82 @@ async function runBounce(deps, params, bounceKind, options) {
764
814
  });
765
815
  return result;
766
816
  }
817
+ // TMPLSORT/PTMPL template leg families (the `day` scope only — the native
818
+ // `tomorrow` one-call wire carries templates as ordinary members). A repeating
819
+ // template's projection is a first-class day-block member, but a dated when=/
820
+ // deadline leg CRASHES it (§1), so it NEVER receives one. Split per class:
821
+ // - TO-DO template: a single-id `list "Upcoming"` NATIVE front-insert leg
822
+ // (TMPLSORT-1), interleaved into the reverse-target dispatch on the shared
823
+ // block min-space (TMPLSORT-2). Needs the native surface.
824
+ // - PROJECT template: byte-UNTOUCHED under the SUFFIX RULE — it has no headless
825
+ // reach on an arbitrary future day (PTMPL-B: only `list "Tomorrow"` / a GUI
826
+ // drag place it), so every movable front-inserts ABOVE it. Accept only a wire
827
+ // where the project templates form a suffix in their CURRENT relative order;
828
+ // refuse otherwise, naming the one achievable arrangement.
829
+ // The whole day dispatch is experimental-gated when ANY template is present.
830
+ const memberInfo = new Map(pre.members.map((m) => [m.uuid, m]));
831
+ const isTmpl = (u) => memberInfo.get(u)?.isTemplate === true;
832
+ const isProjectTemplate = (u) => isTmpl(u) && memberInfo.get(u)?.type === 1;
833
+ const templatesPresent = bounceKind === "day" && coBounce.some(isTmpl);
834
+ let dispatchRun = coBounce;
835
+ if (templatesPresent) {
836
+ const dayIso = decodePackedDate(dayPacked) ?? "the target day";
837
+ const templateRefusal = (detail, remediation) => {
838
+ const result = {
839
+ kind: "blocked",
840
+ op: "reorder",
841
+ reason: "hazard",
842
+ hazard: "H-REORDER-SCOPE",
843
+ detail,
844
+ remediation,
845
+ };
846
+ auditSummary(deps, params, startedAt, "blocked:H-REORDER-SCOPE", null, {
847
+ pre: preRanks,
848
+ txnId,
849
+ actor,
850
+ });
851
+ return result;
852
+ };
853
+ if (!nativeReorderAvailable(deps)) {
854
+ // C(iii): refuse honestly, NAMING the templates — never a dated leg (crash),
855
+ // never a silent skip, never a partial sort.
856
+ const tmpls = coBounce.filter(isTmpl);
857
+ return templateRefusal(`the ${dayIso} day-group contains repeating template(s) [${tmpls.join(", ")}] whose ` +
858
+ "day-block placement needs the native private reorder surface (a dated when= leg CRASHES " +
859
+ `a template — §1/§9e), but it is unavailable (${nativeUnavailableReason(deps)})`, "enable it with `things config set allow-experimental true` (and keep Things updated so the " +
860
+ "sdef canary passes), or reorder the day-group without the template(s)");
861
+ }
862
+ // PROJECT-template SUFFIX RULE.
863
+ const projectTemplates = coBounce.filter(isProjectTemplate);
864
+ if (projectTemplates.length > 0) {
865
+ const suffix = coBounce.slice(coBounce.length - projectTemplates.length);
866
+ const suffixContiguous = suffix.every(isProjectTemplate);
867
+ // Their achievable order is their CURRENT todayIndex (render) order — untouched.
868
+ const currentOrder = projectTemplates.toSorted((a, b) => (memberInfo.get(a)?.rank ?? 0) - (memberInfo.get(b)?.rank ?? 0));
869
+ const orderPreserved = suffixContiguous && suffix.every((u, i) => u === currentOrder[i]);
870
+ if (!orderPreserved) {
871
+ const movables = coBounce.filter((u) => !isProjectTemplate(u));
872
+ const achievable = [...movables, ...currentOrder];
873
+ return templateRefusal(`a repeating PROJECT template cannot be placed above a movable item on the ${dayIso} ` +
874
+ "day-block, and project templates cannot change their relative order there — an arbitrary " +
875
+ "future day gives a project template no headless reach (PTMPL-B: only the Tomorrow one-call " +
876
+ "sort, when the day is tomorrow, or a GUI drag place it). The only arrangement this " +
877
+ `day-group can reach headlessly is: ${achievable.join(", ")} (every movable above the ` +
878
+ "project template(s), which keep their current relative order)", "request that arrangement (place the project template(s) last, in their current order), or " +
879
+ "drag the project template in the app; if the day is tomorrow, reorder on Tomorrow instead");
880
+ }
881
+ }
882
+ // Movables + to-do templates dispatch; project templates are the untouched suffix.
883
+ dispatchRun = coBounce.filter((u) => !isProjectTemplate(u));
884
+ }
767
885
  // Front-insert contexts (loose/area-direct) place last-first (reverse iterate,
768
886
  // unshift); back-insert contexts (heading/project children) place first-first
769
887
  // (forward iterate, push). Either way `placed` holds the current top-to-bottom
770
888
  // order of the bounced block. The SAME per-item order drives the json-array
771
889
  // collapse (array order == result index order for both directions).
772
890
  const order = direction === "front"
773
- ? coBounce.map((_, i) => coBounce.length - 1 - i)
774
- : coBounce.map((_, i) => i);
891
+ ? dispatchRun.map((_, i) => dispatchRun.length - 1 - i)
892
+ : dispatchRun.map((_, i) => i);
775
893
  // BOUNCEJSON collapse (§9i): when the placement (`back`) leg is when=anytime
776
894
  // into a loose/heading-container bucket, the whole N-item round-trip collapses
777
895
  // to ONE pre-validated `things:///json` array (2N ops, 1 dispatch, ~7×,
@@ -783,18 +901,39 @@ async function runBounce(deps, params, bounceKind, options) {
783
901
  const dispatchVector = spec.jsonCollapsible && legOp !== "per-type" ? pickDispatchVector(deps, legOp) : undefined;
784
902
  const useJson = spec.jsonCollapsible && dispatchVector !== undefined && dispatchVector.simulates !== true;
785
903
  if (options.dryRun === true) {
904
+ // Name the per-row-class legs. A `day` set may mix SCHEDULED rows (when= bounce)
905
+ // and DEADLINE-FORECAST rows (the DLBNC deadline-cycle: deadline= clear + re-set),
906
+ // so the plan discloses the leg family per class + count; every other bounce is a
907
+ // single when= round-trip.
908
+ let legNaming = `when=${awayValue} → when=${backValue}`;
909
+ if (bounceKind === "day") {
910
+ const ttN = coBounce.filter((u) => isTmpl(u) && !isProjectTemplate(u)).length;
911
+ const ptN = coBounce.filter(isProjectTemplate).length;
912
+ const fN = coBounce.filter((u) => !isTmpl(u) && forecastLegOf(u) !== null).length;
913
+ const sN = coBounce.length - fN - ttN - ptN;
914
+ const bits = [];
915
+ if (sN > 0)
916
+ bits.push(`${sN} scheduled via when=${awayValue} → when=${backValue}`);
917
+ if (fN > 0)
918
+ bits.push(`${fN} deadline-forecast via deadline-cycle (deadline= clear + re-set)`);
919
+ if (ttN > 0)
920
+ bits.push(`${ttN} to-do template(s) via single-id \`list "Upcoming"\` front-insert (umd-silent)`);
921
+ if (ptN > 0)
922
+ bits.push(`${ptN} project template(s) left byte-untouched (suffix rule — no headless reach)`);
923
+ legNaming = bits.join("; ");
924
+ }
786
925
  const invocation = useJson
787
926
  ? `json-collapse ×${coBounce.length} (${direction}-insert, ` +
788
927
  `${direction === "front" ? "reverse" : "forward"} array order, ` +
789
928
  (touchedUnnamed.length > 0 ? `touches ${touchedUnnamed.length} unnamed sibling(s), ` : "") +
790
929
  `1 dispatch / ${coBounce.length * 2} ops): ` +
791
- `when=${awayValue} → when=${backValue} interleaved per item; validate-first full-abort, ` +
930
+ `${legNaming} interleaved per item; validate-first full-abort, ` +
792
931
  `one terminal order verify`
793
932
  : `bounce ×${coBounce.length} (${direction}-insert, ` +
794
933
  `${direction === "front" ? "reverse" : "forward"} order, ` +
795
934
  (touchedUnnamed.length > 0 ? `touches ${touchedUnnamed.length} unnamed sibling(s), ` : "") +
796
935
  `${coBounce.length * 2} legs): ` +
797
- `when=${awayValue} → when=${backValue}; one verify per item round-trip`;
936
+ `${legNaming}; one verify per item round-trip`;
798
937
  return {
799
938
  kind: "dry-run",
800
939
  op: "reorder",
@@ -826,8 +965,8 @@ async function runBounce(deps, params, bounceKind, options) {
826
965
  const placed = [];
827
966
  for (let step = 0; step < order.length; step++) {
828
967
  const i = order[step];
829
- const uuid = coBounce[i];
830
- const remainingBefore = () => direction === "front" ? coBounce.slice(0, i + 1) : coBounce.slice(i);
968
+ const uuid = dispatchRun[i];
969
+ const remainingBefore = () => direction === "front" ? dispatchRun.slice(0, i + 1) : dispatchRun.slice(i);
831
970
  // Concurrent-edit re-check: the item must still be an eligible member.
832
971
  const memberProblem = checkStillMember(deps, uuid, bounceKind, containerUuid, now(), dayPacked);
833
972
  if (memberProblem !== null) {
@@ -843,31 +982,91 @@ async function runBounce(deps, params, bounceKind, options) {
843
982
  cause: null,
844
983
  };
845
984
  }
985
+ // A repeating TO-DO template rides ONE native single-id `list "Upcoming"` front-
986
+ // insert leg (TMPLSORT-1) — NEVER a dated when=/deadline leg (§1 crash). It front-
987
+ // inserts on the SAME shared block min-space as the when=/deadline families, so the
988
+ // reverse-target dispatch interleaves it exactly (TMPLSORT-2). Project templates are
989
+ // excluded from `dispatchRun` (the untouched suffix), so only to-do templates reach
990
+ // this branch; the leg is umd-silent (§9r — disclosed in the result note).
991
+ if (isTmpl(uuid)) {
992
+ const tmplLeg = await runMutation(deps, "reorder", { scope: "upcoming", uuids: [uuid], named: [uuid] }, legOptions(options, txnId));
993
+ if (tmplLeg.kind !== "ok") {
994
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
995
+ return {
996
+ kind: "bounce-aborted",
997
+ op: "reorder",
998
+ detail: `the native \`list "Upcoming"\` front-insert leg failed for repeating template ${uuid} ` +
999
+ "— it was NOT moved (its position is unchanged)",
1000
+ placed: [...placed],
1001
+ remaining: remainingBefore(),
1002
+ cause: tmplLeg,
1003
+ };
1004
+ }
1005
+ if (direction === "front")
1006
+ placed.unshift(uuid);
1007
+ else
1008
+ placed.push(uuid);
1009
+ const tmplPrefix = await pollUntilVerified(() => evaluateDelta({ mode: "ordering", key: rankKey, sequence: [...placed] }, createDbReader(deps.db), { modDates: {}, fields: {} }), options.verifyTimeoutMs ?? 4000, deps.poller ?? {});
1010
+ if (tmplPrefix.kind !== "ok") {
1011
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
1012
+ return {
1013
+ kind: "bounce-aborted",
1014
+ op: "reorder",
1015
+ detail: `placed items fell out of order after front-inserting template ${uuid} (concurrent ` +
1016
+ "edit?); re-run the reorder once Things is idle",
1017
+ placed: [...placed],
1018
+ remaining: direction === "front" ? dispatchRun.slice(0, i) : dispatchRun.slice(i + 1),
1019
+ cause: null,
1020
+ };
1021
+ }
1022
+ continue;
1023
+ }
846
1024
  // leg 1 sends the item AWAY from its resting bucket; leg 2 returns it — the
847
- // return leg is what front/back-inserts (BOUNCE2 re-entry law). The op is
848
- // chosen per row type (to-do vs project) for the mixed-kind day/evening bounce.
1025
+ // return leg is what front-inserts (BOUNCE2 re-entry law). SCHEDULED rows use
1026
+ // the `when=` round-trip; a day-scope DEADLINE-FORECAST row (startDate NULL,
1027
+ // deadline set) uses the DLBNC deadline-cycle instead: leg 1 CLEARS the deadline
1028
+ // (transiently off the block), leg 2 RE-SETS the same deadline byte-identical
1029
+ // (front-inserts on the block todayIndex axis). BOTH classes pick the leg op PER
1030
+ // ROW TYPE — `todo.update` (→ `update?…`) for a to-do, `project.update` (→
1031
+ // `update-project?…`) for a project — so a forecast PROJECT rides the #385-
1032
+ // certified `update-project?deadline=` cycle (PROJDL-2b, PROJSTAR-safe). Both
1033
+ // classes front-insert at the day's global min, so one reverse-target pass
1034
+ // interleaves them exactly (o-suite forecast-cycle + mixed-interleave locks).
1035
+ const forecast = forecastLegOf(uuid);
849
1036
  const rowLegOp = opForRow(uuid);
850
- const leg1 = await runMutation(deps, rowLegOp, { uuid, when: awayValue }, legOptions(options, txnId));
1037
+ const awayLabel = forecast !== null ? "deadline= clear" : `when=${awayValue}`;
1038
+ const backLabel = forecast !== null ? "deadline= re-set" : `when=${backValue}`;
1039
+ const leg1 = forecast !== null
1040
+ ? await runMutation(deps, rowLegOp, { uuid, deadline: null }, legOptions(options, txnId))
1041
+ : await runMutation(deps, rowLegOp, { uuid, when: awayValue }, legOptions(options, txnId));
851
1042
  if (leg1.kind !== "ok") {
852
1043
  auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
853
1044
  return {
854
1045
  kind: "bounce-aborted",
855
1046
  op: "reorder",
856
- detail: `bounce leg 1 (when=${awayValue}) failed for ${uuid} — the item was NOT moved`,
1047
+ detail: `bounce leg 1 (${awayLabel}) failed for ${uuid} — the item was NOT moved`,
857
1048
  placed: [...placed],
858
1049
  remaining: remainingBefore(),
859
1050
  cause: leg1,
860
1051
  };
861
1052
  }
862
1053
  // leg 2 must follow leg 1's committed state for the same item before the next item's bounce begins
863
- const leg2 = await runMutation(deps, rowLegOp, { uuid, when: backValue }, legOptions(options, txnId));
1054
+ const leg2 = forecast !== null
1055
+ ? await runMutation(deps, rowLegOp, { uuid, deadline: forecast.iso }, legOptions(options, txnId))
1056
+ : await runMutation(deps, rowLegOp, { uuid, when: backValue }, legOptions(options, txnId));
864
1057
  if (leg2.kind !== "ok") {
865
1058
  auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
1059
+ const strandedState = forecast !== null
1060
+ ? "left DEADLINE-LESS (transiently off the day-block)"
1061
+ : `STRANDED IN ${String(awayValue).toUpperCase()}`;
1062
+ const recover = forecast !== null
1063
+ ? `re-set its deadline (${forecast.iso})`
1064
+ : `re-schedule it (when=${backValue})`;
866
1065
  return {
867
1066
  kind: "bounce-aborted",
868
1067
  op: "reorder",
869
- detail: `bounce leg 2 (when=${backValue}) failed for ${uuid} — THE ITEM IS STRANDED IN ` +
870
- `${String(awayValue).toUpperCase()}; re-schedule it (when=${backValue}) or fix in the app`,
1068
+ detail: `bounce leg 2 (${backLabel}) failed for ${uuid} — THE ITEM IS ${strandedState}; ` +
1069
+ `${recover} or fix in the app`,
871
1070
  placed: [...placed],
872
1071
  remaining: remainingBefore(),
873
1072
  cause: leg2,
@@ -889,7 +1088,7 @@ async function runBounce(deps, params, bounceKind, options) {
889
1088
  detail: `placed items fell out of order after bouncing ${uuid} (concurrent edit?); ` +
890
1089
  "re-run the reorder once Things is idle",
891
1090
  placed: [...placed],
892
- remaining: direction === "front" ? coBounce.slice(0, i) : coBounce.slice(i + 1),
1091
+ remaining: direction === "front" ? dispatchRun.slice(0, i) : dispatchRun.slice(i + 1),
893
1092
  cause: null,
894
1093
  };
895
1094
  }
@@ -899,6 +1098,25 @@ async function runBounce(deps, params, bounceKind, options) {
899
1098
  for (const uuid of coBounce)
900
1099
  observed[uuid] = reader.rankOf(uuid, rankKey);
901
1100
  auditSummary(deps, params, startedAt, "ok", observed, { pre: preRanks, txnId, actor });
1101
+ // §9r disclosure: a template's `list "Upcoming"` front-insert leg is
1102
+ // userModificationDate-SILENT (a umd-diffing sync/watcher misses it), and a project
1103
+ // template left as the untouched suffix moved not at all — surface both so the caller
1104
+ // never mistakes a umd-silent placement for a no-op.
1105
+ const warnings = [];
1106
+ if (templatesPresent) {
1107
+ const tt = coBounce.filter((u) => isTmpl(u) && !isProjectTemplate(u));
1108
+ const pt = coBounce.filter(isProjectTemplate);
1109
+ if (tt.length > 0) {
1110
+ warnings.push(`${tt.length} repeating to-do template(s) were front-inserted via \`list "Upcoming"\` and ` +
1111
+ "are userModificationDate-SILENT (a umd-diffing sync/watcher will not see the move): " +
1112
+ tt.join(", "));
1113
+ }
1114
+ if (pt.length > 0) {
1115
+ warnings.push(`${pt.length} repeating project template(s) were left byte-untouched as the day-block suffix ` +
1116
+ "(no headless reach on this day — every movable was placed above them): " +
1117
+ pt.join(", "));
1118
+ }
1119
+ }
902
1120
  return {
903
1121
  kind: "ok",
904
1122
  op: "reorder",
@@ -909,6 +1127,7 @@ async function runBounce(deps, params, bounceKind, options) {
909
1127
  // A bounce reorder is a summary txn, so its token is the txn id (matches
910
1128
  // the audit record's undoToken); pass it to `things undo --txn <token>`.
911
1129
  undoToken: txnId,
1130
+ ...(warnings.length > 0 && { warnings }),
912
1131
  // Co-bounced siblings the anchor placement re-inserted (honest disclosure).
913
1132
  ...(touchedUnnamed.length > 0 && { touched: touchedUnnamed }),
914
1133
  };
@@ -1398,7 +1617,7 @@ async function runMoveFallback(deps, params, fallback, options) {
1398
1617
  const cap = deps.config.bounceMaxItems ?? BOUNCE_MAX_ITEMS;
1399
1618
  const containerUuid = resolveContainerUuid(deps, params);
1400
1619
  const txnId = `txn-${startedAt.getTime().toString(36)}-${process.pid.toString(36)}`;
1401
- const pre = computeReorderPre(deps.db, params, containerUuid, now());
1620
+ const pre = computeReorderPre(deps.db, params, containerUuid, now(), { zone: deps.zone });
1402
1621
  // proj-root BACK-inserts (forward dispatch → suffix from the first named slot);
1403
1622
  // inbox/area FRONT-insert (reverse dispatch → prefix to the last named slot).
1404
1623
  const direction = fallback === "proj-root" ? "back" : "front";
@@ -1969,7 +2188,8 @@ function resolveContainerUuid(deps, params) {
1969
2188
  function checkStillMember(deps, uuid, bounceKind, containerUuid, now, dayPacked) {
1970
2189
  const packedToday = encodePackedDate(localToday(now));
1971
2190
  const row = deps.db
1972
- .prepare("SELECT status, trashed, startBucket, startDate, start, type, area, project, heading " +
2191
+ .prepare("SELECT status, trashed, startBucket, startDate, start, type, area, project, heading, deadline, " +
2192
+ "rt1_recurrenceRule AS rule, repeater, rt1_nextInstanceStartDate AS proj " +
1973
2193
  "FROM TMTask WHERE uuid = ?")
1974
2194
  .get(uuid);
1975
2195
  if (row === undefined)
@@ -2052,14 +2272,28 @@ function checkStillMember(deps, uuid, bounceKind, containerUuid, now, dayPacked)
2052
2272
  return null;
2053
2273
  }
2054
2274
  case "day": {
2055
- // The dated bounce's members are to-dos (any container) and scheduled project
2056
- // rows (area-less OR area-direct — SIT5 AREAPROJDAY) sharing the day D on the
2057
- // startBucket=0 axis. A concurrent edit that re-dates, de-schedules, or
2058
- // evenings the row ejects it from the group.
2275
+ // The dated bounce's members are SCHEDULED to-dos (any container) and scheduled
2276
+ // project rows (area-less OR area-direct — SIT5 AREAPROJDAY) sharing the day D
2277
+ // on the startBucket=0 axis, PLUS DEADLINE-FORECAST to-dos (startDate NULL,
2278
+ // start IN (1,2), deadline == D — DLBNC/§9o) on the SAME block todayIndex axis.
2279
+ // A concurrent edit that re-dates, de-schedules, evenings, clears the deadline,
2280
+ // or drops the row to the Inbox (start=0) ejects it from the group.
2059
2281
  if (row.type !== 0 && row.type !== 1)
2060
2282
  return "the item is not a to-do or project";
2061
- if (row.startBucket !== 0 || row.startDate !== dayPacked) {
2062
- return "the item left the day-group (re-dated, evening-ed, or de-scheduled)";
2283
+ // A repeating TEMPLATE stays a day-block member via its PROJECTION day
2284
+ // (rt1_nextInstanceStartDate TMPLSORT/PTMPL), not startDate/deadline.
2285
+ if (row.rule !== null || row.repeater !== null) {
2286
+ return row.proj === dayPacked
2287
+ ? null
2288
+ : "the template's projection day changed (its recurrence rule was edited)";
2289
+ }
2290
+ const scheduled = row.startBucket === 0 && row.startDate !== null && row.startDate === dayPacked;
2291
+ const forecast = row.startDate === null &&
2292
+ (row.start === 1 || row.start === 2) &&
2293
+ row.deadline !== null &&
2294
+ row.deadline === dayPacked;
2295
+ if (!scheduled && !forecast) {
2296
+ return "the item left the day-group (re-dated, evening-ed, de-scheduled, or its deadline changed)";
2063
2297
  }
2064
2298
  return null;
2065
2299
  }