things-api 0.13.0 → 0.15.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 (133) hide show
  1. package/README.md +3 -3
  2. package/dist/cli/commands/area.d.ts +5 -5
  3. package/dist/cli/commands/area.js +13 -8
  4. package/dist/cli/commands/area.js.map +1 -1
  5. package/dist/cli/commands/project.js +2 -2
  6. package/dist/cli/commands/project.js.map +1 -1
  7. package/dist/cli/commands/reads.js +94 -16
  8. package/dist/cli/commands/reads.js.map +1 -1
  9. package/dist/cli/commands/show.js +6 -2
  10. package/dist/cli/commands/show.js.map +1 -1
  11. package/dist/cli/commands/todo.js +2 -2
  12. package/dist/cli/commands/todo.js.map +1 -1
  13. package/dist/cli/commands/writes.js +136 -70
  14. package/dist/cli/commands/writes.js.map +1 -1
  15. package/dist/cli/glyphs.d.ts +9 -2
  16. package/dist/cli/glyphs.js +20 -15
  17. package/dist/cli/glyphs.js.map +1 -1
  18. package/dist/cli/help.js +4 -2
  19. package/dist/cli/help.js.map +1 -1
  20. package/dist/cli/read-driver.d.ts +45 -9
  21. package/dist/cli/read-driver.js +39 -16
  22. package/dist/cli/read-driver.js.map +1 -1
  23. package/dist/cli/render.d.ts +57 -27
  24. package/dist/cli/render.js +117 -65
  25. package/dist/cli/render.js.map +1 -1
  26. package/dist/client.d.ts +127 -38
  27. package/dist/client.js +86 -22
  28. package/dist/client.js.map +1 -1
  29. package/dist/contracts.d.ts +35 -34
  30. package/dist/contracts.js +1 -1
  31. package/dist/contracts.js.map +1 -1
  32. package/dist/index.d.ts +9 -7
  33. package/dist/index.js +3 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/server.js +212 -148
  36. package/dist/mcp/server.js.map +1 -1
  37. package/dist/model/dates.d.ts +26 -0
  38. package/dist/model/dates.js +46 -4
  39. package/dist/model/dates.js.map +1 -1
  40. package/dist/model/entities.d.ts +61 -39
  41. package/dist/model/entities.js +0 -4
  42. package/dist/model/entities.js.map +1 -1
  43. package/dist/model/mappers.js +24 -43
  44. package/dist/model/mappers.js.map +1 -1
  45. package/dist/read/area-filter.d.ts +1 -1
  46. package/dist/read/area-filter.js +5 -4
  47. package/dist/read/area-filter.js.map +1 -1
  48. package/dist/read/area-view.d.ts +0 -11
  49. package/dist/read/area-view.js +7 -11
  50. package/dist/read/area-view.js.map +1 -1
  51. package/dist/read/detail.js +2 -2
  52. package/dist/read/detail.js.map +1 -1
  53. package/dist/read/filter-contract.d.ts +1 -1
  54. package/dist/read/filter-contract.js +3 -0
  55. package/dist/read/filter-contract.js.map +1 -1
  56. package/dist/read/log-boundary.d.ts +46 -2
  57. package/dist/read/log-boundary.js +43 -2
  58. package/dist/read/log-boundary.js.map +1 -1
  59. package/dist/read/predicates.d.ts +13 -0
  60. package/dist/read/predicates.js +13 -0
  61. package/dist/read/predicates.js.map +1 -1
  62. package/dist/read/project-view.d.ts +34 -19
  63. package/dist/read/project-view.js +31 -25
  64. package/dist/read/project-view.js.map +1 -1
  65. package/dist/read/scope.d.ts +1 -1
  66. package/dist/read/scope.js +5 -4
  67. package/dist/read/scope.js.map +1 -1
  68. package/dist/read/search-rank.js +2 -2
  69. package/dist/read/search-rank.js.map +1 -1
  70. package/dist/read/shape.d.ts +78 -18
  71. package/dist/read/shape.js +496 -185
  72. package/dist/read/shape.js.map +1 -1
  73. package/dist/read/snapshot.js +2 -2
  74. package/dist/read/snapshot.js.map +1 -1
  75. package/dist/read/stage.d.ts +31 -13
  76. package/dist/read/stage.js +28 -11
  77. package/dist/read/stage.js.map +1 -1
  78. package/dist/read/truncation.d.ts +75 -2
  79. package/dist/read/truncation.js +69 -46
  80. package/dist/read/truncation.js.map +1 -1
  81. package/dist/read/views.d.ts +81 -18
  82. package/dist/read/views.js +136 -17
  83. package/dist/read/views.js.map +1 -1
  84. package/dist/surface-copy.d.ts +6 -0
  85. package/dist/surface-copy.js +6 -0
  86. package/dist/surface-copy.js.map +1 -1
  87. package/dist/write/batch.js +0 -1
  88. package/dist/write/batch.js.map +1 -1
  89. package/dist/write/clear-reminder.js +3 -1
  90. package/dist/write/clear-reminder.js.map +1 -1
  91. package/dist/write/commands.d.ts +25 -1
  92. package/dist/write/commands.js +350 -134
  93. package/dist/write/commands.js.map +1 -1
  94. package/dist/write/guards.js +36 -16
  95. package/dist/write/guards.js.map +1 -1
  96. package/dist/write/move.d.ts +26 -5
  97. package/dist/write/move.js +358 -2
  98. package/dist/write/move.js.map +1 -1
  99. package/dist/write/operations.d.ts +50 -19
  100. package/dist/write/operations.js +3 -2
  101. package/dist/write/operations.js.map +1 -1
  102. package/dist/write/pipeline.js +28 -7
  103. package/dist/write/pipeline.js.map +1 -1
  104. package/dist/write/pre-state.d.ts +89 -28
  105. package/dist/write/pre-state.js +197 -19
  106. package/dist/write/pre-state.js.map +1 -1
  107. package/dist/write/reorder.js +52 -5
  108. package/dist/write/reorder.js.map +1 -1
  109. package/dist/write/resolution-timestamps.d.ts +34 -0
  110. package/dist/write/resolution-timestamps.js +286 -0
  111. package/dist/write/resolution-timestamps.js.map +1 -0
  112. package/dist/write/reversibility.js +10 -5
  113. package/dist/write/reversibility.js.map +1 -1
  114. package/dist/write/scope-guard.js +2 -5
  115. package/dist/write/scope-guard.js.map +1 -1
  116. package/dist/write/undo.d.ts +1 -7
  117. package/dist/write/undo.js +63 -16
  118. package/dist/write/undo.js.map +1 -1
  119. package/dist/write/vectors/applescript.js +23 -4
  120. package/dist/write/vectors/applescript.js.map +1 -1
  121. package/dist/write/vectors/simulator.js +210 -9
  122. package/dist/write/vectors/simulator.js.map +1 -1
  123. package/dist/write/vectors/url-scheme.js +0 -8
  124. package/dist/write/vectors/url-scheme.js.map +1 -1
  125. package/dist/write/verify/delta.d.ts +71 -12
  126. package/dist/write/verify/delta.js +94 -12
  127. package/dist/write/verify/delta.js.map +1 -1
  128. package/package.json +1 -1
  129. package/schema/envelope.schema.json +19 -70
  130. package/skills/things-cli/SKILL.md +13 -9
  131. package/skills/things-cli/references/contracts.md +3 -4
  132. package/skills/things-cli/references/{model.md → data-model.md} +10 -8
  133. package/skills/things-cli/references/ordering.md +18 -7
