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
@@ -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, REF_RULE_NOTE, 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
  }
@@ -52,10 +52,11 @@ function truncatedResult(data, truncation, meta) {
52
52
  };
53
53
  }
54
54
  /**
55
- * Grouped read result (anytime/someday): the per-block-truncated sections plus
56
- * a second block carrying the unified {@link Truncation} counts (its `blocks`
57
- * hold the per-block nesting) and, when anything was hidden, a one-line note the
58
- * 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).
59
60
  */
60
61
  function groupedResult(data, truncation) {
61
62
  const note = truncation.truncated
@@ -238,6 +239,26 @@ function movePositionArgs(args) {
238
239
  return { after: args.after };
239
240
  return undefined;
240
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
+ }
241
262
  /** The shared position input schema for the move tools. */
242
263
  const positionShape = {
243
264
  first: z.boolean().optional().describe("place the block at the top of its bucket"),
@@ -392,10 +413,9 @@ function buildInstructions(getClient) {
392
413
  "confirmation parameter named in their description; refused calls return an error saying " +
393
414
  "what to pass.",
394
415
  "- Read-result semantics: an item's tags are its direct tags; its effective tags also include " +
395
- "tags inherited from its containing project and area. An item's when is today or evening for " +
396
- "a Today member (evening implies today), a future date for a scheduled item, else absent; an " +
397
- "unscheduled start=active item is in Anytime and carries no when. Completing an item makes it " +
398
- "findable in Logbook.",
416
+ "tags inherited from its containing project and area. The today and evening markers are " +
417
+ "present only for an item in Today (evening implies today); an unscheduled start=active item " +
418
+ "is in Anytime and carries neither. Completing an item makes it findable in Logbook.",
399
419
  "- For capped reads, pass limit to cap rows or all: true for everything; if both are set, all wins.",
400
420
  `- Read results are compact: ${OMIT_EMPTY_NOTE}`,
401
421
  ];
@@ -577,14 +597,18 @@ export function createThingsMcpServer(options = {}) {
577
597
  };
578
598
  // ------------------------------------------------------------------ reads
579
599
  server.registerTool("read_view", {
580
- description: "Read a Things list as the app presents it: today (one flat list of Today " +
581
- "members, each item's when marking Today vs This Evening), inbox, anytime, " +
582
- "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, trash, or deadlines (a flat items list of everything " +
604
+ "carrying a deadline — to-dos and projects — in deadline order, most-overdue first; " +
605
+ "repeating items appear at their next occurrence's projected deadline; scope with " +
606
+ "today/overdue/project/area/tag). For upcoming, " +
583
607
  "horizon > 1 also includes future occurrences of repeating items (up to 10 each). " +
584
608
  "anytime/someday return sections in canonical order (area + items; null area = the " +
585
609
  "top-level block); children of someday/future-scheduled projects are excluded " +
586
610
  "from anytime — the project row represents them; someday lists each group's " +
587
- "project rows before its to-dos. Flat views (today/inbox/upcoming/logbook/trash) " +
611
+ "project rows before its to-dos. Row-capped views (today/inbox/upcoming/logbook/trash) " +
588
612
  `return at most ${DEFAULT_LIST_LIMIT} items by default (raise with limit); ` +
589
613
  "anytime/someday always return every group and cap per block instead — " +
590
614
  `area_limit (default ${AREA_PREVIEW_LIMIT}) per area block, and on anytime ` +
@@ -594,17 +618,31 @@ export function createThingsMcpServer(options = {}) {
594
618
  " " +
595
619
  REF_RULE_NOTE,
596
620
  inputSchema: {
597
- view: z.enum(["today", "inbox", "anytime", "upcoming", "someday", "logbook", "trash"]),
621
+ view: z.enum([
622
+ "today",
623
+ "inbox",
624
+ "anytime",
625
+ "upcoming",
626
+ "someday",
627
+ "logbook",
628
+ "trash",
629
+ "deadlines",
630
+ ]),
598
631
  ...tagFilterShape,
599
632
  ...tzShape,
600
633
  area: z
601
634
  .string()
602
635
  .optional()
603
- .describe(`today/anytime/someday/upcoming/logbook only: ${AREA_FILTER_DESC}`),
604
- evening: z
636
+ .describe(`today/anytime/someday/upcoming/logbook/deadlines only: ${AREA_FILTER_DESC}`),
637
+ today: z
605
638
  .boolean()
606
639
  .optional()
607
- .describe("today only: show only This-Evening members (when = evening)"),
640
+ .describe("deadlines only: keep only current Today members (This Evening included)"),
641
+ project: z
642
+ .string()
643
+ .optional()
644
+ .describe("deadlines only: restrict to one project's children (uuid or unique name)"),
645
+ evening: z.boolean().optional().describe("today only: show only the This Evening section"),
608
646
  show_active_project_items: z
609
647
  .union([z.boolean(), z.number().int().min(1)])
610
648
  .optional()
@@ -661,7 +699,7 @@ export function createThingsMcpServer(options = {}) {
661
699
  // (the current-work views), matching FILTER_CONTRACT.
662
700
  const validated = validateViewArgs(args.view, { ...tagPresence(args), overdue: args.overdue }, {
663
701
  untaggedConflict: MCP_UNTAGGED_CONFLICT,
664
- overdueRejected: `overdue applies to today/inbox/anytime/someday, not ${args.view}`,
702
+ overdueRejected: `overdue applies to today/inbox/anytime/someday/deadlines, not ${args.view}`,
665
703
  overdueStatusWiden: "",
666
704
  });
667
705
  if (!validated.ok)
@@ -683,6 +721,14 @@ export function createThingsMcpServer(options = {}) {
683
721
  if (args.view !== "today" && args.evening === true) {
684
722
  return usage(`evening applies only to today, not ${args.view}`);
685
723
  }
724
+ // The two deadlines-only scopes. `area`/`tag`/`overdue`/`limit` are
725
+ // shared with the other flat views; `today`/`project` are not.
726
+ if (args.view !== "deadlines" && args.today === true) {
727
+ return usage(`today applies only to deadlines, not ${args.view}`);
728
+ }
729
+ if (args.view !== "deadlines" && args.project !== undefined) {
730
+ return usage(`project applies only to deadlines, not ${args.view}`);
731
+ }
686
732
  // The `area` filter applies to the area-carrying views only; inbox
687
733
  // (area-less captures) and trash have no area to scope by.
688
734
  if ((args.view === "inbox" || args.view === "trash") && args.area !== undefined) {
@@ -707,7 +753,7 @@ export function createThingsMcpServer(options = {}) {
707
753
  const area = args.area !== undefined ? { area: args.area } : {};
708
754
  switch (args.view) {
709
755
  case "today": {
710
- const { view, truncation, filter: fm, } = c.read.today({
756
+ const { view, truncation, totals, filter: fm, } = c.read.today({
711
757
  ...filter,
712
758
  ...zone,
713
759
  ...area,
@@ -715,14 +761,17 @@ export function createThingsMcpServer(options = {}) {
715
761
  limit,
716
762
  });
717
763
  filterMeta = fm;
718
- return truncatedResult(shapeReadPayload("today", view, full, c.refPromoter()), truncation, { counts: view.counts });
764
+ // The data block is the two `children` bucket records (each with its
765
+ // inline `total` when capped); the whole-view `counts` aggregate
766
+ // rides the metadata block (the CLI meta.counts analog).
767
+ return truncatedResult(withTodayBucketTotals(shapeReadPayload("today", view, full, c.refPromoter()), totals), truncation, { counts: view.counts });
719
768
  }
720
769
  case "inbox": {
721
770
  const { items, truncation } = c.read.inbox({ ...filter, ...zone, limit });
722
771
  return truncatedResult(shapeReadPayload("inbox", items, full, c.refPromoter()), truncation);
723
772
  }
724
773
  case "anytime": {
725
- const { view, truncation, filter: fm, } = c.read.anytime({
774
+ const { view, truncation, sectionTotals, filter: fm, } = c.read.anytime({
726
775
  ...filter,
727
776
  ...zone,
728
777
  ...area,
@@ -730,10 +779,10 @@ export function createThingsMcpServer(options = {}) {
730
779
  projectLimit,
731
780
  });
732
781
  filterMeta = fm;
733
- return groupedResult(shapeReadPayload("anytime", view, full, c.refPromoter()), truncation);
782
+ return groupedResult(withSectionTotals(shapeReadPayload("anytime", view, full, c.refPromoter()), sectionTotals), truncation);
734
783
  }
735
784
  case "upcoming": {
736
- const { items, truncation, filter: fm, } = c.read.upcoming({
785
+ const { items, truncation, upcomingTotals, filter: fm, } = c.read.upcoming({
737
786
  ...filter,
738
787
  ...zone,
739
788
  ...area,
@@ -741,14 +790,16 @@ export function createThingsMcpServer(options = {}) {
741
790
  limit,
742
791
  });
743
792
  filterMeta = fm;
744
- return truncatedResult(shapeReadPayload("upcoming", items, full, c.refPromoter()), truncation);
793
+ // The data block is the day-block `sections` array, each capped block
794
+ // carrying its inline `total` (R1); the flat rollup rides truncation.
795
+ return truncatedResult(withUpcomingBlockTotals(shapeReadPayload("upcoming", items, full, c.refPromoter()), upcomingTotals), truncation);
745
796
  }
746
797
  case "someday": {
747
798
  const active = showActiveProjectItems;
748
799
  if (typeof active === "number" && args.all === true) {
749
800
  return usage("pass at most one of a numeric show_active_project_items / all");
750
801
  }
751
- const { view, truncation, filter: fm, } = c.read.someday({
802
+ const { view, truncation, sectionTotals, filter: fm, } = c.read.someday({
752
803
  ...filter,
753
804
  ...zone,
754
805
  ...area,
@@ -760,22 +811,37 @@ export function createThingsMcpServer(options = {}) {
760
811
  projectLimit: typeof active === "number" ? active : null,
761
812
  });
762
813
  filterMeta = fm;
763
- return groupedResult(shapeReadPayload("someday", view, full, c.refPromoter()), truncation);
814
+ return groupedResult(withSectionTotals(shapeReadPayload("someday", view, full, c.refPromoter()), sectionTotals), truncation);
764
815
  }
765
816
  case "logbook": {
766
- const { items, truncation, filter: fm, } = c.read.logbook({
817
+ const { items, truncation, logging, filter: fm, } = c.read.logbook({
767
818
  ...filter,
768
819
  ...zone,
769
820
  ...area,
770
821
  limit,
771
822
  });
772
823
  filterMeta = fm;
773
- return truncatedResult(shapeReadPayload("logbook", items, full, c.refPromoter()), truncation);
824
+ // The log-move cadence fact rides the metadata block (the CLI
825
+ // meta.logging analog, exactly as today's counts do).
826
+ return truncatedResult(shapeReadPayload("logbook", items, full, c.refPromoter()), truncation, { logging });
774
827
  }
775
828
  case "trash": {
776
829
  const { items, truncation } = c.read.trash({ ...zone, limit });
777
830
  return truncatedResult(shapeReadPayload("trash", items, full, c.refPromoter()), truncation);
778
831
  }
832
+ case "deadlines": {
833
+ // `project`/`area` scope inside the view (SQL), not the shared
834
+ // area post-filter — so no `meta.filter` here (parity with search).
835
+ const { items, truncation } = c.read.deadlines({
836
+ ...filter,
837
+ ...zone,
838
+ ...(args.today === true && { todayOnly: true }),
839
+ ...(args.project !== undefined && { project: args.project }),
840
+ ...(args.area !== undefined && { area: args.area }),
841
+ limit,
842
+ });
843
+ return truncatedResult(shapeReadPayload("deadlines", items, full, c.refPromoter()), truncation);
844
+ }
779
845
  }
780
846
  }, args.tz, () => (filterMeta !== undefined ? { filter: filterMeta } : undefined), () => looseAreaWarnings(getClient(), args.area));
781
847
  });
@@ -888,13 +954,11 @@ export function createThingsMcpServer(options = {}) {
888
954
  : readResult(shapeReadPayload("detail", item, false, getClient().refPromoter()));
889
955
  }));
890
956
  server.registerTool("get_project", {
891
- description: "One project's full contents: metadata, its live child to-dos as one flat list (items, " +
892
- "in index order each carrying its stage, when, and, when it lives under a heading, " +
893
- "its heading ref), the heading catalog (headings every heading in index order, " +
894
- "carrying archived once archived), and its logged children (logbook, most recent " +
895
- "first, each carrying its heading ref). Reconstruct a heading's members by filtering " +
896
- "items (or logbook) on the heading ref. The tag filters keep only the child to-dos " +
897
- "matching by their own tags (a heading left with none is dropped). " +
957
+ description: "One project's full contents: metadata plus its to-dos grouped under their headings. " +
958
+ "Open headings (and archived ones not yet swept to the logbook) stay live, carrying " +
959
+ "archived once archived; a swept archived heading moves into the logged region as a " +
960
+ "group with its children. The tag filters keep only the child to-dos matching by their " +
961
+ "own tags (a heading left with none is dropped). " +
898
962
  OMIT_EMPTY_NOTE +
899
963
  " " +
900
964
  REF_RULE_NOTE,
@@ -922,11 +986,11 @@ export function createThingsMcpServer(options = {}) {
922
986
  }), args.full === true, getClient().refPromoter()));
923
987
  }, args.tz));
924
988
  server.registerTool("get_area", {
925
- description: "One area's contents: metadata plus its direct to-dos as one flat list (items, in " +
926
- "index order each row carrying its stage and when) and its projects in canonical " +
927
- "order (projects). The area logbook is not returned here — read it with read_view " +
928
- "logbook + area; trashed rows live in read_view trash. " +
929
- `The project rows and the open/current direct to-dos are capped at ${AREA_PREVIEW_LIMIT} each ` +
989
+ description: "One area's contents: metadata plus its direct to-dos (active first), its " +
990
+ "projects in canonical order, and later (scheduled/repeating/someday). The area " +
991
+ "logbook is not returned here — read it with read_view logbook + area; trashed " +
992
+ "rows live in read_view trash. " +
993
+ `The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` +
930
994
  "by default (project_limit / area_limit adjust them; all: true lifts both); the " +
931
995
  "second result block reports the counts. " +
932
996
  OMIT_EMPTY_NOTE +
@@ -972,7 +1036,7 @@ export function createThingsMcpServer(options = {}) {
972
1036
  if (areaLimit === "conflict" || projectLimit === "conflict") {
973
1037
  return usage("pass at most one of area_limit/project_limit / all");
974
1038
  }
975
- const { view, truncation, notice } = getClient().read.areaView(args.ref, {
1039
+ const { view, truncation, totals, notice } = getClient().read.areaView(args.ref, {
976
1040
  overdue: args.overdue === true,
977
1041
  ...tagFilterFields(tagPresence(args)),
978
1042
  ...(args.tz !== undefined && { zone: args.tz }),
@@ -980,7 +1044,9 @@ export function createThingsMcpServer(options = {}) {
980
1044
  projectLimit,
981
1045
  });
982
1046
  areaNotice = notice;
983
- return groupedResult(shapeReadPayload("area-view", view, args.full === true, getClient().refPromoter()), truncation);
1047
+ // Each capped scope's completeness rides its inline `total` (R1, PR 3);
1048
+ // the whole-view rollup rides `truncation` (no `blocks[]` sidecar).
1049
+ return groupedResult(withAreaBucketTotals(shapeReadPayload("area-view", view, args.full === true, getClient().refPromoter()), totals), truncation);
984
1050
  }, args.tz, undefined, () => (areaNotice !== undefined ? [areaNotice] : undefined));
985
1051
  });
986
1052
  server.registerTool("list_collections", {
@@ -1059,6 +1125,15 @@ export function createThingsMcpServer(options = {}) {
1059
1125
  .boolean()
1060
1126
  .optional()
1061
1127
  .describe("Confirm adding into a completed/canceled project (this reopens it)"),
1128
+ created_at: z
1129
+ .string()
1130
+ .optional()
1131
+ .describe(`Born with this creation timestamp (${RESOLUTION_DATE_FORMAT})`),
1132
+ completed_at: z
1133
+ .string()
1134
+ .optional()
1135
+ .describe(`Born completed, in the Logbook, with this completion timestamp ` +
1136
+ `(${RESOLUTION_DATE_FORMAT}); drop when/reminder`),
1062
1137
  ...createTagsShape,
1063
1138
  ...tzShape,
1064
1139
  ...dryRunShape,
@@ -1085,6 +1160,8 @@ export function createThingsMcpServer(options = {}) {
1085
1160
  ...(args.project !== undefined && { project: containerRef(args.project) }),
1086
1161
  ...(args.area !== undefined && { area: containerRef(args.area) }),
1087
1162
  ...(args.heading !== undefined && { heading: args.heading }),
1163
+ ...(args.created_at !== undefined && { createdAt: args.created_at }),
1164
+ ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1088
1165
  }, writeOptions(args)));
1089
1166
  }));
1090
1167
  server.registerTool("update", {
@@ -1094,7 +1171,10 @@ export function createThingsMcpServer(options = {}) {
1094
1171
  "deadline/clear_deadline; changing the schedule keeps an existing reminder unless a new " +
1095
1172
  "one is set, schedule and deadline changes are unavailable for repeating items, and " +
1096
1173
  "clear_reminder needs the item scheduled for today or this evening (a reminder on a " +
1097
- "future date can only be changed, not cleared). kind area: title and/or tags (the full " +
1174
+ "future date can only be changed, not cleared). created_at/completed_at rewrite the " +
1175
+ "resolution timestamps of an already-resolved item; created_at is status-safe, but " +
1176
+ "completed_at on an open item is refused — resolving it is set_status's boundary, not " +
1177
+ "update's. kind area: title and/or tags (the full " +
1098
1178
  "replacement set). kind tag: title, parent (nest under it) or unnest (to the top level; " +
1099
1179
  "exclusive), and shortcut or clear_shortcut (exclusive). Tags must exist unless " +
1100
1180
  "create_tags is set.",
@@ -1113,6 +1193,16 @@ export function createThingsMcpServer(options = {}) {
1113
1193
  clear_reminder: z.boolean().optional().describe("todo/project: remove the reminder"),
1114
1194
  deadline: z.string().optional().describe(`todo/project: ${DATE_FORMAT}`),
1115
1195
  clear_deadline: z.boolean().optional().describe("todo/project: remove the deadline"),
1196
+ created_at: z
1197
+ .string()
1198
+ .optional()
1199
+ .describe(`todo/project: rewrite the creation timestamp (${RESOLUTION_DATE_FORMAT}); status-safe`),
1200
+ completed_at: z
1201
+ .string()
1202
+ .optional()
1203
+ .describe("todo/project: rewrite the completion timestamp of an already-resolved item " +
1204
+ `(${RESOLUTION_DATE_FORMAT}; a canceled one stays canceled); an open item is ` +
1205
+ "refused — use set_status to resolve it"),
1116
1206
  tags: z
1117
1207
  .array(z.string())
1118
1208
  .optional()
@@ -1160,6 +1250,8 @@ export function createThingsMcpServer(options = {}) {
1160
1250
  ...(args.clear_reminder === true && { reminder: null }),
1161
1251
  ...(args.deadline !== undefined && { deadline: args.deadline }),
1162
1252
  ...(args.clear_deadline === true && { deadline: null }),
1253
+ ...(args.created_at !== undefined && { createdAt: args.created_at }),
1254
+ ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1163
1255
  }, opts));
1164
1256
  }
1165
1257
  return mutationResult(await c.write.updateProject(args.uuid, {
@@ -1172,6 +1264,8 @@ export function createThingsMcpServer(options = {}) {
1172
1264
  ...(args.clear_reminder === true && { reminder: null }),
1173
1265
  ...(args.deadline !== undefined && { deadline: args.deadline }),
1174
1266
  ...(args.clear_deadline === true && { deadline: null }),
1267
+ ...(args.created_at !== undefined && { createdAt: args.created_at }),
1268
+ ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1175
1269
  }, opts));
1176
1270
  }
1177
1271
  if (args.kind === "area") {
@@ -1211,7 +1305,10 @@ export function createThingsMcpServer(options = {}) {
1211
1305
  "scope project, completing or canceling requires a children policy: 'require-resolved' " +
1212
1306
  "errors if open to-dos remain; 'auto-complete'/'auto-cancel' resolves them together with " +
1213
1307
  "the project (canceling never alters already-completed children). scope project, status " +
1214
- "open, restore_children also reopens the to-dos that were resolved with the project.",
1308
+ "open, restore_children also reopens the to-dos that were resolved with the project. " +
1309
+ "completed_at (status completed or canceled) sets the completion timestamp — also the " +
1310
+ '"Completed on" stamp for a canceled item — backdating it; reaching a backdated canceled ' +
1311
+ "item is a multi-leg, non-atomic sequence, disclosed in the result and in dry_run.",
1215
1312
  inputSchema: {
1216
1313
  scope: z.enum(["todo", "project"]),
1217
1314
  uuid: z
@@ -1226,21 +1323,33 @@ export function createThingsMcpServer(options = {}) {
1226
1323
  .boolean()
1227
1324
  .optional()
1228
1325
  .describe("scope project, open only: also reopen the to-dos resolved with the project"),
1326
+ completed_at: z
1327
+ .string()
1328
+ .optional()
1329
+ .describe(`status completed or canceled: the completion timestamp (${RESOLUTION_DATE_FORMAT})`),
1330
+ ...tzShape,
1229
1331
  ...dryRunShape,
1230
1332
  ...opIdShape,
1231
1333
  },
1232
1334
  annotations: NON_DESTRUCTIVE,
1233
1335
  }, async (args) => guard(async () => {
1336
+ const badZone = badTz(args.tz);
1337
+ if (badZone !== null)
1338
+ return badZone;
1234
1339
  const c = getClient();
1235
1340
  const opts = writeOptions(args);
1341
+ if (args.completed_at !== undefined && args.status === "open") {
1342
+ return usage("completed_at applies only to status 'completed' or 'canceled'");
1343
+ }
1344
+ const resolution = args.completed_at !== undefined ? { completedAt: args.completed_at } : {};
1236
1345
  if (args.scope === "todo") {
1237
1346
  if (args.children !== undefined || args.restore_children !== undefined) {
1238
1347
  return usage("children/restore_children apply only to scope project");
1239
1348
  }
1240
1349
  return mutationResult(args.status === "completed"
1241
- ? await c.write.completeTodo(args.uuid, opts)
1350
+ ? await c.write.completeTodo(args.uuid, resolution, opts)
1242
1351
  : args.status === "canceled"
1243
- ? await c.write.cancelTodo(args.uuid, opts)
1352
+ ? await c.write.cancelTodo(args.uuid, resolution, opts)
1244
1353
  : await c.write.reopenTodo(args.uuid, opts));
1245
1354
  }
1246
1355
  // scope project
@@ -1251,13 +1360,13 @@ export function createThingsMcpServer(options = {}) {
1251
1360
  if (args.children !== "require-resolved" && args.children !== "auto-complete") {
1252
1361
  return usage("status 'completed' requires children: 'require-resolved' or 'auto-complete'");
1253
1362
  }
1254
- return mutationResult(await c.write.completeProject(args.uuid, { children: args.children }, opts));
1363
+ return mutationResult(await c.write.completeProject(args.uuid, { children: args.children, ...resolution }, opts));
1255
1364
  }
1256
1365
  if (args.status === "canceled") {
1257
1366
  if (args.children !== "require-resolved" && args.children !== "auto-cancel") {
1258
1367
  return usage("status 'canceled' requires children: 'require-resolved' or 'auto-cancel'");
1259
1368
  }
1260
- return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children }, opts));
1369
+ return mutationResult(await c.write.cancelProject(args.uuid, { children: args.children, ...resolution }, opts));
1261
1370
  }
1262
1371
  if (args.children !== undefined) {
1263
1372
  return usage("children applies only to status 'completed' or 'canceled'");
@@ -1521,41 +1630,12 @@ export function createThingsMcpServer(options = {}) {
1521
1630
  ? await c.write.restoreTodo(args.uuid, writeOptions(args))
1522
1631
  : await c.write.restoreProject(args.uuid, writeOptions(args)));
1523
1632
  }));
1524
- server.registerTool("backdate_todo", {
1525
- description: "Rewrite a to-do's completion and/or creation timestamp to noon (local) on the " +
1526
- "given date. completion_date requires the to-do to already be completed or " +
1527
- "canceled; the Logbook re-sorts to the new date.",
1528
- inputSchema: {
1529
- uuid: z.string(),
1530
- completion_date: z.string().optional().describe(DATE_FORMAT),
1531
- creation_date: z.string().optional().describe(DATE_FORMAT),
1532
- ...dryRunShape,
1533
- ...opIdShape,
1534
- },
1535
- annotations: NON_DESTRUCTIVE,
1536
- }, async (args) => guard(async () => mutationResult(await getClient().write.backdateTodo(args.uuid, {
1537
- ...(args.completion_date !== undefined && { completionDate: args.completion_date }),
1538
- ...(args.creation_date !== undefined && { creationDate: args.creation_date }),
1539
- }, writeOptions(args)))));
1540
- server.registerTool("add_logged_todo", {
1541
- description: "Create a to-do directly in the Logbook: completed, with the given past " +
1542
- "completion date (and optionally a past creation date). For importing history " +
1543
- "from another system.",
1544
- inputSchema: {
1545
- title: z.string(),
1546
- completion_date: z.string().describe(DATE_FORMAT),
1547
- creation_date: z.string().optional().describe(`${DATE_FORMAT}; <= completion_date`),
1548
- notes: z.string().optional(),
1549
- ...dryRunShape,
1550
- ...opIdShape,
1551
- },
1552
- annotations: NON_DESTRUCTIVE,
1553
- }, async (args) => guard(async () => mutationResult(await getClient().write.addLoggedTodo({
1554
- title: args.title,
1555
- completionDate: args.completion_date,
1556
- ...(args.creation_date !== undefined && { creationDate: args.creation_date }),
1557
- ...(args.notes !== undefined && { notes: args.notes }),
1558
- }, writeOptions(args)))));
1633
+ // NB: the bespoke backdate_todo / add_logged_todo tools were removed with the
1634
+ // engine ops (plan PR A). MCP parity for the resolution-timestamp surface is
1635
+ // restored (PR B) as created_at/completed_at params folded onto the existing
1636
+ // write tools: add_todo / add_project (Logbook import), update (rewrite an
1637
+ // already-resolved item's timestamps), and set_status (backdate on
1638
+ // complete/cancel) — matching the CLI's --created-at/--completed-at flags.
1559
1639
  server.registerTool("heading", {
1560
1640
  description: "Manage a project's headings — action selects which; project is always required, and a " +
1561
1641
  "heading is selected by its exact title or its uuid (never an ordinal). add_heading: a " +
@@ -1945,6 +2025,15 @@ export function createThingsMcpServer(options = {}) {
1945
2025
  when: whenSchema,
1946
2026
  deadline: z.string().optional().describe(DATE_FORMAT),
1947
2027
  todos: z.array(z.string()).optional().describe("Initial child to-do titles"),
2028
+ created_at: z
2029
+ .string()
2030
+ .optional()
2031
+ .describe(`Born with this creation timestamp (${RESOLUTION_DATE_FORMAT})`),
2032
+ completed_at: z
2033
+ .string()
2034
+ .optional()
2035
+ .describe(`Born completed, in the Logbook, with this completion timestamp ` +
2036
+ `(${RESOLUTION_DATE_FORMAT}); cannot seed open child to-dos`),
1948
2037
  ...tzShape,
1949
2038
  ...dryRunShape,
1950
2039
  ...opIdShape,
@@ -1961,6 +2050,8 @@ export function createThingsMcpServer(options = {}) {
1961
2050
  ...(args.when !== undefined && { when: args.when }),
1962
2051
  ...(args.deadline !== undefined && { deadline: args.deadline }),
1963
2052
  ...(args.todos !== undefined && { todos: args.todos }),
2053
+ ...(args.created_at !== undefined && { createdAt: args.created_at }),
2054
+ ...(args.completed_at !== undefined && { completedAt: args.completed_at }),
1964
2055
  }, writeOptions(args)));
1965
2056
  }));
1966
2057
  server.registerTool("move_project", {
@@ -2019,6 +2110,13 @@ export function createThingsMcpServer(options = {}) {
2019
2110
  },
2020
2111
  annotations: NON_DESTRUCTIVE,
2021
2112
  }, async (args) => guard(async () => mutationResult(await getClient().write.addTag({ title: args.title, ...(args.parent !== undefined && { parent: args.parent }) }, writeOptions(args)))));
2113
+ server.registerTool("log_now", {
2114
+ description: "Move completed items to the Logbook now. The result discloses how many items were moved " +
2115
+ "(observed.logged); when none are waiting it logs nothing — a clean no-op, not an error. " +
2116
+ "This cannot be undone.",
2117
+ inputSchema: { ...dryRunShape, ...opIdShape },
2118
+ annotations: DESTRUCTIVE,
2119
+ }, async (args) => guard(async () => mutationResult(await getClient().write.logNow(writeOptions(args)))));
2022
2120
  // -------------------------------------------------- generic + discovery
2023
2121
  server.registerTool("run_operation", {
2024
2122
  description: "Run any cataloged operation by kind — the generic entry for operations without a " +
@@ -2141,87 +2239,53 @@ export function createThingsMcpServer(options = {}) {
2141
2239
  };
2142
2240
  }));
2143
2241
  server.registerTool("reorder", {
2144
- description: "Rearrange to-dos IN PLACE within the list or container and the bucket they already " +
2145
- "share this REARRANGES, never changes what an item belongs to (to change membership " +
2146
- "use move_todo / move_project). The refs order is the resulting order; unmentioned " +
2147
- "siblings keep theirs. Bare (no position) assembles the named items as a block at the " +
2148
- "EARLIEST one's current slot (partial-selection friendly); first/last/before/after " +
2149
- "position the block instead. Refs that span different containers or buckets are refused. " +
2150
- "A Today or This Evening member also holds a slot in its own container, so a set that is " +
2151
- "coherent on BOTH axes is ambiguous pass `in` to name the axis (the refusal names both " +
2152
- "choices). A deadline-forecast set sharing one Upcoming day is dual-axis the same way (the " +
2153
- "day-block vs its container order); `in: upcoming` or `in: <YYYY-MM-DD>` names the day-block. " +
2154
- "The project rows the Today/Evening/day lists intermix with to-dos may be " +
2155
- "reordered alongside them. Ordering the Today, Inbox, or Someday lists, a project's " +
2156
- "to-dos, or an area must first be enabled once via `things config set allow-experimental " +
2157
- "true`. To reorder a project's HEADINGS (children follow) use the heading tool's " +
2158
- "move_heading action; to reorder sidebar AREAS use reorder_areas.",
2242
+ description: "The ONE reorder tool rearrange a single-KIND set IN PLACE: to-dos, projects, headings, " +
2243
+ "OR sidebar areas. This REARRANGES, never changes what an item belongs to (to change " +
2244
+ "membership use move_todo / move_project). All refs must be one kind; only to-dos and " +
2245
+ "projects may intermix, and only on the shared Today/Evening/day axes. A mixed-kind set, " +
2246
+ "a cross-container set, and a non-member anchor each get one precise refusal. The refs " +
2247
+ "order is the resulting order; unmentioned siblings keep theirs. Bare (no position) " +
2248
+ "assembles the named items as a block at the EARLIEST one's current slot; start/end/" +
2249
+ "before/after position the block. A Today or This Evening member also holds a slot in its " +
2250
+ "container, so a set coherent on BOTH axes is ambiguous pass `in` to name the axis. " +
2251
+ "HEADINGS: same-project heading re-ranking runs the native heading-block wire (children " +
2252
+ "follow); an archived heading is reorderable but repositioning it brings it back to open " +
2253
+ "(disclosed in the result). AREAS: this drives the local Things app (sidebar drag) and " +
2254
+ "must be turned on with `things config set ui-enabled true` plus dangerously_drive_gui; " +
2255
+ "the areas' projects and to-dos are untouched. Ordering the Today, Inbox, or Someday " +
2256
+ "lists, or a project's to-dos, must first be enabled once via `things config set " +
2257
+ "allow-experimental true`.",
2159
2258
  inputSchema: {
2160
2259
  refs: z
2161
2260
  .array(z.string())
2162
- .describe("The items to rearrange, in the order they should land (may be a subset)"),
2163
- ...positionShape,
2261
+ .describe("The items to rearrange (one kind: to-dos, projects, headings, or areas), in the " +
2262
+ "order they should land (may be a subset)"),
2263
+ start: z.boolean().optional().describe("place the block at the start of its scope"),
2264
+ end: z.boolean().optional().describe("place the block at the end of its scope"),
2265
+ before: z.string().optional().describe("place the block immediately before this item"),
2266
+ after: z.string().optional().describe("place the block immediately after this item"),
2164
2267
  in: z
2165
2268
  .string()
2166
2269
  .optional()
2167
- .describe("Name the axis to reorder on: today | evening | anytime | someday | inbox, a project/" +
2168
- "area/heading ref (uuid or unique title), upcoming (the one future day the set " +
2169
- "shares), or a YYYY-MM-DD day-block. A stage-list or container axis sorts one KIND " +
2170
- "at a time a mixed to-do+project set is refused (even sharing a container); only " +
2171
- "today | evening | upcoming | a day-block intermix both kinds. Reorder a project's " +
2172
- "headings with the heading tool's move_heading action."),
2270
+ .describe("to-dos/projects only — name the axis to reorder on: today | evening | anytime | " +
2271
+ "someday | inbox, a project/area/heading ref (uuid or unique title), upcoming (the " +
2272
+ "one future day the set shares), or a YYYY-MM-DD day-block. A stage-list or " +
2273
+ "container axis sorts one KIND at a time; only today | evening | upcoming | a " +
2274
+ "day-block intermix both kinds."),
2275
+ ...driveGuiShape,
2173
2276
  ...dryRunShape,
2174
2277
  },
2175
2278
  annotations: NON_DESTRUCTIVE,
2176
2279
  }, async (args) => guard(async () => {
2177
- const position = movePositionArgs(args);
2280
+ const position = reorderPositionArgs(args);
2178
2281
  if (position === "conflict")
2179
- return usage("pass at most one of first/last/before/after");
2282
+ return usage("pass at most one of start/end/before/after");
2180
2283
  const request = {
2181
2284
  uuids: args.refs,
2182
2285
  ...(position !== undefined && { position }),
2183
2286
  ...(args.in !== undefined && { in: args.in }),
2184
2287
  };
2185
- return moveResult(await getClient().write.reorderTodos(request, writeOptions(args)));
2186
- }));
2187
- server.registerTool("reorder_areas", {
2188
- description: "Move a sidebar area to a new position in the area order (target by uuid or unique " +
2189
- "name). Pass exactly one destination: before/after another area, or first/last. This " +
2190
- "visibly drives the local Things app (the window comes forward and the sidebar may " +
2191
- "scroll) and must be turned on first with `things config set ui-enabled true`; the " +
2192
- "area's projects and to-dos are untouched.",
2193
- inputSchema: {
2194
- target: z.string().describe(`the area to move (${REF_FORMAT})`),
2195
- before: z
2196
- .string()
2197
- .optional()
2198
- .describe(`place it immediately above this area (${REF_FORMAT})`),
2199
- after: z
2200
- .string()
2201
- .optional()
2202
- .describe(`place it immediately below this area (${REF_FORMAT})`),
2203
- first: z.boolean().optional().describe("move it to the top of the area list"),
2204
- last: z.boolean().optional().describe("move it to the bottom of the area list"),
2205
- ...driveGuiShape,
2206
- ...dryRunShape,
2207
- },
2208
- annotations: NON_DESTRUCTIVE,
2209
- }, async (args) => guard(async () => {
2210
- const chosen = [
2211
- args.before !== undefined,
2212
- args.after !== undefined,
2213
- args.first === true,
2214
- args.last === true,
2215
- ].filter(Boolean).length;
2216
- if (chosen !== 1)
2217
- return usage("pass exactly one of before / after / first / last");
2218
- return mutationResult(await getClient().write.run("area.reorder", {
2219
- target: args.target,
2220
- ...(args.before !== undefined && { before: args.before }),
2221
- ...(args.after !== undefined && { after: args.after }),
2222
- ...(args.first === true && { position: "first" }),
2223
- ...(args.last === true && { position: "last" }),
2224
- }, writeOptions(args)));
2288
+ return moveResult(await getClient().write.reorderAny(request, writeOptions(args)));
2225
2289
  }));
2226
2290
  server.registerTool("undo", {
2227
2291
  description: "Undo the last N changes, newest first (changes made directly in the Things app " +