things-api 0.18.0 → 0.19.1

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 (224) hide show
  1. package/README.md +18 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -1
  4. package/deputy/helpers-Info.plist +4 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +3 -1
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +15 -2
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
  11. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  12. package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
  13. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +14 -2
  14. package/deputy/reader/Info.plist +2 -0
  15. package/deputy/reader/entitlements.plist +30 -0
  16. package/deputy/reader/main.swift +129 -40
  17. package/deputy/src/server.swift +25 -2
  18. package/deputy/src/tcc.swift +58 -0
  19. package/dist/audit/schema.d.ts +30 -1
  20. package/dist/audit/schema.js.map +1 -1
  21. package/dist/capability.d.ts +322 -0
  22. package/dist/capability.js +706 -0
  23. package/dist/capability.js.map +1 -0
  24. package/dist/cli/commands/doctor.d.ts +13 -1
  25. package/dist/cli/commands/doctor.js +141 -5
  26. package/dist/cli/commands/doctor.js.map +1 -1
  27. package/dist/cli/commands/helpers.js +189 -51
  28. package/dist/cli/commands/helpers.js.map +1 -1
  29. package/dist/cli/commands/mcp.js +8 -0
  30. package/dist/cli/commands/mcp.js.map +1 -1
  31. package/dist/cli/commands/op-result.js +6 -0
  32. package/dist/cli/commands/op-result.js.map +1 -1
  33. package/dist/cli/commands/reads.js +47 -1
  34. package/dist/cli/commands/reads.js.map +1 -1
  35. package/dist/cli/commands/repeat-flags.js +34 -2
  36. package/dist/cli/commands/repeat-flags.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +76 -83
  39. package/dist/cli/commands/setup.js.map +1 -1
  40. package/dist/cli/commands/writes.d.ts +1 -1
  41. package/dist/cli/commands/writes.js +311 -142
  42. package/dist/cli/commands/writes.js.map +1 -1
  43. package/dist/cli/dry-run.d.ts +1 -1
  44. package/dist/cli/dry-run.js +1 -1
  45. package/dist/cli/glyphs.d.ts +23 -4
  46. package/dist/cli/glyphs.js +46 -19
  47. package/dist/cli/glyphs.js.map +1 -1
  48. package/dist/cli/help.d.ts +1 -1
  49. package/dist/cli/help.js +49 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/interrupt.d.ts +54 -6
  52. package/dist/cli/interrupt.js +78 -12
  53. package/dist/cli/interrupt.js.map +1 -1
  54. package/dist/cli/main.js +8 -5
  55. package/dist/cli/main.js.map +1 -1
  56. package/dist/cli/period.d.ts +3 -1
  57. package/dist/cli/period.js +1 -1
  58. package/dist/cli/period.js.map +1 -1
  59. package/dist/cli/read-driver.js +26 -1
  60. package/dist/cli/read-driver.js.map +1 -1
  61. package/dist/cli/render.d.ts +31 -4
  62. package/dist/cli/render.js +127 -15
  63. package/dist/cli/render.js.map +1 -1
  64. package/dist/client.d.ts +23 -3
  65. package/dist/client.js +36 -13
  66. package/dist/client.js.map +1 -1
  67. package/dist/contracts.d.ts +8 -4
  68. package/dist/contracts.js +1 -1
  69. package/dist/contracts.js.map +1 -1
  70. package/dist/deputy/install.d.ts +234 -6
  71. package/dist/deputy/install.js +843 -31
  72. package/dist/deputy/install.js.map +1 -1
  73. package/dist/deputy/notices.js +2 -2
  74. package/dist/deputy/notices.js.map +1 -1
  75. package/dist/deputy/protocol.d.ts +59 -5
  76. package/dist/deputy/protocol.js +48 -11
  77. package/dist/deputy/protocol.js.map +1 -1
  78. package/dist/deputy/routing.d.ts +37 -0
  79. package/dist/deputy/routing.js +181 -27
  80. package/dist/deputy/routing.js.map +1 -1
  81. package/dist/deputy/wake.d.ts +61 -0
  82. package/dist/deputy/wake.js +149 -0
  83. package/dist/deputy/wake.js.map +1 -0
  84. package/dist/diagnose.d.ts +19 -4
  85. package/dist/diagnose.js +64 -8
  86. package/dist/diagnose.js.map +1 -1
  87. package/dist/direct-setup.d.ts +104 -0
  88. package/dist/direct-setup.js +658 -0
  89. package/dist/direct-setup.js.map +1 -0
  90. package/dist/host-access.d.ts +75 -0
  91. package/dist/host-access.js +177 -0
  92. package/dist/host-access.js.map +1 -0
  93. package/dist/index.d.ts +20 -5
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/mcp/server.d.ts +27 -1
  97. package/dist/mcp/server.js +308 -109
  98. package/dist/mcp/server.js.map +1 -1
  99. package/dist/op-result.d.ts +9 -0
  100. package/dist/op-result.js +13 -1
  101. package/dist/op-result.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +1 -1
  103. package/dist/read/filter-contract.js +4 -0
  104. package/dist/read/filter-contract.js.map +1 -1
  105. package/dist/read/shape.js +4 -0
  106. package/dist/read/shape.js.map +1 -1
  107. package/dist/read/views.d.ts +29 -0
  108. package/dist/read/views.js +60 -0
  109. package/dist/read/views.js.map +1 -1
  110. package/dist/session-grant.d.ts +48 -0
  111. package/dist/session-grant.js +185 -0
  112. package/dist/session-grant.js.map +1 -0
  113. package/dist/surface-copy.d.ts +19 -3
  114. package/dist/surface-copy.js +19 -3
  115. package/dist/surface-copy.js.map +1 -1
  116. package/dist/wizard.d.ts +58 -0
  117. package/dist/wizard.js +174 -0
  118. package/dist/wizard.js.map +1 -0
  119. package/dist/write/availability.d.ts +1 -16
  120. package/dist/write/availability.js +10 -65
  121. package/dist/write/availability.js.map +1 -1
  122. package/dist/write/batch.d.ts +20 -0
  123. package/dist/write/batch.js +111 -21
  124. package/dist/write/batch.js.map +1 -1
  125. package/dist/write/capabilities.d.ts +9 -0
  126. package/dist/write/capabilities.js +2 -0
  127. package/dist/write/capabilities.js.map +1 -1
  128. package/dist/write/clear-reminder.js +1 -1
  129. package/dist/write/clear-reminder.js.map +1 -1
  130. package/dist/write/commands.d.ts +22 -7
  131. package/dist/write/commands.js +231 -254
  132. package/dist/write/commands.js.map +1 -1
  133. package/dist/write/failure-hints.d.ts +32 -7
  134. package/dist/write/failure-hints.js +9 -14
  135. package/dist/write/failure-hints.js.map +1 -1
  136. package/dist/write/guards.js +22 -4
  137. package/dist/write/guards.js.map +1 -1
  138. package/dist/write/lock.d.ts +16 -13
  139. package/dist/write/lock.js +55 -0
  140. package/dist/write/lock.js.map +1 -1
  141. package/dist/write/make-repeating-project.js +9 -13
  142. package/dist/write/make-repeating-project.js.map +1 -1
  143. package/dist/write/move.js +9 -12
  144. package/dist/write/move.js.map +1 -1
  145. package/dist/write/operations.d.ts +51 -29
  146. package/dist/write/operations.js +16 -0
  147. package/dist/write/operations.js.map +1 -1
  148. package/dist/write/opid.d.ts +72 -14
  149. package/dist/write/opid.js +152 -8
  150. package/dist/write/opid.js.map +1 -1
  151. package/dist/write/param-schema.d.ts +80 -0
  152. package/dist/write/param-schema.js +754 -0
  153. package/dist/write/param-schema.js.map +1 -0
  154. package/dist/write/pipeline.d.ts +110 -5
  155. package/dist/write/pipeline.js +288 -14
  156. package/dist/write/pipeline.js.map +1 -1
  157. package/dist/write/pre-state.d.ts +38 -16
  158. package/dist/write/pre-state.js +117 -22
  159. package/dist/write/pre-state.js.map +1 -1
  160. package/dist/write/promote-clone.js +658 -444
  161. package/dist/write/promote-clone.js.map +1 -1
  162. package/dist/write/reorder.js +25 -8
  163. package/dist/write/reorder.js.map +1 -1
  164. package/dist/write/repeat-collateral.d.ts +31 -0
  165. package/dist/write/repeat-collateral.js +153 -0
  166. package/dist/write/repeat-collateral.js.map +1 -0
  167. package/dist/write/repeat-rule.d.ts +28 -1
  168. package/dist/write/repeat-rule.js +54 -4
  169. package/dist/write/repeat-rule.js.map +1 -1
  170. package/dist/write/resolution-timestamps.d.ts +11 -0
  171. package/dist/write/resolution-timestamps.js +108 -40
  172. package/dist/write/resolution-timestamps.js.map +1 -1
  173. package/dist/write/reversibility.js +4 -0
  174. package/dist/write/reversibility.js.map +1 -1
  175. package/dist/write/template-mutation.d.ts +137 -0
  176. package/dist/write/template-mutation.js +598 -0
  177. package/dist/write/template-mutation.js.map +1 -0
  178. package/dist/write/undo.js +10 -20
  179. package/dist/write/undo.js.map +1 -1
  180. package/dist/write/update-fields.d.ts +151 -0
  181. package/dist/write/update-fields.js +427 -0
  182. package/dist/write/update-fields.js.map +1 -0
  183. package/dist/write/vectors/applescript.js +6 -10
  184. package/dist/write/vectors/applescript.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +1 -1
  186. package/dist/write/vectors/registry.js +10 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/shortcuts.js +2 -2
  189. package/dist/write/vectors/shortcuts.js.map +1 -1
  190. package/dist/write/vectors/simulator.js +15 -4
  191. package/dist/write/vectors/simulator.js.map +1 -1
  192. package/dist/write/vectors/types.d.ts +268 -1
  193. package/dist/write/vectors/ui-certification.js +63 -13
  194. package/dist/write/vectors/ui-certification.js.map +1 -1
  195. package/dist/write/vectors/ui-chord.d.ts +144 -0
  196. package/dist/write/vectors/ui-chord.js +428 -0
  197. package/dist/write/vectors/ui-chord.js.map +1 -0
  198. package/dist/write/vectors/ui-drag.d.ts +8 -4
  199. package/dist/write/vectors/ui-drag.js +2 -0
  200. package/dist/write/vectors/ui-drag.js.map +1 -1
  201. package/dist/write/vectors/ui-recipes.d.ts +70 -3
  202. package/dist/write/vectors/ui-recipes.js +482 -44
  203. package/dist/write/vectors/ui-recipes.js.map +1 -1
  204. package/dist/write/vectors/ui.d.ts +280 -10
  205. package/dist/write/vectors/ui.js +1068 -77
  206. package/dist/write/vectors/ui.js.map +1 -1
  207. package/dist/write/vectors/url-scheme.js +3 -0
  208. package/dist/write/vectors/url-scheme.js.map +1 -1
  209. package/dist/write/verify/delta.d.ts +62 -0
  210. package/dist/write/verify/delta.js +36 -2
  211. package/dist/write/verify/delta.js.map +1 -1
  212. package/dist/write/verify/poller.d.ts +4 -1
  213. package/dist/write/verify/poller.js +14 -0
  214. package/dist/write/verify/poller.js.map +1 -1
  215. package/package.json +2 -1
  216. package/schema/envelope.schema.json +6 -4
  217. package/scripts/build-helpers.sh +5 -0
  218. package/skills/things-cli/SKILL.md +5 -3
  219. package/skills/things-cli/references/contracts.md +1 -0
  220. package/skills/things-cli/references/data-model.md +3 -0
  221. package/skills/things-cli/references/errors.md +1 -1
  222. package/skills/things-cli/references/gui.md +1 -0
  223. package/skills/things-cli/references/repeating.md +6 -0
  224. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -1,30 +1,56 @@
