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
@@ -9,13 +9,51 @@ import type { ChecklistRow, TaskRow } from "../model/mappers.ts";
9
9
  export declare const NOT_TEMPLATE = "(t.rt1_recurrenceRule IS NULL AND t.repeater IS NULL)";
10
10
  /**
11
11
  * UI-faithful tag membership for list filtering: direct tag, or inherited
12
- * through the ancestor chain heading → project → area (T18/U18/A13 — the
13
- * same chain inheritedTagsFor() walks). Takes a SET of tag uuids (the target
14
- * plus its hierarchy descendants) each of the six clauses gets the full
15
- * set, so callers bind `uuids.length * 6` values via tagScopeBinds().
12
+ * through the ancestor chain heading → project → area — the OR of every
13
+ * {@link INHERITANCE_CLAUSES} hop. Takes a SET of tag uuids (the target plus its
14
+ * hierarchy descendants); each hop gets the full set, so callers bind
15
+ * `uuids.length * INHERITANCE_CLAUSES.length` values via {@link tagScopeBinds}.
16
16
  */
17
17
  export declare function tagScopeSql(uuidCount: number): string;
18
+ /**
19
+ * The bind list for {@link tagScopeSql}: the uuid set repeated once per hop, in
20
+ * clause order. Derived from the clause count so the bind multiplicity can never
21
+ * drift from the number of hops the SQL actually emits.
22
+ */
18
23
  export declare function tagScopeBinds(uuids: string[]): string[];
24
+ /**
25
+ * The DIRECT-ONLY projection of {@link tagScopeSql}: {@link DIRECT_TAG_CLAUSE}
26
+ * alone — the item's own `TMTaskTag` assignments — WITHOUT the five container-
27
+ * inheritance hops (project/area/heading). This is the SQL behind the CONTAINER
28
+ * `--tag` (the `project show` / `area show` / `projects` list views): every
29
+ * child inherits its container's tags, so the inheritance-inclusive relation is
30
+ * vacuous there — matching a DIRECT assignment is the useful, GUI-faithful
31
+ * behavior. It keeps tag-hierarchy descendant expansion (the uuid SET is still
32
+ * the tag plus its descendants, OR-matched) but drops container inheritance, so
33
+ * an item matches only when it is DIRECTLY tagged. Takes the uuid set once, so
34
+ * callers bind `uuids` exactly one time (not `× 6`).
35
+ */
36
+ export declare function directTagScopeSql(uuidCount: number): string;
37
+ /**
38
+ * The negation of tag membership — the SQL behind the `untagged` filter (the
39
+ * GUI's "No Tag"). It negates the SAME {@link INHERITANCE_CLAUSES} relation with
40
+ * the tag-set restriction dropped: "carries ANY tag by any hop", wrapped in NOT.
41
+ * An item is untagged iff NO possible `--tag X` could ever match it — so this
42
+ * negates the whole membership relation, not merely the row's own direct
43
+ * assignments. Takes no binds.
44
+ */
45
+ export declare function untaggedScopeSql(): string;
46
+ /**
47
+ * The DIRECT-ONLY counterpart of {@link untaggedScopeSql} — the SQL behind the
48
+ * CONTAINER `--untagged` (the GUI's in-context "No Tag" inside a project/area
49
+ * card). It negates only {@link DIRECT_TAG_CLAUSE} (the item's OWN direct
50
+ * assignments), leaving container inheritance untouched: an item qualifies when
51
+ * it carries no DIRECT tag, even if it inherits one from its project/area/
52
+ * heading. Every child inherits the container's tags, so the whole-relation
53
+ * {@link untaggedScopeSql} would exclude every row there — direct-only is the
54
+ * useful negation. Takes no binds.
55
+ */
56
+ export declare function directUntaggedScopeSql(): string;
19
57
  /**
20
58
  * A tag plus every hierarchy descendant. Filtering by a parent tag matches
21
59
  * child-tagged items — DOCUMENTED app behavior (the UI's tag filter works
@@ -39,7 +77,43 @@ export declare function tagWithDescendants(db: DatabaseSync, uuid: string): stri
39
77
  * input passes through untouched (after trimming).
40
78
  */
41
79
  export declare function stripThingsUri(ref: string): string;
