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