1
+ /**
2
+ * Write commands. Help text is the agent API: every command's --help states
3
+ * its behavior, side effects, and required confirmation flags in consumer
4
+ * voice (docs/design/surface-copy.md) — internals stay in docs/ and the
5
+ * capabilities/dry-run OUTPUT. No interactive prompts: risky semantics
6
+ * require explicit flags.
7
+ */
8
+ import { Option } from "commander";
1
9
  import { readFileSync } from "node:fs";
2
10
  import { armInterrupt, disarmInterrupt } from "../interrupt.js";
3
11
  import { isDevVersion } from "../skill.js";
4
12
  import { CLI_VERSION } from "../version.js";
5
13
  import { addRepeatCalendarFlags, addRepeatingRuleFieldsFromOpts, addRepeatRuleFlags, repeatRuleFlagsFromOpts, } from "./repeat-flags.js";
6
- import { aggregateExitCode, blockedCode, capabilitiesTable, ClockError, closeCliTrace, describeConfig, errorEnvelope, ExitCode, HELPERS_MODES, installCliTrace, getConfigKey, parseHelpersMode, mutationWireData, okEnvelope, openThings, OP_ID_RE, outcomeFailed, ReferenceResolutionError, saveConfigKey, splitWhenSugar, trace, ThingsDbNotFoundError, ThingsDbOpenError, verifyFailedCode, } from "../../index.js";
14
+ import { aggregateExitCode, blockedCode, buildUpdatePatch, capabilitiesTable, CLI_UPDATE_LABELS, ClockError, closeCliTrace, describeConfig, errorEnvelope, ExitCode, HELPERS_MODES, installCliTrace, getConfigKey, parseHelpersMode, mutationWireData, NOTES_FORMAT, okEnvelope, openThings, OP_ID_RE, outcomeFailed, ParamSchemaError, ReferenceResolutionError, RESOLUTION_DATE_FORMAT, saveConfigKey, splitWhenSugar, trace, ThingsDbNotFoundError, ThingsDbOpenError, verifyFailedCode, } from "../../index.js";
7
15
  import { usageError } from "../read-driver.js";
8
16
  import { dim } from "../style.js";