42
- export declare function resolveTaskUuidPrefix(db: DatabaseSync, refRaw: string): string;
80
+ /**
81
+ * The single source for uuid-miss not-found copy. Reused by the read-side
82
+ * resolvers, the write guards, and the MCP item lookups so a uuid/partial-uuid
83
+ * that matches nothing reads identically wherever the miss is reported.
84
+ */
85
+ export declare function noUuidMatch(entity: string, ref: string): string;
86
+ /** A disambiguation candidate for a reference-resolution failure. */
87
+ export interface RefCandidate {
88
+ uuid: string;
89
+ title: string;
90
+ /** Optional context that distinguishes same-named candidates (area for a project, parent path for a tag). */
91
+ context?: string;
92
+ }
93
+ /**
94
+ * A reference (uuid / partial-uuid / name) that did not resolve to exactly one
95
+ * entity. Extends RangeError so every existing `instanceof RangeError` handler
96
+ * keeps treating it as a usage-class failure — but the surfaces that know about
97
+ * it (CLI --json envelope, MCP tool result) additionally lift the structured
98
+ * `candidates` onto `error.details.candidates` so an agent can self-correct
99
+ * without re-parsing the prose message. `code` mirrors the envelope error code.
100
+ *
101
+ * PUBLIC API — exported from src/index.ts. This is the one error the consumer
102
+ * surfaces catch to render structured disambiguation; its `code`
103
+ * ("not-found" | "ambiguous") and `candidates` ({@link RefCandidate}[]) are the
104
+ * documented machine shape (docs/design/architecture.md, Consumer boundary).
105
+ */
106
+ export declare class ReferenceResolutionError extends RangeError {
107
+ readonly code: "not-found" | "ambiguous";
108
+ readonly ref: string;
109
+ readonly candidates: RefCandidate[];
110
+ constructor(message: string, opts: {
111
+ code: "not-found" | "ambiguous";
112
+ ref: string;
113
+ candidates?: RefCandidate[];
114
+ });
115
+ }
116
+ export declare function resolveTaskUuidPrefix(db: DatabaseSync, refRaw: string, entity?: string): string;
43
117
  /**
44
118
  * Fold a name to its match key: NFC + case-fold + strip all whitespace and
45
119
  * dashes/hyphens (ASCII hyphen, the U+2010–2015 dash block, U+2212 minus).
@@ -54,6 +128,15 @@ export interface NamedResolution {
54
128
  } | null;
55
129
  /** 0 = not found, 1 = ok, >1 = ambiguous at the deciding tier. */
56
130
  matches: number;
131
+ /**
132
+ * The rows at the deciding tier when it was ambiguous (matches > 1) — the
133
+ * candidates a fail-closed resolver lists so the caller can disambiguate by
134
+ * uuid. Absent when resolved or not-found.
135
+ */
136
+ candidates?: {
137
+ uuid: string;
138
+ title: string;
139
+ }[];
57
140
  }
58
141
  /**
59
142
  * Tiered reference resolution (docs/design/reference-resolution.md): exact
@@ -65,6 +148,22 @@ export interface NamedResolution {
65
148
  export declare function resolveNamedRef(db: DatabaseSync, table: string, extraWhere: string, extraBinds: (string | number)[], refRaw: string, options?: {
66
149
  prefixTier?: boolean;
67
150
  }): NamedResolution;
151
+ /**
152
+ * Resolve a PROJECT write target from a uuid, partial-uuid, or unique name.
153
+ * Project write verbs (`things project update <ref>`, etc.) accept names
154
+ * through this; to-do and heading write targets stay uuid-only.
155
+ *
156
+ * A uuid / unique uuid-prefix resolves FIRST over every task (reusing
157
+ * {@link resolveTaskUuidPrefix}), so a wrong-TYPE id — a to-do uuid handed to a
158
+ * project verb — passes through to the op's own guard, which reports it with a
159
+ * targeted "that is a to-do, not a project" message rather than a misleading
160
+ * not-found. Otherwise the ref resolves as a project NAME through the SAME
161
+ * tiered {@link resolveNamedRef} matching the read side uses (shared core, not
162
+ * a fork) across projects (trashed included, for `project restore`), fail-
163
+ * closed with a candidate listing on an ambiguous name so a duplicated project
164
+ * title is disambiguated by uuid rather than guessed.
165
+ */
166
+ export declare function resolveProjectWriteTarget(db: DatabaseSync, refRaw: string): string;
68
167
  export declare function resolveTagUuid(db: DatabaseSync, ref: string): string;
69
168
  /**
70
169
  * Write destinations stay strict (a trashed project is not a valid target);
@@ -78,15 +177,61 @@ export declare function resolveProjectUuid(db: DatabaseSync, ref: string, option
78
177
  export declare function resolveAreaUuid(db: DatabaseSync, ref: string, options?: {
79
178
  prefixTier?: boolean;
80
179
  }): string;
180
+ /**
181
+ * A row's EFFECTIVE area: its own `area` link, else the area of its project,
182
+ * else the area of its heading's project. To-dos nested in a project (or under a
183
+ * heading) carry `area = NULL` in the DB — the area lives on the container — so
184
+ * this resolves the nearest area walking the SAME chain the tag-inheritance SQL
185
+ * uses (t.area → t.project's area → t.heading's project's area). Projects carry
186
+ * their area directly (project/heading are NULL), so COALESCE returns `t.area`
187
+ * unchanged for them — areas are not inherited. Surfaced as the entity's `area`
188
+ * Ref (mappers.ts); whether it is direct vs effective stays derivable from
189
+ * whether `project`/`heading` is set. Emitted as the extra `effectiveArea`
190
+ * column so the raw `t.area` (which tag inheritance and the write layer read)
191
+ * stays available.
192
+ */
193
+ export declare const EFFECTIVE_AREA = "COALESCE(\n t.area,\n (SELECT p.area FROM TMTask p WHERE p.uuid = t.project),\n (SELECT hp.area FROM TMTask h JOIN TMTask hp ON hp.uuid = h.project WHERE h.uuid = t.heading)\n)";
81
194
  export declare function fetchTaskRows(db: DatabaseSync, where: string, params?: unknown[]): TaskRow[];
82
195
  export declare function fetchTaskByUuid(db: DatabaseSync, uuid: string): TaskRow | null;
83
196
  export declare function fetchChecklistRows(db: DatabaseSync, taskUuid: string): ChecklistRow[];