@@ -1,5 +1,6 @@
1
- import { decodeReminderTime, encodeReminderTime, localToday, reminderUrlToken, } from "../model/dates.js";
1
+ import { decodeReminderTime, encodeReminderTime, localToday, reminderUrlToken, zonedWallInstant, } from "../model/dates.js";
2
2
  import { byUuid } from "../read/detail.js";
3
+ import { manualLogDateEpoch, pendingLogCount } from "../read/log-boundary.js";
3
4
  import { isLooseRef } from "../read/pseudo-area.js";
4
5
  import { reminderIsLive } from "../read/stage.js";
5
6
  import { areaMemberCounts, childTagTitles, classifyHeadingConvert, classifyHeadingDissolve, classifyHeadingMoveToProject, classifyProjectRepeat, computeHeadingMovePre, computeReorderPre, emptyPreState, loadTarget, projectChildren, projectStatus, projectSubtreeUuids, resolveArea, resolveHeading, resolveProject, resolveTag, sameTitleTaskUuids, trashedCount, } from "./pre-state.js";
@@ -62,7 +63,7 @@ function q(value) {
62
63
  function unsupportedVector(op, vector) {
63
64
  throw new Error(`${op} cannot be compiled for vector ${vector} (planner bug)`);
64
65
  }
65
- function whenAssertions(when, todayIso) {
66
+ function whenAssertions(when, todayIso, opts = { mode: "add" }) {
66
67
  // Strict shape check: an unvalidated string used to flow straight into the
67
68
  // URL (e.g. "2026-07-20@09:30", the raw URL grammar) — the app would SET
68
69
  // date+reminder while verification asserted the literal string as the date,
@@ -78,16 +79,35 @@ function whenAssertions(when, todayIso) {
78
79
  }
79
80
  switch (when) {
80
81
  case "today":
82
+ // Today's non-evening section: in Today (the `today` marker) AND NOT in the
83
+ // evening sub-bucket (the presence-keyed `evening` marker absent — asserted
84
+ // as null, which valuesEqual treats as absent). Gated to Today members under
85
+ // the verify clock exactly as the retired `todaySection` was.
86
+ //
87
+ // The `startDate` assertion differs by op (field bug §0½.8):
88
+ // - ADD mints a fresh Today item with no schedule history, so the app dates
89
+ // it EXACTLY today — exact equality is right.
90
+ // - UPDATE of an item ALREADY in Today whose `startDate` has already arrived
91
+ // (past or today): the app PRESERVES that historical date rather than
92
+ // rewriting the storage byte to today (arrived-date law). Assert the
93
+ // arrived-date PREDICATE (non-null and <= today) so a preserved historical
94
+ // date verifies, while an undated deadline-only pull (null startDate) is
95
+ // still rejected — the item's Today membership then rests only on a
96
+ // deadline, not on the requested schedule.
81
97
  return [
82
98
  { field: "start", equals: "active" },
83
- { field: "startDate", equals: todayIso },
84
- { field: "todaySection", equals: "today" },
99
+ opts.mode === "update"
100
+ ? { field: "startDate", satisfies: { predicate: "arrived-on-or-before", date: todayIso } }
101
+ : { field: "startDate", equals: todayIso },
102
+ { field: "today", equals: true },
103
+ { field: "evening", equals: null },
85
104
  ];
86
105
  case "evening":
106
+ // The This-Evening sub-bucket: the `evening` marker (which implies `today`).
87
107
  return [
88
108
  { field: "start", equals: "active" },
89
109
  { field: "startDate", equals: todayIso },
90
- { field: "todaySection", equals: "evening" },
110
+ { field: "evening", equals: true },
91
111
  ];
92
112
  case "anytime":
93
113
  return [
@@ -144,6 +164,68 @@ function nonHeadingTitle(pre) {
144
164
  const target = pre.target;
145
165
  return target !== null && target.type !== "heading" ? target.title : "";
146
166
  }
167
+ /** True when an add carries a resolution-timestamp flag (routes it through json). */
168
+ function addHasTimestamps(p) {
169
+ return p.createdAt !== undefined || p.completedAt !== undefined;
170
+ }
171
+ /**
172
+ * Validate the resolution-timestamp flags on an add (shape, chronology, and the
173
+ * schedule contradiction). Instant ordering is zone-independent (both values
174
+ * share one effective zone), so the chronology check resolves against the host
175
+ * zone. Throws RangeError on a bad combination.
176
+ */
177
+ function assertAddTimestamps(p) {
178
+ if (p.createdAt !== undefined)
179
+ resolveResolutionInstant(p.createdAt);
180
+ if (p.completedAt !== undefined)
181
+ resolveResolutionInstant(p.completedAt);
182
+ if (p.createdAt !== undefined &&
183
+ p.completedAt !== undefined &&
184
+ resolveResolutionInstant(p.createdAt).getTime() >
185
+ resolveResolutionInstant(p.completedAt).getTime()) {
186
+ throw new RangeError("--created-at must not be after --completed-at");
187
+ }
188
+ if (p.completedAt !== undefined && (p.when !== undefined || p.reminder !== undefined)) {
189
+ throw new RangeError("--completed-at creates a resolved (Logbook) item, which has no active schedule — drop --when/--reminder");
190
+ }
191
+ if (p.reminder !== undefined && p.createdAt !== undefined) {
192
+ throw new RangeError("--reminder is not available with --created-at");
193
+ }
194
+ }
195
+ /** A `things:///json` import payload for a single created to-do/project. */
196
+ function addJsonUrl(type, attributes, token) {
197
+ return thingsUrl("json", { data: JSON.stringify([{ type, attributes }]) }, token);
198
+ }
199
+ /** The completion/creation json attributes for a timestamped add. */
200
+ function addDateAttributes(p, zone) {
201
+ const attrs = {};
202
+ if (p.createdAt !== undefined) {
203
+ attrs["creation-date"] = jsonTimestamp(resolveResolutionInstant(p.createdAt, zone));
204
+ }
205
+ if (p.completedAt !== undefined) {
206
+ attrs["completed"] = true;
207
+ attrs["completion-date"] = jsonTimestamp(resolveResolutionInstant(p.completedAt, zone));
208
+ }
209
+ return attrs;
210
+ }
211
+ /** The completion/creation delta assertions for a timestamped add. */
212
+ function addDateAssertions(p, zone) {
213
+ const assert = [];
214
+ if (p.completedAt !== undefined) {
215
+ assert.push({ field: "status", equals: "completed" });
216
+ assert.push({
217
+ field: "stoppedDate",
218
+ equals: hostLocalDate(resolveResolutionInstant(p.completedAt, zone)),
219
+ });
220
+ }
221
+ if (p.createdAt !== undefined) {
222
+ assert.push({
223
+ field: "createdDate",
224
+ equals: hostLocalDate(resolveResolutionInstant(p.createdAt, zone)),
225
+ });
226
+ }
227
+ return assert;
228
+ }
147
229
  // ----------------------------------------------------------------- commands
148
230
  const todoAdd = {
149
231
  op: "todo.add",
@@ -161,6 +243,7 @@ const todoAdd = {
161
243
  if (params.heading !== undefined && !containerGiven(params.project)) {
162
244
  throw new RangeError("heading requires a project destination");
163
245
  }
246
+ assertAddTimestamps(params);
164
247
  const pre = emptyPreState();
165
248
  if (containerGiven(params.project)) {
166
249
  pre.destProject = resolveProject(db, params.project);
@@ -182,13 +265,18 @@ const todoAdd = {
182
265
  const assert = [];
183
266
  if (params.notes !== undefined)
184
267
  assert.push({ field: "notes", equals: params.notes });
185
- if (params.when !== undefined)
186
- assert.push(...whenAssertions(params.when, ctx.todayIso));
187
- if (params.reminder !== undefined) {
188
- assert.push({ field: "reminder", equals: normalizeReminder(params.reminder) });
268
+ // A born-resolved (completedAt) item has no active schedule — skip when/reminder.
269
+ if (params.completedAt === undefined) {
270
+ if (params.when !== undefined)
271
+ assert.push(...whenAssertions(params.when, ctx.todayIso));
272
+ if (params.reminder !== undefined) {
273
+ assert.push({ field: "reminder", equals: normalizeReminder(params.reminder) });
274
+ }
275
+ if (params.deadline !== undefined) {
276
+ assert.push({ field: "deadline", equals: params.deadline });
277
+ }
189
278
  }
190
- if (params.deadline !== undefined)
191
- assert.push({ field: "deadline", equals: params.deadline });
279
+ assert.push(...addDateAssertions(params, ctx.zone));
192
280
  if (params.tags !== undefined) {
193
281
  assert.push({ field: "tags", equals: sortedTags(pre.resolvedTagTitles) });
194
282
  }
@@ -221,6 +309,33 @@ const todoAdd = {
221
309
  if (vector !== "url-scheme")
222
310
  unsupportedVector(this.op, vector);
223
311
  const container = pre.destProject?.resolved ?? pre.destArea?.resolved;
312
+ // Timestamped add: the only at-creation backdating surface is things:///json
313
+ // (the plain add URL drops date params — oddity 2g); route through it.
314
+ if (addHasTimestamps(params)) {
315
+ const attrs = { title: params.title };
316
+ if (params.notes !== undefined)
317
+ attrs["notes"] = params.notes;
318
+ Object.assign(attrs, addDateAttributes(params, ctx.zone));
319
+ if (params.completedAt === undefined) {
320
+ if (params.when !== undefined)
321
+ attrs["when"] = params.when;
322
+ if (params.deadline !== undefined)
323
+ attrs["deadline"] = params.deadline;
324
+ }
325
+ if (params.tags !== undefined)
326
+ attrs["tags"] = pre.resolvedTagTitles;
327
+ if (params.checklistItems !== undefined) {
328
+ // §9y: things:///json rejects a bare STRING array wholesale (silent
329
+ // no-op) — emit the OBJECT-array shape the app accepts.
330
+ attrs["checklist-items"] = checklistItemsJsonAttr(params.checklistItems.map((title) => ({ title })));
331
+ }
332
+ if (container?.uuid !== undefined)
333
+ attrs["list-id"] = container.uuid;
334
+ if (pre.destHeading?.resolved?.title !== undefined) {
335
+ attrs["heading"] = pre.destHeading.resolved.title;
336
+ }
337
+ return addJsonUrl("to-do", attrs, ctx.token);
338
+ }
224
339
  return thingsUrl("add", {
225
340
  title: params.title,
226
341
  notes: params.notes,
@@ -261,7 +376,12 @@ function effectiveReminder(pre, params) {
261
376
  return null;
262
377
  if (!reminderIsLive(target.startDate, pre.todayIso))
263
378
  return null;
264
- return target.reminder;
379
+ // Read the RAW stored byte off the substrate (top-level `reminder` is
380
+ // live-gated under the LOAD clock, which can differ from `pre.todayIso` under a
381
+ // pinned THINGS_NOW). The `reminderIsLive` guard above applies liveness under
382
+ // the injected clock explicitly, so the raw byte + this gate reproduce the
383
+ // former semantics exactly.
384
+ return target.derived.reminder;
265
385
  }
266
386
  function assertNotesModesExclusive(params) {
267
387
  if (params.notes !== undefined &&
@@ -304,7 +424,7 @@ const todoUpdate = {
304
424
  if (joined !== undefined)
305
425
  assert.push({ field: "notes", equals: joined });
306
426
  if (params.when !== undefined) {
307
- assert.push(...whenAssertions(params.when, ctx.todayIso));
427
+ assert.push(...whenAssertions(params.when, ctx.todayIso, { mode: "update" }));
308
428
  const reminder = effectiveReminder(pre, params);
309
429
  assert.push({
310
430
  field: "reminder",
@@ -509,6 +629,21 @@ const todoSetTags = {
509
629
  },
510
630
  };
511
631
  /** Normalize the string | spec union; decide whether states force the json form. */
632
+ /**
633
+ * The `checklist-items` value `things:///json` accepts: an array of
634
+ * `{type:"checklist-item",attributes:{title[,completed]}}` OBJECTS. A bare
635
+ * STRING array is rejected wholesale — the whole import silently no-ops, no row,
636
+ * no error (§9y; RESID1 R-JSONPAR RAW-a..d). `completed` is emitted only when a
637
+ * spec carries it, so an at-creation add (all items open) yields the minimal
638
+ * `{title}` attributes the probe validated (OBJ/OBJ3), while the checklist
639
+ * replace path forwards its per-item state.
640
+ */
641
+ function checklistItemsJsonAttr(specs) {
642
+ return specs.map((s) => ({
643
+ type: "checklist-item",
644
+ attributes: s.completed === undefined ? { title: s.title } : { title: s.title, completed: s.completed },
645
+ }));
646
+ }
512
647
  function checklistSpecs(items) {
513
648
  const specs = items.map((i) => typeof i === "string"
514
649
  ? { title: i, completed: false }
@@ -555,10 +690,7 @@ const todoReplaceChecklist = {
555
690
  operation: "update",
556
691
  id: params.uuid,
557
692
  attributes: {
558
- "checklist-items": specs.map((s) => ({
559
- type: "checklist-item",
560
- attributes: { title: s.title, completed: s.completed },
561
- })),
693
+ "checklist-items": checklistItemsJsonAttr(specs),
562
694
  },
563
695
  },
564
696
  ]);
@@ -604,6 +736,14 @@ const projectAdd = {
604
736
  op: "project.add",
605
737
  hazards: ["H-UNKNOWN-DESTINATION"],
606
738
  preRead(db, params) {
739
+ assertAddTimestamps(params);
740
+ // §5b (B-PROJ-JSON.2): a completed-project json import silently reverts to
741
+ // OPEN if any child spec is unresolved. Seed to-dos are always open, so a
742
+ // born-resolved project cannot carry them.
743
+ if (params.completedAt !== undefined && params.todos !== undefined && params.todos.length > 0) {
744
+ throw new RangeError("--completed-at cannot seed child to-dos: a completed-project import reverts to open " +
745
+ "unless every child is resolved (§5b) — create the project resolved, then add logged children");
746
+ }
607
747
  const pre = emptyPreState();
608
748
  if (containerGiven(params.area))
609
749
  pre.destArea = resolveArea(db, params.area);
@@ -614,10 +754,14 @@ const projectAdd = {
614
754
  const assert = [];
615
755
  if (params.notes !== undefined)
616
756
  assert.push({ field: "notes", equals: params.notes });
617
- if (params.when !== undefined)
618
- assert.push(...whenAssertions(params.when, ctx.todayIso));
619
- if (params.deadline !== undefined)
620
- assert.push({ field: "deadline", equals: params.deadline });
757
+ if (params.completedAt === undefined) {
758
+ if (params.when !== undefined)
759
+ assert.push(...whenAssertions(params.when, ctx.todayIso));
760
+ if (params.deadline !== undefined) {
761
+ assert.push({ field: "deadline", equals: params.deadline });
762
+ }
763
+ }
764
+ assert.push(...addDateAssertions(params, ctx.zone));
621
765
  const area = pre.destArea?.resolved;
622
766
  if (area !== undefined && area !== null)
623
767
  assert.push({ field: "area.uuid", equals: area.uuid });
@@ -635,6 +779,24 @@ const projectAdd = {
635
779
  compile(params, vector, pre, ctx) {
636
780
  if (vector !== "url-scheme")
637
781
  unsupportedVector(this.op, vector);
782
+ if (addHasTimestamps(params)) {
783
+ const attrs = { title: params.title };
784
+ if (params.notes !== undefined)
785
+ attrs["notes"] = params.notes;
786
+ Object.assign(attrs, addDateAttributes(params, ctx.zone));
787
+ if (params.completedAt === undefined) {
788
+ if (params.when !== undefined)
789
+ attrs["when"] = params.when;
790
+ if (params.deadline !== undefined)
791
+ attrs["deadline"] = params.deadline;
792
+ if (params.todos !== undefined) {
793
+ attrs["items"] = params.todos.map((t) => ({ type: "to-do", attributes: { title: t } }));
794
+ }
795
+ }
796
+ if (pre.destArea?.resolved?.uuid !== undefined)
797
+ attrs["area-id"] = pre.destArea.resolved.uuid;
798
+ return addJsonUrl("project", attrs, ctx.token);
799
+ }
638
800
  return thingsUrl("add-project", {
639
801
  title: params.title,
640
802
  notes: params.notes,
@@ -665,7 +827,7 @@ const projectUpdate = {
665
827
  if (joined !== undefined)
666
828
  assert.push({ field: "notes", equals: joined });
667
829
  if (params.when !== undefined) {
668
- assert.push(...whenAssertions(params.when, ctx.todayIso));
830
+ assert.push(...whenAssertions(params.when, ctx.todayIso, { mode: "update" }));
669
831
  // Projects carry the same reminderTime codec as to-dos (A3); a bare
670
832
  // when= clears an existing reminder unless auto-preserved.
671
833
  const reminder = effectiveReminder(pre, params);
@@ -1229,7 +1391,7 @@ const tagUpdate = {
1229
1391
  const reorder = {
1230
1392
  op: "reorder",
1231
1393
  hazards: ["H-UNKNOWN-DESTINATION", "H-REORDER-SCOPE"],
1232
- preRead(db, params, now) {
1394
+ preRead(db, params, now, zone) {
1233
1395
  const pre = emptyPreState();
1234
1396
  let containerUuid = null;
1235
1397
  if (params.scope === "project") {
@@ -1254,18 +1416,46 @@ const reorder = {
1254
1416
  containerUuid = pre.destArea.resolved?.uuid ?? null;
1255
1417
  }
1256
1418
  }
1257
- pre.reorder = computeReorderPre(db, params, containerUuid, now);
1419
+ // The `reorder` op IS the pure-native private index wire — the ONE surface
1420
+ // runReorder dispatches here (strategy === "native"). So the LOGSORT ORD-13
1421
+ // permit is unconditionally offered (`admitResolved: true`); computeReorderPre
1422
+ // gates it internally on `key === "index"`, so day-axis native re-ranks
1423
+ // (today/container-day/tomorrow → todayIndex) still refuse resolved movees.
1424
+ // Every bounce/move/day-axis orchestrator calls computeReorderPre WITHOUT the
1425
+ // flag, so a resolved movee reaching an uncertified protocol stays refused.
1426
+ pre.reorder = computeReorderPre(db, params, containerUuid, now, { admitResolved: true, zone });
1258
1427
  return pre;
1259
1428
  },
1260
1429
  expectedDelta(pre, params) {
1261
1430
  // Verify the REQUESTED sequence (strictly ascending ranks). The wire
1262
1431
  // list pins the unrequested tail too, but the caller's contract is the
1263
1432
  // requested prefix; tail members are covered by pre-rank tripwires.
1433
+ // TODWIRE: on the `today` scope the wire is now MINIMAL — only the named
1434
+ // block gets fresh (front, monotonic) `todayIndex`; the unnamed tail keeps
1435
+ // its cohort-interleaved (non-monotonic) `todayIndex`, so the FULL
1436
+ // `params.uuids` is NOT strictly-ascending after the write. Verify the
1437
+ // `todayWire` (the named block) instead — it lands at the visible front in
1438
+ // wire order with fresh ascending values, and the untouched tail rides the
1439
+ // pre-rank tripwires. Every other scope verifies the requested prefix.
1440
+ // LOGSORT ORD-13 byte-lock: any admitted UNSWEPT-resolved movee must read
1441
+ // back index-only — status still closed, stoppedDate intact, umd unbumped
1442
+ // (a reopen would flip all three). Frozen assertions carry that into verify.
1443
+ const frozen = (pre.reorder?.resolvedMembers ?? []).map((m) => ({
1444
+ uuid: m.uuid,
1445
+ assert: [
1446
+ { field: "status", equals: m.status },
1447
+ { field: "stoppedDate", equals: m.stoppedDate },
1448
+ ],
1449
+ }));
1450
+ const sequence = params.scope === "today" && pre.reorder?.todayWire != null
1451
+ ? pre.reorder.todayWire
1452
+ : params.uuids;
1264
1453
  return {
1265
1454
  mode: "ordering",
1266
1455
  key: pre.reorder?.key ??
1267
1456
  (params.scope === "today" || params.scope === "evening" ? "todayIndex" : "index"),
1268
- sequence: params.uuids,
1457
+ sequence,
1458
+ ...(frozen.length > 0 && { frozen }),
1269
1459
  };
1270
1460
  },
1271
1461
  compile(params, vector, pre) {
@@ -1289,7 +1479,14 @@ const reorder = {
1289
1479
  : params.scope === "upcoming"
1290
1480
  ? `list "Upcoming"`
1291
1481
  : `list "Today"`;
1292
- const wire = pre.reorder?.wireList ?? params.uuids;
1482
+ // TODWIRE the `today` scope sends the MINIMAL visible-order wire (names only
1483
+ // what must move; unnamed rows keep their entry cohorts + visible positions),
1484
+ // not the OLD full `wireList` that fused every cohort (MOVPLC/ORD-20). Every
1485
+ // other native scope keeps the full wire (their `index`/day axes do not
1486
+ // re-stamp `tiRef`, so a full re-rank is non-damaging).
1487
+ const wire = (params.scope === "today" ? pre.reorder?.todayWire : undefined) ??
1488
+ pre.reorder?.wireList ??
1489
+ params.uuids;
1293
1490
  if (params.scope === "someday") {
1294
1491
  // The Someday handler STACKS each sent id above the list's current top
1295
1492
  // (the current top itself never moves), with OPPOSITE stack directions
@@ -1311,117 +1508,108 @@ const reorder = {
1311
1508
  return osa(`${PRIVATE_REORDER_COMMAND} ${specifier} with ids ${q(wire.join(","))}`);
1312
1509
  },
1313
1510
  };
1314
- /** Locale-proof AppleScript date literal: local noon on an ISO date. */
1315
- function asDateBlock(varName, iso) {
1316
- const [y, m, d] = iso.split("-").map(Number);
1511
+ // ---- resolution-timestamp normalization (§5 of the plan) ------------------
1512
+ /**
1513
+ * Parse an ISO date (`2025-01-15`) OR datetime (`2025-01-15T09:30[:ss]`) and
1514
+ * resolve it to a single UTC instant in the effective `zone`. A date-only value
1515
+ * lands at NOON in that zone (B-DATEONLY: noon decodes to the intended calendar
1516
+ * date in every zone; midnight can slip a day). A datetime is read as wall-clock
1517
+ * time in the effective zone. `zone` undefined = the process-local (app host)
1518
+ * zone, which for a local CLI run IS the app's own zone.
1519
+ */
1520
+ export function resolveResolutionInstant(input, zone) {
1521
+ const dateOnly = /^(\d{4}-\d{2}-\d{2})$/.exec(input);
1522
+ if (dateOnly !== null)
1523
+ return zonedWallInstant(dateOnly[1], 12, 0, 0, zone);
1524
+ const dt = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(input);
1525
+ if (dt !== null) {
1526
+ return zonedWallInstant(dt[1], Number(dt[2]), Number(dt[3]), dt[4] === undefined ? 0 : Number(dt[4]), zone);
1527
+ }
1528
+ throw new RangeError(`invalid timestamp "${input}" — expected an ISO date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:mm)`);
1529
+ }
1530
+ const pad2 = (n) => String(n).padStart(2, "0");
1531
+ /** The host-local calendar date (`YYYY-MM-DD`) an instant renders as — what the verify layer observes for stopped/created. */
1532
+ function hostLocalDate(instant) {
1533
+ return `${instant.getFullYear()}-${pad2(instant.getMonth() + 1)}-${pad2(instant.getDate())}`;
1534
+ }
1535
+ /**
1536
+ * The host-local calendar date a resolution-timestamp value resolves to — the
1537
+ * value the verify layer observes for `stoppedDate`/`createdDate`. Shared with
1538
+ * the multi-leg orchestrators so their synthesized deltas match the op's.
1539
+ */
1540
+ export function resolutionDeltaDate(input, zone) {
1541
+ return hostLocalDate(resolveResolutionInstant(input, zone));
1542
+ }
1543
+ /**
1544
+ * An instant as a second-precision UTC timestamp (`…Thh:mm:ssZ`) for the json
1545
+ * import. WITHOUT milliseconds: the app's json date parser rejects fractional
1546
+ * seconds — a `.000Z` stamp fails the whole command (oddity 2h; P4d's validated
1547
+ * shape was second-precision).
1548
+ */
1549
+ function jsonTimestamp(instant) {
1550
+ return instant.toISOString().replace(/\.\d{3}Z$/, "Z");
1551
+ }
1552
+ /**
1553
+ * A locale-proof AppleScript date literal built from an instant's HOST-LOCAL
1554
+ * wall-clock components — AS `current date` mutation lives in the host zone, so
1555
+ * setting those components reproduces exactly this instant regardless of the
1556
+ * effective zone the noon was computed in.
1557
+ */
1558
+ function asDateBlockFromInstant(varName, instant) {
1317
1559
  return [
1318
1560
  `set ${varName} to current date`,
1319
- `set time of ${varName} to 12 * hours`,
1561
+ `set time of ${varName} to ${instant.getHours()} * hours + ${instant.getMinutes()} * minutes + ${instant.getSeconds()}`,
1320
1562
  `set day of ${varName} to 1`,
1321
- `set year of ${varName} to ${y}`,
1322
- `set month of ${varName} to ${m}`,
1323
- `set day of ${varName} to ${d}`,
1563
+ `set year of ${varName} to ${instant.getFullYear()}`,
1564
+ `set month of ${varName} to ${instant.getMonth() + 1}`,
1565
+ `set day of ${varName} to ${instant.getDate()}`,
1324
1566
  ];
1325
1567
  }
1326
- /**
1327
- * Local noon -> UTC instant, so the stored timestamp decodes back to the
1328
- * requested local DATE in every timezone (P4d: json attrs honored exactly).
1329
- * WITHOUT milliseconds: the app's json date parser rejects fractional
1330
- * seconds — a `.000Z` timestamp fails the whole command (error modal, no
1331
- * write; caught live by the e2e 2026-07-09 — P4d's validated shape was
1332
- * second-precision).
1333
- */
1334
- function utcNoon(iso) {
1335
- const [y, m, d] = iso.split("-").map(Number);
1336
- return new Date(y ?? 0, (m ?? 1) - 1, d ?? 1, 12, 0, 0).toISOString().replace(/\.\d{3}Z$/, "Z");
1568
+ /** Shared spec for the kind-agnostic `set-dates` op (to-do / project). */
1569
+ function setDatesSpec(op, addressor) {
1570
+ return {
1571
+ op,
1572
+ hazards: ["H-UNKNOWN-DESTINATION", "H-BACKDATE-OPEN"],
1573
+ preRead(db, params) {
1574
+ if (params.completedAt === undefined && params.createdAt === undefined) {
1575
+ throw new RangeError("nothing to set: give completedAt and/or createdAt");
1576
+ }
1577
+ const pre = emptyPreState();
1578
+ pre.target = loadTarget(db, params.uuid);
1579
+ return pre;
1580
+ },
1581
+ expectedDelta(_pre, params, ctx) {
1582
+ const assert = [];
1583
+ if (params.completedAt !== undefined) {
1584
+ assert.push({
1585
+ field: "stoppedDate",
1586
+ equals: hostLocalDate(resolveResolutionInstant(params.completedAt, ctx.zone)),
1587
+ });
1588
+ }
1589
+ if (params.createdAt !== undefined) {
1590
+ assert.push({
1591
+ field: "createdDate",
1592
+ equals: hostLocalDate(resolveResolutionInstant(params.createdAt, ctx.zone)),
1593
+ });
1594
+ }
1595
+ return { mode: "update", uuid: params.uuid, assert };
1596
+ },
1597
+ compile(params, vector, _pre, ctx) {
1598
+ if (vector !== "applescript")
1599
+ unsupportedVector(this.op, vector);
1600
+ const statements = [];
1601
+ if (params.completedAt !== undefined) {
1602
+ statements.push(...asDateBlockFromInstant("compDate", resolveResolutionInstant(params.completedAt, ctx.zone)), `set completion date of ${addressor} id ${q(params.uuid)} to compDate`);
1603
+ }
1604
+ if (params.createdAt !== undefined) {
1605
+ statements.push(...asDateBlockFromInstant("createDate", resolveResolutionInstant(params.createdAt, ctx.zone)), `set creation date of ${addressor} id ${q(params.uuid)} to createDate`);
1606
+ }
1607
+ return osaBlock(statements);
1608
+ },
1609
+ };
1337
1610
  }
1338
- const todoBackdate = {
1339
- op: "todo.backdate",
1340
- hazards: ["H-UNKNOWN-DESTINATION", "H-BACKDATE-OPEN"],
1341
- preRead(db, params) {
1342
- if (params.completionDate === undefined && params.creationDate === undefined) {
1343
- throw new RangeError("nothing to backdate: give completionDate and/or creationDate");
1344
- }
1345
- const pre = emptyPreState();
1346
- pre.target = loadTarget(db, params.uuid);
1347
- return pre;
1348
- },
1349
- expectedDelta(_pre, params) {
1350
- const assert = [];
1351
- if (params.completionDate !== undefined) {
1352
- assert.push({ field: "stoppedDate", equals: params.completionDate });
1353
- }
1354
- if (params.creationDate !== undefined) {
1355
- assert.push({ field: "createdDate", equals: params.creationDate });
1356
- }
1357
- return { mode: "update", uuid: params.uuid, assert };
1358
- },
1359
- compile(params, vector) {
1360
- if (vector !== "applescript")
1361
- unsupportedVector(this.op, vector);
1362
- const statements = [];
1363
- if (params.completionDate !== undefined) {
1364
- statements.push(...asDateBlock("compDate", params.completionDate), `set completion date of to do id ${q(params.uuid)} to compDate`);
1365
- }
1366
- if (params.creationDate !== undefined) {
1367
- statements.push(...asDateBlock("createDate", params.creationDate), `set creation date of to do id ${q(params.uuid)} to createDate`);
1368
- }
1369
- return osaBlock(statements);
1370
- },
1371
- };
1372
- const todoAddLogged = {
1373
- op: "todo.add-logged",
1374
- hazards: [],
1375
- preRead(db, params) {
1376
- if (params.creationDate !== undefined &&
1377
- params.creationDate.localeCompare(params.completionDate) > 0) {
1378
- throw new RangeError("creationDate must not be after completionDate");
1379
- }
1380
- const pre = emptyPreState();
1381
- pre.sameTitleUuids = sameTitleTaskUuids(db, params.title, "to-do");
1382
- return pre;
1383
- },
1384
- expectedDelta(pre, params) {
1385
- const assert = [
1386
- { field: "status", equals: "completed" },
1387
- { field: "stoppedDate", equals: params.completionDate },
1388
- ];
1389
- if (params.creationDate !== undefined) {
1390
- assert.push({ field: "createdDate", equals: params.creationDate });
1391
- }
1392
- if (params.notes !== undefined)
1393
- assert.push({ field: "notes", equals: params.notes });
1394
- return {
1395
- mode: "create",
1396
- probe: {
1397
- title: params.title,
1398
- type: "to-do",
1399
- sinceEpoch: 0,
1400
- excludeUuids: pre.sameTitleUuids,
1401
- },
1402
- assert,
1403
- };
1404
- },
1405
- compile(params, vector, _pre, ctx) {
1406
- if (vector !== "url-scheme")
1407
- unsupportedVector(this.op, vector);
1408
- const payload = JSON.stringify([
1409
- {
1410
- type: "to-do",
1411
- attributes: {
1412
- title: params.title,
1413
- ...(params.notes !== undefined && { notes: params.notes }),
1414
- completed: true,
1415
- "completion-date": utcNoon(params.completionDate),
1416
- ...(params.creationDate !== undefined && {
1417
- "creation-date": utcNoon(params.creationDate),
1418
- }),
1419
- },
1420
- },
1421
- ]);
1422
- return thingsUrl("json", { data: payload }, ctx.token);
1423
- },
1424
- };
1611
+ const todoSetDates = setDatesSpec("todo.set-dates", "to do");
1612
+ const projectSetDates = setDatesSpec("project.set-dates", "project");
1425
1613
  /** Children of a heading (open ones drive the archive policies). */
1426
1614
  function headingChildren(db, headingUuid) {
1427
1615
  const rows = db
@@ -1589,7 +1777,11 @@ const projectMoveHeading = {
1589
1777
  // fed the computed full order.
1590
1778
  if (vector !== "applescript")
1591
1779
  unsupportedVector(this.op, vector);
1592
- const order = pre.headingMove?.targetOrder ?? [];
1780
+ // #V11: send the MINIMAL front-cluster wire, not the full order an archived
1781
+ // heading that need not move stays out of it (untouched, HEADSORT H-UNSWEPT);
1782
+ // any archived heading the target FORCES in (pre.headingMove.reopened) is
1783
+ // reopened and disclosed. NEVER put un-targeted rows into the wire.
1784
+ const order = pre.headingMove?.wire ?? [];
1593
1785
  return osa(`${PRIVATE_REORDER_COMMAND} project id ${q(pre.destProject?.resolved?.uuid ?? "")} ` +
1594
1786
  `with ids ${q(order.join(","))}`);
1595
1787
  },
@@ -2221,6 +2413,29 @@ const trashEmpty = {
2221
2413
  return osa("empty trash");
2222
2414
  },
2223
2415
  };
2416
+ const logNow = {
2417
+ op: "log-now",
2418
+ hazards: [],
2419
+ preRead(db, _params, now, zone) {
2420
+ const pre = emptyPreState();
2421
+ // The resolved-but-unlogged census the verb will move (the disclosed count),
2422
+ // and the pre-op boundary stamp the delta compares against.
2423
+ pre.logNow = {
2424
+ pending: pendingLogCount(db, now, zone),
2425
+ manualLogDatePre: manualLogDateEpoch(db),
2426
+ };
2427
+ return pre;
2428
+ },
2429
+ expectedDelta(pre) {
2430
+ const ln = pre.logNow ?? { pending: 0, manualLogDatePre: null };
2431
+ return { mode: "logged-now", pending: ln.pending, manualLogDatePre: ln.manualLogDatePre };
2432
+ },
2433
+ compile(_params, vector) {
2434
+ if (vector !== "applescript")
2435
+ unsupportedVector(this.op, vector);
2436
+ return osa("log completed now");
2437
+ },
2438
+ };
2224
2439
  export const COMMANDS = {
2225
2440
  "todo.add": todoAdd,
2226
2441
  "todo.update": todoUpdate,
@@ -2252,8 +2467,6 @@ export const COMMANDS = {
2252
2467
  "project.reopen": projectReopen,
2253
2468
  "project.restore": projectRestore,
2254
2469
  "project.set-tags": projectSetTags,
2255
- "todo.backdate": todoBackdate,
2256
- "todo.add-logged": todoAddLogged,
2257
2470
  "project.add-heading": headingAdd,
2258
2471
  "project.rename-heading": headingRename,
2259
2472
  "project.archive-heading": headingArchive,
@@ -2271,8 +2484,11 @@ export const COMMANDS = {
2271
2484
  "project.reschedule-repeat": projectRescheduleRepeat,
2272
2485
  "project.pause-repeat": projectPauseRepeat,
2273
2486
  "project.resume-repeat": projectResumeRepeat,
2487
+ "todo.set-dates": todoSetDates,
2488
+ "project.set-dates": projectSetDates,
2274
2489
  "area.reorder": areaReorderSidebar,
2275
2490
  "project.make-repeating": projectMakeRepeating,
2276
2491
  "project.add-repeating": projectAddRepeating,
2492
+ "log-now": logNow,
2277
2493
  };
2278
2494
  //# sourceMappingURL=commands.js.map