9
- function addWriteFlags(cmd) {
17
+ /**
18
+ * The `--preserve-modified` help text, shared by the universal write flag and
19
+ * the run-level `batch` flag (which applies it as the default for every line) —
20
+ * one source so the two surfaces can never drift.
21
+ */
22
+ const PRESERVE_MODIFIED_HELP = "keep this change off the modification-date timeline: capture each pre-existing edited " +
23
+ "item's modification date and restore it (to the whole second) after the change, so a " +
24
+ "changes/watch query keyed on it does not surface the edit. A no-op on a pure create. " +
25
+ "Safe with Things Cloud: the restored date syncs to your other devices and stays put, so " +
26
+ "the item stays off the timeline everywhere — unless another device edits the same item " +
27
+ "at nearly the same time, which re-dates it.";
28
+ function addWriteFlags(cmd, capability = {}) {
29
+ const opId = new Option("--op-id <key>", "idempotency key: a resubmission with the same key is recognized as already applied " +
30
+ "and not re-run (matches [A-Za-z0-9_-], 1-64 chars)");
31
+ if (capability.opId === "unsupported")
32
+ opId.hideHelp();
10
33
  return cmd
11
34
  .option("--json", "emit versioned JSON envelope on stdout")
12
35
  .option("--db <path>", "explicit database path")
13
36
  .option("--dry-run", "preview the planned change and its expected effect; nothing executes")
14
- .option("--preserve-modified", "keep this change off the modification-date timeline: capture each pre-existing edited " +
15
- "item's modification date and restore it (to the whole second) after the change, so a " +
16
- "changes/watch query keyed on it does not surface the edit. A no-op on a pure create. " +
17
- "Safe with Things Cloud: the restored date syncs to your other devices and stays put, so " +
18
- "the item stays off the timeline everywhere — unless another device edits the same item " +
19
- "at nearly the same time, which re-dates it.")
37
+ .option("--preserve-modified", PRESERVE_MODIFIED_HELP)
20
38
  .option("--vector <id>", "force how the change is delivered: url-scheme | applescript | shortcuts | ui")
21
39
  .option("--allow-disruptive", "permit changes that briefly steal window focus")
22
40
  .option("--allow-very-disruptive", "permit changes that visibly drive the Things UI")
23
41
  .option("--verify-timeout <ms>", "how long to wait for the change to take effect")
24
42
  .option("--actor <name>", "author name recorded for this change (default: from config)")
25
- .option("--op-id <key>", "idempotency key: a resubmission with the same key is recognized as already applied " +
26
- "and not re-run (matches [A-Za-z0-9_-], 1-64 chars)");
43
+ .addOption(opId);
27
44
  }
45
+ /**
46
+ * The write flags for a multi-result COMPOUND command: the same set, with
47
+ * `--op-id` kept out of `--help` because that command's execution refuses it
48
+ * (see `opIdCompoundRefused` / `runMoveCmd`). Every command wrapped in this MUST
49
+ * refuse `--op-id` at runtime, and every command that refuses it MUST be wrapped
50
+ * in this — `test/cli/help-contract.test.ts` holds the two halves together. A
51
+ * compound that records ONE summary the key rides uses plain `addWriteFlags`.
52
+ */
53
+ const addCompoundWriteFlags = (cmd) => addWriteFlags(cmd, { opId: "unsupported" });
28
54
  /** Validate an `--op-id`; on a malformed value emit the usage error and return false. */
29
55
  function opIdOk(opts) {
30
56
  if (opts.opId === undefined)
@@ -35,11 +61,16 @@ function opIdOk(opts) {
35
61
  return false;
36
62
  }
37
63
  /**
38
- * Refuse `--op-id` on a multi-leg COMPOUND command. Single-op idempotency (phase
39
- * 1) replays exactly ONE recorded ok result; a compound records several (or a
40
- * summary) and its idempotency is the batch-shaped per-line `op_id`. Refusing is
41
- * honest dropping the flag silently would leave a resubmission un-deduped.
42
- * Returns true when refused (the caller returns).
64
+ * Refuse `--op-id` on a multi-leg COMPOUND command. The rule is ONE RECORDED
65
+ * RESULT PER KEY: an idempotency key replays exactly one recorded outcome, so a
66
+ * command that records SEVERAL the variadic move/reorder, the granular
67
+ * checklist edit, clear-reminder, the heading archive pair, project reopen
68
+ * cannot honor one, and its idempotency is the batch-shaped per-line `op_id`.
69
+ * (A compound that records ONE SUMMARY the key rides — the template-target
70
+ * verbs, the `--completed-at` flip-dance, and the promote verbs — accepts the
71
+ * flag instead; ruling 2026-08-25.) Refusing is honest — dropping the flag
72
+ * silently would leave a resubmission un-deduped. Returns true when refused
73
+ * (the caller returns).
43
74
  */
44
75
  function opIdCompoundRefused(opts, what) {
45
76
  if (opts.opId === undefined)
@@ -190,20 +221,64 @@ function splitCsv(value) {
190
221
  .map((s) => s.trim())
191
222
  .filter((s) => s !== "");
192
223
  }
193
- async function runWrite(opts, fn, emitFn = emitResult) {
194
- if (!opIdOk(opts))
195
- return;
196
- const started = Date.now();
197
- // Dev-mode step-timeline trace + signal-safe interrupt guard (TRACE1, #487).
198
- // Scoped to the write driver the invocations where a per-step timeline earns
199
- // its keep, and where a mid-drive kill would otherwise leave empty stdout. The
200
- // sink is a no-op unless tracing is on (a `-dev` build, or config/env forced).
224
+ /**
225
+ * Open a CLI write invocation: install the dev-mode step-timeline trace, and
226
+ * hand back the two things every write driver needs (TRACE1, #487) —
227
+ *
228
+ * - `openClient(dbPath)`: opens the Things client AND arms the signal-safe
229
+ * interrupt guard once that open has returned. This is the ONLY arming seam
230
+ * for the CLI; see below for why it sits past the open.
231
+ * - `endInvocation()`: the driver's `finally` closes the trace with the
232
+ * final exit code, then disarms the guard so a signal during teardown or a
233
+ * trailing read emits nothing.
234
+ *
235
+ * EVERY write driver calls this, not just the single-mutation one: the library
236
+ * half (the in-flight marker, the watchdog, the plain-stderr interrupt line)
237
+ * works everywhere, but the trace FILE and the `--json` `interrupted` envelope
238
+ * exist only where the driver installs and arms them. `json` says whether this
239
+ * invocation's stdout is machine-readable — drivers that emit JSONL regardless
240
+ * of `--json` (batch, undo) pass true.
241
+ *
242
+ * ## Why the arm sits PAST the client open
243
+ *
244
+ * `openThings` is the read gate, and its `open(2)` on the Things container is
245
+ * synchronous and can block indefinitely behind a TCC dialog. A listener armed
246
+ * across that span cannot dispatch (see src/cli/interrupt.ts's module note), so
247
+ * arming from the top of the driver made a write stalled there swallow SIGTERM
248
+ * — measured exit 137. Nothing can be in flight before the client exists, so
249
+ * the window this gives up is one where the guard had nothing to report anyway.
250
+ *
251
+ * The sink is a no-op unless tracing is on (a `-dev` build, or config/env
252
+ * forced), so this costs a config read on every write and nothing else.
253
+ */
254
+ function beginWriteInvocation(json, startedAt) {
201
255
  installCliTrace({
202
256
  argv: process.argv.slice(1),
203
257
  version: CLI_VERSION,
204
258
  isDev: isDevVersion(CLI_VERSION),
205
259
  });
206
- armInterrupt(opts.json === true);
260
+ return {
261
+ openClient: (dbPath) => {
262
+ const client = openThings(dbPath ? { dbPath } : {});
263
+ armInterrupt(json);
264
+ return client;
265
+ },
266
+ endInvocation: () => {
267
+ trace(() => ({
268
+ phase: "invocation-end",
269
+ exitCode: process.exitCode ?? 0,
270
+ elapsedMs: Date.now() - startedAt,
271
+ }));
272
+ disarmInterrupt();
273
+ closeCliTrace();
274
+ },
275
+ };
276
+ }
277
+ async function runWrite(opts, fn, emitFn = emitResult) {
278
+ if (!opIdOk(opts))
279
+ return;
280
+ const started = Date.now();
281
+ const { openClient, endInvocation } = beginWriteInvocation(opts.json === true, started);
207
282
  let client = null;
208
283
  const meta = (client_) => {
209
284
  let dbVersion = null;
@@ -218,7 +293,7 @@ async function runWrite(opts, fn, emitFn = emitResult) {
218
293
  return { dbVersion, fingerprint, elapsedMs: Date.now() - started, ...(clock && { clock }) };
219
294
  };
220
295
  try {
221
- client = openThings(opts.db ? { dbPath: opts.db } : {});
296
+ client = openClient(opts.db);
222
297
  const result = await fn(client);
223
298
  emitFn(result, opts, meta(client));
224
299
  }
@@ -245,6 +320,14 @@ async function runWrite(opts, fn, emitFn = emitResult) {
245
320
  usageError(opts, err.message);
246
321
  return;
247
322
  }
323
+ // A structural parameter refusal (#580) is an INPUT-CONTRACT error — the
324
+ // caller's bag was malformed and nothing was dispatched — not an internal
325
+ // fault. It exits `usage`, naming the JSON path, the expected shape, and
326
+ // what was received.
327
+ if (err instanceof ParamSchemaError) {
328
+ usageError(opts, err.message);
329
+ return;
330
+ }
248
331
  const isEnv = err instanceof ThingsDbNotFoundError || err instanceof ThingsDbOpenError;
249
332
  const message = err instanceof Error ? err.message : String(err);
250
333
  if (opts.json) {
@@ -257,16 +340,7 @@ async function runWrite(opts, fn, emitFn = emitResult) {
257
340
  }
258
341
  finally {
259
342
  client?.close();
260
- // Close the trace with a final result line (the exit code the caller sees),
261
- // then disarm the interrupt guard so a later signal during teardown/reads
262
- // emits nothing.
263
- trace(() => ({
264
- phase: "invocation-end",
265
- exitCode: process.exitCode ?? 0,
266
- elapsedMs: Date.now() - started,
267
- }));
268
- disarmInterrupt();
269
- closeCliTrace();
343
+ endInvocation();
270
344
  }
271
345
  }
272
346
  /**
@@ -286,6 +360,22 @@ function emitPreserveNote(result) {
286
360
  process.stderr.write(`warning: preserve-modified: ${f.uuid}: ${f.detail}\n`);
287
361
  }
288
362
  }
363
+ /**
364
+ * TTY disclosure for a TEMPLATE-TARGET composite (`complete`/`cancel`/`update
365
+ * --exception` aimed at a repeating to-do): a dim line naming BOTH uuids — the
366
+ * occurrence that was written (the result's own uuid) and the series it belongs
367
+ * to — plus whether that occurrence was created for this call. The human render
368
+ * would otherwise show one uuid with nothing saying which of the two it is.
369
+ * Silent for every other op (the field is absent).
370
+ */
371
+ function emitOccurrenceNote(result) {
372
+ const o = result.occurrence;
373
+ if (o === undefined)
374
+ return;
375
+ const dated = o.date === null ? "" : ` dated ${o.date}`;
376
+ const origin = o.minted ? "created for this change" : "already open";
377
+ process.stdout.write(dim(` occurrence ${o.occurrenceUuid}${dated} (${origin}) of repeating to-do ${o.templateUuid}\n`));
378
+ }
289
379
  /**
290
380
  * TTY disclosure for the HINTS1 completion-context on a verified complete/cancel:
291
381
  * a dim line naming the OPEN work remaining in the to-do's project and/or Today,
@@ -337,6 +427,7 @@ function emitResult(result, opts, meta) {
337
427
  ? "already applied — matched op-id in the change history, not re-run"
338
428
  : `vector=${result.vector}, tier=${result.tier}, verified`;
339
429
  process.stdout.write(`ok ${result.op}${uuid} (${status})\n`);
430
+ emitOccurrenceNote(result);
340
431
  emitPreserveNote(result);
341
432
  emitContextNote(result);
342
433
  }
@@ -432,6 +523,7 @@ async function runMoveCmd(opts, fn) {
432
523
  return;
433
524
  }
434
525
  const started = Date.now();
526
+ const { openClient, endInvocation } = beginWriteInvocation(opts.json === true, started);
435
527
  let client = null;
436
528
  const meta = () => {
437
529
  let dbVersion = null;
@@ -444,7 +536,7 @@ async function runMoveCmd(opts, fn) {
444
536
  return { dbVersion, fingerprint, elapsedMs: Date.now() - started };
445
537
  };
446
538
  try {
447
- client = openThings(opts.db ? { dbPath: opts.db } : {});
539
+ client = openClient(opts.db);
448
540
  emitMoveResult(await fn(client), opts, meta());
449
541
  }
450
542
  catch (err) {
@@ -469,6 +561,7 @@ async function runMoveCmd(opts, fn) {
469
561
  }
470
562
  finally {
471
563
  client?.close();
564
+ endInvocation();
472
565
  }
473
566
  }
474
567
  function emitMoveResult(result, opts, meta) {
@@ -690,9 +783,10 @@ function addResultLine(r) {
690
783
  * in creation order, and nothing else. Exit code is the worst leg's failure.
691
784
  */
692
785
  async function runBulkAdd(opts, ops, idOnly) {
786
+ const { openClient, endInvocation } = beginWriteInvocation(opts.json === true, Date.now());
693
787
  let client = null;
694
788
  try {
695
- client = openThings(opts.db ? { dbPath: opts.db } : {});
789
+ client = openClient(opts.db);
696
790
  const batchResult = await client.write.batch(ops, {
697
791
  ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
698
792
  ...(opts.actor !== undefined && { actor: opts.actor }),
@@ -735,6 +829,7 @@ async function runBulkAdd(opts, ops, idOnly) {
735
829
  }
736
830
  finally {
737
831
  client?.close();
832
+ endInvocation();
738
833
  }
739
834
  }
740
835
  /** Read newline-delimited titles from stdin; blank lines (whitespace-only) skipped. */
@@ -748,6 +843,45 @@ async function readStdinTitles() {
748
843
  .map((l) => l.replace(/\r$/, ""))
749
844
  .filter((l) => l.trim() !== "");
750
845
  }
846
+ /**
847
+ * The CLI-only half of the notes vocabulary (the shared half is NOTES_FORMAT):
848
+ * how a multi-line body gets in from a shell. `-` reads STDIN, so a heredoc or
849
+ * a pipe composes; the `$'…'` form covers the inline case.
850
+ */
851
+ const NOTES_INPUT = "pass - to read the body from stdin, or $'line 1\\nline 2' to give it inline";
852
+ /**
853
+ * Read the whole of STDIN as one UTF-8 note body. One trailing newline is
854
+ * dropped (a heredoc and most pipes add one); every other newline is kept.
855
+ */
856
+ async function readStdinText() {
857
+ const chunks = [];
858
+ for await (const chunk of process.stdin)
859
+ chunks.push(chunk);
860
+ return Buffer.concat(chunks)
861
+ .toString("utf8")
862
+ .replace(/\r?\n$/, "");
863
+ }
864
+ /**
865
+ * Resolve `--notes -` into the body waiting on STDIN, in place on `opts`. A
866
+ * terminal STDIN has nothing to read, so it is refused with the two working
867
+ * spellings rather than hanging on an input nobody is going to type. Returns
868
+ * false when the invocation was refused (the usage error is already emitted).
869
+ */
870
+ async function resolveNotesStdin(opts, conflictsWithStdinFlag = false) {
871
+ if (opts["notes"] !== "-")
872
+ return true;
873
+ if (conflictsWithStdinFlag && opts["stdin"] === true) {
874
+ usageError(opts, "--notes - and --stdin both read stdin; pass at most one of them");
875
+ return false;
876
+ }
877
+ if (process.stdin.isTTY === true) {
878
+ usageError(opts, "--notes - reads the note body from stdin, and stdin is a terminal: pipe the body in " +
879
+ "(or redirect a file with < notes.md), or give the text inline with --notes $'line 1\\nline 2'");
880
+ return false;
881
+ }
882
+ opts["notes"] = await readStdinText();
883
+ return true;
884
+ }
751
885
  function group(program, name, description) {
752
886
  const existing = program.commands.find((c) => c.name() === name);
753
887
  if (existing !== undefined)
@@ -772,7 +906,7 @@ export function registerWriteCommands(program) {
772
906
  "--create-tags. Adding into a completed/canceled project reopens that project — " +
773
907
  "requires --acknowledge-project-reopen. When several to-dos are created, one undo " +
774
908
  "token removes the whole skeleton at once.")
775
- .option("--notes <text>", "notes body")
909
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
776
910
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
777
911
  .option("--reminder <HH:mm>", "time-of-day reminder (24h); requires --when today|evening|YYYY-MM-DD")
778
912
  .option("--deadline <date>", "YYYY-MM-DD")
@@ -781,8 +915,8 @@ export function registerWriteCommands(program) {
781
915
  .option("--project <ref>", "destination project (uuid or unique name)")
782
916
  .option("--area <ref>", "destination area (uuid or unique name)")
783
917
  .option("--heading <name>", "existing heading in the destination project")
784
- .option("--created-at <iso>", "born with this creation timestamp (ISO date or datetime; a date is noon in the effective zone)")
785
- .option("--completed-at <iso>", "born completed (in the Logbook) with this completion timestamp (ISO date or datetime); drop --when/--reminder")
918
+ .option("--created-at <iso>", `born with this creation timestamp (${RESOLUTION_DATE_FORMAT})`)
919
+ .option("--completed-at <iso>", `born completed (in the Logbook) with this completion timestamp (${RESOLUTION_DATE_FORMAT}); drop --when/--reminder`)
786
920
  .option("--acknowledge-project-reopen", "allow adding into a completed/canceled project")
787
921
  .option("--stdin", "read newline-delimited titles from stdin (blank lines skipped); exclusive with title arguments")
788
922
  .option("--id-only", "print only the new uuid(s), one per line in creation order, and nothing else (exclusive with --json)"))).action(async (titles, opts) => {
@@ -792,6 +926,8 @@ export function registerWriteCommands(program) {
792
926
  usageError(opts, "--id-only and --json are mutually exclusive");
793
927
  return;
794
928
  }
929
+ if (!(await resolveNotesStdin(opts, true)))
930
+ return;
795
931
  if (useStdin && titles.length > 0) {
796
932
  usageError(opts, "--stdin is mutually exclusive with title arguments");
797
933
  return;
@@ -884,15 +1020,17 @@ export function registerWriteCommands(program) {
884
1020
  });
885
1021
  addWriteFlags(todo
886
1022
  .command("update <uuid>")
887
- .description("Update title/notes/when/reminder/deadline. Schedule and deadline changes are not " +
888
- "available for repeating to-dos (title/notes are). --reminder needs --when " +
1023
+ .description("Update title/notes/when/reminder/deadline. On a REPEATING to-do a schedule or " +
1024
+ "deadline change needs --exception (change only the next occurrence) or " +
1025
+ "`things todo reschedule-repeat` (change the series); title/notes apply to the " +
1026
+ "series as they always have. --reminder needs --when " +
889
1027
  "today|evening|YYYY-MM-DD; when re-scheduling WITHOUT --reminder an existing " +
890
1028
  "reminder is auto-preserved. --clear-reminder works while the to-do is scheduled " +
891
1029
  "for today|evening — a DATED reminder can only be changed, not cleared " +
892
1030
  "(re-schedule to today first). --append-notes/--prepend-notes join with a " +
893
1031
  "newline (exclusive with --notes).")
894
1032
  .option("--title <text>", "new title")
895
- .option("--notes <text>", "replace notes")
1033
+ .option("--notes <text>", `replace the notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
896
1034
  .option("--append-notes <text>", "append to existing notes (newline-joined)")
897
1035
  .option("--prepend-notes <text>", "prepend to existing notes (newline-joined)")
898
1036
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
@@ -900,34 +1038,36 @@ export function registerWriteCommands(program) {
900
1038
  .option("--clear-reminder", "clear the reminder (works while scheduled today|evening)")
901
1039
  .option("--deadline <date>", "YYYY-MM-DD")
902
1040
  .option("--clear-deadline", "remove the deadline")
903
- .option("--created-at <iso>", "rewrite the creation timestamp (ISO date or datetime; a date is noon in the effective zone); status-safe")
904
- .option("--completed-at <iso>", "rewrite the completion timestamp of an already-resolved to-do (a canceled one stays canceled); open to-dos are refused — use complete/cancel")).action(async (uuid, opts) => {
905
- const notesModes = ["notes", "appendNotes", "prependNotes"].filter((k) => opts[k] !== undefined);
906
- if (notesModes.length > 1) {
907
- usageError(opts, "--notes, --append-notes, --prepend-notes are exclusive");
1041
+ .option("--created-at <iso>", `rewrite the creation timestamp (${RESOLUTION_DATE_FORMAT}); status-safe`)
1042
+ .option("--completed-at <iso>", "rewrite the completion timestamp of an already-resolved to-do (a canceled one stays canceled); open to-dos are refused — use complete/cancel")
1043
+ .option("--exception", "repeating to-dos only: change just the NEXT occurrence and leave the series alone " +
1044
+ "(creates that occurrence if it has not appeared yet); refused when the series already " +
1045
+ "lands on the requested day. The result names both the occurrence and the series. Each " +
1046
+ "re-run takes another occurrence out of the series, so pass --op-id on a retry: a " +
1047
+ "resubmission with the same key replays the first result instead of creating a second " +
1048
+ "occurrence")).action(async (uuid, opts) => {
1049
+ if (!(await resolveNotesStdin(opts)))
908
1050
  return;
909
- }
910
- if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
911
- usageError(opts, "pass at most one of --reminder / --clear-reminder");
1051
+ // The patch (and every exclusive-flag pair in it, the @time sugar included)
1052
+ // comes from the ONE update-vocabulary builder both surfaces share.
1053
+ const built = buildUpdatePatch(opts, CLI_UPDATE_LABELS);
1054
+ if (built.kind === "error") {
1055
+ usageError(opts, built.message);
912
1056
  return;
913
1057
  }
914
- if (!whenSugarOk(opts))
1058
+ if (opts["exception"] === true) {
1059
+ // The occurrence is a to-do of its own once it exists, so the whole update
1060
+ // vocabulary applies to it — except the timestamp rewrites, which are about
1061
+ // a row's history and have no meaning for an occurrence created this second.
1062
+ const stamped = ["createdAt", "completedAt"].filter((k) => opts[k] !== undefined);
1063
+ if (stamped.length > 0) {
1064
+ usageError(opts, "--exception cannot be combined with --created-at/--completed-at");
1065
+ return;
1066
+ }
1067
+ await runWrite(opts, (c) => c.write.updateTodoOccurrence(uuid, built.patch, writeOptionsFrom(opts)));
915
1068
  return;
916
- await runWrite(opts, (c) => c.write.updateTodo(uuid, {
917
- ...(opts["title"] !== undefined && { title: opts["title"] }),
918
- ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
919
- ...(opts["appendNotes"] !== undefined && { appendNotes: opts["appendNotes"] }),
920
- ...(opts["prependNotes"] !== undefined && {
921
- prependNotes: opts["prependNotes"],
922
- }),
923
- ...(opts["when"] !== undefined && { when: opts["when"] }),
924
- ...(opts["reminder"] !== undefined && { reminder: opts["reminder"] }),
925
- ...(opts["clearReminder"] === true && { reminder: null }),
926
- ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
927
- ...(opts["clearDeadline"] === true && { deadline: null }),
928
- ...(opts["createdAt"] !== undefined && { createdAt: opts["createdAt"] }),
929
- ...(opts["completedAt"] !== undefined && { completedAt: opts["completedAt"] }),
930
- }, writeOptionsFrom(opts)));
1069
+ }
1070
+ await runWrite(opts, (c) => c.write.updateTodo(uuid, built.patch, writeOptionsFrom(opts)));
931
1071
  });
932
1072
  for (const verb of ["complete", "cancel"]) {
933
1073
  const method = verb === "complete" ? "completeTodo" : "cancelTodo";
@@ -936,10 +1076,16 @@ export function registerWriteCommands(program) {
936
1076
  : "keeping it canceled via the certified flip legs";
937
1077
  addWriteFlags(todo
938
1078
  .command(`${verb} <uuid>`)
939
- .description(`${verb[0]?.toUpperCase()}${verb.slice(1)} a to-do. Not available for repeating to-dos. ` +
1079
+ .description(`${verb[0]?.toUpperCase()}${verb.slice(1)} a to-do. On a REPEATING to-do this ${verb}s ` +
1080
+ "the series' current occurrence — the unfinished one if there is one, otherwise the " +
1081
+ "next one, created for the purpose — and leaves the series running; the result names " +
1082
+ "both the occurrence and the series, and says which of the two it created. Each " +
1083
+ `re-run is a new ${verb} that takes the FOLLOWING occurrence, so pass --op-id on a ` +
1084
+ "retry: a resubmission with the same key replays the first result instead of " +
1085
+ "creating a second occurrence. " +
940
1086
  '--completed-at sets the completion timestamp (also the "Completed on" stamp for a canceled ' +
941
1087
  `item), ${stampNote} — a multi-leg sequence, disclosed in the result and --dry-run.`)
942
- .option("--completed-at <iso>", "completion timestamp (ISO date or datetime; a date is noon in the effective zone)")).action(async (uuid, opts) => {
1088
+ .option("--completed-at <iso>", `completion timestamp (${RESOLUTION_DATE_FORMAT})`)).action(async (uuid, opts) => {
943
1089
  const resolution = opts["completedAt"] !== undefined ? { completedAt: opts["completedAt"] } : {};
944
1090
  await runWrite(opts, (c) => c.write[method](uuid, resolution, writeOptionsFrom(opts)));
945
1091
  });
@@ -949,7 +1095,7 @@ export function registerWriteCommands(program) {
949
1095
  .description("Reopen a to-do. Not available for repeating to-dos.")).action(async (uuid, opts) => {
950
1096
  await runWrite(opts, (c) => c.write.reopenTodo(uuid, writeOptionsFrom(opts)));
951
1097
  });
952
- addPositionFlags(addWriteFlags(todo
1098
+ addPositionFlags(addCompoundWriteFlags(todo
953
1099
  .command("move <refs...>")
954
1100
  .description("Move one or more to-dos as an ordered block (the argument order is the order they " +
955
1101
  "land — name them backwards to reverse). MOVE changes WHAT a to-do belongs to; to " +
@@ -1109,8 +1255,12 @@ export function registerWriteCommands(program) {
1109
1255
  });
1110
1256
  addWriteFlags(todo
1111
1257
  .command("delete <uuid>")
1112
- .description("Move a to-do to the Trash (recover with `things todo restore`). Not available " +
1113
- "for repeating to-dos.")).action(async (uuid, opts) => {
1258
+ .description("Move a to-do to the Trash (recover with `things todo restore`). Deleting a repeating " +
1259
+ "series' TEMPLATE is allowed and ends the series: it stops generating new occurrences " +
1260
+ "and its existing occurrences are left in place (the result names how many, and the " +
1261
+ "current one). That cannot be undone here — the series comes back only via Put Back " +
1262
+ "in the Things app's Trash — so no undo token is returned. Deleting a single " +
1263
+ "occurrence leaves the series running.")).action(async (uuid, opts) => {
1114
1264
  await runWrite(opts, (c) => c.write.deleteTodo(uuid, writeOptionsFrom(opts)));
1115
1265
  });
1116
1266
  addWriteFlags(todo
@@ -1123,7 +1273,7 @@ export function registerWriteCommands(program) {
1123
1273
  addWriteFlags(todo
1124
1274
  .command("clear-reminder <uuid>")
1125
1275
  .description("Clear a to-do's time-of-day reminder, keeping its scheduled date. With the proxy " +
1126
- "shortcuts installed (`things setup shortcuts`) this is in place, and is the only " +
1276
+ "shortcuts installed (`things setup`) this is in place, and is the only " +
1127
1277
  "way for a repeating to-do; otherwise a date-scheduled to-do is cleared by a brief " +
1128
1278
  "re-schedule through Today. Reversible with `things undo`.")).action(async (uuid, opts) => {
1129
1279
  if (opIdCompoundRefused(opts, "clear-reminder"))
@@ -1145,7 +1295,9 @@ export function registerWriteCommands(program) {
1145
1295
  .description("Turn a plain to-do into a repeating one. A disposable copy is promoted and the " +
1146
1296
  "ORIGINAL is moved to the Trash — so `things undo` reverses it (it removes the new " +
1147
1297
  "series and restores the original). The new template's uuid is printed. Set the rule " +
1148
- "with the flags below; see `things help repeating`.")
1298
+ "with the flags below; see `things help repeating`. Each re-run makes ANOTHER series, " +
1299
+ "so pass --op-id on a retry: a resubmission with the same key replays the first " +
1300
+ "result instead of making a second series.")
1149
1301
  .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
1150
1302
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (uuid, opts) => {
1151
1303
  const frequency = opts["frequency"];
@@ -1201,11 +1353,11 @@ export function registerWriteCommands(program) {
1201
1353
  // silently re-targets a different heading after any reorder). An empty-string
1202
1354
  // title selects a titleless heading; duplicates fail closed with uuid
1203
1355
  // candidates.
1204
- addPlacementFlags(addWriteFlags(project
1356
+ addPlacementFlags(addCompoundWriteFlags(project
1205
1357
  .command("add-heading <project> <title>")
1206
1358
  .description("Create a heading inside an existing project; its uuid is printed on success. The " +
1207
1359
  "project must name an existing project (uuid or unique name). Uses the Things proxy " +
1208
- "shortcuts — run `things setup shortcuts` once first. By default the heading is " +
1360
+ "shortcuts — run `things setup` once first. By default the heading is " +
1209
1361
  "appended; a placement flag positions it among the project's headings (that leg " +
1210
1362
  "needs `things config set allow-experimental true`)."))).action(async (projectRef, title, opts) => {
1211
1363
  if (countPlacementFlags(opts) > 1) {
@@ -1230,7 +1382,7 @@ export function registerWriteCommands(program) {
1230
1382
  return c.write.renameHeading(h.uuid, opts["to"], writeOptionsFrom(opts));
1231
1383
  });
1232
1384
  });
1233
- addWriteFlags(project
1385
+ addCompoundWriteFlags(project
1234
1386
  .command("archive-heading <project> <heading>")
1235
1387
  .description("Archive a heading — it leaves the active project view (reversible with " +
1236
1388
  "`things project unarchive-heading`). This is the preferred way to retire a heading: " +
@@ -1253,7 +1405,7 @@ export function registerWriteCommands(program) {
1253
1405
  return outcome.heading;
1254
1406
  });
1255
1407
  });
1256
- addWriteFlags(project
1408
+ addCompoundWriteFlags(project
1257
1409
  .command("unarchive-heading <project> <heading>")
1258
1410
  .description("Un-archive a heading. --restore-children also reopens the children the archive " +
1259
1411
  "cascade resolved with it (identified by matching resolution timestamps; a " +
@@ -1284,14 +1436,18 @@ export function registerWriteCommands(program) {
1284
1436
  return c.write.run("project.promote-heading", { uuid: h.uuid }, writeOptionsFrom(opts));
1285
1437
  });
1286
1438
  });
1287
- addPlacementFlags(addWriteFlags(project
1439
+ addPlacementFlags(addDriveGuiFlag(addWriteFlags(project
1288
1440
  .command("move-heading <project> <headings...>")
1289
1441
  .description("Reposition one or more of a project's headings as an ordered block — the selection " +
1290
- "order is the resulting order, and each heading's to-dos follow it. Pass exactly one " +
1291
- "placement: --first, --last, --before-heading <sel>, or --after-heading <sel>. Each " +
1292
- `<heading> is a ${HEADING_SEL_HELP}. Reordering headings rides the same experimental ` +
1293
- "surface as `things reorder` enable it once with `things config set " +
1294
- "allow-experimental true`."))).action(async (projectRef, sels, opts) => {
1442
+ "order is the resulting order, and each heading's to-dos follow it. Pass exactly " +
1443
+ "one placement: --first, --last, --before-heading <sel>, or --after-heading <sel>. " +
1444
+ `Each <heading> is a ${HEADING_SEL_HELP}. GUI-ONLY: Things offers heading order as ` +
1445
+ "keyboard shortcuts on a selected heading row and nowhere else, so this drives the " +
1446
+ "app — enable it once with `things config set ui-enabled true` and pass " +
1447
+ "--dangerously-drive-gui. It moves one heading one slot at a time, re-reading the " +
1448
+ "order after each step. Completed and canceled headings are not shown in the " +
1449
+ "project view, so they are skipped over and keep their place in the list; naming " +
1450
+ "one as a heading to move or as an anchor is refused.")))).action(async (projectRef, sels, opts) => {
1295
1451
  if (countPlacementFlags(opts) !== 1) {
1296
1452
  usageError(opts, "pass exactly one of --first / --last / --before-heading / --after-heading");
1297
1453
  return;
@@ -1312,8 +1468,9 @@ export function registerWriteCommands(program) {
1312
1468
  "destination. GUI-ONLY: it drives the heading row's ellipsis Move… menu, so enable it " +
1313
1469
  "once with `things config set ui.enabled true` and pass --dangerously-drive-gui. Fails " +
1314
1470
  "closed when the heading title is shared by another heading in the project, or the " +
1315
- "destination title by another project (the drive addresses both by title). No undo " +
1316
- "move it back to reverse."))).action(async (projectRef, sel, opts) => {
1471
+ "destination title by another project (both are addressed by title), and when the " +
1472
+ "destination project is completed or canceled — reopen it first. No undo move it " +
1473
+ "back to reverse."))).action(async (projectRef, sel, opts) => {
1317
1474
  await runWrite(opts, (c) => {
1318
1475
  const proj = c.resolve.project(projectRef);
1319
1476
  const dest = c.resolve.project(String(opts["to"]));
@@ -1374,12 +1531,13 @@ export function registerWriteCommands(program) {
1374
1531
  "moved to the Trash — so `things undo` reverses it (it removes the new series and " +
1375
1532
  "restores the original). Refuses a project that holds a nested repeating template. An " +
1376
1533
  "Anytime project with no area is moved to Someday first (a cleanup-free intermediate " +
1377
- "step, shown in --dry-run). Set the rule with the flags below; see `things help repeating`.")
1534
+ "step, shown in --dry-run). Set the rule with the flags below; see `things help " +
1535
+ "repeating`. Each re-run makes ANOTHER series, so pass --op-id on a retry: a " +
1536
+ "resubmission with the same key replays the first result instead of making a second " +
1537
+ "series.")
1378
1538
  .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
1379
1539
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (uuid, opts) => {
1380
1540
  const frequency = opts["frequency"];
1381
- if (opIdCompoundRefused(opts, "project make-repeating"))
1382
- return;
1383
1541
  await runWrite(opts, (c) => c.write.makeRepeatingProject(uuid, {
1384
1542
  frequency,
1385
1543
  interval: Number(opts["interval"]),
@@ -1392,18 +1550,22 @@ export function registerWriteCommands(program) {
1392
1550
  "is created (notes/area/when/deadline/child to-dos) and PERSISTS even if the promote " +
1393
1551
  "refuses; then it is promoted (which drives the GUI). Give --area to place it, or omit " +
1394
1552
  "it to create in Someday. The new repeating project's uuid is printed; `things undo` " +
1395
- "removes the created series (trash-both).")
1396
- .option("--notes <text>", "notes body")
1553
+ "removes the created series (trash-both). Each re-run makes ANOTHER series, so pass " +
1554
+ "--op-id on a retry: a resubmission with the same key replays the first result " +
1555
+ "instead of making a second series.")
1556
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1397
1557
  .option("--area <ref>", "destination area (uuid or unique name)")
1398
1558
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
1399
- .option("--deadline <date>", "YYYY-MM-DD")
1559
+ .option("--deadline <date>", "YYYY-MM-DD — deadlines EVERY occurrence: each starts (deadline − --when) days " +
1560
+ "before its own deadline (needs a concrete --when, on or before the deadline; " +
1561
+ "not available with --after-completion)")
1400
1562
  .option("--todo <title>", "initial child to-do (repeatable)", collect, [])
1401
1563
  .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
1402
1564
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (title, opts) => {
1403
1565
  const todos = opts["todo"];
1404
1566
  const area = containerRef(opts["area"]);
1405
1567
  const frequency = opts["frequency"];
1406
- if (opIdCompoundRefused(opts, "project add-repeating"))
1568
+ if (!(await resolveNotesStdin(opts)))
1407
1569
  return;
1408
1570
  await runWrite(opts, (c) => c.write.addRepeatingProject({
1409
1571
  title,
@@ -1420,8 +1582,10 @@ export function registerWriteCommands(program) {
1420
1582
  .description("Create a to-do and turn it into a repeating series in ONE call. Two legs: the to-do is " +
1421
1583
  "created (notes/tags/when/deadline/reminder/checklist/--created-at) and PERSISTS even " +
1422
1584
  "if the promote refuses; then it is promoted (which drives the GUI). The new repeating " +
1423
- "template's uuid is printed; `things undo` removes the created series (trash-both).")
1424
- .option("--notes <text>", "notes body")
1585
+ "template's uuid is printed; `things undo` removes the created series (trash-both). " +
1586
+ "Each re-run makes ANOTHER series, so pass --op-id on a retry: a resubmission with " +
1587
+ "the same key replays the first result instead of making a second series.")
1588
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1425
1589
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
1426
1590
  .option("--reminder <time>", "HH:mm reminder (needs a schedulable --when)")
1427
1591
  .option("--deadline <date>", "YYYY-MM-DD — deadlines EVERY occurrence: each starts (deadline − --when) days " +
@@ -1435,7 +1599,7 @@ export function registerWriteCommands(program) {
1435
1599
  .option("--project <ref>", "destination project (uuid or unique name)")
1436
1600
  .option("--area <ref>", "destination area (uuid or unique name)")
1437
1601
  .option("--heading <title>", "existing heading inside the target project")
1438
- .option("--created-at <iso>", "born with this creation timestamp (ISO date or datetime; a date is noon in the effective zone)")
1602
+ .option("--created-at <iso>", `born with this creation timestamp (${RESOLUTION_DATE_FORMAT})`)
1439
1603
  .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
1440
1604
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (title, opts) => {
1441
1605
  const tags = opts["tag"];
@@ -1443,7 +1607,7 @@ export function registerWriteCommands(program) {
1443
1607
  const projectRef = containerRef(opts["project"]);
1444
1608
  const area = containerRef(opts["area"]);
1445
1609
  const frequency = opts["frequency"];
1446
- if (opIdCompoundRefused(opts, "todo add-repeating"))
1610
+ if (!(await resolveNotesStdin(opts)))
1447
1611
  return;
1448
1612
  await runWrite(opts, (c) => c.write.addRepeatingTodo({
1449
1613
  title,
@@ -1468,15 +1632,17 @@ export function registerWriteCommands(program) {
1468
1632
  .description("Create a project; its uuid is printed on success. Give --todo (repeatable) to " +
1469
1633
  "seed it with child to-dos in the same call — the quick way to stand up a new " +
1470
1634
  "project skeleton.")
1471
- .option("--notes <text>", "notes body")
1635
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1472
1636
  .option("--area <ref>", "destination area (uuid or unique name)")
1473
1637
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
1474
1638
  .option("--deadline <date>", "YYYY-MM-DD")
1475
1639
  .option("--todo <title>", "initial child to-do, repeatable (seeds the new project)", collect, [])
1476
- .option("--created-at <iso>", "born with this creation timestamp (ISO date or datetime; a date is noon in the effective zone)")
1640
+ .option("--created-at <iso>", `born with this creation timestamp (${RESOLUTION_DATE_FORMAT})`)
1477
1641
  .option("--completed-at <iso>", "born completed (in the Logbook) with this completion timestamp; cannot seed open child to-dos (§5b)")).action(async (title, opts) => {
1478
1642
  const todos = opts["todo"];
1479
1643
  const area = containerRef(opts["area"]);
1644
+ if (!(await resolveNotesStdin(opts)))
1645
+ return;
1480
1646
  await runWrite(opts, (c) => c.write.addProject({
1481
1647
  title,
1482
1648
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
@@ -1496,7 +1662,7 @@ export function registerWriteCommands(program) {
1496
1662
  "--notes). --reminder needs --when today|evening|YYYY-MM-DD; when re-scheduling " +
1497
1663
  "WITHOUT --reminder an existing reminder is auto-preserved."))
1498
1664
  .option("--title <text>", "new title")
1499
- .option("--notes <text>", "replace notes")
1665
+ .option("--notes <text>", `replace the notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1500
1666
  .option("--append-notes <text>", "append to existing notes (newline-joined)")
1501
1667
  .option("--prepend-notes <text>", "prepend to existing notes (newline-joined)")
1502
1668
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
@@ -1504,39 +1670,17 @@ export function registerWriteCommands(program) {
1504
1670
  .option("--clear-reminder", "clear the reminder (works while scheduled today|evening)")
1505
1671
  .option("--deadline <date>", "YYYY-MM-DD")
1506
1672
  .option("--clear-deadline", "remove the deadline")
1507
- .option("--created-at <iso>", "rewrite the creation timestamp (ISO date or datetime; a date is noon in the effective zone); status-safe")
1673
+ .option("--created-at <iso>", `rewrite the creation timestamp (${RESOLUTION_DATE_FORMAT}); status-safe`)
1508
1674
  .option("--completed-at <iso>", "rewrite the completion timestamp of an already-resolved project (a canceled one stays canceled); open projects are refused — use complete/cancel")
1509
1675
  .action(async (uuid, opts) => {
1510
- const notesModes = ["notes", "appendNotes", "prependNotes"].filter((k) => opts[k] !== undefined);
1511
- if (notesModes.length > 1) {
1512
- usageError(opts, "--notes, --append-notes, --prepend-notes are exclusive");
1676
+ if (!(await resolveNotesStdin(opts)))
1513
1677
  return;
1514
- }
1515
- if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
1516
- usageError(opts, "pass at most one of --reminder / --clear-reminder");
1678
+ const built = buildUpdatePatch(opts, CLI_UPDATE_LABELS);
1679
+ if (built.kind === "error") {
1680
+ usageError(opts, built.message);
1517
1681
  return;
1518
1682
  }
1519
- if (!whenSugarOk(opts))
1520
- return;
1521
- await runWrite(opts, (c) => c.write.updateProject(uuid, {
1522
- ...(opts["title"] !== undefined && { title: opts["title"] }),
1523
- ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
1524
- ...(opts["appendNotes"] !== undefined && {
1525
- appendNotes: opts["appendNotes"],
1526
- }),
1527
- ...(opts["prependNotes"] !== undefined && {
1528
- prependNotes: opts["prependNotes"],
1529
- }),
1530
- ...(opts["when"] !== undefined && { when: opts["when"] }),
1531
- ...(opts["reminder"] !== undefined && { reminder: opts["reminder"] }),
1532
- ...(opts["clearReminder"] === true && { reminder: null }),
1533
- ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
1534
- ...(opts["clearDeadline"] === true && { deadline: null }),
1535
- ...(opts["createdAt"] !== undefined && { createdAt: opts["createdAt"] }),
1536
- ...(opts["completedAt"] !== undefined && {
1537
- completedAt: opts["completedAt"],
1538
- }),
1539
- }, writeOptionsFrom(opts)));
1683
+ await runWrite(opts, (c) => c.write.updateProject(uuid, built.patch, writeOptionsFrom(opts)));
1540
1684
  });
1541
1685
  addCreateTagsFlag(addWriteFlags(project
1542
1686
  .command("tags <ref>")
@@ -1556,7 +1700,7 @@ export function registerWriteCommands(program) {
1556
1700
  ? c.write.setProjectTags(uuid, set, writeOptionsFrom(opts, createTagsExtra(opts)))
1557
1701
  : c.write.addProjectTags(uuid, add ?? [], writeOptionsFrom(opts, createTagsExtra(opts))));
1558
1702
  });
1559
- addPositionFlags(addWriteFlags(project
1703
+ addPositionFlags(addCompoundWriteFlags(project
1560
1704
  .command("move <refs...>")
1561
1705
  .description("Move one or more projects as an ordered block (argument order = resulting order). " +
1562
1706
  "Pass one destination: --to-area (uuid or unique name) or --no-area (leave the " +
@@ -1594,7 +1738,7 @@ export function registerWriteCommands(program) {
1594
1738
  "canceled via the certified flip legs (multi-leg, disclosed); it is refused while the " +
1595
1739
  "project still has open children.")
1596
1740
  .requiredOption("--children <policy>", "require-resolved (error if open to-dos remain) | auto-cancel (cancel them too)")
1597
- .option("--completed-at <iso>", "completion timestamp (ISO date or datetime; a date is noon in the effective zone)")).action(async (uuid, opts) => {
1741
+ .option("--completed-at <iso>", `completion timestamp (${RESOLUTION_DATE_FORMAT})`)).action(async (uuid, opts) => {
1598
1742
  await runWrite(opts, (c) => c.write.cancelProject(uuid, {
1599
1743
  children: opts.children,
1600
1744
  ...(opts["completedAt"] !== undefined && {
@@ -1602,7 +1746,7 @@ export function registerWriteCommands(program) {
1602
1746
  }),
1603
1747
  }, writeOptionsFrom(opts)));
1604
1748
  });
1605
- addWriteFlags(project
1749
+ addCompoundWriteFlags(project
1606
1750
  .command("reopen <ref>")
1607
1751
  .description("Reopen a completed/canceled project (target by uuid or unique name). Its children " +
1608
1752
  "stay completed/canceled unless " +
@@ -1613,9 +1757,10 @@ export function registerWriteCommands(program) {
1613
1757
  if (opIdCompoundRefused(opts, "project reopen"))
1614
1758
  return;
1615
1759
  const started = Date.now();
1760
+ const { openClient, endInvocation } = beginWriteInvocation(opts.json === true, started);
1616
1761
  let client = null;
1617
1762
  try {
1618
- client = openThings(opts.db ? { dbPath: opts.db } : {});
1763
+ client = openClient(opts.db);
1619
1764
  const outcome = await client.write.reopenProject(uuid, {
1620
1765
  ...writeOptionsFrom(opts),
1621
1766
  ...(opts.restoreChildren === true && { restoreChildren: true }),
@@ -1649,6 +1794,7 @@ export function registerWriteCommands(program) {
1649
1794
  }
1650
1795
  finally {
1651
1796
  client?.close();
1797
+ endInvocation();
1652
1798
  }
1653
1799
  });
1654
1800
  addWriteFlags(project
@@ -1686,7 +1832,7 @@ export function registerWriteCommands(program) {
1686
1832
  "open to-dos, so an explicit --children policy is required. --completed-at sets the " +
1687
1833
  "completion timestamp and backdates it (multi-leg, disclosed).")
1688
1834
  .requiredOption("--children <policy>", "require-resolved (error if open to-dos remain) | auto-complete (complete them too)")
1689
- .option("--completed-at <iso>", "completion timestamp (ISO date or datetime; a date is noon in the effective zone)")).action(async (uuid, opts) => {
1835
+ .option("--completed-at <iso>", `completion timestamp (${RESOLUTION_DATE_FORMAT})`)).action(async (uuid, opts) => {
1690
1836
  await runWrite(opts, (c) => c.write.completeProject(uuid, {
1691
1837
  children: opts.children,
1692
1838
  ...(opts["completedAt"] !== undefined && {
@@ -1697,7 +1843,10 @@ export function registerWriteCommands(program) {
1697
1843
  addWriteFlags(project
1698
1844
  .command("delete <ref>")
1699
1845
  .description("Move a project (target by uuid or unique name) to the Trash; its children go with " +
1700
- "it (recover with `things project restore`).")).action(async (uuid, opts) => {
1846
+ "it (recover with `things project restore`). Deleting a repeating series' TEMPLATE is " +
1847
+ "allowed and ends the series: it stops generating new occurrences and its existing " +
1848
+ "occurrences are left in place. That cannot be undone here — the series comes back " +
1849
+ "only via Put Back in the Things app's Trash — so no undo token is returned.")).action(async (uuid, opts) => {
1701
1850
  await runWrite(opts, (c) => c.write.deleteProject(uuid, writeOptionsFrom(opts)));
1702
1851
  });
1703
1852
  const area = group(program, "area", "Area-scoped operations");
@@ -1867,9 +2016,11 @@ export function registerWriteCommands(program) {
1867
2016
  .description("Run MANY mutations from JSONL (file, or stdin when omitted/'-'): one op per line, " +
1868
2017
  '{"op": "<kind>", "params": {...}, "options": {...}} — see `things capabilities` for ' +
1869
2018
  "op kinds and params. Ops run sequentially and independently — NO transactions; a " +
1870
- "failure does not roll back earlier ops. A statically-invalid line (bad shape, unknown " +
1871
- "op, a $ref to an undeclared/forward tempId, a duplicate tempId) refuses the WHOLE " +
1872
- "batch before anything runs, naming every bad line. Otherwise ops run and per-op " +
2019
+ "failure does not roll back earlier ops. A statically-invalid line (unknown op, an " +
2020
+ "unknown or wrongly-typed param, a $ref to an undeclared/forward tempId, a duplicate " +
2021
+ "tempId) refuses the WHOLE batch before anything runs, naming every bad line and the " +
2022
+ 'field it names. A container param takes an object — {"project": {"uuid": "…"}} — ' +
2023
+ "never a bare string. Otherwise ops run and per-op " +
1873
2024
  "results stream as JSONL. Per-op " +
1874
2025
  "options carry the confirmation flags (acknowledgeChecklistReset, " +
1875
2026
  "acknowledgeProjectReopen, dangerouslyPermanent, acknowledgeTagSubtree). " +
@@ -1882,12 +2033,18 @@ export function registerWriteCommands(program) {
1882
2033
  "re-created (put an opId on EVERY line so a stopped batch can be resubmitted verbatim " +
1883
2034
  "to resume). The trailing summary line adds `tempIdMapping` (handle → uuid) and " +
1884
2035
  "`undoToken` — undo the WHOLE batch with `things undo --txn <undoToken>`. " +
2036
+ "TIMELINE: --preserve-modified applies to every line (each line may override it with its " +
2037
+ 'own `"options": {"preserveModified": true|false}`), so a bulk re-tag stays off the ' +
2038
+ "modification-date timeline under one undo token; each line's result then carries " +
2039
+ "`preservedModified` (and `preserveFailures` for any restore that did not land). " +
1885
2040
  "By DEFAULT a runtime failure STOPS the batch (later lines reported not-run, with resume " +
1886
2041
  "guidance in the summary); --continue-on-error runs past failures. --dry-run plans " +
1887
2042
  "everything without executing. Exit (worst failure wins): 0 all ok · 3 any " +
1888
2043
  "verify-failed/invalid · 4 any blocked · 5 any drift-blocked · 6 any unsupported.")
1889
2044
  .option("--dry-run", "plan every op; execute nothing")
1890
2045
  .option("--continue-on-error", "run past a failed op instead of stopping (default: stop)")
2046
+ .option("--preserve-modified", `${PRESERVE_MODIFIED_HELP} Applies to EVERY line; a line that sets its own ` +
2047
+ '`"options": {"preserveModified": false}` opts back onto the timeline.')
1891
2048
  .option("--json", "JSONL results + summary on stdout (also the default)")
1892
2049
  .option("--db <path>", "explicit database path")
1893
2050
  .option("--actor <name>", "author name recorded for the whole batch")
@@ -1923,12 +2080,18 @@ export function registerWriteCommands(program) {
1923
2080
  });
1924
2081
  }
1925
2082
  }
2083
+ // batch streams JSONL regardless of --json, so the interrupt guard is
2084
+ // armed machine-readable.
2085
+ const { openClient, endInvocation } = beginWriteInvocation(true, Date.now());
1926
2086
  let client = null;
1927
2087
  try {
1928
- client = openThings(opts.db ? { dbPath: opts.db } : {});
2088
+ client = openClient(opts.db);
1929
2089
  const batchResult = await client.write.batch(ops, {
1930
2090
  ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
1931
2091
  ...(opts["continueOnError"] === true && { continueOnError: true }),
2092
+ // Run-level default for every line; a line's own explicit
2093
+ // `options.preserveModified` outranks it (src/write/batch.ts).
2094
+ ...(opts.preserveModified === true && { preserveModified: true }),
1932
2095
  ...(opts.actor !== undefined && { actor: opts.actor }),
1933
2096
  }, (r) => {
1934
2097
  const pre = preInvalid.find((p) => p.index === r.index);
@@ -1960,6 +2123,7 @@ export function registerWriteCommands(program) {
1960
2123
  }
1961
2124
  finally {
1962
2125
  client?.close();
2126
+ endInvocation();
1963
2127
  }
1964
2128
  });
1965
2129
  program
@@ -2007,9 +2171,12 @@ export function registerWriteCommands(program) {
2007
2171
  usageError(opts, "--txn cannot be combined with --last or --by");
2008
2172
  return;
2009
2173
  }
2174
+ // undo streams JSONL regardless of --json, so the interrupt guard is armed
2175
+ // machine-readable (each inverse is a real write, GUI drives included).
2176
+ const { openClient, endInvocation } = beginWriteInvocation(true, Date.now());
2010
2177
  let client = null;
2011
2178
  try {
2012
- client = openThings(opts.db ? { dbPath: opts.db } : {});
2179
+ client = openClient(opts.db);
2013
2180
  const items = await client.write.undo({
2014
2181
  ...(opts["last"] !== undefined && { last: Number(opts["last"]) }),
2015
2182
  ...(opts["by"] !== undefined && { by: String(opts["by"]) }),
@@ -2063,9 +2230,10 @@ export function registerWriteCommands(program) {
2063
2230
  }
2064
2231
  finally {
2065
2232
  client?.close();
2233
+ endInvocation();
2066
2234
  }
2067
2235
  });
2068
- addReorderPositionFlags(addDriveGuiFlag(addWriteFlags(program
2236
+ addReorderPositionFlags(addDriveGuiFlag(addCompoundWriteFlags(program
2069
2237
  .command("reorder <refs...>")
2070
2238
  .description("The ONE reorder verb — rearrange to-dos, projects, headings, OR sidebar areas IN " +
2071
2239
  "PLACE (REARRANGES, never changes membership; to change what an item belongs to use " +
@@ -2077,8 +2245,9 @@ export function registerWriteCommands(program) {
2077
2245
  "EARLIEST one's current slot; --start/--end/--before/--after position the block. A " +
2078
2246
  "Today/Evening member also has an index slot in its container, so a set sharing BOTH " +
2079
2247
  "axes is ambiguous — pass --in to say which. HEADINGS: same-project heading " +
2080
- "re-ranking runs the native heading-block wire; an archived heading is reorderable " +
2081
- "but repositioning it brings it back to open (disclosed). AREAS: this drives the " +
2248
+ "re-ranking drives the local Things app (keyboard shortcuts on the heading row) " +
2249
+ "and covers the open headings only a completed or canceled heading is not shown " +
2250
+ "in the project view, so it is skipped over and cannot be moved. AREAS: this drives the " +
2082
2251
  "local Things app (sidebar drag). `things area reorder` and `things project " +
2083
2252
  "move-heading` remain as kind-specific spellings.")
2084
2253
  .option("--in <target>", "to-dos/projects only — name the axis to reorder on: today | evening | anytime | " +