84
- /** Direct tags for a set of tasks, in one query. Returns uuid -> Ref[] (sorted by tag title). */
197
+ /**
198
+ * Direct tags for a set of tasks, in one query. Returns uuid -> Ref[] in the
199
+ * app's CANONICAL tag order.
200
+ *
201
+ * CANONICAL ORDER (ratified 2026-07-14; tiebreak corrected 2026-07-15 by
202
+ * TAGORD1): `TMTag."index"` (INTEGER, often negative) is the user-draggable
203
+ * order from the app's Tags window, and the GUI renders every multi-tag pill row
204
+ * in ascending `index`. Live oracle: the `Replace CPAP mask & air filter` to-do
205
+ * shows `#recurring #home #housekeeping`, matching the tags' indexes, NOT their
206
+ * alphabetical order.
207
+ *
208
+ * TIEBREAK = `uuid`, NOT `title` (TAGORD1 lab oracle, docs/lab/taglab-probes.md).
209
+ * Never-dragged tags ubiquitously tie at `index = 0`; the app breaks that tie by
210
+ * the tag's UUID (ascending ASCII), NOT alphabetically. Proven across three
211
+ * surfaces in a VM (Tags window, a to-do's multi-tag pill row — input-order
212
+ * independent, and the list filter-bar chips): 8 tags seeded reverse-alpha all
213
+ * tied at 0 displayed in exact uuid order, and `ORDER BY "index", uuid`
214
+ * reproduced the whole Tags-window order byte-for-byte where `ORDER BY "index",
215
+ * title` diverged. TMTag has no creation-date column, so creation order is not
216
+ * even a candidate comparator.
217
+ *
218
+ * NESTED-TAG CAVEAT (open question, deliberately unsolved): child tags' indexes
219
+ * interleave globally with top-level ones — CONFIRMED by TAGORD1: `TMTag."index"`
220
+ * is a single GLOBAL space, not per-parent (a seeded child landed at -378 among
221
+ * root tags at 0/-35/-67). So a flat-index sort can place a child BEFORE its
222
+ * parent in a multi-tag row. No live item carries a nested tag alongside another
223
+ * tag, so there is no GUI oracle for the interleaved case — flat ascending
224
+ * `index` is the ratified comparator, isolated HERE. If a GUI oracle ever
225
+ * contradicts it, the fix is a DFS-rank swap in this one ORDER BY (rank children
226
+ * after parents).
227
+ */
85
228
  export declare function fetchTagsForTasks(db: DatabaseSync, taskUuids: string[]): Map<string, Ref[]>;
86
229
  /**
87
230
  * Lazy heading-uuid -> owning-project Ref resolver, cached per instance.
88
231
  * Heading-nested to-dos carry project = NULL in the DB (the heading holds
89
- * the link); list views use this to surface the GUI's container label.
232
+ * the link); list views use this to surface the GUI's container label. The
233
+ * owning project's recurrence columns ride along so a to-do nested under a
234
+ * heading of a repeating-template project inherits the template mark.
90
235
  */
91
236
  export declare function makeHeadingProjectResolver(db: DatabaseSync): (headingUuid: string) => Ref | null;
92
237
  /** Lazy uuid -> Ref resolver over TMTask + TMArea titles, cached per instance. */
@@ -1,29 +1,107 @@
1
1
  import { q, selectList } from "../db/schema.js";
2
2
  /** Rows that repeat via a template are normal; template rows are invisible in list views. */
3
3
  export const NOT_TEMPLATE = "(t.rt1_recurrenceRule IS NULL AND t.repeater IS NULL)";
