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
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { z } from "zod";
18
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
- import { ALL_DESC, AREA_FILTER_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, FULL_DESC, hasTagPresence, isLooseRef, isValidTimeZone, LIMIT_DESC, looseShadowNotice, MCP_WHEN_LABELS, mutationWireData, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OP_ID_RE, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, ReferenceResolutionError, REMINDER_FORMAT, schemaWarnings, shapeReadPayload, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
19
+ import { ALL_DESC, AREA_FILTER_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, blockedCode, capabilitiesTable, DATE_FORMAT, DEFAULT_LIST_LIMIT, diagnose, FILTER_CONTRACT, FULL_DESC, hasTagPresence, isLooseRef, isValidTimeZone, LIMIT_DESC, looseShadowNotice, MCP_WHEN_LABELS, mutationWireData, noUuidMatch, omitEmpty, OMIT_EMPTY_NOTE, OP_ID_RE, OPERATION_KINDS, openThings, PKG_VERSION, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, REF_FORMAT, REF_RULE_NOTE, ReferenceResolutionError, REMINDER_FORMAT, RESOLUTION_DATE_FORMAT, schemaWarnings, shapeReadPayload, withTodayBucketTotals, withAreaBucketTotals, withSectionTotals, withUpcomingBlockTotals, splitWhenSugar, tagFilterFields, tagFlagConflict, validateViewArgs, verifyFailedCode, WHEN_VALUES, } from "../index.js";
20
20
  function jsonResult(data) {
21
21
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
22
22
  }
@@ -32,24 +32,31 @@ function readResult(data) {
32
32
  /**
33
33
  * A read result carrying truncation metadata: the data (already limited) in
34
34
  * the first content block, and a second block with the {@link Truncation}
35
- * numbers plus a one-line note the agent can read when rows were dropped.
35
+ * numbers plus a one-line note the agent can read when rows were dropped. `meta`
36
+ * merges extra whole-view metadata into that second block — the today view's
37
+ * `counts` aggregate (the analog of the CLI envelope's `meta.counts`), so `data`
38
+ * stays pure domain rows.
36
39
  */
37
- function truncatedResult(data, truncation) {
40
+ function truncatedResult(data, truncation, meta) {
38
41
  const note = truncation.truncated
39
42
  ? `showing ${truncation.shown} of ${truncation.total} items — pass limit (or all: true) to see more`
40
43
  : undefined;
41
44
  return {
42
45
  content: [
43
46
  { type: "text", text: JSON.stringify(omitEmpty(data)) },
44
- { type: "text", text: JSON.stringify({ truncation, ...(note !== undefined && { note }) }) },
47
+ {
48
+ type: "text",
49
+ text: JSON.stringify({ truncation, ...meta, ...(note !== undefined && { note }) }),
50
+ },
45
51
  ],
46
52
  };
47
53
  }
48
54
  /**
49
- * Grouped read result (anytime/someday): the per-block-truncated sections plus
50
- * a second block carrying the unified {@link Truncation} counts (its `blocks`
51
- * hold the per-block nesting) and, when anything was hidden, a one-line note the
52
- * agent can read.
55
+ * Grouped read result (anytime/someday): the per-block-truncated sections (each
56
+ * capped section carrying its inline `total`, R1) plus a second block carrying
57
+ * the WHOLE-VIEW {@link Truncation} rollup and, when anything was hidden, a
58
+ * one-line note the agent can read. The pre-v2 `blocks[]` sidecar is retired —
59
+ * completeness rides each section's inline `total` (doctrine v2 PR 5).
53
60
  */
54
61
  function groupedResult(data, truncation) {
55
62
  const note = truncation.truncated
@@ -173,7 +180,11 @@ function moveResult(result) {
173
180
  return jsonResult(result.plan);
174
181
  case "move-refused":
175
182
  return errorResult({
176
- code: result.refusal,
183
+ // A hoisted placement block names its hazard — surface the canonical
184
+ // `blocked:<hazard>` code, matching a direct reorder hazard block.
185
+ code: result.hazard !== undefined
186
+ ? blockedCode({ hazard: result.hazard, reason: "hazard" })
187
+ : result.refusal,
177
188
  message: result.detail,
178
189
  ...(result.remediation !== undefined && { remediation: result.remediation }),
179
190
  ...(result.candidates !== undefined && { details: { candidates: result.candidates } }),
@@ -228,6 +239,26 @@ function movePositionArgs(args) {
228
239
  return { after: args.after };
229
240
  return undefined;
230
241
  }
242
+ /** The universal-reorder anchor grammar (start/end/before/after → MovePosition). */
243
+ function reorderPositionArgs(args) {
244
+ const chosen = [
245
+ args.start === true,
246
+ args.end === true,
247
+ args.before !== undefined,
248
+ args.after !== undefined,
249
+ ].filter(Boolean).length;
250
+ if (chosen > 1)
251
+ return "conflict";
252
+ if (args.start === true)
253
+ return { at: "first" };
254
+ if (args.end === true)
255
+ return { at: "last" };
256
+ if (args.before !== undefined)
257
+ return { before: args.before };
258
+ if (args.after !== undefined)
259
+ return { after: args.after };
260
+ return undefined;
261
+ }
231
262
  /** The shared position input schema for the move tools. */
232
263
  const positionShape = {
233
264
  first: z.boolean().optional().describe("place the block at the top of its bucket"),
@@ -566,19 +597,23 @@ export function createThingsMcpServer(options = {}) {
566
597
  };
567
598
  // ------------------------------------------------------------------ reads
568
599
  server.registerTool("read_view", {
569
- description: "Read a Things list as the app presents it: today (split into Today and This " +
570
- "Evening), inbox, anytime, upcoming, someday, logbook, or trash. For upcoming, " +
600
+ description: "Read a Things list as the app presents it: today (two children buckets " +
601
+ "children.today and children.evening, evening expires daily; the whole-view " +
602
+ "count due/overdue vs. other rides the result's second block), inbox, anytime, " +
603
+ "upcoming, someday, logbook, or trash. For upcoming, " +
571
604
  "horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
572
605
  "anytime/someday return sections in canonical order (area + items; null area = the " +
573
606
  "top-level block); children of someday/future-scheduled projects are excluded " +
574
607
  "from anytime — the project row represents them; someday lists each group's " +
575
- "project rows before its to-dos. Flat views (today/inbox/upcoming/logbook/trash) " +
608
+ "project rows before its to-dos. Row-capped views (today/inbox/upcoming/logbook/trash) " +
576
609
  `return at most ${DEFAULT_LIST_LIMIT} items by default (raise with limit); ` +
577
610
  "anytime/someday always return every group and cap per block instead — " +
578
611
  `area_limit (default ${AREA_PREVIEW_LIMIT}) per area block, and on anytime ` +
579
612
  `project_limit (default ${PROJECT_PREVIEW_LIMIT}) per project block. ` +
580
613
  "all: true lifts every cap; the result's second block reports the counts. " +
581
- OMIT_EMPTY_NOTE,
614
+ OMIT_EMPTY_NOTE +
615
+ " " +
616
+ REF_RULE_NOTE,
582
617
  inputSchema: {
583
618
  view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
584
619
  ...tagFilterShape,
@@ -690,7 +725,7 @@ export function createThingsMcpServer(options = {}) {
690
725
  const area = args.area !== undefined ? { area: args.area } : {};
691
726
  switch (args.view) {
692
727
  case "today": {
693
- const { view, truncation, filter: fm, } = c.read.today({
728
+ const { view, truncation, totals, filter: fm, } = c.read.today({
694
729
  ...filter,
695
730
  ...zone,
696
731
  ...area,
@@ -698,14 +733,17 @@ export function createThingsMcpServer(options = {}) {
698
733
  limit,
699
734
  });
700
735
  filterMeta = fm;
701
- return truncatedResult(shapeReadPayload("today", view, full), truncation);
736
+ // The data block is the two `children` bucket records (each with its
737
+ // inline `total` when capped); the whole-view `counts` aggregate
738
+ // rides the metadata block (the CLI meta.counts analog).
739
+ return truncatedResult(withTodayBucketTotals(shapeReadPayload("today", view, full, c.refPromoter()), totals), truncation, { counts: view.counts });
702
740
  }
703
741
  case "inbox": {
704
742
  const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
705
- return truncatedResult(shapeReadPayload("inbox", items, full), truncation);
743
+ return truncatedResult(shapeReadPayload("inbox", items, full, c.refPromoter()), truncation);
706
744
  }
707
745
  case "anytime": {
708
- const { view, truncation, filter: fm, } = c.read.anytime({
746
+ const { view, truncation, sectionTotals, filter: fm, } = c.read.anytime({
709
747
  ...filter,
710
748
  ...zone,
711
749
  ...area,
@@ -713,10 +751,10 @@ export function createThingsMcpServer(options = {}) {
713
751
  projectLimit,
714
752
  });
715
753
  filterMeta = fm;
716
- return groupedResult(shapeReadPayload("anytime", view, full), truncation);
754
+ return groupedResult(withSectionTotals(shapeReadPayload("anytime", view, full, c.refPromoter()), sectionTotals), truncation);
717
755
  }
718
756
  case "upcoming": {
719
- const { items, truncation, filter: fm, } = c.read.upcoming({
757
+ const { items, truncation, upcomingTotals, filter: fm, } = c.read.upcoming({
720
758
  ...filter,
721
759
  ...zone,
722
760
  ...area,
@@ -724,14 +762,16 @@ export function createThingsMcpServer(options = {}) {
724
762
  limit,
725
763
  });
726
764
  filterMeta = fm;
727
- return truncatedResult(shapeReadPayload("upcoming", items, full), truncation);
765
+ // The data block is the day-block `sections` array, each capped block
766
+ // carrying its inline `total` (R1); the flat rollup rides truncation.
767
+ return truncatedResult(withUpcomingBlockTotals(shapeReadPayload("upcoming", items, full, c.refPromoter()), upcomingTotals), truncation);
728
768
  }
729
769
  case "someday": {
730
770
  const active = showActiveProjectItems;
731
771
  if (typeof active === "number" && args.all === true) {
732
772
  return usage("pass at most one of a numeric show_active_project_items / all");
733
773
  }
734
- const { view, truncation, filter: fm, } = c.read.someday({
774
+ const { view, truncation, sectionTotals, filter: fm, } = c.read.someday({
735
775
  ...filter,
736
776
  ...zone,
737
777
  ...area,
@@ -743,7 +783,7 @@ export function createThingsMcpServer(options = {}) {
743
783
  projectLimit: typeof active === "number" ? active : null,
744
784
  });
745
785
  filterMeta = fm;
746
- return groupedResult(shapeReadPayload("someday", view, full), truncation);
786
+ return groupedResult(withSectionTotals(shapeReadPayload("someday", view, full, c.refPromoter()), sectionTotals), truncation);
747
787
  }
748
788
  case "logbook": {
749
789
  const { items, truncation, filter: fm, } = c.read.logbook({
@@ -753,11 +793,11 @@ export function createThingsMcpServer(options = {}) {
753
793
  limit,
754
794
  });
755
795
  filterMeta = fm;
756
- return truncatedResult(shapeReadPayload("logbook", items, full), truncation);
796
+ return truncatedResult(shapeReadPayload("logbook", items, full, c.refPromoter()), truncation);
757
797
  }
758
798
  case "trash": {
759
799
  const { items, truncation } = c.read.trash({ ...zone, limit });
760
- return truncatedResult(shapeReadPayload("trash", items, full), truncation);
800
+ return truncatedResult(shapeReadPayload("trash", items, full, c.refPromoter()), truncation);
761
801
  }
762
802
  }
763
803
  }, args.tz, () => (filterMeta !== undefined ? { filter: filterMeta } : undefined), () => looseAreaWarnings(getClient(), args.area));
@@ -766,7 +806,9 @@ export function createThingsMcpServer(options = {}) {
766
806
  description: "Find items by title/notes substring. Returns open, untrashed items by default; " +
767
807
  "include more with logged/trashed/all. Scope with project/area/tag — scope " +
768
808
  "references must name existing items. " +
769
- OMIT_EMPTY_NOTE,
809
+ OMIT_EMPTY_NOTE +
810
+ " " +
811
+ REF_RULE_NOTE,
770
812
  inputSchema: {
771
813
  query: z.string(),
772
814
  ...tagFilterShape,
@@ -821,13 +863,15 @@ export function createThingsMcpServer(options = {}) {
821
863
  ...(args.trashed === true && { trashed: true }),
822
864
  ...(args.all === true && { all: true }),
823
865
  });
824
- return truncatedResult(shapeReadPayload("search", items, args.full === true), truncation);
866
+ return truncatedResult(shapeReadPayload("search", items, args.full === true, getClient().refPromoter()), truncation);
825
867
  }, args.tz, undefined, () => looseAreaWarnings(getClient(), args.area)));
826
868
  server.registerTool("changes_since", {
827
869
  description: "List items created or modified since a moment — including trashed, logged, and " +
828
870
  "repeating items (inspect each item's fields to tell them apart). Edits to tags, " +
829
871
  "areas, and checklist items do not mark the containing item as modified. " +
830
- OMIT_EMPTY_NOTE,
872
+ OMIT_EMPTY_NOTE +
873
+ " " +
874
+ REF_RULE_NOTE,
831
875
  inputSchema: {
832
876
  since: z.string().describe("ISO date-time, e.g. 2026-07-06T08:00:00"),
833
877
  ...limitShape,
@@ -849,26 +893,32 @@ export function createThingsMcpServer(options = {}) {
849
893
  limit,
850
894
  ...(args.tz !== undefined && { zone: args.tz }),
851
895
  });
852
- return truncatedResult(shapeReadPayload("changes", items, args.full === true), truncation);
896
+ return truncatedResult(shapeReadPayload("changes", items, args.full === true, getClient().refPromoter()), truncation);
853
897
  }, args.tz));
854
898
  server.registerTool("get_item", {
855
899
  description: "Full detail for one item by uuid: notes, schedule, reminder, deadline, tags " +
856
900
  "(direct and inherited), checklist with per-item state, repeat schedule, and its " +
857
901
  "project/area/heading. " +
858
- OMIT_EMPTY_NOTE,
902
+ OMIT_EMPTY_NOTE +
903
+ " " +
904
+ REF_RULE_NOTE,
859
905
  inputSchema: { uuid: z.string() },
860
906
  annotations: READ_ONLY,
861
907
  }, async (args) => readGuard(() => {
862
908
  const item = getClient().read.byUuid(args.uuid);
863
909
  return item === null
864
910
  ? errorResult({ code: "not-found", message: noUuidMatch("item", args.uuid) })
865
- : readResult(shapeReadPayload("detail", item, false));
911
+ : readResult(shapeReadPayload("detail", item, false, getClient().refPromoter()));
866
912
  }));
867
913
  server.registerTool("get_project", {
868
914
  description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
869
- "The tag filters keep only the child to-dos matching by their own tags (a heading left " +
870
- "with none is dropped). " +
871
- OMIT_EMPTY_NOTE,
915
+ "Open headings (and archived ones not yet swept to the logbook) stay live, carrying " +
916
+ "archived once archived; a swept archived heading moves into the logged region as a " +
917
+ "group with its children. The tag filters keep only the child to-dos matching by their " +
918
+ "own tags (a heading left with none is dropped). " +
919
+ OMIT_EMPTY_NOTE +
920
+ " " +
921
+ REF_RULE_NOTE,
872
922
  inputSchema: {
873
923
  uuid: z.string().describe("Project uuid or unique name"),
874
924
  ...tagOnlyShape,
@@ -890,7 +940,7 @@ export function createThingsMcpServer(options = {}) {
890
940
  overdue: args.overdue === true,
891
941
  ...tagFilterFields(tagPresence(args)),
892
942
  ...(args.tz !== undefined && { zone: args.tz }),
893
- }), args.full === true));
943
+ }), args.full === true, getClient().refPromoter()));
894
944
  }, args.tz));
895
945
  server.registerTool("get_area", {
896
946
  description: "One area's contents: metadata plus its direct to-dos (active first), its " +
@@ -900,7 +950,9 @@ export function createThingsMcpServer(options = {}) {
900
950
  `The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
901
951
  "by default (project_limit / area_limit adjust them; all: true lifts both); the " +
902
952
  "second result block reports the counts. " +
903
- OMIT_EMPTY_NOTE,
953
+ OMIT_EMPTY_NOTE +
954
+ " " +
955
+ REF_RULE_NOTE,
904
956
  inputSchema: {
905
957
  ref: z.string().describe("Area uuid or unique name"),
906
958
  ...tagOnlyShape,
@@ -941,7 +993,7 @@ export function createThingsMcpServer(options = {}) {
941
993
  if (areaLimit === "conflict" || projectLimit === "conflict") {
942
994
  return usage("pass at most one of area_limit/project_limit / all");
943
995
  }
944
- const { view, truncation, notice } = getClient().read.areaView(args.ref, {
996
+ const { view, truncation, totals, notice } = getClient().read.areaView(args.ref, {
945
997
  overdue: args.overdue === true,
946
998
  ...tagFilterFields(tagPresence(args)),
947
999
  ...(args.tz !== undefined && { zone: args.tz }),
@@ -949,14 +1001,18 @@ export function createThingsMcpServer(options = {}) {
949
1001
  projectLimit,
950
1002
  });
951
1003
  areaNotice = notice;
952
- return groupedResult(shapeReadPayload("area-view", view, args.full === true), truncation);
1004
+ // Each capped scope's completeness rides its inline `total` (R1, PR 3);
1005
+ // the whole-view rollup rides `truncation` (no `blocks[]` sidecar).
1006
+ return groupedResult(withAreaBucketTotals(shapeReadPayload("area-view", view, args.full === true, getClient().refPromoter()), totals), truncation);
953
1007
  }, args.tz, undefined, () => (areaNotice !== undefined ? [areaNotice] : undefined));
954
1008
  });
955
1009
  server.registerTool("list_collections", {
956
1010
  description: "List every project, area, or tag (tags include their parent-tag nesting). Use to " +
957
1011
  "refresh the inventory summarized in the server instructions. The tag filters scope " +
958
1012
  "the projects list by each project's own tags (areas/tags reject them). " +
959
- OMIT_EMPTY_NOTE,
1013
+ OMIT_EMPTY_NOTE +
1014
+ " " +
1015
+ REF_RULE_NOTE,
960
1016
  inputSchema: {
961
1017
  kind: z.enum(["projects", "areas", "tags"]),
962
1018
  ...tagOnlyShape,
@@ -1001,7 +1057,7 @@ export function createThingsMcpServer(options = {}) {
1001
1057
  })
1002
1058
  : args.kind === "areas"
1003
1059
  ? c.read.areas()
1004
- : c.read.tags(), args.full === true));
1060
+ : c.read.tags(), args.full === true, c.refPromoter()));
1005
1061
  }, args.tz));
1006
1062
  // ---------------------------------------------------------------- to-dos
1007
1063
  const whenSchema = z.string().optional().describe(WHEN_VALUES);
@@ -1026,6 +1082,15 @@ export function createThingsMcpServer(options = {}) {
1026
1082
  .boolean()
1027
1083
  .optional()
1028
1084
  .describe("Confirm adding into a completed/canceled project (this reopens it)"),
1085
+ created_at: z
1086
+ .string()
1087
+ .optional()
1088
+ .describe(`Born with this creation timestamp (${RESOLUTION_DATE_FORMAT})`),
1089
+ completed_at: z
1090
+ .string()
1091
+ .optional()
1092
+ .describe(`Born completed, in the Logbook, with this completion timestamp ` +
1093
+ `(${RESOLUTION_DATE_FORMAT}); drop when/reminder`),
1029
1094
  ...createTagsShape,
1030
1095
  ...tzShape,
1031
1096
  ...dryRunShape,
@@ -1052,6 +1117,8 @@ export function createThingsMcpServer(options = {}) {
1052
1117
  ...(args.project !== undefined && { project: containerRef(args.project) }),
1053
1118
  ...(args.area !== undefined && { area: containerRef(args.area) }),
1054
1119
  ...(args.heading !== undefined && { heading: args.heading }),
1120
+ ...(args.created_at !== undefined && { createdAt: args.created_at }),
1121
+ ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1055
1122
  }, writeOptions(args)));
1056
1123
  }));
1057
1124
  server.registerTool("update", {
@@ -1061,7 +1128,10 @@ export function createThingsMcpServer(options = {}) {
1061
1128
  "deadline/clear_deadline; changing the schedule keeps an existing reminder unless a new " +
1062
1129
  "one is set, schedule and deadline changes are unavailable for repeating items, and " +
1063
1130
  "clear_reminder needs the item scheduled for today or this evening (a reminder on a " +
1064
- "future date can only be changed, not cleared). kind area: title and/or tags (the full " +
1131
+ "future date can only be changed, not cleared). created_at/completed_at rewrite the " +
1132
+ "resolution timestamps of an already-resolved item; created_at is status-safe, but " +
1133
+ "completed_at on an open item is refused — resolving it is set_status's boundary, not " +
1134
+ "update's. kind area: title and/or tags (the full " +
1065
1135
  "replacement set). kind tag: title, parent (nest under it) or unnest (to the top level; " +
1066
1136
  "exclusive), and shortcut or clear_shortcut (exclusive). Tags must exist unless " +
1067
1137
  "create_tags is set.",
@@ -1080,6 +1150,16 @@ export function createThingsMcpServer(options = {}) {
1080
1150
  clear_reminder: z.boolean().optional().describe("todo/project: remove the reminder"),
1081
1151
  deadline: z.string().optional().describe(`todo/project: ${DATE_FORMAT}`),
1082
1152
  clear_deadline: z.boolean().optional().describe("todo/project: remove the deadline"),
1153
+ created_at: z
1154
+ .string()
1155
+ .optional()
1156
+ .describe(`todo/project: rewrite the creation timestamp (${RESOLUTION_DATE_FORMAT}); status-safe`),
1157
+ completed_at: z
1158
+ .string()
1159
+ .optional()
1160
+ .describe("todo/project: rewrite the completion timestamp of an already-resolved item " +
1161
+ `(${RESOLUTION_DATE_FORMAT}; a canceled one stays canceled); an open item is ` +
1162
+ "refused — use set_status to resolve it"),
1083
1163
  tags: z
1084
1164
  .array(z.string())
1085
1165
  .optional()
@@ -1127,6 +1207,8 @@ export function createThingsMcpServer(options = {}) {
1127
1207
  ...(args.clear_reminder === true && { reminder: null }),
1128
1208
  ...(args.deadline !== undefined && { deadline: args.deadline }),
1129
1209
  ...(args.clear_deadline === true && { deadline: null }),
1210
+ ...(args.created_at !== undefined && { createdAt: args.created_at }),
1211
+ ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1130
1212
  }, opts));
1131
1213
  }
1132
1214
  return mutationResult(await c.write.updateProject(args.uuid, {
@@ -1139,6 +1221,8 @@ export function createThingsMcpServer(options = {}) {
1139
1221
  ...(args.clear_reminder === true && { reminder: null }),
1140
1222
  ...(args.deadline !== undefined && { deadline: args.deadline }),
1141
1223
  ...(args.clear_deadline === true && { deadline: null }),
1224
+ ...(args.created_at !== undefined && { createdAt: args.created_at }),
1225
+ ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1142
1226
  }, opts));
1143
1227
  }
1144
1228
  if (args.kind === "area") {
@@ -1178,7 +1262,10 @@ export function createThingsMcpServer(options = {}) {
1178
1262
  "scope project, completing or canceling requires a children policy: 'require-resolved' " +
1179
1263
  "errors if open to-dos remain; 'auto-complete'/'auto-cancel' resolves them together with " +
1180
1264
  "the project (canceling never alters already-completed children). scope project, status " +
1181
- "open, restore_children also reopens the to-dos that were resolved with the project.",
1265
+ "open, restore_children also reopens the to-dos that were resolved with the project. " +
1266
+ "completed_at (status completed or canceled) sets the completion timestamp — also the " +
1267
+ '"Completed on" stamp for a canceled item — backdating it; reaching a backdated canceled ' +
1268
+ "item is a multi-leg, non-atomic sequence, disclosed in the result and in dry_run.",
1182
1269
  inputSchema: {
1183
1270
  scope: z.enum(["todo", "project"]),
1184
1271
  uuid: z
@@ -1193,21 +1280,33 @@ export function createThingsMcpServer(options = {}) {
1193
1280
  .boolean()
1194
1281
  .optional()
1195
1282
  .describe("scope project, open only: also reopen the to-dos resolved with the project"),
1283
+ completed_at: z
1284
+ .string()
1285
+ .optional()
1286
+ .describe(`status completed or canceled: the completion timestamp (${RESOLUTION_DATE_FORMAT})`),
1287
+ ...tzShape,
1196
1288
  ...dryRunShape,
1197
1289
  ...opIdShape,
1198
1290
  },
1199
1291
  annotations: NON_DESTRUCTIVE,
1200
1292
  }, async (args) => guard(async () => {
1293
+ const badZone = badTz(args.tz);
1294
+ if (badZone !== null)
1295
+ return badZone;
1201
1296
  const c = getClient();
1202
1297
  const opts = writeOptions(args);
1298
+ if (args.completed_at !== undefined && args.status === "open") {
1299
+ return usage("completed_at applies only to status 'completed' or 'canceled'");
1300
+ }
1301
+ const resolution = args.completed_at !== undefined ? { completedAt: args.completed_at } : {};
1203
1302
  if (args.scope === "todo") {
1204
1303
  if (args.children !== undefined || args.restore_children !== undefined) {
1205
1304
  return usage("children/restore_children apply only to scope project");
1206
1305
  }
1207
1306
  return mutationResult(args.status === "completed"
1208
- ? await c.write.completeTodo(args.uuid, opts)
1307
+ ? await c.write.completeTodo(args.uuid, resolution, opts)
1209
1308
  : args.status === "canceled"
1210
- ? await c.write.cancelTodo(args.uuid, opts)
1309
+ ? await c.write.cancelTodo(args.uuid, resolution, opts)
1211
1310
  : await c.write.reopenTodo(args.uuid, opts));
1212
1311
  }
1213
1312
  // scope project
@@ -1218,13 +1317,13 @@ export function createThingsMcpServer(options = {}) {
1218
1317
  if (args.children !== "require-resolved" && args.children !== "auto-complete") {
1219
1318
  return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
1220
1319
  }
1221
- return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, opts));
1320
+ return mutationResult(await c.write.completeProject(args.uuid, { children: args.children, ...resolution }, opts));
1222
1321
  }
1223
1322
  if (args.status === "canceled") {
1224
1323
  if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
1225
1324
  return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
1226
1325
  }
1227
- return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, opts));
1326
+ return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children, ...resolution }, opts));
1228
1327
  }
1229
1328
  if (args.children !== undefined) {
1230
1329
  return usage("children applies only to status 'completed' or 'canceled'");
@@ -1488,41 +1587,12 @@ export function createThingsMcpServer(options = {}) {
1488
1587
  ? await c.write.restoreTodo(args.uuid, writeOptions(args))
1489
1588
  : await c.write.restoreProject(args.uuid, writeOptions(args)));
1490
1589
  }));
1491
- server.registerTool("backdate_todo", {
1492
- description: "Rewrite a to-do's completion and/or creation timestamp to noon (local) on the " +
1493
- "given date. completion_date requires the to-do to already be completed or " +
1494
- "canceled; the Logbook re-sorts to the new date.",
1495
- inputSchema: {
1496
- uuid: z.string(),
1497
- completion_date: z.string().optional().describe(DATE_FORMAT),
1498
- creation_date: z.string().optional().describe(DATE_FORMAT),
1499
- ...dryRunShape,
1500
- ...opIdShape,
1501
- },
1502
- annotations: NON_DESTRUCTIVE,
1503
- }, async (args) => guard(async () => mutationResult(await getClient().write.backdateTodo(args.uuid, {
1504
- ...(args.completion_date !== undefined && { completionDate: args.completion_date }),
1505
- ...(args.creation_date !== undefined && { creationDate: args.creation_date }),
1506
- }, writeOptions(args)))));
1507
- server.registerTool("add_logged_todo", {
1508
- description: "Create a to-do directly in the Logbook: completed, with the given past " +
1509
- "completion date (and optionally a past creation date). For importing history " +
1510
- "from another system.",
1511
- inputSchema: {
1512
- title: z.string(),
1513
- completion_date: z.string().describe(DATE_FORMAT),
1514
- creation_date: z.string().optional().describe(`${DATE_FORMAT}; <= completion_date`),
1515
- notes: z.string().optional(),
1516
- ...dryRunShape,
1517
- ...opIdShape,
1518
- },
1519
- annotations: NON_DESTRUCTIVE,
1520
- }, async (args) => guard(async () => mutationResult(await getClient().write.addLoggedTodo({
1521
- title: args.title,
1522
- completionDate: args.completion_date,
1523
- ...(args.creation_date !== undefined && { creationDate: args.creation_date }),
1524
- ...(args.notes !== undefined && { notes: args.notes }),
1525
- }, writeOptions(args)))));
1590
+ // NB: the bespoke backdate_todo / add_logged_todo tools were removed with the
1591
+ // engine ops (plan PR A). MCP parity for the resolution-timestamp surface is
1592
+ // restored (PR B) as created_at/completed_at params folded onto the existing
1593
+ // write tools: add_todo / add_project (Logbook import), update (rewrite an
1594
+ // already-resolved item's timestamps), and set_status (backdate on
1595
+ // complete/cancel) — matching the CLI's --created-at/--completed-at flags.
1526
1596
  server.registerTool("heading", {
1527
1597
  description: "Manage a project's headings — action selects which; project is always required, and a " +
1528
1598
  "heading is selected by its exact title or its uuid (never an ordinal). add_heading: a " +
@@ -1912,6 +1982,15 @@ export function createThingsMcpServer(options = {}) {
1912
1982
  when: whenSchema,
1913
1983
  deadline: z.string().optional().describe(DATE_FORMAT),
1914
1984
  todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
1985
+ created_at: z
1986
+ .string()
1987
+ .optional()
1988
+ .describe(`Born with this creation timestamp (${RESOLUTION_DATE_FORMAT})`),
1989
+ completed_at: z
1990
+ .string()
1991
+ .optional()
1992
+ .describe(`Born completed, in the Logbook, with this completion timestamp ` +
1993
+ `(${RESOLUTION_DATE_FORMAT}); cannot seed open child to-dos`),
1915
1994
  ...tzShape,
1916
1995
  ...dryRunShape,
1917
1996
  ...opIdShape,
@@ -1928,6 +2007,8 @@ export function createThingsMcpServer(options = {}) {
1928
2007
  ...(args.when !== undefined && { when: args.when }),
1929
2008
  ...(args.deadline !== undefined && { deadline: args.deadline }),
1930
2009
  ...(args.todos !== undefined && { todos: args.todos }),
2010
+ ...(args.created_at !== undefined && { createdAt: args.created_at }),
2011
+ ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1931
2012
  }, writeOptions(args)));
1932
2013
  }));
1933
2014
  server.registerTool("move_project", {
@@ -2108,81 +2189,53 @@ export function createThingsMcpServer(options = {}) {
2108
2189
  };
2109
2190
  }));
2110
2191
  server.registerTool("reorder", {
2111
- description: "Rearrange to-dos IN PLACE within the list or container and the bucket they already " +
2112
- "share this REARRANGES, never changes what an item belongs to (to change membership " +
2113
- "use move_todo / move_project). The refs order is the resulting order; unmentioned " +
2114
- "siblings keep theirs. Bare (no position) assembles the named items as a block at the " +
2115
- "EARLIEST one's current slot (partial-selection friendly); first/last/before/after " +
2116
- "position the block instead. Refs that span different containers or buckets are refused. " +
2117
- "A Today or This Evening member also holds a slot in its own container, so a set that is " +
2118
- "coherent on BOTH axes is ambiguous pass `in` to name the axis (the refusal names both " +
2119
- "choices). The project rows the Today/Evening/day lists intermix with to-dos may be " +
2120
- "reordered alongside them. Ordering the Today, Inbox, or Someday lists, a project's " +
2121
- "to-dos, or an area must first be enabled once via `things config set allow-experimental " +
2122
- "true`. To reorder a project's HEADINGS (children follow) use the heading tool's " +
2123
- "move_heading action; to reorder sidebar AREAS use reorder_areas.",
2192
+ description: "The ONE reorder tool rearrange a single-KIND set IN PLACE: to-dos, projects, headings, " +
2193
+ "OR sidebar areas. This REARRANGES, never changes what an item belongs to (to change " +
2194
+ "membership use move_todo / move_project). All refs must be one kind; only to-dos and " +
2195
+ "projects may intermix, and only on the shared Today/Evening/day axes. A mixed-kind set, " +
2196
+ "a cross-container set, and a non-member anchor each get one precise refusal. The refs " +
2197
+ "order is the resulting order; unmentioned siblings keep theirs. Bare (no position) " +
2198
+ "assembles the named items as a block at the EARLIEST one's current slot; start/end/" +
2199
+ "before/after position the block. A Today or This Evening member also holds a slot in its " +
2200
+ "container, so a set coherent on BOTH axes is ambiguous — pass `in` to name the axis. " +
2201
+ "HEADINGS: same-project heading re-ranking runs the native heading-block wire (children " +
2202
+ "follow); an archived heading is reorderable but repositioning it brings it back to open " +
2203
+ "(disclosed in the result). AREAS: this drives the local Things app (sidebar drag) and " +
2204
+ "must be turned on with `things config set ui-enabled true` plus dangerously_drive_gui; " +
2205
+ "the areas' projects and to-dos are untouched. Ordering the Today, Inbox, or Someday " +
2206
+ "lists, or a project's to-dos, must first be enabled once via `things config set " +
2207
+ "allow-experimental true`.",
2124
2208
  inputSchema: {
2125
2209
  refs: z
2126
2210
  .array(z.string())
2127
- .describe("The items to rearrange, in the order they should land (may be a subset)"),
2128
- ...positionShape,
2211
+ .describe("The items to rearrange (one kind: to-dos, projects, headings, or areas), in the " +
2212
+ "order they should land (may be a subset)"),
2213
+ start: z.boolean().optional().describe("place the block at the start of its scope"),
2214
+ end: z.boolean().optional().describe("place the block at the end of its scope"),
2215
+ before: z.string().optional().describe("place the block immediately before this item"),
2216
+ after: z.string().optional().describe("place the block immediately after this item"),
2129
2217
  in: z
2130
2218
  .string()
2131
2219
  .optional()
2132
- .describe("Disambiguate the axis of a Today/This Evening set: today | evening | anytime | " +
2133
- "someday | inbox, or a project/area/heading ref (uuid or unique title)"),
2220
+ .describe("to-dos/projects only — name the axis to reorder on: today | evening | anytime | " +
2221
+ "someday | inbox, a project/area/heading ref (uuid or unique title), upcoming (the " +
2222
+ "one future day the set shares), or a YYYY-MM-DD day-block. A stage-list or " +
2223
+ "container axis sorts one KIND at a time; only today | evening | upcoming | a " +
2224
+ "day-block intermix both kinds."),
2225
+ ...driveGuiShape,
2134
2226
  ...dryRunShape,
2135
2227
  },
2136
2228
  annotations: NON_DESTRUCTIVE,
2137
2229
  }, async (args) => guard(async () => {
2138
- const position = movePositionArgs(args);
2230
+ const position = reorderPositionArgs(args);
2139
2231
  if (position === "conflict")
2140
- return usage("pass at most one of first/last/before/after");
2232
+ return usage("pass at most one of start/end/before/after");
2141
2233
  const request = {
2142
2234
  uuids: args.refs,
2143
2235
  ...(position !== undefined && { position }),
2144
2236
  ...(args.in !== undefined && { in: args.in }),
2145
2237
  };
2146
- return moveResult(await getClient().write.reorderTodos(request, writeOptions(args)));
2147
- }));
2148
- server.registerTool("reorder_areas", {
2149
- description: "Move a sidebar area to a new position in the area order (target by uuid or unique " +
2150
- "name). Pass exactly one destination: before/after another area, or first/last. This " +
2151
- "visibly drives the local Things app (the window comes forward and the sidebar may " +
2152
- "scroll) and must be turned on first with `things config set ui-enabled true`; the " +
2153
- "area's projects and to-dos are untouched.",
2154
- inputSchema: {
2155
- target: z.string().describe(`the area to move (${REF_FORMAT})`),
2156
- before: z
2157
- .string()
2158
- .optional()
2159
- .describe(`place it immediately above this area (${REF_FORMAT})`),
2160
- after: z
2161
- .string()
2162
- .optional()
2163
- .describe(`place it immediately below this area (${REF_FORMAT})`),
2164
- first: z.boolean().optional().describe("move it to the top of the area list"),
2165
- last: z.boolean().optional().describe("move it to the bottom of the area list"),
2166
- ...driveGuiShape,
2167
- ...dryRunShape,
2168
- },
2169
- annotations: NON_DESTRUCTIVE,
2170
- }, async (args) => guard(async () => {
2171
- const chosen = [
2172
- args.before !== undefined,
2173
- args.after !== undefined,
2174
- args.first === true,
2175
- args.last === true,
2176
- ].filter(Boolean).length;
2177
- if (chosen !== 1)
2178
- return usage("pass exactly one of before / after / first / last");
2179
- return mutationResult(await getClient().write.run("area.reorder", {
2180
- target: args.target,
2181
- ...(args.before !== undefined && { before: args.before }),
2182
- ...(args.after !== undefined && { after: args.after }),
2183
- ...(args.first === true && { position: "first" }),
2184
- ...(args.last === true && { position: "last" }),
2185
- }, writeOptions(args)));
2238
+ return moveResult(await getClient().write.reorderAny(request, writeOptions(args)));
2186
2239
  }));
2187
2240
  server.registerTool("undo", {
2188
2241
  description: "Undo the last N changes, newest first (changes made directly in the Things app " +