things-api 0.9.0 → 0.10.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 (214) hide show
  1. package/README.md +30 -11
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/commands/area.d.ts +26 -3
  8. package/dist/cli/commands/area.js +125 -70
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +44 -5
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +23 -4
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +18 -1
  16. package/dist/cli/commands/project.js +69 -31
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.js +423 -157
  19. package/dist/cli/commands/reads.js.map +1 -1
  20. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  21. package/dist/cli/commands/repeat-flags.js +76 -0
  22. package/dist/cli/commands/repeat-flags.js.map +1 -0
  23. package/dist/cli/commands/setup.js +2 -3
  24. package/dist/cli/commands/setup.js.map +1 -1
  25. package/dist/cli/commands/show.js +47 -24
  26. package/dist/cli/commands/show.js.map +1 -1
  27. package/dist/cli/commands/todo.d.ts +1 -1
  28. package/dist/cli/commands/todo.js +7 -6
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +378 -148
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +1 -1
  33. package/dist/cli/excess-args.d.ts +15 -0
  34. package/dist/cli/excess-args.js +51 -0
  35. package/dist/cli/excess-args.js.map +1 -0
  36. package/dist/cli/glyphs.d.ts +39 -2
  37. package/dist/cli/glyphs.js +91 -21
  38. package/dist/cli/glyphs.js.map +1 -1
  39. package/dist/cli/help.d.ts +54 -0
  40. package/dist/cli/help.js +355 -0
  41. package/dist/cli/help.js.map +1 -0
  42. package/dist/cli/main.js +32 -25
  43. package/dist/cli/main.js.map +1 -1
  44. package/dist/cli/period.d.ts +7 -0
  45. package/dist/cli/period.js +12 -0
  46. package/dist/cli/period.js.map +1 -1
  47. package/dist/cli/read-driver.d.ts +26 -19
  48. package/dist/cli/read-driver.js +67 -33
  49. package/dist/cli/read-driver.js.map +1 -1
  50. package/dist/cli/render.d.ts +92 -28
  51. package/dist/cli/render.js +318 -97
  52. package/dist/cli/render.js.map +1 -1
  53. package/dist/cli/resolve-invocation.d.ts +42 -4
  54. package/dist/cli/resolve-invocation.js +97 -14
  55. package/dist/cli/resolve-invocation.js.map +1 -1
  56. package/dist/cli/tag-filters.d.ts +59 -0
  57. package/dist/cli/tag-filters.js +57 -0
  58. package/dist/cli/tag-filters.js.map +1 -0
  59. package/dist/cli/verb-hint.d.ts +25 -0
  60. package/dist/cli/verb-hint.js +138 -0
  61. package/dist/cli/verb-hint.js.map +1 -0
  62. package/dist/cli/width.d.ts +135 -0
  63. package/dist/cli/width.js +313 -0
  64. package/dist/cli/width.js.map +1 -0
  65. package/dist/client.d.ts +137 -24
  66. package/dist/client.js +105 -16
  67. package/dist/client.js.map +1 -1
  68. package/dist/config.d.ts +11 -0
  69. package/dist/config.js +3 -0
  70. package/dist/config.js.map +1 -1
  71. package/dist/contracts.d.ts +87 -17
  72. package/dist/contracts.js +44 -1
  73. package/dist/contracts.js.map +1 -1
  74. package/dist/db/fingerprint.d.ts +12 -0
  75. package/dist/db/fingerprint.js +15 -1
  76. package/dist/db/fingerprint.js.map +1 -1
  77. package/dist/db/locate.js +1 -1
  78. package/dist/db/locate.js.map +1 -1
  79. package/dist/diagnose.d.ts +63 -0
  80. package/dist/diagnose.js +38 -1
  81. package/dist/diagnose.js.map +1 -1
  82. package/dist/index.d.ts +30 -4
  83. package/dist/index.js +35 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/mcp/server.d.ts +9 -1
  86. package/dist/mcp/server.js +691 -133
  87. package/dist/mcp/server.js.map +1 -1
  88. package/dist/model/entities.d.ts +31 -7
  89. package/dist/model/entities.js.map +1 -1
  90. package/dist/model/mappers.d.ts +7 -0
  91. package/dist/model/mappers.js +7 -2
  92. package/dist/model/mappers.js.map +1 -1
  93. package/dist/model/serialize.d.ts +28 -0
  94. package/dist/model/serialize.js +80 -0
  95. package/dist/model/serialize.js.map +1 -0
  96. package/dist/model/when-sugar.d.ts +47 -0
  97. package/dist/model/when-sugar.js +45 -0
  98. package/dist/model/when-sugar.js.map +1 -0
  99. package/dist/read/area-view.d.ts +8 -1
  100. package/dist/read/area-view.js +49 -13
  101. package/dist/read/area-view.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +117 -0
  103. package/dist/read/filter-contract.js +78 -0
  104. package/dist/read/filter-contract.js.map +1 -0
  105. package/dist/read/predicates.d.ts +30 -0
  106. package/dist/read/predicates.js +30 -0
  107. package/dist/read/predicates.js.map +1 -1
  108. package/dist/read/project-view.d.ts +12 -1
  109. package/dist/read/project-view.js +39 -8
  110. package/dist/read/project-view.js.map +1 -1
  111. package/dist/read/queries.d.ts +149 -6
  112. package/dist/read/queries.js +250 -31
  113. package/dist/read/queries.js.map +1 -1
  114. package/dist/read/sections.d.ts +51 -0
  115. package/dist/read/sections.js +37 -0
  116. package/dist/read/sections.js.map +1 -0
  117. package/dist/read/sidebar-order.js +2 -1
  118. package/dist/read/sidebar-order.js.map +1 -1
  119. package/dist/read/tags.d.ts +27 -3
  120. package/dist/read/tags.js +83 -12
  121. package/dist/read/tags.js.map +1 -1
  122. package/dist/read/truncation.d.ts +71 -0
  123. package/dist/read/{pagination.js → truncation.js} +103 -94
  124. package/dist/read/truncation.js.map +1 -0
  125. package/dist/read/views.d.ts +140 -16
  126. package/dist/read/views.js +264 -67
  127. package/dist/read/views.js.map +1 -1
  128. package/dist/surface-copy.d.ts +19 -0
  129. package/dist/surface-copy.js +32 -0
  130. package/dist/surface-copy.js.map +1 -1
  131. package/dist/sync-health.d.ts +78 -0
  132. package/dist/sync-health.js +312 -0
  133. package/dist/sync-health.js.map +1 -0
  134. package/dist/write/accessibility-probe.d.ts +12 -0
  135. package/dist/write/accessibility-probe.js +63 -0
  136. package/dist/write/accessibility-probe.js.map +1 -0
  137. package/dist/write/automation-probe.d.ts +7 -0
  138. package/dist/write/automation-probe.js +8 -1
  139. package/dist/write/automation-probe.js.map +1 -1
  140. package/dist/write/batch.js +4 -2
  141. package/dist/write/batch.js.map +1 -1
  142. package/dist/write/capabilities.d.ts +8 -0
  143. package/dist/write/capabilities.js +13 -6
  144. package/dist/write/capabilities.js.map +1 -1
  145. package/dist/write/commands.d.ts +2 -0
  146. package/dist/write/commands.js +562 -48
  147. package/dist/write/commands.js.map +1 -1
  148. package/dist/write/edit-checklist.js +3 -2
  149. package/dist/write/edit-checklist.js.map +1 -1
  150. package/dist/write/guards.d.ts +2 -7
  151. package/dist/write/guards.js +64 -6
  152. package/dist/write/guards.js.map +1 -1
  153. package/dist/write/heading.js +2 -0
  154. package/dist/write/heading.js.map +1 -1
  155. package/dist/write/lock.d.ts +40 -2
  156. package/dist/write/lock.js +91 -14
  157. package/dist/write/lock.js.map +1 -1
  158. package/dist/write/make-repeating-project.d.ts +4 -0
  159. package/dist/write/make-repeating-project.js +253 -0
  160. package/dist/write/make-repeating-project.js.map +1 -0
  161. package/dist/write/operations.d.ts +145 -1
  162. package/dist/write/operations.js +48 -0
  163. package/dist/write/operations.js.map +1 -1
  164. package/dist/write/pipeline.d.ts +19 -0
  165. package/dist/write/pipeline.js +109 -26
  166. package/dist/write/pipeline.js.map +1 -1
  167. package/dist/write/pre-state.d.ts +68 -3
  168. package/dist/write/pre-state.js +102 -3
  169. package/dist/write/pre-state.js.map +1 -1
  170. package/dist/write/reopen.js +3 -2
  171. package/dist/write/reopen.js.map +1 -1
  172. package/dist/write/reorder.js +27 -17
  173. package/dist/write/reorder.js.map +1 -1
  174. package/dist/write/repeat-rule.d.ts +22 -0
  175. package/dist/write/repeat-rule.js +258 -0
  176. package/dist/write/repeat-rule.js.map +1 -0
  177. package/dist/write/reversibility.js +49 -0
  178. package/dist/write/reversibility.js.map +1 -1
  179. package/dist/write/tag-refs.d.ts +47 -0
  180. package/dist/write/tag-refs.js +126 -0
  181. package/dist/write/tag-refs.js.map +1 -0
  182. package/dist/write/undo.d.ts +74 -5
  183. package/dist/write/undo.js +493 -80
  184. package/dist/write/undo.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +17 -1
  186. package/dist/write/vectors/registry.js +18 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/types.d.ts +119 -3
  189. package/dist/write/vectors/ui-certification.d.ts +48 -0
  190. package/dist/write/vectors/ui-certification.js +46 -0
  191. package/dist/write/vectors/ui-certification.js.map +1 -0
  192. package/dist/write/vectors/ui-drag.d.ts +188 -0
  193. package/dist/write/vectors/ui-drag.js +1095 -0
  194. package/dist/write/vectors/ui-drag.js.map +1 -0
  195. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  196. package/dist/write/vectors/ui-recipes.js +558 -0
  197. package/dist/write/vectors/ui-recipes.js.map +1 -0
  198. package/dist/write/vectors/ui.d.ts +137 -0
  199. package/dist/write/vectors/ui.js +656 -0
  200. package/dist/write/vectors/ui.js.map +1 -0
  201. package/dist/write/verify/delta.d.ts +31 -3
  202. package/dist/write/verify/delta.js +22 -6
  203. package/dist/write/verify/delta.js.map +1 -1
  204. package/dist/write/verify/poller.js +1 -0
  205. package/dist/write/verify/poller.js.map +1 -1
  206. package/package.json +1 -1
  207. package/dist/cli/exit-codes.d.ts +0 -26
  208. package/dist/cli/exit-codes.js +0 -26
  209. package/dist/cli/exit-codes.js.map +0 -1
  210. package/dist/cli/output.d.ts +0 -42
  211. package/dist/cli/output.js +0 -16
  212. package/dist/cli/output.js.map +0 -1
  213. package/dist/read/pagination.d.ts +0 -104
  214. package/dist/read/pagination.js.map +0 -1