4
+ /** ` AND col IN (…)` for the restricted form; empty for the tag-agnostic form. */
5
+ const tagIn = (col, set) => set === null ? "" : ` AND ${col} IN ${set}`;
6
+ /**
7
+ * Clause 1 — the item's OWN direct `TMTaskTag` assignments. Named apart from the
8
+ * rest because it is ALSO the whole story for the CONTAINER `--tag`/`--untagged`
9
+ * projections (see {@link directTagScopeSql} / {@link directUntaggedScopeSql}).
10
+ */
11
+ const DIRECT_TAG_CLAUSE = {
12
+ exists: (set) => `EXISTS (SELECT 1 FROM TMTaskTag tt WHERE tt.tasks = t.uuid${tagIn("tt.tags", set)})`,
13
+ };
14
+ /**
15
+ * The full direct+inherited membership relation, heading → project → area
16
+ * (T18/U18/A13 — the same chain inheritedTagsFor() walks), written ONCE. Clause 1
17
+ * is the direct assignment; clauses 2–6 are the five container-inheritance hops.
18
+ */
19
+ const INHERITANCE_CLAUSES = [
20
+ // 1. the item's own direct tags.
21
+ DIRECT_TAG_CLAUSE,
22
+ // 2. inherited from the item's PROJECT's own direct tags.
23
+ {
24
+ exists: (set) => `EXISTS (SELECT 1 FROM TMTaskTag tt WHERE tt.tasks = t.project${tagIn("tt.tags", set)})`,
25
+ },
26
+ // 3. inherited from the item's AREA's tags.
27
+ {
28
+ exists: (set) => `EXISTS (SELECT 1 FROM TMAreaTag at WHERE at.areas = t.area${tagIn("at.tags", set)})`,
29
+ },
30
+ // 4. inherited from the item's PROJECT's AREA's tags.
31
+ {
32
+ exists: (set) => `EXISTS (SELECT 1 FROM TMTask p JOIN TMAreaTag at ON at.areas = p.area
33
+ WHERE p.uuid = t.project${tagIn("at.tags", set)})`,
34
+ },
35
+ // 5. inherited through the item's HEADING → that heading's project's direct tags.
36
+ {
37
+ exists: (set) => `EXISTS (SELECT 1 FROM TMTask h JOIN TMTaskTag tt ON tt.tasks = h.project
38
+ WHERE h.uuid = t.heading${tagIn("tt.tags", set)})`,
39
+ },
40
+ // 6. inherited through the item's HEADING → its project → that project's AREA's tags.
41
+ {
42
+ exists: (set) => `EXISTS (SELECT 1 FROM TMTask h JOIN TMTask p ON p.uuid = h.project
43
+ JOIN TMAreaTag at ON at.areas = p.area WHERE h.uuid = t.heading${tagIn("at.tags", set)})`,
44
+ },
45
+ ];
46
+ /** A `(?, ?, …)` placeholder list for a tag-uuid set of the given size. */
47
+ const placeholderSet = (uuidCount) => `(${Array.from({ length: uuidCount }, () => "?").join(", ")})`;
4
48
  /**
5
49
  * UI-faithful tag membership for list filtering: direct tag, or inherited
6
- * through the ancestor chain heading → project → area (T18/U18/A13 — the
7
- * same chain inheritedTagsFor() walks). Takes a SET of tag uuids (the target
8
- * plus its hierarchy descendants) each of the six clauses gets the full
9
- * set, so callers bind `uuids.length * 6` values via tagScopeBinds().
50
+ * through the ancestor chain heading → project → area — the OR of every
51
+ * {@link INHERITANCE_CLAUSES} hop. Takes a SET of tag uuids (the target plus its
52
+ * hierarchy descendants); each hop gets the full set, so callers bind
53
+ * `uuids.length * INHERITANCE_CLAUSES.length` values via {@link tagScopeBinds}.
10
54
  */
11
55
  export function tagScopeSql(uuidCount) {
12
- const set = `(${Array.from({ length: uuidCount }, () => "?").join(", ")})`;
13
- return `(
14
- EXISTS (SELECT 1 FROM TMTaskTag tt WHERE tt.tasks = t.uuid AND tt.tags IN ${set})
15
- OR EXISTS (SELECT 1 FROM TMTaskTag tt WHERE tt.tasks = t.project AND tt.tags IN ${set})
16
- OR EXISTS (SELECT 1 FROM TMAreaTag at WHERE at.areas = t.area AND at.tags IN ${set})
17
- OR EXISTS (SELECT 1 FROM TMTask p JOIN TMAreaTag at ON at.areas = p.area
18
- WHERE p.uuid = t.project AND at.tags IN ${set})
19
- OR EXISTS (SELECT 1 FROM TMTask h JOIN TMTaskTag tt ON tt.tasks = h.project
20
- WHERE h.uuid = t.heading AND tt.tags IN ${set})
21
- OR EXISTS (SELECT 1 FROM TMTask h JOIN TMTask p ON p.uuid = h.project
22
- JOIN TMAreaTag at ON at.areas = p.area WHERE h.uuid = t.heading AND at.tags IN ${set})
23
- )`;
56
+ const set = placeholderSet(uuidCount);
57
+ return `(\n ${INHERITANCE_CLAUSES.map((c) => c.exists(set)).join("\n OR ")}\n)`;
24
58
  }
59
+ /**
60
+ * The bind list for {@link tagScopeSql}: the uuid set repeated once per hop, in
61
+ * clause order. Derived from the clause count so the bind multiplicity can never
62
+ * drift from the number of hops the SQL actually emits.
63
+ */
25
64
  export function tagScopeBinds(uuids) {
26
- return Array.from({ length: 6 }, () => uuids).flat();
65
+ return Array.from({ length: INHERITANCE_CLAUSES.length }, () => uuids).flat();
66
+ }
67
+ /**
68
+ * The DIRECT-ONLY projection of {@link tagScopeSql}: {@link DIRECT_TAG_CLAUSE}
69
+ * alone — the item's own `TMTaskTag` assignments — WITHOUT the five container-
70
+ * inheritance hops (project/area/heading). This is the SQL behind the CONTAINER
71
+ * `--tag` (the `project show` / `area show` / `projects` list views): every
72
+ * child inherits its container's tags, so the inheritance-inclusive relation is
73
+ * vacuous there — matching a DIRECT assignment is the useful, GUI-faithful
74
+ * behavior. It keeps tag-hierarchy descendant expansion (the uuid SET is still
75
+ * the tag plus its descendants, OR-matched) but drops container inheritance, so
76
+ * an item matches only when it is DIRECTLY tagged. Takes the uuid set once, so
77
+ * callers bind `uuids` exactly one time (not `× 6`).
78
+ */
79
+ export function directTagScopeSql(uuidCount) {
80
+ return DIRECT_TAG_CLAUSE.exists(placeholderSet(uuidCount));
81
+ }
82
+ /**
83
+ * The negation of tag membership — the SQL behind the `untagged` filter (the
84
+ * GUI's "No Tag"). It negates the SAME {@link INHERITANCE_CLAUSES} relation with
85
+ * the tag-set restriction dropped: "carries ANY tag by any hop", wrapped in NOT.
86
+ * An item is untagged iff NO possible `--tag X` could ever match it — so this
87
+ * negates the whole membership relation, not merely the row's own direct
88
+ * assignments. Takes no binds.
89
+ */
90
+ export function untaggedScopeSql() {
91
+ return `NOT (\n ${INHERITANCE_CLAUSES.map((c) => c.exists(null)).join("\n OR ")}\n)`;
92
+ }
93
+ /**
94
+ * The DIRECT-ONLY counterpart of {@link untaggedScopeSql} — the SQL behind the
95
+ * CONTAINER `--untagged` (the GUI's in-context "No Tag" inside a project/area
96
+ * card). It negates only {@link DIRECT_TAG_CLAUSE} (the item's OWN direct
97
+ * assignments), leaving container inheritance untouched: an item qualifies when
98
+ * it carries no DIRECT tag, even if it inherits one from its project/area/
99
+ * heading. Every child inherits the container's tags, so the whole-relation
100
+ * {@link untaggedScopeSql} would exclude every row there — direct-only is the
101
+ * useful negation. Takes no binds.
102
+ */
103
+ export function directUntaggedScopeSql() {
104
+ return `NOT ${DIRECT_TAG_CLAUSE.exists(null)}`;
27
105
  }
28
106
  /**
29
107
  * A tag plus every hierarchy descendant. Filtering by a parent tag matches
@@ -70,23 +148,61 @@ export function stripThingsUri(ref) {
70
148
  }
71
149
  return s;
72
150
  }
73
- export function resolveTaskUuidPrefix(db, refRaw) {
151
+ /**
152
+ * The single source for uuid-miss not-found copy. Reused by the read-side
153
+ * resolvers, the write guards, and the MCP item lookups so a uuid/partial-uuid
154
+ * that matches nothing reads identically wherever the miss is reported.
155
+ */
156
+ export function noUuidMatch(entity, ref) {
157
+ return `no ${entity} matching uuid or partial-uuid "${ref}"`;
158
+ }
159
+ /**
160
+ * A reference (uuid / partial-uuid / name) that did not resolve to exactly one
161
+ * entity. Extends RangeError so every existing `instanceof RangeError` handler
162
+ * keeps treating it as a usage-class failure — but the surfaces that know about
163
+ * it (CLI --json envelope, MCP tool result) additionally lift the structured
164
+ * `candidates` onto `error.details.candidates` so an agent can self-correct
165
+ * without re-parsing the prose message. `code` mirrors the envelope error code.
166
+ *
167
+ * PUBLIC API — exported from src/index.ts. This is the one error the consumer
168
+ * surfaces catch to render structured disambiguation; its `code`
169
+ * ("not-found" | "ambiguous") and `candidates` ({@link RefCandidate}[]) are the
170
+ * documented machine shape (docs/design/architecture.md, Consumer boundary).
171
+ */
172
+ export class ReferenceResolutionError extends RangeError {
173
+ code;
174
+ ref;
175
+ candidates;
176
+ constructor(message, opts) {
177
+ super(message);
178
+ this.name = "ReferenceResolutionError";
179
+ this.code = opts.code;
180
+ this.ref = opts.ref;
181
+ this.candidates = opts.candidates ?? [];
182
+ }
183
+ }
184
+ export function resolveTaskUuidPrefix(db, refRaw, entity = "to-do") {
74
185
  const ref = stripThingsUri(refRaw);
75
186
  const exact = db.prepare("SELECT uuid FROM TMTask WHERE uuid = ?").get(ref);
76
187
  if (exact !== undefined)
77
188
  return exact.uuid;
78
189
  if (ref.length < 6) {
79
- throw new RangeError(`no record with uuid "${ref}" (prefixes need at least 6 characters)`);
190
+ throw new RangeError(`${noUuidMatch(entity, ref)} (a partial-uuid needs at least 6 characters)`);
80
191
  }
81
192
  const upper = ref.slice(0, -1) + String.fromCharCode(ref.charCodeAt(ref.length - 1) + 1);
82
193
  const rows = db
83
194
  .prepare("SELECT t.uuid, t.title FROM TMTask t WHERE t.uuid >= ? AND t.uuid < ? LIMIT 6")
84
195
  .all(ref, upper);
85
- if (rows.length === 0)
86
- throw new RangeError(`no record with uuid or prefix "${ref}"`);
196
+ if (rows.length === 0) {
197
+ throw new ReferenceResolutionError(noUuidMatch(entity, ref), { code: "not-found", ref });
198
+ }
87
199
  if (rows.length > 1) {
88
200
  const list = rows.map((r) => `${r.uuid} (${r.title ?? ""})`).join("; ");
89
- throw new RangeError(`uuid prefix "${ref}" is ambiguous — matches: ${list}`);
201
+ throw new ReferenceResolutionError(`partial-uuid "${ref}" is ambiguous — matches: ${list}`, {
202
+ code: "ambiguous",
203
+ ref,
204
+ candidates: rows.map((r) => ({ uuid: r.uuid, title: r.title ?? "" })),
205
+ });
90
206
  }
91
207
  return rows[0]?.uuid ?? ref;
92
208
  }