@@ -7,15 +7,15 @@
7
7
  */
8
8
  import { Option } from "commander";
9
9
  import { execFileSync } from "node:child_process";
10
- import { localToday } from "../../model/dates.js";
10
+ import { runAreaShow } from "./area.js";
11
+ import { runProjectShow } from "./project.js";
11
12
  import { dim } from "../style.js";
12
13
  import { areaMark, LEGEND, shortDate } from "../glyphs.js";
13
- import { formatItem, renderAnytimePreview, renderLegend, renderList, renderLogbook, renderProjectsSidebar, renderSearch, renderSections, renderSomedayPreview, renderToday, renderUpcoming, stripAnsi, uuidCol, uuidDisplayWidth, } from "../render.js";
14
- import { invocation, parseCap, parseLimit, runRead, shellQuote, withClient, } from "../read-driver.js";
15
- import { parsePeriodEnd, parsePeriodStart } from "../period.js";
16
- import { ExitCode, okEnvelope } from "../../contracts.js";
17
- import { AREA_PREVIEW_LIMIT, PROJECT_PREVIEW_LIMIT, paginateList, paginateToday, partitionSomedaySection, previewSections, previewSomedaySections, } from "../../read/pagination.js";
18
- import { ALL_DESC, AREA_LIMIT_DESC, GROUPED_ALL_DESC, LIMIT_DESC, PERIOD_SINCE, PERIOD_UNTIL, PROJECT_LIMIT_DESC, } from "../../surface-copy.js";
14
+ import { formatItem, disclosureHint, renderAnytimePreview, renderLegend, renderList, renderLogbook, renderProjectsSidebar, renderSearch, renderSections, renderSomedayPreview, renderToday, renderUpcoming, stripAnsi, uuidCol, uuidDisplayWidth, } from "../render.js";
15
+ import { invocation, parseCap, parseLimit, runRead, shellQuote, truncationHint, usageError, withClient, } from "../read-driver.js";
16
+ import { addTagFilterOptions, collectRef, CONTAINER_TAG_HINT, EXACT_TAG_DESC, hasTagPresence, TAG_DESC, UNTAGGED_DESC, tagFilterFields, tagFlagConflict, tagInvocationParts, } from "../tag-filters.js";
17
+ import { doublePeriod, parsePeriodEnd, parsePeriodStart } from "../period.js";
18
+ import { ALL_DESC, AREA_LIMIT_DESC, AREA_PREVIEW_LIMIT, ExitCode, FILTER_CONTRACT, GROUPED_ALL_DESC, LIMIT_DESC, localToday, okEnvelope, PERIOD_SINCE, PERIOD_UNTIL, PROJECT_LIMIT_DESC, PROJECT_PREVIEW_LIMIT, validateViewArgs, } from "../../index.js";
19
19
  /**
20
20
  * Foreground the Things app on a resource via its share URI. A GUI action
21
21
  * on this Mac — NOT headless; the shared implementation behind every
@@ -26,27 +26,8 @@ export function openInThings(uuid) {
26
26
  execFileSync("/usr/bin/open", [uri]);
27
27
  return uri;
28
28
  }
29
- function registerListView(program, spec) {
30
- program
31
- .command(spec.name)
32
- .description(spec.description)
33
- .option("--tag <ref>", "filter by tag (uuid or unique name): direct, inherited, or descendant-tagged")
34
- .option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
35
- .option("--limit <n>", LIMIT_DESC)
36
- .option("--all", ALL_DESC)
37
- .option("--json", "emit versioned JSON envelope on stdout")
38
- .option("--db <path>", "explicit database path")
39
- .action((opts) => {
40
- const lim = parseLimit(opts);
41
- if (!lim.ok)
42
- return;
43
- const base = invocation(spec.name, [
44
- opts.tag !== undefined && `--tag ${shellQuote(opts.tag)}`,
45
- opts.exactTag === true && "--exact-tag",
46
- ]);
47
- runRead(opts, spec.name, (c) => spec.paginate(spec.fetch(c, opts.tag, opts.exactTag), lim.limit), spec.render, base, spec.name);
48
- });
49
- }
29
+ /** Help copy for the `--overdue` content scope (open items past their deadline). */
30
+ const OVERDUE_DESC = "only open items past their deadline (due today is not overdue)";
50
31
  export function registerReadCommands(program) {
51
32
  program
52
33
  .command("legend")
@@ -77,51 +58,137 @@ export function registerReadCommands(program) {
77
58
  program
78
59
  .command("today")
79
60
  .description("The Today list, split into Today and This Evening (evening expires daily), with the sidebar badge split (red = deadline due/overdue)")
80
- .option("--tag <ref>", "filter by tag (uuid or unique name): direct, inherited, or descendant-tagged")
81
- .option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
61
+ .option("--tag <ref>", TAG_DESC, collectRef, [])
62
+ .option("--exact-tag", EXACT_TAG_DESC)
63
+ .option("--untagged", UNTAGGED_DESC)
64
+ .option("--overdue", OVERDUE_DESC)
65
+ .option("--evening", "show only the This Evening section")
82
66
  .option("--limit <n>", LIMIT_DESC)
83
67
  .option("--all", ALL_DESC)
84
68
  .option("--json", "emit versioned JSON envelope on stdout")
85
69
  .option("--db <path>", "explicit database path")
86
70
  .action((opts) => {
71
+ if (tagFlagConflict(opts))
72
+ return;
87
73
  const lim = parseLimit(opts);
88
74
  if (!lim.ok)
89
75
  return;
76
+ const eveningOnly = opts.evening === true;
90
77
  const base = invocation("today", [
91
- opts.tag !== undefined && `--tag ${shellQuote(opts.tag)}`,
92
- opts.exactTag === true && "--exact-tag",
78
+ ...tagInvocationParts(opts),
79
+ opts.overdue === true && "--overdue",
80
+ eveningOnly && "--evening",
93
81
  ]);
82
+ const filter = {
83
+ ...tagFilterFields(opts),
84
+ ...(opts.overdue === true && { overdue: true }),
85
+ ...(eveningOnly && { eveningOnly: true }),
86
+ };
94
87
  runRead(opts, "today", (c) => {
95
- const full = c.read.today(opts.tag === undefined
96
- ? undefined
97
- : { tag: opts.tag, ...(opts.exactTag === true && { exactTag: true }) });
98
- const { data, pagination } = paginateToday(full, lim.limit);
99
- // The renderer needs the PRE-cap view to keep This Evening honest
100
- // under truncation, so the lines are precomputed here; the global
101
- // footer (whole-view remainder) is still appended by the driver.
102
- return { data, pagination, lines: renderToday(full, data, base) };
88
+ const { view, truncation } = c.read.today({ ...filter, limit: lim.limit });
89
+ // The renderer keeps This Evening honest under truncation from the
90
+ // metadata's per-section counts; the lines are precomputed here and
91
+ // the global footer (whole-view remainder) is appended by the driver.
92
+ return {
93
+ data: view,
94
+ truncation,
95
+ lines: renderToday(view, truncation.sections, base, { eveningOnly }),
96
+ };
103
97
  },
104
98
  // Type-correct fallback for the TodayView payload; never reached
105
99
  // because `lines` is always precomputed above.
106
- (data) => renderToday(data, data, base), base, "today");
100
+ (data) => renderToday(data, undefined, base, { eveningOnly }), base, "today");
107
101
  });
108
- registerListView(program, {
109
- name: "inbox",
110
- description: "Unprocessed captures (Inbox)",
111
- fetch: (c, tag, exactTag) => c.read.inbox(tag === undefined ? undefined : { tag, ...(exactTag === true && { exactTag }) }),
112
- render: renderList,
113
- paginate: paginateList,
102
+ program
103
+ .command("inbox")
104
+ .description("Unprocessed captures (Inbox). Bound the CREATION date with --since/--until — an " +
105
+ "item's arrival into Things (a demoted item keeps its original creation date, so " +
106
+ "this is not strictly when it entered the Inbox).")
107
+ .option("--tag <ref>", TAG_DESC, collectRef, [])
108
+ .option("--exact-tag", EXACT_TAG_DESC)
109
+ .option("--untagged", UNTAGGED_DESC)
110
+ .option("--overdue", OVERDUE_DESC)
111
+ .option("--since <when>", `only captures created on/after this bound: ${PERIOD_SINCE}`)
112
+ .option("--until <when>", `only captures created on/before this bound: ${PERIOD_UNTIL}`)
113
+ .option("--limit <n>", LIMIT_DESC)
114
+ .option("--all", ALL_DESC)
115
+ .option("--json", "emit versioned JSON envelope on stdout")
116
+ .option("--db <path>", "explicit database path")
117
+ .action((opts) => {
118
+ if (tagFlagConflict(opts))
119
+ return;
120
+ const lim = parseLimit(opts);
121
+ if (!lim.ok)
122
+ return;
123
+ const since = opts.since !== undefined ? parsePeriodStart(opts.since) : undefined;
124
+ const until = opts.until !== undefined ? parsePeriodEnd(opts.until) : undefined;
125
+ for (const [flag, value] of [
126
+ ["--since", since],
127
+ ["--until", until],
128
+ ]) {
129
+ if (value !== undefined && Number.isNaN(value.getTime())) {
130
+ usageError(opts, `${flag} is not a parseable date`);
131
+ return;
132
+ }
133
+ }
134
+ // Bounds-&-defaults lift rule (identical to logbook): an explicit range
135
+ // bound (--since/--until) drops the default row cap unless --limit is
136
+ // also stated — the creation window the user named IS the bound, not an
137
+ // arbitrary 50-row cut.
138
+ const boundGiven = opts.since !== undefined || opts.until !== undefined;
139
+ const effectiveLimit = opts.limit === undefined && opts.all !== true && boundGiven ? null : lim.limit;
140
+ const base = invocation("inbox", [
141
+ ...tagInvocationParts(opts),
142
+ opts.overdue === true && "--overdue",
143
+ opts.since !== undefined && `--since ${shellQuote(opts.since)}`,
144
+ opts.until !== undefined && `--until ${shellQuote(opts.until)}`,
145
+ ]);
146
+ runRead(opts, "inbox", (c) => {
147
+ const { items: data, truncation } = c.read.inbox({
148
+ ...tagFilterFields(opts),
149
+ ...(opts.overdue === true && { overdue: true }),
150
+ ...(since !== undefined && { since }),
151
+ ...(until !== undefined && { until }),
152
+ limit: effectiveLimit,
153
+ });
154
+ const lines = renderList(data);
155
+ // Standard row-truncation hint (bigger --limit / --all), as every
156
+ // flat view — handled here (not via the driver's hintBase) so the
157
+ // creation-window note can sit last, mirroring upcoming's window
158
+ // footer mechanics.
159
+ const hint = truncationHint(base, truncation);
160
+ if (hint !== null)
161
+ lines.push("", hint);
162
+ // Presentation order is unchanged (manual ORDER BY index); the date
163
+ // bound is an invisible axis in the rows, so name the effective
164
+ // window in a dim footer note. Human output only — the precomputed
165
+ // lines never ride --json.
166
+ if (boundGiven) {
167
+ const today = localToday();
168
+ const sinceLabel = since !== undefined ? shortDate(localToday(since), today) : null;
169
+ const untilLabel = until !== undefined ? shortDate(localToday(until), today) : null;
170
+ const note = sinceLabel !== null && untilLabel !== null
171
+ ? `(created ${sinceLabel} – ${untilLabel})`
172
+ : sinceLabel !== null
173
+ ? `(created since ${sinceLabel})`
174
+ : `(created through ${untilLabel})`;
175
+ lines.push("", dim(note));
176
+ }
177
+ return { data, truncation, lines };
178
+ }, renderList, undefined, "inbox");
114
179
  });
115
180
  program
116
181
  .command("anytime")
117
- .description("All active items in the UI's sidebar-mirroring order (area-less first, then per " +
182
+ .description("All active items in canonical order (area-less first, then per " +
118
183
  "area: direct to-dos, then each project with its members). Today members are " +
119
184
  "starred (★). Children of someday/future-scheduled projects are excluded — the " +
120
185
  "project row represents them. Every group and project row is always shown; " +
121
186
  "--area-limit caps each area's direct list, --project-limit each project's list, " +
122
187
  "--all shows everything")
123
- .option("--tag <ref>", "filter by tag (uuid or unique name): direct, inherited, or descendant-tagged")
124
- .option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
188
+ .option("--tag <ref>", TAG_DESC, collectRef, [])
189
+ .option("--exact-tag", EXACT_TAG_DESC)
190
+ .option("--untagged", UNTAGGED_DESC)
191
+ .option("--overdue", OVERDUE_DESC)
125
192
  .option("--area-limit <n>", AREA_LIMIT_DESC)
126
193
  .option("--project-limit <n>", PROJECT_LIMIT_DESC)
127
194
  .option("--all", GROUPED_ALL_DESC)
@@ -129,28 +196,40 @@ export function registerReadCommands(program) {
129
196
  .option("--json", "emit versioned JSON envelope on stdout")
130
197
  .option("--db <path>", "explicit database path")
131
198
  .action((opts) => {
199
+ if (tagFlagConflict(opts))
200
+ return;
132
201
  if (opts.limit !== undefined) {
133
- process.stderr.write("error: --limit is not available on anytime — cap blocks with --area-limit / --project-limit, or pass --all\n");
134
- process.exitCode = ExitCode.Usage;
202
+ usageError(opts, "--limit is not available on anytime — cap blocks with --area-limit / --project-limit, or pass --all");
135
203
  return;
136
204
  }
137
- const area = parseCap("--area-limit", opts.areaLimit, AREA_PREVIEW_LIMIT, opts.all === true);
205
+ const area = parseCap("--area-limit", opts.areaLimit, AREA_PREVIEW_LIMIT, opts.all === true, opts.json === true);
138
206
  if (!area.ok)
139
207
  return;
140
- const project = parseCap("--project-limit", opts.projectLimit, PROJECT_PREVIEW_LIMIT, opts.all === true);
208
+ const project = parseCap("--project-limit", opts.projectLimit, PROJECT_PREVIEW_LIMIT, opts.all === true, opts.json === true);
141
209
  if (!project.ok)
142
210
  return;
143
211
  const limits = { area: area.limit, project: project.limit };
144
212
  const base = invocation("anytime", [
145
- opts.tag !== undefined && `--tag ${shellQuote(opts.tag)}`,
146
- opts.exactTag === true && "--exact-tag",
213
+ ...tagInvocationParts(opts),
214
+ opts.overdue === true && "--overdue",
147
215
  ]);
216
+ // Content scopes compose (AND): --overdue narrows a tagged/untagged
217
+ // set, so the filter is built additively rather than one-or-the-other.
218
+ const filter = {
219
+ ...tagFilterFields(opts),
220
+ ...(opts.overdue === true && { overdue: true }),
221
+ };
148
222
  runRead(opts, "anytime", (c) => {
149
- const full = c.read.anytime(opts.tag === undefined
150
- ? undefined
151
- : { tag: opts.tag, ...(opts.exactTag === true && { exactTag: true }) });
152
- const { data, grouped } = previewSections(full, limits);
153
- return { data, grouped, lines: renderAnytimePreview(full, limits, base) };
223
+ const { view, grouped } = c.read.anytime({
224
+ ...filter,
225
+ areaLimit: area.limit,
226
+ projectLimit: project.limit,
227
+ });
228
+ return {
229
+ data: view,
230
+ grouped,
231
+ lines: renderAnytimePreview(view, grouped, limits, base),
232
+ };
154
233
  },
155
234
  // Grouped views hand back precomputed `lines`; renderSections is the
156
235
  // type-correct fallback for the SidebarSection[] payload but is never
@@ -159,14 +238,16 @@ export function registerReadCommands(program) {
159
238
  });
160
239
  program
161
240
  .command("someday")
162
- .description("Someday items (incubated, undated) in sidebar order — inside each group the " +
241
+ .description("Someday items (incubated, undated) in canonical order — inside each group the " +
163
242
  "someday projects list first, then the to-dos; project children are represented " +
164
243
  "by their project row. --show-active-project-items [n] appends a trailing section " +
165
244
  "of someday to-dos inside active projects, grouped under their project (the UI's " +
166
245
  "'Show items from active projects' toggle; n caps each project's list). " +
167
246
  "--area-limit caps each group, --all shows everything")
168
- .option("--tag <ref>", "filter by tag (uuid or unique name): direct, inherited, or descendant-tagged")
169
- .option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
247
+ .option("--tag <ref>", TAG_DESC, collectRef, [])
248
+ .option("--exact-tag", EXACT_TAG_DESC)
249
+ .option("--untagged", UNTAGGED_DESC)
250
+ .option("--overdue", OVERDUE_DESC)
170
251
  .option("--area-limit <n>", AREA_LIMIT_DESC)
171
252
  .option("--show-active-project-items [n]", "append someday to-dos inside active projects, grouped under their project; " +
172
253
  "n caps each project's list (bare flag: every item)")
@@ -175,6 +256,8 @@ export function registerReadCommands(program) {
175
256
  .option("--json", "emit versioned JSON envelope on stdout")
176
257
  .option("--db <path>", "explicit database path")
177
258
  .action((opts) => {
259
+ if (tagFlagConflict(opts))
260
+ return;
178
261
  if (opts.limit !== undefined) {
179
262
  process.stderr.write("error: --limit is not available on someday — cap groups with --area-limit, or pass --all\n");
180
263
  process.exitCode = ExitCode.Usage;
@@ -183,41 +266,45 @@ export function registerReadCommands(program) {
183
266
  const showActive = opts.showActiveProjectItems !== undefined;
184
267
  let projectCap = null;
185
268
  if (typeof opts.showActiveProjectItems === "string") {
186
- const capped = parseCap("--show-active-project-items", opts.showActiveProjectItems, 0, opts.all === true);
269
+ const capped = parseCap("--show-active-project-items", opts.showActiveProjectItems, 0, opts.all === true, opts.json === true);
187
270
  if (!capped.ok)
188
271
  return;
189
272
  projectCap = capped.limit;
190
273
  }
191
- const area = parseCap("--area-limit", opts.areaLimit, AREA_PREVIEW_LIMIT, opts.all === true);
274
+ const area = parseCap("--area-limit", opts.areaLimit, AREA_PREVIEW_LIMIT, opts.all === true, opts.json === true);
192
275
  if (!area.ok)
193
276
  return;
194
277
  const limits = { area: area.limit, project: projectCap };
195
278
  const filter = {
196
- ...(opts.tag !== undefined && { tag: opts.tag }),
197
- ...(opts.exactTag === true && { exactTag: true }),
279
+ ...tagFilterFields(opts),
280
+ ...(opts.overdue === true && { overdue: true }),
198
281
  };
199
282
  const base = invocation("someday", [
200
- opts.tag !== undefined && `--tag ${shellQuote(opts.tag)}`,
201
- opts.exactTag === true && "--exact-tag",
283
+ ...tagInvocationParts(opts),
284
+ opts.overdue === true && "--overdue",
202
285
  ]);
203
286
  runRead(opts, "someday", (c) => {
204
- const full = c.read.someday({
287
+ const { view, grouped } = c.read.someday({
205
288
  ...filter,
206
289
  ...(showActive && { activeProjectItems: true }),
290
+ areaLimit: area.limit,
291
+ projectLimit: projectCap,
207
292
  });
208
293
  // Hidden-items-never-silent: when the toggle is off, one extra
209
- // query counts what it would reveal so the hint can say so.
294
+ // query counts what it would reveal (someday to-dos inside active
295
+ // projects) — summing the active-project child blocks' totals from
296
+ // that query's grouped metadata (projectLimit defaults to every
297
+ // item there, so each block's total is the full group size).
210
298
  const hiddenActiveItems = showActive
211
299
  ? 0
212
300
  : c.read
213
301
  .someday({ ...filter, activeProjectItems: true })
214
- .reduce((n, s) => n +
215
- partitionSomedaySection(s).children.reduce((m, g) => m + g.items.length, 0), 0);
216
- const { data, grouped } = previewSomedaySections(full, limits);
302
+ .grouped.blocks.reduce((n, b) => n +
303
+ (b.children?.reduce((m, child) => m + (child.kind === "project" ? child.total : 0), 0) ?? 0), 0);
217
304
  return {
218
- data,
305
+ data: view,
219
306
  grouped,
220
- lines: renderSomedayPreview(full, limits, base, showActive, hiddenActiveItems),
307
+ lines: renderSomedayPreview(view, grouped, limits, base, showActive, hiddenActiveItems),
221
308
  };
222
309
  },
223
310
  // Precomputed lines above; renderSections is the type-correct
@@ -237,56 +324,95 @@ export function registerReadCommands(program) {
237
324
  .option("--since <period>", `skip items scheduled before this bound: ${PERIOD_SINCE}`)
238
325
  .option("--all", "no date bound and no row limit — the app's full Upcoming")
239
326
  .option("--limit <n>", LIMIT_DESC)
240
- .option("--tag <ref>", "filter by tag (uuid or unique name): direct, inherited, or descendant-tagged")
241
- .option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
327
+ .option("--tag <ref>", TAG_DESC, collectRef, [])
328
+ .option("--exact-tag", EXACT_TAG_DESC)
329
+ .option("--untagged", UNTAGGED_DESC)
242
330
  .option("--horizon <n>", "occurrences per repeating item (default 1 = UI parity)")
243
331
  .option("--json", "emit versioned JSON envelope on stdout")
244
332
  .option("--db <path>", "explicit database path")
245
333
  .action((opts, command) => {
334
+ if (tagFlagConflict(opts))
335
+ return;
246
336
  const untilGiven = command.getOptionValueSource("until") !== "default";
247
337
  const sinceGiven = opts.since !== undefined;
338
+ const limitGiven = opts.limit !== undefined;
248
339
  if (opts.all === true && (untilGiven || sinceGiven)) {
249
- process.stderr.write("error: --all does not combine with --until/--since\n");
250
- process.exitCode = ExitCode.Usage;
340
+ usageError(opts, "--all does not combine with --until/--since");
251
341
  return;
252
342
  }
253
343
  const lim = parseLimit(opts);
254
344
  if (!lim.ok)
255
345
  return;
256
- const untilDate = opts.all === true ? undefined : parsePeriodEnd(opts.until);
346
+ // Bounds-&-defaults rule: an explicit volume cap (--limit) or range
347
+ // bound (--until/--since) disables the OTHER class's default. So an
348
+ // explicit --limit drops the default window (the next N scheduled
349
+ // items), and an explicit window drops the default row cap — each
350
+ // stated bound takes over output sizing.
351
+ const dropWindowDefault = !untilGiven && (limitGiven || sinceGiven);
352
+ const dropLimitDefault = !limitGiven && opts.all !== true && (untilGiven || sinceGiven);
353
+ const effectiveLimit = dropLimitDefault ? null : lim.limit;
354
+ const untilDate = opts.all === true || dropWindowDefault ? undefined : parsePeriodEnd(opts.until);
257
355
  if (untilDate !== undefined && Number.isNaN(untilDate.getTime())) {
258
- process.stderr.write(`error: --until is not a parseable period: ${opts.until}\n`);
259
- process.exitCode = ExitCode.Usage;
356
+ usageError(opts, `--until is not a parseable period: ${opts.until}`);
260
357
  return;
261
358
  }
262
359
  const sinceDate = sinceGiven ? parsePeriodStart(opts.since) : undefined;
263
360
  if (sinceDate !== undefined && Number.isNaN(sinceDate.getTime())) {
264
- process.stderr.write(`error: --since is not a parseable period: ${opts.since}\n`);
265
- process.exitCode = ExitCode.Usage;
361
+ usageError(opts, `--since is not a parseable period: ${opts.since}`);
266
362
  return;
267
363
  }
268
364
  const until = untilDate === undefined ? undefined : localToday(untilDate);
269
365
  const since = sinceDate === undefined ? undefined : localToday(sinceDate);
366
+ // The default window is in force only for a bare invocation (no
367
+ // explicit cap or bound); that is the one case whose footer names the
368
+ // window itself alongside the levers.
369
+ const defaultWindowActive = until !== undefined && !untilGiven;
270
370
  const base = invocation("upcoming", [
271
371
  untilGiven && `--until ${shellQuote(opts.until)}`,
272
372
  sinceGiven && `--since ${shellQuote(opts.since)}`,
273
- opts.tag !== undefined && `--tag ${shellQuote(opts.tag)}`,
274
- opts.exactTag === true && "--exact-tag",
373
+ ...tagInvocationParts(opts),
275
374
  opts.horizon !== undefined && `--horizon ${shellQuote(opts.horizon)}`,
276
375
  ]);
277
- runRead(opts, "upcoming", (c) => paginateList(c.read.upcoming({
278
- ...(until !== undefined && { until }),
279
- ...(since !== undefined && { since }),
280
- ...(opts.tag !== undefined && { tag: opts.tag }),
281
- ...(opts.exactTag === true && { exactTag: true }),
282
- ...(opts.horizon !== undefined && { horizon: Number(opts.horizon) }),
283
- }), lim.limit), (items) => {
284
- const lines = renderUpcoming(items);
285
- if (until !== undefined) {
286
- lines.push("", dim(`(through ${shortDate(until, localToday())} — --all for the full horizon)`));
376
+ runRead(opts, "upcoming", (c) => {
377
+ const { items: data, truncation } = c.read.upcoming({
378
+ ...(until !== undefined && { until }),
379
+ ...(since !== undefined && { since }),
380
+ ...tagFilterFields(opts),
381
+ ...(opts.horizon !== undefined && { horizon: Number(opts.horizon) }),
382
+ limit: effectiveLimit,
383
+ });
384
+ const lines = renderUpcoming(data);
385
+ if (defaultWindowActive && until !== undefined) {
386
+ const windowLabel = shortDate(until, localToday());
387
+ if (truncation.truncated && truncation.limit !== null) {
388
+ // Bare invocation, row cap biting inside the default window: one
389
+ // line names BOTH the window and the two levers, so neither the
390
+ // limit nor the horizon is a hidden second bound.
391
+ const more = truncation.total - truncation.shown;
392
+ lines.push("", dim(`── ${more} more item${more === 1 ? "" : "s"} through ${windowLabel} — ` +
393
+ `see more: \`${base} --limit ${truncation.limit * 2}\` · ` +
394
+ `\`${base} --all\` ──`));
395
+ }
396
+ else {
397
+ // Default window active, row cap NOT biting: the only useful
398
+ // lever is a wider window (or everything).
399
+ lines.push("", dim(`(through ${windowLabel} — wider: \`${base} --until ${doublePeriod(opts.until)}\`` +
400
+ ` · \`${base} --all\`)`));
401
+ }
287
402
  }
288
- return lines;
289
- }, base, "upcoming");
403
+ else if (truncation.truncated && truncation.limit !== null) {
404
+ // The user stated a bound: no window line, only a row hint, and
405
+ // only when an explicit --limit truncated. A stated --until/--since
406
+ // makes --all a usage error (and would discard the very window
407
+ // they asked for), so a bounded run offers just a bigger cap.
408
+ const more = truncation.total - truncation.shown;
409
+ const bounded = untilGiven || sinceGiven;
410
+ const allLever = bounded ? "" : ` · \`${base} --all\``;
411
+ lines.push("", dim(`── ${more} more item${more === 1 ? "" : "s"} — ` +
412
+ `see more: \`${base} --limit ${truncation.limit * 2}\`${allLever} ──`));
413
+ }
414
+ return { data, truncation, lines };
415
+ }, (items) => renderUpcoming(items), undefined, "upcoming");
290
416
  });
291
417
  program
292
418
  .command("logbook")
@@ -300,11 +426,14 @@ export function registerReadCommands(program) {
300
426
  .option("--project <ref>", "restrict to one project's children (uuid or unique name)")
301
427
  .option("--since <when>", `only entries logged on/after this bound: ${PERIOD_SINCE}`)
302
428
  .option("--until <when>", `only entries logged on/before this bound: ${PERIOD_UNTIL}`)
303
- .option("--tag <ref>", "filter by tag (uuid or unique name), direct OR inherited")
304
- .option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
429
+ .option("--tag <ref>", TAG_DESC, collectRef, [])
430
+ .option("--exact-tag", EXACT_TAG_DESC)
431
+ .option("--untagged", UNTAGGED_DESC)
305
432
  .option("--json", "emit versioned JSON envelope on stdout")
306
433
  .option("--db <path>", "explicit database path")
307
434
  .action((opts) => {
435
+ if (tagFlagConflict(opts))
436
+ return;
308
437
  const lim = parseLimit(opts);
309
438
  if (!lim.ok)
310
439
  return;
@@ -315,28 +444,33 @@ export function registerReadCommands(program) {
315
444
  ["--until", until],
316
445
  ]) {
317
446
  if (value !== undefined && Number.isNaN(value.getTime())) {
318
- process.stderr.write(`error: ${flag} is not a parseable date\n`);
319
- process.exitCode = ExitCode.Usage;
447
+ usageError(opts, `${flag} is not a parseable date`);
320
448
  return;
321
449
  }
322
450
  }
451
+ // Bounds-&-defaults rule: an explicit range bound (--since/--until)
452
+ // drops the default row cap unless --limit is also stated — the logged
453
+ // window the user named is the bound, not an arbitrary 50-row cut.
454
+ const boundGiven = opts.since !== undefined || opts.until !== undefined;
455
+ const effectiveLimit = opts.limit === undefined && opts.all !== true && boundGiven ? null : lim.limit;
323
456
  const base = invocation("logbook", [
324
457
  opts.area !== undefined && `--area ${shellQuote(opts.area)}`,
325
458
  opts.project !== undefined && `--project ${shellQuote(opts.project)}`,
326
459
  opts.since !== undefined && `--since ${shellQuote(opts.since)}`,
327
460
  opts.until !== undefined && `--until ${shellQuote(opts.until)}`,
328
- opts.tag !== undefined && `--tag ${shellQuote(opts.tag)}`,
329
- opts.exactTag === true && "--exact-tag",
461
+ ...tagInvocationParts(opts),
330
462
  ]);
331
- runRead(opts, "logbook", (c) => paginateList(c.read.logbook({
332
- limit: null,
333
- ...(opts.area !== undefined && { area: opts.area }),
334
- ...(opts.project !== undefined && { project: opts.project }),
335
- ...(since !== undefined && { since }),
336
- ...(until !== undefined && { until }),
337
- ...(opts.tag !== undefined && { tag: opts.tag }),
338
- ...(opts.exactTag === true && { exactTag: true }),
339
- }), lim.limit), (items) => renderLogbook(items), base, "logbook");
463
+ runRead(opts, "logbook", (c) => {
464
+ const { items, truncation } = c.read.logbook({
465
+ ...(opts.area !== undefined && { area: opts.area }),
466
+ ...(opts.project !== undefined && { project: opts.project }),
467
+ ...(since !== undefined && { since }),
468
+ ...(until !== undefined && { until }),
469
+ ...tagFilterFields(opts),
470
+ limit: effectiveLimit,
471
+ });
472
+ return { data: items, truncation };
473
+ }, (items) => renderLogbook(items), base, "logbook");
340
474
  });
341
475
  program
342
476
  .command("trash")
@@ -349,36 +483,68 @@ export function registerReadCommands(program) {
349
483
  const lim = parseLimit(opts);
350
484
  if (!lim.ok)
351
485
  return;
352
- runRead(opts, "trash", (c) => paginateList(c.read.trash({ limit: null }), lim.limit), renderList, invocation("trash", []), "trash");
486
+ runRead(opts, "trash", (c) => {
487
+ const { items, truncation } = c.read.trash({ limit: lim.limit });
488
+ return { data: items, truncation };
489
+ }, renderList, invocation("trash", []), "trash");
353
490
  });
354
- program
355
- .command("projects")
356
- .description("Active projects in sidebar order: loose projects first, then grouped under " +
357
- "their area (optionally scoped to --area <ref>). Someday and future-scheduled " +
358
- "projects are hidden --show-later appends them after each group's active " +
359
- "block (state carried by the (~) mark and ‹date› chip)")
491
+ const projects = program
492
+ .command("projects [ref]")
493
+ .description("List active projects in canonical order, or given a ref show that one " +
494
+ "project (exactly like `things project show <ref>`). List: loose projects " +
495
+ "first, then grouped under their area (optionally scoped to --area <ref>). " +
496
+ "Someday and future-scheduled projects are hidden --show-later appends them " +
497
+ "after each group's active block (state carried by the (~) mark and ‹date› " +
498
+ "chip). Filter the listed projects by their own tags with --tag / --untagged — " +
499
+ "inheritance-inclusive (a project inherits its area's tags), the same as the flat " +
500
+ "views. --show-logged applies only when showing one project.")
360
501
  .option("--area <ref>", "filter by area (uuid or unique name)")
361
502
  .option("--show-later", "include someday/future-scheduled projects after each active block")
503
+ .option("--show-logged [n]", "showing one project: include logged items (bare = all)")
504
+ .option("--overdue", "only projects past their deadline (due today is not overdue)")
362
505
  .option("--all", "include someday/future-scheduled projects (same as --show-later)")
363
506
  .option("--json", "emit versioned JSON envelope on stdout")
364
- .option("--db <path>", "explicit database path")
365
- .action((opts) => {
507
+ .option("--db <path>", "explicit database path");
508
+ addTagFilterOptions(projects).action((id, opts) => {
509
+ // Given a ref, this IS `things project show <ref>` — delegate to the same
510
+ // code path so the output is identical (a true synonym). --overdue rides
511
+ // along (it filters the shown project's children there).
512
+ if (id !== undefined) {
513
+ runProjectShow(id, opts);
514
+ return;
515
+ }
516
+ if (tagFlagConflict(opts))
517
+ return;
366
518
  // --all lifts the sole default restriction here — the hidden later block
367
519
  // — so it is exactly --show-later (the charter: --all removes every
368
520
  // default restriction on the view's own content).
369
521
  const showLater = opts.showLater === true || opts.all === true;
522
+ // OWN-DEADLINE UNIFORM content scope: keep only projects whose own
523
+ // deadline is overdue. Composes as AND with --area/--show-later; a
524
+ // content scope, it never lifts a limit. Threaded into the hidden-later
525
+ // counting queries too so the counts reflect the same filter.
526
+ const overdue = opts.overdue === true;
527
+ // Tag scope (§9a): the projects LIST is a FLAT view — each project ROW is
528
+ // filtered by its own tags, INHERITANCE-INCLUSIVE (a project inherits its
529
+ // area's tags), the same as `anytime`. NOT a single-container view.
530
+ // Threaded into the hidden-later counts too so they reflect the same filter.
531
+ const tagFilter = tagFilterFields(opts);
370
532
  let hints;
371
533
  withClient(opts, "projects", (c) => {
372
534
  const scope = opts.area !== undefined ? { areaUuid: opts.area } : {};
373
535
  const visible = c.read.projects({
374
536
  ...scope,
375
537
  ...(showLater && { later: true }),
538
+ ...(overdue && { overdue: true }),
539
+ ...tagFilter,
376
540
  });
377
541
  if (opts.area === undefined) {
378
542
  // Sidebar scaffold: every VISIBLE area renders (project-less
379
543
  // ones say so), in sidebar order; the loose block leads. One
380
544
  // extra projects query buys the hidden-later counts.
381
- const full = showLater ? visible : c.read.projects({ later: true });
545
+ const full = showLater
546
+ ? visible
547
+ : c.read.projects({ later: true, ...(overdue && { overdue: true }), ...tagFilter });
382
548
  const shown = new Set(visible.map((i) => i.uuid));
383
549
  const groups = [
384
550
  { area: null, hidden: 0 },
@@ -399,7 +565,12 @@ export function registerReadCommands(program) {
399
565
  }
400
566
  else if (!showLater) {
401
567
  // --area scoped: only the bottom hint needs a count.
402
- const full = c.read.projects({ ...scope, later: true });
568
+ const full = c.read.projects({
569
+ ...scope,
570
+ later: true,
571
+ ...(overdue && { overdue: true }),
572
+ ...tagFilter,
573
+ });
403
574
  hints = { groups: [{ area: null, hidden: full.length - visible.length }] };
404
575
  }
405
576
  return visible;
@@ -411,18 +582,60 @@ export function registerReadCommands(program) {
411
582
  return renderProjectsSidebar(items, hints);
412
583
  const lines = renderList(items);
413
584
  const hidden = hints?.groups.reduce((n, g) => n + g.hidden, 0) ?? 0;
414
- if (hidden > 0)
415
- lines.push("", dim(`(${hidden} later project${hidden === 1 ? "" : "s"} — visible with \`--show-later\`)`));
585
+ if (hidden > 0) {
586
+ // Hidden-section placeholder: the reveal command echoes the user's
587
+ // own scope (--area) plus the flag that surfaces the later block.
588
+ const reveal = invocation("projects", [
589
+ opts.area !== undefined && `--area ${shellQuote(opts.area)}`,
590
+ overdue && "--overdue",
591
+ ...tagInvocationParts(opts),
592
+ "--show-later",
593
+ ]);
594
+ lines.push("", disclosureHint(hidden, "later project", [{ command: reveal }]));
595
+ }
416
596
  return lines;
417
597
  });
418
598
  });
419
- program
420
- .command("areas")
421
- .description("All areas with their direct tags")
599
+ const areas = program
600
+ .command("areas [ref]")
601
+ .description("List all areas with their direct tags, or — given a ref — show that one area " +
602
+ "(exactly like `things area show <ref>`: its projects and direct to-dos). " +
603
+ "--show-later / --show-logged / --area-limit / --project-limit / the tag filters " +
604
+ "apply only when showing one area.")
422
605
  .option("--all", "show every area (no default restriction applies)")
606
+ .option("--show-later", "showing one area: include its Upcoming and Someday sections")
607
+ .option("--show-logged [n]", "showing one area: include the n most recent logged items")
608
+ .option("--project-limit <n>", "showing one area: maximum project rows to show")
609
+ .option("--area-limit <n>", "showing one area: maximum direct to-dos to show")
610
+ .option("--overdue", "showing one area: only rows whose own deadline is past (due today is not overdue)")
423
611
  .option("--json", "emit versioned JSON envelope on stdout")
424
- .option("--db <path>", "explicit database path")
425
- .action((opts) => {
612
+ .option("--db <path>", "explicit database path");
613
+ addTagFilterOptions(areas).addHelpText("after", CONTAINER_TAG_HINT);
614
+ areas.action((id, opts) => {
615
+ // Given a ref, this IS `things area show <ref>` — delegate to the same code
616
+ // path so the output is identical (a true synonym); --overdue and the tag
617
+ // filters scope its rows there.
618
+ if (id !== undefined) {
619
+ runAreaShow(id, opts);
620
+ return;
621
+ }
622
+ // The areas LIST has no deadline to compare against — an area is not a
623
+ // dated entity — so --overdue is vacuous here and rejected fail-closed
624
+ // (the same exclusion style #159 used for upcoming/logbook/trash). The
625
+ // decision is the contract's (areas: overdue = false).
626
+ if (!FILTER_CONTRACT.areas.overdue && opts.overdue === true) {
627
+ usageError(opts, "--overdue does not apply to the areas list — areas have no deadline; use it on `things areas <ref>` (that area's rows) or `things projects --overdue`");
628
+ return;
629
+ }
630
+ // The tag filters scope an area's ROWS, not the area LIST — the bare list
631
+ // shows every area with its direct tags. Rejected fail-closed here (same
632
+ // style as --overdue; contract: areas tag = "rejected"). Use
633
+ // `things areas <ref>` or `things projects --tag`.
634
+ if (FILTER_CONTRACT.areas.tag === "rejected" &&
635
+ (hasTagPresence(opts) || opts.untagged === true)) {
636
+ usageError(opts, "the tag filters (--tag/--untagged) do not apply to the areas list — use them on `things areas <ref>` (that area's rows) or `things projects --tag`");
637
+ return;
638
+ }
426
639
  withClient(opts, "areas", (c) => c.read.areas(), (data) => {
427
640
  const w = uuidDisplayWidth(data);
428
641
  return data.map((a) => `${dim(uuidCol(a.uuid, w))} ${areaMark()} ${a.title}${a.tags.length ? ` ${dim(`#${a.tags.map((t) => t.title).join(" #")}`)}` : ""}`);
@@ -430,12 +643,34 @@ export function registerReadCommands(program) {
430
643
  });
431
644
  program
432
645
  .command("tags")
433
- .description("Tag taxonomy (parent → child hierarchy flattened with refs)")
646
+ .description("Tag taxonomy — the parent → child hierarchy as an indented tree")
434
647
  .option("--all", "show every tag (no default restriction applies)")
435
648
  .option("--json", "emit versioned JSON envelope on stdout")
436
649
  .option("--db <path>", "explicit database path")
437
650
  .action((opts) => {
438
- withClient(opts, "tags", (c) => c.read.tags(), (data) => data.map((t) => `${t.uuid} ${t.parent ? `${t.parent.title}/` : ""}${t.title}`));
651
+ withClient(opts, "tags", (c) => c.read.tags(),
652
+ // An INDENTED TREE, leaf names only — nesting is conveyed by indentation
653
+ // (2 spaces per depth level), mirroring the Things GUI. Tag names are
654
+ // globally unique (TAGW1-c), so each displayed leaf name is directly
655
+ // usable as a tag ref — no path prefix or uuid needed. Rows arrive in
656
+ // canonical DFS order (index, uuid) from tagsView, children following
657
+ // their parent; the tree IS structure, so leaf names render plainly (not
658
+ // dim). Depth is the length of the parent-name chain (names are unique).
659
+ (data) => {
660
+ const parentOf = new Map(data.map((t) => [t.title, t.parent]));
661
+ const depthOf = (title) => {
662
+ let depth = 0;
663
+ const seen = new Set();
664
+ let cur = parentOf.get(title) ?? null;
665
+ while (cur !== null && !seen.has(cur)) {
666
+ seen.add(cur);
667
+ depth++;
668
+ cur = parentOf.get(cur) ?? null;
669
+ }
670
+ return depth;
671
+ };
672
+ return data.map((t) => `${" ".repeat(depthOf(t.title))}${t.title}`);
673
+ });
439
674
  });
440
675
  program
441
676
  .command("changes")
@@ -454,12 +689,14 @@ export function registerReadCommands(program) {
454
689
  return;
455
690
  const since = parsePeriodStart(opts.since);
456
691
  if (Number.isNaN(since.getTime())) {
457
- process.stderr.write(`error: --since is not a parseable date: ${opts.since}\n`);
458
- process.exitCode = ExitCode.Usage;
692
+ usageError(opts, `--since is not a parseable date: ${opts.since}`);
459
693
  return;
460
694
  }
461
695
  const base = invocation("changes", [`--since ${shellQuote(opts.since)}`]);
462
- runRead(opts, "changes", (c) => paginateList(c.read.changes({ since, limit: null }), lim.limit), (items) => items.length === 0
696
+ runRead(opts, "changes", (c) => {
697
+ const { items, truncation } = c.read.changes({ since, limit: lim.limit });
698
+ return { data: items, truncation };
699
+ }, (items) => items.length === 0
463
700
  ? ["(no changes)"]
464
701
  : items.map((i) => `${i.changeKind === "created" ? "+" : "~"} ${formatItem(i)}${i.trashed ? " [trashed]" : ""}`), base);
465
702
  });
@@ -470,11 +707,13 @@ export function registerReadCommands(program) {
470
707
  "`via heading`); projects rank above to-dos, active above someday, ties broken by " +
471
708
  "most-recently-modified. Default scope: OPEN + untrashed items only — widen with " +
472
709
  "--logged / --trashed / --all. Scope with --project / --area / --tag (tag matches " +
473
- "include hierarchy descendants) / --type.")
710
+ "include hierarchy descendants) / --type / --overdue (open items past their deadline).")
474
711
  .option("--project <ref>", "restrict to one project's children (uuid or unique name)")
475
712
  .option("--area <ref>", "restrict to one area's direct members (uuid or unique name)")
476
- .option("--tag <ref>", "restrict by tag: direct, inherited, or descendant-tagged")
477
- .option("--exact-tag", "match the named tag only — exclude hierarchy descendants")
713
+ .option("--tag <ref>", TAG_DESC, collectRef, [])
714
+ .option("--exact-tag", EXACT_TAG_DESC)
715
+ .option("--untagged", UNTAGGED_DESC)
716
+ .option("--overdue", OVERDUE_DESC)
478
717
  .option("--type <kind>", "todo | project")
479
718
  .option("--logged", "include completed/canceled items")
480
719
  .option("--trashed", "include trashed items")
@@ -483,40 +722,67 @@ export function registerReadCommands(program) {
483
722
  .option("--json", "emit versioned JSON envelope on stdout")
484
723
  .option("--db <path>", "explicit database path")
485
724
  .action((query, opts) => {
725
+ const json = opts["json"] === true;
486
726
  const type = opts["type"];
487
727
  if (type !== undefined && type !== "todo" && type !== "project") {
488
- process.stderr.write("error: --type must be todo or project\n");
489
- process.exitCode = ExitCode.Usage;
728
+ usageError({ json }, "--type must be todo or project");
490
729
  return;
491
730
  }
731
+ const tagFlags = {
732
+ ...(Array.isArray(opts["tag"]) && { tag: opts["tag"] }),
733
+ exactTag: opts["exactTag"] === true,
734
+ untagged: opts["untagged"] === true,
735
+ };
736
+ const overdue = opts["overdue"] === true;
492
737
  const all = opts["all"] === true;
738
+ // Tag-conflict AND the --overdue/status-widening incompatibility both
739
+ // derive from the shared contract (search: statusWidening = true) —
740
+ // --overdue lists OPEN, past-deadline items, so combining it with the
741
+ // completed/canceled/trashed-widening flags is contradictory.
742
+ const validated = validateViewArgs("search", {
743
+ ...tagFlags,
744
+ overdue,
745
+ logged: opts["logged"] === true,
746
+ trashed: opts["trashed"] === true,
747
+ all,
748
+ }, {
749
+ untaggedConflict: "--untagged does not combine with --tag/--exact-tag",
750
+ overdueRejected: "--overdue does not apply to search",
751
+ overdueStatusWiden: "--overdue does not combine with --logged/--trashed/--all",
752
+ });
753
+ if (!validated.ok) {
754
+ usageError({ json }, validated.message);
755
+ return;
756
+ }
493
757
  const limitOpt = opts["limit"];
494
758
  // --all widens the scope AND lifts the row limit — combining it with an
495
759
  // explicit --limit is contradictory (like every other view).
496
- const lim = parseLimit({ all, ...(limitOpt !== undefined && { limit: limitOpt }) });
760
+ const lim = parseLimit({ all, json, ...(limitOpt !== undefined && { limit: limitOpt }) });
497
761
  if (!lim.ok)
498
762
  return;
499
763
  const base = invocation("search", [
500
764
  shellQuote(query),
501
765
  opts["project"] !== undefined && `--project ${shellQuote(opts["project"])}`,
502
766
  opts["area"] !== undefined && `--area ${shellQuote(opts["area"])}`,
503
- opts["tag"] !== undefined && `--tag ${shellQuote(opts["tag"])}`,
504
- opts["exactTag"] === true && "--exact-tag",
767
+ ...tagInvocationParts(tagFlags),
768
+ overdue && "--overdue",
505
769
  type !== undefined && `--type ${type}`,
506
770
  opts["logged"] === true && "--logged",
507
771
  opts["trashed"] === true && "--trashed",
508
772
  ]);
509
- runRead(opts, "search", (c) => paginateList(c.read.search(query, {
510
- limit: null,
511
- ...(opts["project"] !== undefined && { project: opts["project"] }),
512
- ...(opts["area"] !== undefined && { area: opts["area"] }),
513
- ...(opts["tag"] !== undefined && { tag: opts["tag"] }),
514
- ...(opts["exactTag"] === true && { exactTag: true }),
515
- ...(type !== undefined && { type: type === "todo" ? "to-do" : "project" }),
516
- ...(opts["logged"] === true && { logged: true }),
517
- ...(opts["trashed"] === true && { trashed: true }),
518
- ...(all && { all: true }),
519
- }), lim.limit), renderSearch, base);
773
+ runRead(opts, "search", (c) => {
774
+ const { items, truncation } = c.read.search(query, {
775
+ limit: lim.limit,
776
+ ...(opts["project"] !== undefined && { project: opts["project"] }),
777
+ ...(opts["area"] !== undefined && { area: opts["area"] }),
778
+ ...validated.filter,
779
+ ...(type !== undefined && { type: type === "todo" ? "to-do" : "project" }),
780
+ ...(opts["logged"] === true && { logged: true }),
781
+ ...(opts["trashed"] === true && { trashed: true }),
782
+ ...(all && { all: true }),
783
+ });
784
+ return { data: items, truncation };
785
+ }, renderSearch, base);
520
786
  });
521
787
  // Every row-rendering view points at `things legend` for its glyph language.
522
788
  const GLYPH_VIEWS = new Set([