@@ -123,7 +239,7 @@ export function resolveNamedRef(db, table, extraWhere, extraBinds, refRaw, optio
123
239
  if (rows.length === 1)
124
240
  return { resolved: rows[0] ?? null, matches: 1 };
125
241
  if (rows.length > 1)
126
- return { resolved: null, matches: rows.length };
242
+ return { resolved: null, matches: rows.length, candidates: rows };
127
243
  }
128
244
  const key = normalizeNameKey(ref);
129
245
  if (key !== "") {
@@ -131,7 +247,7 @@ export function resolveNamedRef(db, table, extraWhere, extraBinds, refRaw, optio
131
247
  if (hits.length === 1)
132
248
  return { resolved: hits[0] ?? null, matches: 1 };
133
249
  if (hits.length > 1)
134
- return { resolved: null, matches: hits.length };
250
+ return { resolved: null, matches: hits.length, candidates: hits };
135
251
  }
136
252
  // The uuid-prefix tier is suppressed on the sugar routing path (bare-noun /
137
253
  // loose-show): there, a NAME subject resolves through exact/case/normalized
@@ -143,17 +259,72 @@ export function resolveNamedRef(db, table, extraWhere, extraBinds, refRaw, optio
143
259
  if (rows.length === 1)
144
260
  return { resolved: rows[0] ?? null, matches: 1 };
145
261
  if (rows.length > 1)
146
- return { resolved: null, matches: rows.length };
262
+ return { resolved: null, matches: rows.length, candidates: rows };
147
263
  }
148
264
  return { resolved: null, matches: 0 };
149
265
  }
266
+ /** The accepted-forms clause for a name-accepting resolver's not-found copy. */
267
+ function acceptedForms(prefixTier) {
268
+ return prefixTier ? "tried uuid, partial-uuid, and name" : "tried uuid and name";
269
+ }
150
270
  function resolveUuidOrThrow(db, table, extraWhere, ref, kind, listCmd, options) {
151
271
  const r = resolveNamedRef(db, table, extraWhere, [], ref, options);
152
272
  if (r.resolved !== null)
153
273
  return r.resolved.uuid;
154
- throw new RangeError(r.matches === 0
155
- ? `${kind} not found: ${ref} (list ${kind}s with \`${listCmd}\`)`
156
- : `${kind} reference is ambiguous: ${ref} (${r.matches} matches — use the exact name or uuid)`);
274
+ if (r.matches === 0) {
275
+ throw new ReferenceResolutionError(`no ${kind} matching "${ref}" — ${acceptedForms(options?.prefixTier !== false)} (list ${kind}s with \`${listCmd}\`)`, { code: "not-found", ref });
276
+ }
277
+ throw new ReferenceResolutionError(`"${ref}" matches ${r.matches} ${kind}s — use the exact name or a uuid`, {
278
+ code: "ambiguous",
279
+ ref,
280
+ candidates: (r.candidates ?? []).map((c) => ({ uuid: c.uuid, title: c.title })),
281
+ });
282
+ }
283
+ /**
284
+ * Resolve a PROJECT write target from a uuid, partial-uuid, or unique name.
285
+ * Project write verbs (`things project update <ref>`, etc.) accept names
286
+ * through this; to-do and heading write targets stay uuid-only.
287
+ *
288
+ * A uuid / unique uuid-prefix resolves FIRST over every task (reusing
289
+ * {@link resolveTaskUuidPrefix}), so a wrong-TYPE id — a to-do uuid handed to a
290
+ * project verb — passes through to the op's own guard, which reports it with a
291
+ * targeted "that is a to-do, not a project" message rather than a misleading
292
+ * not-found. Otherwise the ref resolves as a project NAME through the SAME
293
+ * tiered {@link resolveNamedRef} matching the read side uses (shared core, not
294
+ * a fork) across projects (trashed included, for `project restore`), fail-
295
+ * closed with a candidate listing on an ambiguous name so a duplicated project
296
+ * title is disambiguated by uuid rather than guessed.
297
+ */
298
+ export function resolveProjectWriteTarget(db, refRaw) {
299
+ const ref = stripThingsUri(refRaw);
300
+ try {
301
+ return resolveTaskUuidPrefix(db, ref, "project");
302
+ }
303
+ catch (err) {
304
+ // An ambiguous uuid-prefix is a real conflict — surface it verbatim. A
305
+ // plain not-found (or too-short) ref is not a uuid: fall to the name tiers.
306
+ if (err instanceof RangeError && err.message.includes("ambiguous"))
307
+ throw err;
308
+ }
309
+ const r = resolveNamedRef(db, "TMTask", "type = 1", [], ref, { prefixTier: false });
310
+ if (r.resolved !== null)
311
+ return r.resolved.uuid;
312
+ if (r.matches === 0) {
313
+ throw new ReferenceResolutionError(`no project matching "${ref}" — tried uuid, partial-uuid, and name (list projects with \`things projects\`)`, { code: "not-found", ref });
314
+ }
315
+ const candidates = describeProjectCandidates(db, r.candidates ?? []);
316
+ const lines = candidates
317
+ .map((c) => ` ${c.uuid.slice(0, 8)} — ${c.title}${c.context !== undefined ? ` (in ${c.context})` : ""}`)
318
+ .join("\n");
319
+ throw new ReferenceResolutionError(`"${ref}" matches ${r.matches} projects — disambiguate with a uuid or partial-uuid:\n${lines}`, { code: "ambiguous", ref, candidates });
320
+ }
321
+ /** Short-uuid + area-context candidates for an ambiguous project name. */
322
+ function describeProjectCandidates(db, candidates) {
323
+ const areaStmt = db.prepare("SELECT a.title AS title FROM TMTask p LEFT JOIN TMArea a ON a.uuid = p.area WHERE p.uuid = ?");
324
+ return candidates.map((c) => {
325
+ const area = areaStmt.get(c.uuid)?.title ?? null;
326
+ return { uuid: c.uuid, title: c.title, ...(area !== null && { context: area }) };
327
+ });
157
328
  }
158
329
  export function resolveTagUuid(db, ref) {
159
330
  return resolveUuidOrThrow(db, "TMTag", "1=1", ref, "tag", "things tags");
@@ -169,11 +340,29 @@ export function resolveProjectUuid(db, ref, options) {
169
340
  export function resolveAreaUuid(db, ref, options) {
170
341
  return resolveUuidOrThrow(db, "TMArea", "1=1", ref, "area", "things areas", options);
171
342
  }
343
+ /**
344
+ * A row's EFFECTIVE area: its own `area` link, else the area of its project,
345
+ * else the area of its heading's project. To-dos nested in a project (or under a
346
+ * heading) carry `area = NULL` in the DB — the area lives on the container — so
347
+ * this resolves the nearest area walking the SAME chain the tag-inheritance SQL
348
+ * uses (t.area → t.project's area → t.heading's project's area). Projects carry
349
+ * their area directly (project/heading are NULL), so COALESCE returns `t.area`
350
+ * unchanged for them — areas are not inherited. Surfaced as the entity's `area`
351
+ * Ref (mappers.ts); whether it is direct vs effective stays derivable from
352
+ * whether `project`/`heading` is set. Emitted as the extra `effectiveArea`
353
+ * column so the raw `t.area` (which tag inheritance and the write layer read)
354
+ * stays available.
355
+ */
356
+ export const EFFECTIVE_AREA = `COALESCE(
357
+ t.area,
358
+ (SELECT p.area FROM TMTask p WHERE p.uuid = t.project),
359
+ (SELECT hp.area FROM TMTask h JOIN TMTask hp ON hp.uuid = h.project WHERE h.uuid = t.heading)
360
+ )`;
172
361
  export function fetchTaskRows(db, where, params = []) {
173
362
  const sql = `SELECT ${selectList("TMTask")
174
363
  .split(", ")
175
364
  .map((c) => `t.${c}`)
176
- .join(", ")} FROM TMTask t WHERE ${where}`;
365
+ .join(", ")}, ${EFFECTIVE_AREA} AS effectiveArea FROM TMTask t WHERE ${where}`;
177
366
  return db.prepare(sql).all(...params);
178
367
  }
179
368
  export function fetchTaskByUuid(db, uuid) {
@@ -184,7 +373,37 @@ export function fetchChecklistRows(db, taskUuid) {
184
373
  const sql = `SELECT ${selectList("TMChecklistItem")} FROM TMChecklistItem WHERE task = ? ORDER BY ${q("index")} ASC`;
185
374
  return db.prepare(sql).all(taskUuid);
186
375
  }
187
- /** Direct tags for a set of tasks, in one query. Returns uuid -> Ref[] (sorted by tag title). */
376
+ /**
377
+ * Direct tags for a set of tasks, in one query. Returns uuid -> Ref[] in the
378
+ * app's CANONICAL tag order.
379
+ *
380
+ * CANONICAL ORDER (ratified 2026-07-14; tiebreak corrected 2026-07-15 by
381
+ * TAGORD1): `TMTag."index"` (INTEGER, often negative) is the user-draggable
382
+ * order from the app's Tags window, and the GUI renders every multi-tag pill row
383
+ * in ascending `index`. Live oracle: the `Replace CPAP mask & air filter` to-do
384
+ * shows `#recurring #home #housekeeping`, matching the tags' indexes, NOT their
385
+ * alphabetical order.
386
+ *
387
+ * TIEBREAK = `uuid`, NOT `title` (TAGORD1 lab oracle, docs/lab/taglab-probes.md).
388
+ * Never-dragged tags ubiquitously tie at `index = 0`; the app breaks that tie by
389
+ * the tag's UUID (ascending ASCII), NOT alphabetically. Proven across three
390
+ * surfaces in a VM (Tags window, a to-do's multi-tag pill row — input-order
391
+ * independent, and the list filter-bar chips): 8 tags seeded reverse-alpha all
392
+ * tied at 0 displayed in exact uuid order, and `ORDER BY "index", uuid`
393
+ * reproduced the whole Tags-window order byte-for-byte where `ORDER BY "index",
394
+ * title` diverged. TMTag has no creation-date column, so creation order is not
395
+ * even a candidate comparator.
396
+ *
397
+ * NESTED-TAG CAVEAT (open question, deliberately unsolved): child tags' indexes
398
+ * interleave globally with top-level ones — CONFIRMED by TAGORD1: `TMTag."index"`
399
+ * is a single GLOBAL space, not per-parent (a seeded child landed at -378 among
400
+ * root tags at 0/-35/-67). So a flat-index sort can place a child BEFORE its
401
+ * parent in a multi-tag row. No live item carries a nested tag alongside another
402
+ * tag, so there is no GUI oracle for the interleaved case — flat ascending
403
+ * `index` is the ratified comparator, isolated HERE. If a GUI oracle ever
404
+ * contradicts it, the fix is a DFS-rank swap in this one ORDER BY (rank children
405
+ * after parents).
406
+ */
188
407
  export function fetchTagsForTasks(db, taskUuids) {
189
408
  const map = new Map();
190
409
  if (taskUuids.length === 0)
@@ -193,7 +412,7 @@ export function fetchTagsForTasks(db, taskUuids) {
193
412
  const sql = `SELECT tt.tasks AS task, tg.uuid AS uuid, tg.title AS title
194
413
  FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags
195
414
  WHERE tt.tasks IN (${placeholders})
196
- ORDER BY tg.title`;
415
+ ORDER BY tg.${q("index")}, tg.uuid`;
197
416
  const rows = db.prepare(sql).all(...taskUuids);
198
417
  for (const row of rows) {
199
418
  const list = map.get(row.task) ?? [];
@@ -202,20 +421,37 @@ export function fetchTagsForTasks(db, taskUuids) {
202
421
  }
203
422
  return map;
204
423
  }
424
+ /**
425
+ * Stamps the repeating-template flag on a resolved container ref when the
426
+ * TMTask row it came from carries recurrence columns (`rt1_recurrenceRule` /
427
+ * `repeater` non-null) — the disambiguator the CLI renders as the ↻ prefix on
428
+ * a muted container label. Area rows carry neither column, so an area ref is
429
+ * never marked. The flag is set ONLY when true (omit-when-false; the entity
430
+ * emit boundary keeps `false`/`0` but not an absent key — see entities.Ref).
431
+ */
432
+ function markTemplate(ref, recurrenceRule, repeater) {
433
+ if (recurrenceRule != null || repeater != null)
434
+ ref.isRepeatingTemplate = true;
435
+ return ref;
436
+ }
205
437
  /**
206
438
  * Lazy heading-uuid -> owning-project Ref resolver, cached per instance.
207
439
  * Heading-nested to-dos carry project = NULL in the DB (the heading holds
208
- * the link); list views use this to surface the GUI's container label.
440
+ * the link); list views use this to surface the GUI's container label. The
441
+ * owning project's recurrence columns ride along so a to-do nested under a
442
+ * heading of a repeating-template project inherits the template mark.
209
443
  */
210
444
  export function makeHeadingProjectResolver(db) {
211
445
  const cache = new Map();
212
- const stmt = db.prepare("SELECT p.uuid AS uuid, p.title AS title FROM TMTask h JOIN TMTask p ON p.uuid = h.project WHERE h.uuid = ?");
446
+ const stmt = db.prepare("SELECT p.uuid AS uuid, p.title AS title, p.rt1_recurrenceRule AS rt1_recurrenceRule, p.repeater AS repeater FROM TMTask h JOIN TMTask p ON p.uuid = h.project WHERE h.uuid = ?");
213
447
  return (headingUuid) => {
214
448
  const cached = cache.get(headingUuid);
215
449
  if (cached !== undefined)
216
450
  return cached;
217
451
  const hit = stmt.get(headingUuid);
218
- const ref = hit ? { uuid: hit.uuid, title: hit.title ?? "" } : null;
452
+ const ref = hit
453
+ ? markTemplate({ uuid: hit.uuid, title: hit.title ?? "" }, hit.rt1_recurrenceRule, hit.repeater)
454
+ : null;
219
455
  cache.set(headingUuid, ref);
220
456
  return ref;
221
457
  };
@@ -223,7 +459,10 @@ export function makeHeadingProjectResolver(db) {
223
459
  /** Lazy uuid -> Ref resolver over TMTask + TMArea titles, cached per instance. */
224
460
  export function makeRefResolver(db) {
225
461
  const cache = new Map();
226
- const taskStmt = db.prepare("SELECT uuid, title FROM TMTask WHERE uuid = ?");
462
+ // The recurrence columns ride along so a to-do whose container PROJECT is a
463
+ // repeating template resolves a marked ref (TMArea has no such columns, so an
464
+ // area ref — same resolver — is never marked).
465
+ const taskStmt = db.prepare("SELECT uuid, title, rt1_recurrenceRule, repeater FROM TMTask WHERE uuid = ?");
227
466
  const areaStmt = db.prepare("SELECT uuid, title FROM TMArea WHERE uuid = ?");
228
467
  return (uuid) => {
229
468
  if (uuid === null)
@@ -232,7 +471,9 @@ export function makeRefResolver(db) {
232
471
  if (cached !== undefined)
233
472
  return cached;
234
473
  const hit = (taskStmt.get(uuid) ?? areaStmt.get(uuid));
235
- const ref = hit ? { uuid: hit.uuid, title: hit.title ?? "" } : null;
474
+ const ref = hit
475
+ ? markTemplate({ uuid: hit.uuid, title: hit.title ?? "" }, hit.rt1_recurrenceRule, hit.repeater)
476
+ : null;
236
477
  cache.set(uuid, ref);
237
478
  return ref;
238
479
  };