things-api 0.17.0 → 0.19.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 (277) hide show
  1. package/README.md +16 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -0
  4. package/deputy/helpers-Info.plist +27 -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 +22 -0
  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 +128 -0
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +27 -0
  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 +137 -0
  14. package/deputy/reader/Info.plist +22 -0
  15. package/deputy/reader/entitlements.plist +45 -0
  16. package/deputy/reader/main.swift +580 -0
  17. package/deputy/src/main.swift +133 -0
  18. package/deputy/src/osascript.swift +102 -0
  19. package/deputy/src/server.swift +373 -0
  20. package/deputy/src/sqlite.swift +121 -0
  21. package/deputy/src/tcc.swift +58 -0
  22. package/dist/audit/schema.d.ts +13 -1
  23. package/dist/audit/schema.js.map +1 -1
  24. package/dist/capability.d.ts +160 -0
  25. package/dist/capability.js +416 -0
  26. package/dist/capability.js.map +1 -0
  27. package/dist/cli/commands/doctor.js +181 -2
  28. package/dist/cli/commands/doctor.js.map +1 -1
  29. package/dist/cli/commands/helpers.d.ts +17 -0
  30. package/dist/cli/commands/helpers.js +275 -0
  31. package/dist/cli/commands/helpers.js.map +1 -0
  32. package/dist/cli/commands/op-result.d.ts +15 -0
  33. package/dist/cli/commands/op-result.js +45 -0
  34. package/dist/cli/commands/op-result.js.map +1 -0
  35. package/dist/cli/commands/reads.js +47 -1
  36. package/dist/cli/commands/reads.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +63 -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 +262 -109
  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 +53 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/helpers-check.d.ts +10 -0
  52. package/dist/cli/helpers-check.js +59 -0
  53. package/dist/cli/helpers-check.js.map +1 -0
  54. package/dist/cli/main.js +22 -1
  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 +22 -2
  65. package/dist/client.js +52 -15
  66. package/dist/client.js.map +1 -1
  67. package/dist/config.d.ts +31 -0
  68. package/dist/config.js +9 -0
  69. package/dist/config.js.map +1 -1
  70. package/dist/contracts.d.ts +6 -2
  71. package/dist/contracts.js +1 -1
  72. package/dist/contracts.js.map +1 -1
  73. package/dist/db/baselines/db-v26.js +8 -4
  74. package/dist/db/baselines/db-v26.js.map +1 -1
  75. package/dist/db/baselines/db-v27.d.ts +42 -0
  76. package/dist/db/baselines/db-v27.js +47 -0
  77. package/dist/db/baselines/db-v27.js.map +1 -0
  78. package/dist/db/baselines/index.js +2 -1
  79. package/dist/db/baselines/index.js.map +1 -1
  80. package/dist/db/locate.d.ts +4 -1
  81. package/dist/db/locate.js +3 -1
  82. package/dist/db/locate.js.map +1 -1
  83. package/dist/db/schema.d.ts +1 -1
  84. package/dist/db/schema.js +9 -0
  85. package/dist/db/schema.js.map +1 -1
  86. package/dist/deputy/bridge-worker.d.ts +1 -0
  87. package/dist/deputy/bridge-worker.js +79 -0
  88. package/dist/deputy/bridge-worker.js.map +1 -0
  89. package/dist/deputy/bridge.d.ts +14 -0
  90. package/dist/deputy/bridge.js +70 -0
  91. package/dist/deputy/bridge.js.map +1 -0
  92. package/dist/deputy/client.d.ts +19 -0
  93. package/dist/deputy/client.js +104 -0
  94. package/dist/deputy/client.js.map +1 -0
  95. package/dist/deputy/db-facade.d.ts +18 -0
  96. package/dist/deputy/db-facade.js +71 -0
  97. package/dist/deputy/db-facade.js.map +1 -0
  98. package/dist/deputy/files.d.ts +1 -0
  99. package/dist/deputy/files.js +16 -0
  100. package/dist/deputy/files.js.map +1 -0
  101. package/dist/deputy/install.d.ts +319 -0
  102. package/dist/deputy/install.js +1239 -0
  103. package/dist/deputy/install.js.map +1 -0
  104. package/dist/deputy/notice.d.ts +6 -0
  105. package/dist/deputy/notice.js +28 -0
  106. package/dist/deputy/notice.js.map +1 -0
  107. package/dist/deputy/notices.d.ts +31 -0
  108. package/dist/deputy/notices.js +94 -0
  109. package/dist/deputy/notices.js.map +1 -0
  110. package/dist/deputy/osa.d.ts +21 -0
  111. package/dist/deputy/osa.js +72 -0
  112. package/dist/deputy/osa.js.map +1 -0
  113. package/dist/deputy/protocol.d.ts +140 -0
  114. package/dist/deputy/protocol.js +143 -0
  115. package/dist/deputy/protocol.js.map +1 -0
  116. package/dist/deputy/routing.d.ts +84 -0
  117. package/dist/deputy/routing.js +422 -0
  118. package/dist/deputy/routing.js.map +1 -0
  119. package/dist/deputy/shortcuts-exec.d.ts +10 -0
  120. package/dist/deputy/shortcuts-exec.js +50 -0
  121. package/dist/deputy/shortcuts-exec.js.map +1 -0
  122. package/dist/diagnose.d.ts +57 -2
  123. package/dist/diagnose.js +105 -3
  124. package/dist/diagnose.js.map +1 -1
  125. package/dist/direct-setup.d.ts +78 -0
  126. package/dist/direct-setup.js +484 -0
  127. package/dist/direct-setup.js.map +1 -0
  128. package/dist/host-access.d.ts +63 -0
  129. package/dist/host-access.js +165 -0
  130. package/dist/host-access.js.map +1 -0
  131. package/dist/index.d.ts +31 -5
  132. package/dist/index.js +23 -1
  133. package/dist/index.js.map +1 -1
  134. package/dist/mcp/server.d.ts +27 -1
  135. package/dist/mcp/server.js +300 -97
  136. package/dist/mcp/server.js.map +1 -1
  137. package/dist/model/mappers.d.ts +12 -3
  138. package/dist/model/mappers.js +6 -1
  139. package/dist/model/mappers.js.map +1 -1
  140. package/dist/model/recurrence.d.ts +5 -1
  141. package/dist/model/recurrence.js +19 -1
  142. package/dist/model/recurrence.js.map +1 -1
  143. package/dist/model/template-projection.d.ts +26 -0
  144. package/dist/model/template-projection.js +113 -0
  145. package/dist/model/template-projection.js.map +1 -0
  146. package/dist/op-result.d.ts +55 -0
  147. package/dist/op-result.js +161 -0
  148. package/dist/op-result.js.map +1 -0
  149. package/dist/read/detail.js +7 -1
  150. package/dist/read/detail.js.map +1 -1
  151. package/dist/read/filter-contract.d.ts +1 -1
  152. package/dist/read/filter-contract.js +4 -0
  153. package/dist/read/filter-contract.js.map +1 -1
  154. package/dist/read/queries.js +8 -1
  155. package/dist/read/queries.js.map +1 -1
  156. package/dist/read/shape.js +4 -0
  157. package/dist/read/shape.js.map +1 -1
  158. package/dist/read/views.d.ts +35 -4
  159. package/dist/read/views.js +120 -18
  160. package/dist/read/views.js.map +1 -1
  161. package/dist/session-grant.d.ts +48 -0
  162. package/dist/session-grant.js +170 -0
  163. package/dist/session-grant.js.map +1 -0
  164. package/dist/surface-copy.d.ts +8 -0
  165. package/dist/surface-copy.js +8 -0
  166. package/dist/surface-copy.js.map +1 -1
  167. package/dist/sync-health.js +6 -2
  168. package/dist/sync-health.js.map +1 -1
  169. package/dist/wizard.d.ts +58 -0
  170. package/dist/wizard.js +167 -0
  171. package/dist/wizard.js.map +1 -0
  172. package/dist/write/accessibility-probe.js +4 -2
  173. package/dist/write/accessibility-probe.js.map +1 -1
  174. package/dist/write/automation-probe.js +5 -1
  175. package/dist/write/automation-probe.js.map +1 -1
  176. package/dist/write/availability.d.ts +1 -1
  177. package/dist/write/availability.js +10 -6
  178. package/dist/write/availability.js.map +1 -1
  179. package/dist/write/batch.d.ts +20 -0
  180. package/dist/write/batch.js +94 -15
  181. package/dist/write/batch.js.map +1 -1
  182. package/dist/write/capabilities.d.ts +9 -0
  183. package/dist/write/capabilities.js +2 -0
  184. package/dist/write/capabilities.js.map +1 -1
  185. package/dist/write/clear-reminder.js +1 -1
  186. package/dist/write/clear-reminder.js.map +1 -1
  187. package/dist/write/commands.d.ts +12 -1
  188. package/dist/write/commands.js +170 -237
  189. package/dist/write/commands.js.map +1 -1
  190. package/dist/write/experimental.d.ts +19 -0
  191. package/dist/write/experimental.js +50 -0
  192. package/dist/write/experimental.js.map +1 -1
  193. package/dist/write/failure-hints.d.ts +3 -1
  194. package/dist/write/failure-hints.js.map +1 -1
  195. package/dist/write/guards.js +19 -2
  196. package/dist/write/guards.js.map +1 -1
  197. package/dist/write/lock.d.ts +16 -13
  198. package/dist/write/lock.js +55 -0
  199. package/dist/write/lock.js.map +1 -1
  200. package/dist/write/make-repeating-project.js +9 -13
  201. package/dist/write/make-repeating-project.js.map +1 -1
  202. package/dist/write/move.js +14 -6
  203. package/dist/write/move.js.map +1 -1
  204. package/dist/write/operations.d.ts +64 -25
  205. package/dist/write/operations.js +7 -0
  206. package/dist/write/operations.js.map +1 -1
  207. package/dist/write/opid.d.ts +14 -0
  208. package/dist/write/opid.js +22 -0
  209. package/dist/write/opid.js.map +1 -1
  210. package/dist/write/param-schema.d.ts +124 -0
  211. package/dist/write/param-schema.js +747 -0
  212. package/dist/write/param-schema.js.map +1 -0
  213. package/dist/write/pipeline.d.ts +78 -2
  214. package/dist/write/pipeline.js +249 -4
  215. package/dist/write/pipeline.js.map +1 -1
  216. package/dist/write/pre-state.d.ts +8 -0
  217. package/dist/write/pre-state.js +62 -17
  218. package/dist/write/pre-state.js.map +1 -1
  219. package/dist/write/promote-clone.js +635 -367
  220. package/dist/write/promote-clone.js.map +1 -1
  221. package/dist/write/reorder.js +95 -29
  222. package/dist/write/reorder.js.map +1 -1
  223. package/dist/write/repeat-anchor.d.ts +30 -0
  224. package/dist/write/repeat-anchor.js +166 -1
  225. package/dist/write/repeat-anchor.js.map +1 -1
  226. package/dist/write/repeat-rule.d.ts +28 -1
  227. package/dist/write/repeat-rule.js +64 -4
  228. package/dist/write/repeat-rule.js.map +1 -1
  229. package/dist/write/resolution-timestamps.d.ts +11 -0
  230. package/dist/write/resolution-timestamps.js +83 -38
  231. package/dist/write/resolution-timestamps.js.map +1 -1
  232. package/dist/write/reversibility.js +4 -0
  233. package/dist/write/reversibility.js.map +1 -1
  234. package/dist/write/template-mutation.d.ts +137 -0
  235. package/dist/write/template-mutation.js +451 -0
  236. package/dist/write/template-mutation.js.map +1 -0
  237. package/dist/write/undo.js +10 -20
  238. package/dist/write/undo.js.map +1 -1
  239. package/dist/write/update-fields.d.ts +151 -0
  240. package/dist/write/update-fields.js +427 -0
  241. package/dist/write/update-fields.js.map +1 -0
  242. package/dist/write/vectors/applescript.js +10 -16
  243. package/dist/write/vectors/applescript.js.map +1 -1
  244. package/dist/write/vectors/registry.d.ts +1 -1
  245. package/dist/write/vectors/registry.js +10 -2
  246. package/dist/write/vectors/registry.js.map +1 -1
  247. package/dist/write/vectors/session-reachability.d.ts +55 -0
  248. package/dist/write/vectors/session-reachability.js +56 -8
  249. package/dist/write/vectors/session-reachability.js.map +1 -1
  250. package/dist/write/vectors/shortcuts.js +7 -16
  251. package/dist/write/vectors/shortcuts.js.map +1 -1
  252. package/dist/write/vectors/simulator.d.ts +16 -1
  253. package/dist/write/vectors/simulator.js +68 -12
  254. package/dist/write/vectors/simulator.js.map +1 -1
  255. package/dist/write/vectors/types.d.ts +124 -1
  256. package/dist/write/vectors/ui-recipes.d.ts +19 -0
  257. package/dist/write/vectors/ui-recipes.js +158 -38
  258. package/dist/write/vectors/ui-recipes.js.map +1 -1
  259. package/dist/write/vectors/ui.d.ts +89 -0
  260. package/dist/write/vectors/ui.js +504 -49
  261. package/dist/write/vectors/ui.js.map +1 -1
  262. package/dist/write/verify/delta.d.ts +21 -0
  263. package/dist/write/verify/delta.js.map +1 -1
  264. package/dist/write/verify/poller.d.ts +13 -0
  265. package/dist/write/verify/poller.js +38 -0
  266. package/dist/write/verify/poller.js.map +1 -1
  267. package/package.json +9 -1
  268. package/schema/envelope.schema.json +5 -3
  269. package/scripts/build-helpers.sh +109 -0
  270. package/scripts/deputy-cert-setup.sh +60 -0
  271. package/skills/things-cli/SKILL.md +12 -5
  272. package/skills/things-cli/references/contracts.md +1 -0
  273. package/skills/things-cli/references/data-model.md +3 -0
  274. package/skills/things-cli/references/errors.md +5 -1
  275. package/skills/things-cli/references/gui.md +1 -0
  276. package/skills/things-cli/references/repeating.md +75 -0
  277. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -1,30 +1,55 @@
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, installCliTrace, getConfigKey, 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, 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-leg COMPOUND command: the same set, with `--op-id`
47
+ * kept out of `--help` because that command's execution refuses it (see
48
+ * `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.
51
+ */
52
+ const addCompoundWriteFlags = (cmd) => addWriteFlags(cmd, { opId: "unsupported" });
28
53
  /** Validate an `--op-id`; on a malformed value emit the usage error and return false. */
29
54
  function opIdOk(opts) {
30
55
  if (opts.opId === undefined)
@@ -190,20 +215,44 @@ function splitCsv(value) {
190
215
  .map((s) => s.trim())
191
216
  .filter((s) => s !== "");
192
217
  }
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).
218
+ /**
219
+ * Open a CLI write invocation: install the dev-mode step-timeline trace and arm
220
+ * the signal-safe interrupt guard (TRACE1, #487). Returns the teardown for the
221
+ * driver's `finally` — it closes the trace with the final exit code, then
222
+ * disarms the guard so a signal during teardown/reads emits nothing.
223
+ *
224
+ * EVERY write driver calls this, not just the single-mutation one: the library
225
+ * half (the in-flight marker, the watchdog, the plain-stderr interrupt line)
226
+ * works everywhere, but the trace FILE and the `--json` `interrupted` envelope
227
+ * exist only where the driver installs and arms them. `json` says whether this
228
+ * invocation's stdout is machine-readable — drivers that emit JSONL regardless
229
+ * of `--json` (batch, undo) pass true.
230
+ *
231
+ * The sink is a no-op unless tracing is on (a `-dev` build, or config/env
232
+ * forced), so this costs a config read on every write and nothing else.
233
+ */
234
+ function beginWriteInvocation(json, startedAt) {
201
235
  installCliTrace({
202
236
  argv: process.argv.slice(1),
203
237
  version: CLI_VERSION,
204
238
  isDev: isDevVersion(CLI_VERSION),
205
239
  });
206
- armInterrupt(opts.json === true);
240
+ armInterrupt(json);
241
+ return () => {
242
+ trace(() => ({
243
+ phase: "invocation-end",
244
+ exitCode: process.exitCode ?? 0,
245
+ elapsedMs: Date.now() - startedAt,
246
+ }));
247
+ disarmInterrupt();
248
+ closeCliTrace();
249
+ };
250
+ }
251
+ async function runWrite(opts, fn, emitFn = emitResult) {
252
+ if (!opIdOk(opts))
253
+ return;
254
+ const started = Date.now();
255
+ const endInvocation = beginWriteInvocation(opts.json === true, started);
207
256
  let client = null;
208
257
  const meta = (client_) => {
209
258
  let dbVersion = null;
@@ -245,6 +294,14 @@ async function runWrite(opts, fn, emitFn = emitResult) {
245
294
  usageError(opts, err.message);
246
295
  return;
247
296
  }
297
+ // A structural parameter refusal (#580) is an INPUT-CONTRACT error — the
298
+ // caller's bag was malformed and nothing was dispatched — not an internal
299
+ // fault. It exits `usage`, naming the JSON path, the expected shape, and
300
+ // what was received.
301
+ if (err instanceof ParamSchemaError) {
302
+ usageError(opts, err.message);
303
+ return;
304
+ }
248
305
  const isEnv = err instanceof ThingsDbNotFoundError || err instanceof ThingsDbOpenError;
249
306
  const message = err instanceof Error ? err.message : String(err);
250
307
  if (opts.json) {
@@ -257,16 +314,7 @@ async function runWrite(opts, fn, emitFn = emitResult) {
257
314
  }
258
315
  finally {
259
316
  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();
317
+ endInvocation();
270
318
  }
271
319
  }
272
320
  /**
@@ -286,6 +334,22 @@ function emitPreserveNote(result) {
286
334
  process.stderr.write(`warning: preserve-modified: ${f.uuid}: ${f.detail}\n`);
287
335
  }
288
336
  }
337
+ /**
338
+ * TTY disclosure for a TEMPLATE-TARGET composite (`complete`/`cancel`/`update
339
+ * --exception` aimed at a repeating to-do): a dim line naming BOTH uuids — the
340
+ * occurrence that was written (the result's own uuid) and the series it belongs
341
+ * to — plus whether that occurrence was created for this call. The human render
342
+ * would otherwise show one uuid with nothing saying which of the two it is.
343
+ * Silent for every other op (the field is absent).
344
+ */
345
+ function emitOccurrenceNote(result) {
346
+ const o = result.occurrence;
347
+ if (o === undefined)
348
+ return;
349
+ const dated = o.date === null ? "" : ` dated ${o.date}`;
350
+ const origin = o.minted ? "created for this change" : "already open";
351
+ process.stdout.write(dim(` occurrence ${o.occurrenceUuid}${dated} (${origin}) of repeating to-do ${o.templateUuid}\n`));
352
+ }
289
353
  /**
290
354
  * TTY disclosure for the HINTS1 completion-context on a verified complete/cancel:
291
355
  * a dim line naming the OPEN work remaining in the to-do's project and/or Today,
@@ -337,6 +401,7 @@ function emitResult(result, opts, meta) {
337
401
  ? "already applied — matched op-id in the change history, not re-run"
338
402
  : `vector=${result.vector}, tier=${result.tier}, verified`;
339
403
  process.stdout.write(`ok ${result.op}${uuid} (${status})\n`);
404
+ emitOccurrenceNote(result);
340
405
  emitPreserveNote(result);
341
406
  emitContextNote(result);
342
407
  }
@@ -354,6 +419,7 @@ function emitResult(result, opts, meta) {
354
419
  ` invocation: ${result.plan.invocation}`,
355
420
  ` hazards checked: ${result.plan.hazardsChecked.join(", ") || "none"}`,
356
421
  ` expected delta: ${JSON.stringify(result.plan.expectedDelta)}`,
422
+ ...(result.plan.notes ?? []).map((n) => ` note: ${n}`),
357
423
  "",
358
424
  ].join("\n"));
359
425
  }
@@ -431,6 +497,7 @@ async function runMoveCmd(opts, fn) {
431
497
  return;
432
498
  }
433
499
  const started = Date.now();
500
+ const endInvocation = beginWriteInvocation(opts.json === true, started);
434
501
  let client = null;
435
502
  const meta = () => {
436
503
  let dbVersion = null;
@@ -468,6 +535,7 @@ async function runMoveCmd(opts, fn) {
468
535
  }
469
536
  finally {
470
537
  client?.close();
538
+ endInvocation();
471
539
  }
472
540
  }
473
541
  function emitMoveResult(result, opts, meta) {
@@ -689,6 +757,7 @@ function addResultLine(r) {
689
757
  * in creation order, and nothing else. Exit code is the worst leg's failure.
690
758
  */
691
759
  async function runBulkAdd(opts, ops, idOnly) {
760
+ const endInvocation = beginWriteInvocation(opts.json === true, Date.now());
692
761
  let client = null;
693
762
  try {
694
763
  client = openThings(opts.db ? { dbPath: opts.db } : {});
@@ -734,6 +803,7 @@ async function runBulkAdd(opts, ops, idOnly) {
734
803
  }
735
804
  finally {
736
805
  client?.close();
806
+ endInvocation();
737
807
  }
738
808
  }
739
809
  /** Read newline-delimited titles from stdin; blank lines (whitespace-only) skipped. */
@@ -747,6 +817,45 @@ async function readStdinTitles() {
747
817
  .map((l) => l.replace(/\r$/, ""))
748
818
  .filter((l) => l.trim() !== "");
749
819
  }
820
+ /**
821
+ * The CLI-only half of the notes vocabulary (the shared half is NOTES_FORMAT):
822
+ * how a multi-line body gets in from a shell. `-` reads STDIN, so a heredoc or
823
+ * a pipe composes; the `$'…'` form covers the inline case.
824
+ */
825
+ const NOTES_INPUT = "pass - to read the body from stdin, or $'line 1\\nline 2' to give it inline";
826
+ /**
827
+ * Read the whole of STDIN as one UTF-8 note body. One trailing newline is
828
+ * dropped (a heredoc and most pipes add one); every other newline is kept.
829
+ */
830
+ async function readStdinText() {
831
+ const chunks = [];
832
+ for await (const chunk of process.stdin)
833
+ chunks.push(chunk);
834
+ return Buffer.concat(chunks)
835
+ .toString("utf8")
836
+ .replace(/\r?\n$/, "");
837
+ }
838
+ /**
839
+ * Resolve `--notes -` into the body waiting on STDIN, in place on `opts`. A
840
+ * terminal STDIN has nothing to read, so it is refused with the two working
841
+ * spellings rather than hanging on an input nobody is going to type. Returns
842
+ * false when the invocation was refused (the usage error is already emitted).
843
+ */
844
+ async function resolveNotesStdin(opts, conflictsWithStdinFlag = false) {
845
+ if (opts["notes"] !== "-")
846
+ return true;
847
+ if (conflictsWithStdinFlag && opts["stdin"] === true) {
848
+ usageError(opts, "--notes - and --stdin both read stdin; pass at most one of them");
849
+ return false;
850
+ }
851
+ if (process.stdin.isTTY === true) {
852
+ usageError(opts, "--notes - reads the note body from stdin, and stdin is a terminal: pipe the body in " +
853
+ "(or redirect a file with < notes.md), or give the text inline with --notes $'line 1\\nline 2'");
854
+ return false;
855
+ }
856
+ opts["notes"] = await readStdinText();
857
+ return true;
858
+ }
750
859
  function group(program, name, description) {
751
860
  const existing = program.commands.find((c) => c.name() === name);
752
861
  if (existing !== undefined)
@@ -771,7 +880,7 @@ export function registerWriteCommands(program) {
771
880
  "--create-tags. Adding into a completed/canceled project reopens that project — " +
772
881
  "requires --acknowledge-project-reopen. When several to-dos are created, one undo " +
773
882
  "token removes the whole skeleton at once.")
774
- .option("--notes <text>", "notes body")
883
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
775
884
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
776
885
  .option("--reminder <HH:mm>", "time-of-day reminder (24h); requires --when today|evening|YYYY-MM-DD")
777
886
  .option("--deadline <date>", "YYYY-MM-DD")
@@ -791,6 +900,8 @@ export function registerWriteCommands(program) {
791
900
  usageError(opts, "--id-only and --json are mutually exclusive");
792
901
  return;
793
902
  }
903
+ if (!(await resolveNotesStdin(opts, true)))
904
+ return;
794
905
  if (useStdin && titles.length > 0) {
795
906
  usageError(opts, "--stdin is mutually exclusive with title arguments");
796
907
  return;
@@ -883,15 +994,17 @@ export function registerWriteCommands(program) {
883
994
  });
884
995
  addWriteFlags(todo
885
996
  .command("update <uuid>")
886
- .description("Update title/notes/when/reminder/deadline. Schedule and deadline changes are not " +
887
- "available for repeating to-dos (title/notes are). --reminder needs --when " +
997
+ .description("Update title/notes/when/reminder/deadline. On a REPEATING to-do a schedule or " +
998
+ "deadline change needs --exception (change only the next occurrence) or " +
999
+ "`things todo reschedule-repeat` (change the series); title/notes apply to the " +
1000
+ "series as they always have. --reminder needs --when " +
888
1001
  "today|evening|YYYY-MM-DD; when re-scheduling WITHOUT --reminder an existing " +
889
1002
  "reminder is auto-preserved. --clear-reminder works while the to-do is scheduled " +
890
1003
  "for today|evening — a DATED reminder can only be changed, not cleared " +
891
1004
  "(re-schedule to today first). --append-notes/--prepend-notes join with a " +
892
1005
  "newline (exclusive with --notes).")
893
1006
  .option("--title <text>", "new title")
894
- .option("--notes <text>", "replace notes")
1007
+ .option("--notes <text>", `replace the notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
895
1008
  .option("--append-notes <text>", "append to existing notes (newline-joined)")
896
1009
  .option("--prepend-notes <text>", "prepend to existing notes (newline-joined)")
897
1010
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
@@ -900,33 +1013,35 @@ export function registerWriteCommands(program) {
900
1013
  .option("--deadline <date>", "YYYY-MM-DD")
901
1014
  .option("--clear-deadline", "remove the deadline")
902
1015
  .option("--created-at <iso>", "rewrite the creation timestamp (ISO date or datetime; a date is noon in the effective zone); status-safe")
903
- .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) => {
904
- const notesModes = ["notes", "appendNotes", "prependNotes"].filter((k) => opts[k] !== undefined);
905
- if (notesModes.length > 1) {
906
- usageError(opts, "--notes, --append-notes, --prepend-notes are exclusive");
1016
+ .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")
1017
+ .option("--exception", "repeating to-dos only: change just the NEXT occurrence and leave the series alone " +
1018
+ "(creates that occurrence if it has not appeared yet); refused when the series already " +
1019
+ "lands on the requested day. The result names both the occurrence and the series. Each " +
1020
+ "re-run takes another occurrence out of the series, so pass --op-id on a retry: a " +
1021
+ "resubmission with the same key replays the first result instead of creating a second " +
1022
+ "occurrence")).action(async (uuid, opts) => {
1023
+ if (!(await resolveNotesStdin(opts)))
907
1024
  return;
908
- }
909
- if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
910
- usageError(opts, "pass at most one of --reminder / --clear-reminder");
1025
+ // The patch (and every exclusive-flag pair in it, the @time sugar included)
1026
+ // comes from the ONE update-vocabulary builder both surfaces share.
1027
+ const built = buildUpdatePatch(opts, CLI_UPDATE_LABELS);
1028
+ if (built.kind === "error") {
1029
+ usageError(opts, built.message);
911
1030
  return;
912
1031
  }
913
- if (!whenSugarOk(opts))
1032
+ if (opts["exception"] === true) {
1033
+ // The occurrence is a to-do of its own once it exists, so the whole update
1034
+ // vocabulary applies to it — except the timestamp rewrites, which are about
1035
+ // a row's history and have no meaning for an occurrence created this second.
1036
+ const stamped = ["createdAt", "completedAt"].filter((k) => opts[k] !== undefined);
1037
+ if (stamped.length > 0) {
1038
+ usageError(opts, "--exception cannot be combined with --created-at/--completed-at");
1039
+ return;
1040
+ }
1041
+ await runWrite(opts, (c) => c.write.updateTodoOccurrence(uuid, built.patch, writeOptionsFrom(opts)));
914
1042
  return;
915
- await runWrite(opts, (c) => c.write.updateTodo(uuid, {
916
- ...(opts["title"] !== undefined && { title: opts["title"] }),
917
- ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
918
- ...(opts["appendNotes"] !== undefined && { appendNotes: opts["appendNotes"] }),
919
- ...(opts["prependNotes"] !== undefined && {
920
- prependNotes: opts["prependNotes"],
921
- }),
922
- ...(opts["when"] !== undefined && { when: opts["when"] }),
923
- ...(opts["reminder"] !== undefined && { reminder: opts["reminder"] }),
924
- ...(opts["clearReminder"] === true && { reminder: null }),
925
- ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
926
- ...(opts["clearDeadline"] === true && { deadline: null }),
927
- ...(opts["createdAt"] !== undefined && { createdAt: opts["createdAt"] }),
928
- ...(opts["completedAt"] !== undefined && { completedAt: opts["completedAt"] }),
929
- }, writeOptionsFrom(opts)));
1043
+ }
1044
+ await runWrite(opts, (c) => c.write.updateTodo(uuid, built.patch, writeOptionsFrom(opts)));
930
1045
  });
931
1046
  for (const verb of ["complete", "cancel"]) {
932
1047
  const method = verb === "complete" ? "completeTodo" : "cancelTodo";
@@ -935,7 +1050,13 @@ export function registerWriteCommands(program) {
935
1050
  : "keeping it canceled via the certified flip legs";
936
1051
  addWriteFlags(todo
937
1052
  .command(`${verb} <uuid>`)
938
- .description(`${verb[0]?.toUpperCase()}${verb.slice(1)} a to-do. Not available for repeating to-dos. ` +
1053
+ .description(`${verb[0]?.toUpperCase()}${verb.slice(1)} a to-do. On a REPEATING to-do this ${verb}s ` +
1054
+ "the series' current occurrence — the unfinished one if there is one, otherwise the " +
1055
+ "next one, created for the purpose — and leaves the series running; the result names " +
1056
+ "both the occurrence and the series, and says which of the two it created. Each " +
1057
+ `re-run is a new ${verb} that takes the FOLLOWING occurrence, so pass --op-id on a ` +
1058
+ "retry: a resubmission with the same key replays the first result instead of " +
1059
+ "creating a second occurrence. " +
939
1060
  '--completed-at sets the completion timestamp (also the "Completed on" stamp for a canceled ' +
940
1061
  `item), ${stampNote} — a multi-leg sequence, disclosed in the result and --dry-run.`)
941
1062
  .option("--completed-at <iso>", "completion timestamp (ISO date or datetime; a date is noon in the effective zone)")).action(async (uuid, opts) => {
@@ -948,7 +1069,7 @@ export function registerWriteCommands(program) {
948
1069
  .description("Reopen a to-do. Not available for repeating to-dos.")).action(async (uuid, opts) => {
949
1070
  await runWrite(opts, (c) => c.write.reopenTodo(uuid, writeOptionsFrom(opts)));
950
1071
  });
951
- addPositionFlags(addWriteFlags(todo
1072
+ addPositionFlags(addCompoundWriteFlags(todo
952
1073
  .command("move <refs...>")
953
1074
  .description("Move one or more to-dos as an ordered block (the argument order is the order they " +
954
1075
  "land — name them backwards to reverse). MOVE changes WHAT a to-do belongs to; to " +
@@ -1108,8 +1229,12 @@ export function registerWriteCommands(program) {
1108
1229
  });
1109
1230
  addWriteFlags(todo
1110
1231
  .command("delete <uuid>")
1111
- .description("Move a to-do to the Trash (recover with `things todo restore`). Not available " +
1112
- "for repeating to-dos.")).action(async (uuid, opts) => {
1232
+ .description("Move a to-do to the Trash (recover with `things todo restore`). Deleting a repeating " +
1233
+ "series' TEMPLATE is allowed and ends the series: it stops generating new occurrences " +
1234
+ "and its existing occurrences are left in place (the result names how many, and the " +
1235
+ "current one). That cannot be undone here — the series comes back only via Put Back " +
1236
+ "in the Things app's Trash — so no undo token is returned. Deleting a single " +
1237
+ "occurrence leaves the series running.")).action(async (uuid, opts) => {
1113
1238
  await runWrite(opts, (c) => c.write.deleteTodo(uuid, writeOptionsFrom(opts)));
1114
1239
  });
1115
1240
  addWriteFlags(todo
@@ -1122,7 +1247,7 @@ export function registerWriteCommands(program) {
1122
1247
  addWriteFlags(todo
1123
1248
  .command("clear-reminder <uuid>")
1124
1249
  .description("Clear a to-do's time-of-day reminder, keeping its scheduled date. With the proxy " +
1125
- "shortcuts installed (`things setup shortcuts`) this is in place, and is the only " +
1250
+ "shortcuts installed (`things setup`) this is in place, and is the only " +
1126
1251
  "way for a repeating to-do; otherwise a date-scheduled to-do is cleared by a brief " +
1127
1252
  "re-schedule through Today. Reversible with `things undo`.")).action(async (uuid, opts) => {
1128
1253
  if (opIdCompoundRefused(opts, "clear-reminder"))
@@ -1200,11 +1325,11 @@ export function registerWriteCommands(program) {
1200
1325
  // silently re-targets a different heading after any reorder). An empty-string
1201
1326
  // title selects a titleless heading; duplicates fail closed with uuid
1202
1327
  // candidates.
1203
- addPlacementFlags(addWriteFlags(project
1328
+ addPlacementFlags(addCompoundWriteFlags(project
1204
1329
  .command("add-heading <project> <title>")
1205
1330
  .description("Create a heading inside an existing project; its uuid is printed on success. The " +
1206
1331
  "project must name an existing project (uuid or unique name). Uses the Things proxy " +
1207
- "shortcuts — run `things setup shortcuts` once first. By default the heading is " +
1332
+ "shortcuts — run `things setup` once first. By default the heading is " +
1208
1333
  "appended; a placement flag positions it among the project's headings (that leg " +
1209
1334
  "needs `things config set allow-experimental true`)."))).action(async (projectRef, title, opts) => {
1210
1335
  if (countPlacementFlags(opts) > 1) {
@@ -1229,7 +1354,7 @@ export function registerWriteCommands(program) {
1229
1354
  return c.write.renameHeading(h.uuid, opts["to"], writeOptionsFrom(opts));
1230
1355
  });
1231
1356
  });
1232
- addWriteFlags(project
1357
+ addCompoundWriteFlags(project
1233
1358
  .command("archive-heading <project> <heading>")
1234
1359
  .description("Archive a heading — it leaves the active project view (reversible with " +
1235
1360
  "`things project unarchive-heading`). This is the preferred way to retire a heading: " +
@@ -1252,7 +1377,7 @@ export function registerWriteCommands(program) {
1252
1377
  return outcome.heading;
1253
1378
  });
1254
1379
  });
1255
- addWriteFlags(project
1380
+ addCompoundWriteFlags(project
1256
1381
  .command("unarchive-heading <project> <heading>")
1257
1382
  .description("Un-archive a heading. --restore-children also reopens the children the archive " +
1258
1383
  "cascade resolved with it (identified by matching resolution timestamps; a " +
@@ -1367,7 +1492,7 @@ export function registerWriteCommands(program) {
1367
1492
  await runWrite(opts, (c) => c.write.run(op, { uuid }, writeOptionsFrom(opts)));
1368
1493
  });
1369
1494
  }
1370
- addDriveGuiFlag(addRepeatRuleFlags(addWriteFlags(project
1495
+ addDriveGuiFlag(addRepeatRuleFlags(addCompoundWriteFlags(project
1371
1496
  .command("make-repeating <ref>")
1372
1497
  .description("Turn a project into a repeating one. A disposable copy is promoted and the ORIGINAL is " +
1373
1498
  "moved to the Trash — so `things undo` reverses it (it removes the new series and " +
@@ -1385,17 +1510,19 @@ export function registerWriteCommands(program) {
1385
1510
  ...repeatRuleFlagsFromOpts(opts, frequency),
1386
1511
  }, writeOptionsFrom(opts)));
1387
1512
  });
1388
- addDriveGuiFlag(addRepeatCalendarFlags(addWriteFlags(project
1513
+ addDriveGuiFlag(addRepeatCalendarFlags(addCompoundWriteFlags(project
1389
1514
  .command("add-repeating <title>")
1390
1515
  .description("Create a project and turn it into a repeating series in ONE call. Two legs: the project " +
1391
1516
  "is created (notes/area/when/deadline/child to-dos) and PERSISTS even if the promote " +
1392
1517
  "refuses; then it is promoted (which drives the GUI). Give --area to place it, or omit " +
1393
1518
  "it to create in Someday. The new repeating project's uuid is printed; `things undo` " +
1394
1519
  "removes the created series (trash-both).")
1395
- .option("--notes <text>", "notes body")
1520
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1396
1521
  .option("--area <ref>", "destination area (uuid or unique name)")
1397
1522
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
1398
- .option("--deadline <date>", "YYYY-MM-DD")
1523
+ .option("--deadline <date>", "YYYY-MM-DD — deadlines EVERY occurrence: each starts (deadline − --when) days " +
1524
+ "before its own deadline (needs a concrete --when, on or before the deadline; " +
1525
+ "not available with --after-completion)")
1399
1526
  .option("--todo <title>", "initial child to-do (repeatable)", collect, [])
1400
1527
  .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
1401
1528
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (title, opts) => {
@@ -1404,6 +1531,8 @@ export function registerWriteCommands(program) {
1404
1531
  const frequency = opts["frequency"];
1405
1532
  if (opIdCompoundRefused(opts, "project add-repeating"))
1406
1533
  return;
1534
+ if (!(await resolveNotesStdin(opts)))
1535
+ return;
1407
1536
  await runWrite(opts, (c) => c.write.addRepeatingProject({
1408
1537
  title,
1409
1538
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
@@ -1414,16 +1543,21 @@ export function registerWriteCommands(program) {
1414
1543
  ...addRepeatingRuleFieldsFromOpts(opts, frequency, Number(opts["interval"])),
1415
1544
  }, writeOptionsFrom(opts)));
1416
1545
  });
1417
- addDriveGuiFlag(addRepeatCalendarFlags(addWriteFlags(todo
1546
+ addDriveGuiFlag(addRepeatCalendarFlags(addCompoundWriteFlags(todo
1418
1547
  .command("add-repeating <title>")
1419
1548
  .description("Create a to-do and turn it into a repeating series in ONE call. Two legs: the to-do is " +
1420
1549
  "created (notes/tags/when/deadline/reminder/checklist/--created-at) and PERSISTS even " +
1421
1550
  "if the promote refuses; then it is promoted (which drives the GUI). The new repeating " +
1422
1551
  "template's uuid is printed; `things undo` removes the created series (trash-both).")
1423
- .option("--notes <text>", "notes body")
1552
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1424
1553
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
1425
1554
  .option("--reminder <time>", "HH:mm reminder (needs a schedulable --when)")
1426
- .option("--deadline <date>", "YYYY-MM-DD")
1555
+ .option("--deadline <date>", "YYYY-MM-DD — deadlines EVERY occurrence: each starts (deadline − --when) days " +
1556
+ "before its own deadline (needs a concrete --when, on or before the deadline; " +
1557
+ "alternative spelling of --start-days-earlier — give one or the other, both must agree)")
1558
+ .option("--start-days-earlier <n>", "deadlines EVERY occurrence, naming the lead directly: each starts N days before " +
1559
+ "its own deadline (needs a concrete --when; alternative spelling of --deadline <date> — " +
1560
+ "give one or the other, both must agree)")
1427
1561
  .option("--tag <name>", "tag to attach (repeatable)", collect, [])
1428
1562
  .option("--checklist-item <title>", "checklist item (repeatable)", collect, [])
1429
1563
  .option("--project <ref>", "destination project (uuid or unique name)")
@@ -1439,12 +1573,17 @@ export function registerWriteCommands(program) {
1439
1573
  const frequency = opts["frequency"];
1440
1574
  if (opIdCompoundRefused(opts, "todo add-repeating"))
1441
1575
  return;
1576
+ if (!(await resolveNotesStdin(opts)))
1577
+ return;
1442
1578
  await runWrite(opts, (c) => c.write.addRepeatingTodo({
1443
1579
  title,
1444
1580
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
1445
1581
  ...(opts["when"] !== undefined && { when: opts["when"] }),
1446
1582
  ...(opts["reminder"] !== undefined && { reminder: opts["reminder"] }),
1447
1583
  ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
1584
+ ...(opts["startDaysEarlier"] !== undefined && {
1585
+ startDaysEarlier: Number(opts["startDaysEarlier"]),
1586
+ }),
1448
1587
  ...(tags.length > 0 && { tags }),
1449
1588
  ...(checklistItems.length > 0 && { checklistItems }),
1450
1589
  ...(projectRef !== undefined && { project: projectRef }),
@@ -1459,7 +1598,7 @@ export function registerWriteCommands(program) {
1459
1598
  .description("Create a project; its uuid is printed on success. Give --todo (repeatable) to " +
1460
1599
  "seed it with child to-dos in the same call — the quick way to stand up a new " +
1461
1600
  "project skeleton.")
1462
- .option("--notes <text>", "notes body")
1601
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1463
1602
  .option("--area <ref>", "destination area (uuid or unique name)")
1464
1603
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
1465
1604
  .option("--deadline <date>", "YYYY-MM-DD")
@@ -1468,6 +1607,8 @@ export function registerWriteCommands(program) {
1468
1607
  .option("--completed-at <iso>", "born completed (in the Logbook) with this completion timestamp; cannot seed open child to-dos (§5b)")).action(async (title, opts) => {
1469
1608
  const todos = opts["todo"];
1470
1609
  const area = containerRef(opts["area"]);
1610
+ if (!(await resolveNotesStdin(opts)))
1611
+ return;
1471
1612
  await runWrite(opts, (c) => c.write.addProject({
1472
1613
  title,
1473
1614
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
@@ -1487,7 +1628,7 @@ export function registerWriteCommands(program) {
1487
1628
  "--notes). --reminder needs --when today|evening|YYYY-MM-DD; when re-scheduling " +
1488
1629
  "WITHOUT --reminder an existing reminder is auto-preserved."))
1489
1630
  .option("--title <text>", "new title")
1490
- .option("--notes <text>", "replace notes")
1631
+ .option("--notes <text>", `replace the notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1491
1632
  .option("--append-notes <text>", "append to existing notes (newline-joined)")
1492
1633
  .option("--prepend-notes <text>", "prepend to existing notes (newline-joined)")
1493
1634
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
@@ -1498,36 +1639,14 @@ export function registerWriteCommands(program) {
1498
1639
  .option("--created-at <iso>", "rewrite the creation timestamp (ISO date or datetime; a date is noon in the effective zone); status-safe")
1499
1640
  .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")
1500
1641
  .action(async (uuid, opts) => {
1501
- const notesModes = ["notes", "appendNotes", "prependNotes"].filter((k) => opts[k] !== undefined);
1502
- if (notesModes.length > 1) {
1503
- usageError(opts, "--notes, --append-notes, --prepend-notes are exclusive");
1642
+ if (!(await resolveNotesStdin(opts)))
1504
1643
  return;
1505
- }
1506
- if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
1507
- usageError(opts, "pass at most one of --reminder / --clear-reminder");
1644
+ const built = buildUpdatePatch(opts, CLI_UPDATE_LABELS);
1645
+ if (built.kind === "error") {
1646
+ usageError(opts, built.message);
1508
1647
  return;
1509
1648
  }
1510
- if (!whenSugarOk(opts))
1511
- return;
1512
- await runWrite(opts, (c) => c.write.updateProject(uuid, {
1513
- ...(opts["title"] !== undefined && { title: opts["title"] }),
1514
- ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
1515
- ...(opts["appendNotes"] !== undefined && {
1516
- appendNotes: opts["appendNotes"],
1517
- }),
1518
- ...(opts["prependNotes"] !== undefined && {
1519
- prependNotes: opts["prependNotes"],
1520
- }),
1521
- ...(opts["when"] !== undefined && { when: opts["when"] }),
1522
- ...(opts["reminder"] !== undefined && { reminder: opts["reminder"] }),
1523
- ...(opts["clearReminder"] === true && { reminder: null }),
1524
- ...(opts["deadline"] !== undefined && { deadline: opts["deadline"] }),
1525
- ...(opts["clearDeadline"] === true && { deadline: null }),
1526
- ...(opts["createdAt"] !== undefined && { createdAt: opts["createdAt"] }),
1527
- ...(opts["completedAt"] !== undefined && {
1528
- completedAt: opts["completedAt"],
1529
- }),
1530
- }, writeOptionsFrom(opts)));
1649
+ await runWrite(opts, (c) => c.write.updateProject(uuid, built.patch, writeOptionsFrom(opts)));
1531
1650
  });
1532
1651
  addCreateTagsFlag(addWriteFlags(project
1533
1652
  .command("tags <ref>")
@@ -1547,7 +1666,7 @@ export function registerWriteCommands(program) {
1547
1666
  ? c.write.setProjectTags(uuid, set, writeOptionsFrom(opts, createTagsExtra(opts)))
1548
1667
  : c.write.addProjectTags(uuid, add ?? [], writeOptionsFrom(opts, createTagsExtra(opts))));
1549
1668
  });
1550
- addPositionFlags(addWriteFlags(project
1669
+ addPositionFlags(addCompoundWriteFlags(project
1551
1670
  .command("move <refs...>")
1552
1671
  .description("Move one or more projects as an ordered block (argument order = resulting order). " +
1553
1672
  "Pass one destination: --to-area (uuid or unique name) or --no-area (leave the " +
@@ -1593,7 +1712,7 @@ export function registerWriteCommands(program) {
1593
1712
  }),
1594
1713
  }, writeOptionsFrom(opts)));
1595
1714
  });
1596
- addWriteFlags(project
1715
+ addCompoundWriteFlags(project
1597
1716
  .command("reopen <ref>")
1598
1717
  .description("Reopen a completed/canceled project (target by uuid or unique name). Its children " +
1599
1718
  "stay completed/canceled unless " +
@@ -1604,6 +1723,7 @@ export function registerWriteCommands(program) {
1604
1723
  if (opIdCompoundRefused(opts, "project reopen"))
1605
1724
  return;
1606
1725
  const started = Date.now();
1726
+ const endInvocation = beginWriteInvocation(opts.json === true, started);
1607
1727
  let client = null;
1608
1728
  try {
1609
1729
  client = openThings(opts.db ? { dbPath: opts.db } : {});
@@ -1640,6 +1760,7 @@ export function registerWriteCommands(program) {
1640
1760
  }
1641
1761
  finally {
1642
1762
  client?.close();
1763
+ endInvocation();
1643
1764
  }
1644
1765
  });
1645
1766
  addWriteFlags(project
@@ -1688,7 +1809,10 @@ export function registerWriteCommands(program) {
1688
1809
  addWriteFlags(project
1689
1810
  .command("delete <ref>")
1690
1811
  .description("Move a project (target by uuid or unique name) to the Trash; its children go with " +
1691
- "it (recover with `things project restore`).")).action(async (uuid, opts) => {
1812
+ "it (recover with `things project restore`). Deleting a repeating series' TEMPLATE is " +
1813
+ "allowed and ends the series: it stops generating new occurrences and its existing " +
1814
+ "occurrences are left in place. That cannot be undone here — the series comes back " +
1815
+ "only via Put Back in the Things app's Trash — so no undo token is returned.")).action(async (uuid, opts) => {
1692
1816
  await runWrite(opts, (c) => c.write.deleteProject(uuid, writeOptionsFrom(opts)));
1693
1817
  });
1694
1818
  const area = group(program, "area", "Area-scoped operations");
@@ -1858,9 +1982,11 @@ export function registerWriteCommands(program) {
1858
1982
  .description("Run MANY mutations from JSONL (file, or stdin when omitted/'-'): one op per line, " +
1859
1983
  '{"op": "<kind>", "params": {...}, "options": {...}} — see `things capabilities` for ' +
1860
1984
  "op kinds and params. Ops run sequentially and independently — NO transactions; a " +
1861
- "failure does not roll back earlier ops. A statically-invalid line (bad shape, unknown " +
1862
- "op, a $ref to an undeclared/forward tempId, a duplicate tempId) refuses the WHOLE " +
1863
- "batch before anything runs, naming every bad line. Otherwise ops run and per-op " +
1985
+ "failure does not roll back earlier ops. A statically-invalid line (unknown op, an " +
1986
+ "unknown or wrongly-typed param, a $ref to an undeclared/forward tempId, a duplicate " +
1987
+ "tempId) refuses the WHOLE batch before anything runs, naming every bad line and the " +
1988
+ 'field it names. A container param takes an object — {"project": {"uuid": "…"}} — ' +
1989
+ "never a bare string. Otherwise ops run and per-op " +
1864
1990
  "results stream as JSONL. Per-op " +
1865
1991
  "options carry the confirmation flags (acknowledgeChecklistReset, " +
1866
1992
  "acknowledgeProjectReopen, dangerouslyPermanent, acknowledgeTagSubtree). " +
@@ -1873,12 +1999,18 @@ export function registerWriteCommands(program) {
1873
1999
  "re-created (put an opId on EVERY line so a stopped batch can be resubmitted verbatim " +
1874
2000
  "to resume). The trailing summary line adds `tempIdMapping` (handle → uuid) and " +
1875
2001
  "`undoToken` — undo the WHOLE batch with `things undo --txn <undoToken>`. " +
2002
+ "TIMELINE: --preserve-modified applies to every line (each line may override it with its " +
2003
+ 'own `"options": {"preserveModified": true|false}`), so a bulk re-tag stays off the ' +
2004
+ "modification-date timeline under one undo token; each line's result then carries " +
2005
+ "`preservedModified` (and `preserveFailures` for any restore that did not land). " +
1876
2006
  "By DEFAULT a runtime failure STOPS the batch (later lines reported not-run, with resume " +
1877
2007
  "guidance in the summary); --continue-on-error runs past failures. --dry-run plans " +
1878
2008
  "everything without executing. Exit (worst failure wins): 0 all ok · 3 any " +
1879
2009
  "verify-failed/invalid · 4 any blocked · 5 any drift-blocked · 6 any unsupported.")
1880
2010
  .option("--dry-run", "plan every op; execute nothing")
1881
2011
  .option("--continue-on-error", "run past a failed op instead of stopping (default: stop)")
2012
+ .option("--preserve-modified", `${PRESERVE_MODIFIED_HELP} Applies to EVERY line; a line that sets its own ` +
2013
+ '`"options": {"preserveModified": false}` opts back onto the timeline.')
1882
2014
  .option("--json", "JSONL results + summary on stdout (also the default)")
1883
2015
  .option("--db <path>", "explicit database path")
1884
2016
  .option("--actor <name>", "author name recorded for the whole batch")
@@ -1914,12 +2046,18 @@ export function registerWriteCommands(program) {
1914
2046
  });
1915
2047
  }
1916
2048
  }
2049
+ // batch streams JSONL regardless of --json, so the interrupt guard is
2050
+ // armed machine-readable.
2051
+ const endInvocation = beginWriteInvocation(true, Date.now());
1917
2052
  let client = null;
1918
2053
  try {
1919
2054
  client = openThings(opts.db ? { dbPath: opts.db } : {});
1920
2055
  const batchResult = await client.write.batch(ops, {
1921
2056
  ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
1922
2057
  ...(opts["continueOnError"] === true && { continueOnError: true }),
2058
+ // Run-level default for every line; a line's own explicit
2059
+ // `options.preserveModified` outranks it (src/write/batch.ts).
2060
+ ...(opts.preserveModified === true && { preserveModified: true }),
1923
2061
  ...(opts.actor !== undefined && { actor: opts.actor }),
1924
2062
  }, (r) => {
1925
2063
  const pre = preInvalid.find((p) => p.index === r.index);
@@ -1951,6 +2089,7 @@ export function registerWriteCommands(program) {
1951
2089
  }
1952
2090
  finally {
1953
2091
  client?.close();
2092
+ endInvocation();
1954
2093
  }
1955
2094
  });
1956
2095
  program
@@ -1998,6 +2137,9 @@ export function registerWriteCommands(program) {
1998
2137
  usageError(opts, "--txn cannot be combined with --last or --by");
1999
2138
  return;
2000
2139
  }
2140
+ // undo streams JSONL regardless of --json, so the interrupt guard is armed
2141
+ // machine-readable (each inverse is a real write, GUI drives included).
2142
+ const endInvocation = beginWriteInvocation(true, Date.now());
2001
2143
  let client = null;
2002
2144
  try {
2003
2145
  client = openThings(opts.db ? { dbPath: opts.db } : {});
@@ -2054,9 +2196,10 @@ export function registerWriteCommands(program) {
2054
2196
  }
2055
2197
  finally {
2056
2198
  client?.close();
2199
+ endInvocation();
2057
2200
  }
2058
2201
  });
2059
- addReorderPositionFlags(addDriveGuiFlag(addWriteFlags(program
2202
+ addReorderPositionFlags(addDriveGuiFlag(addCompoundWriteFlags(program
2060
2203
  .command("reorder <refs...>")
2061
2204
  .description("The ONE reorder verb — rearrange to-dos, projects, headings, OR sidebar areas IN " +
2062
2205
  "PLACE (REARRANGES, never changes membership; to change what an item belongs to use " +
@@ -2155,7 +2298,8 @@ export function registerWriteCommands(program) {
2155
2298
  .command("set <key> <value>")
2156
2299
  .description("Persist a config key: profile | maxDisruption | actor | auditEnabled | " +
2157
2300
  "accepted-fingerprint | certified-app-version | allow-experimental | bounce-enabled | " +
2158
- "bounce-max-items | auto-launch | ui-enabled | ui-drive-budget-ms | trace | scope")
2301
+ "bounce-max-items | auto-launch | helpers-enabled (auto | true | false) | ui-enabled | " +
2302
+ "ui-drive-budget-ms | trace | scope")
2159
2303
  .action((key, value, opts) => {
2160
2304
  const map = {
2161
2305
  profile: "profile",
@@ -2168,6 +2312,7 @@ export function registerWriteCommands(program) {
2168
2312
  "bounce-enabled": "bounceEnabled",
2169
2313
  "bounce-max-items": "bounceMaxItems",
2170
2314
  "auto-launch": "autoLaunch",
2315
+ "helpers-enabled": "helpersMode",
2171
2316
  "ui-enabled": "uiEnabled",
2172
2317
  "ui-drive-budget-ms": "uiDriveBudgetMs",
2173
2318
  trace: "traceEnabled",
@@ -2179,6 +2324,14 @@ export function registerWriteCommands(program) {
2179
2324
  process.exitCode = ExitCode.Usage;
2180
2325
  return;
2181
2326
  }
2327
+ // `helpers-enabled` is TRI-state (auto | true | false): anything else is
2328
+ // refused rather than silently coerced to a boolean, so a typo can never
2329
+ // read as "off" on a machine that meant "auto".
2330
+ if (target === "helpersMode" && parseHelpersMode(value) === undefined) {
2331
+ process.stderr.write(`error: helpers-enabled accepts ${HELPERS_MODES.join(" | ")} (got "${value}")\n`);
2332
+ process.exitCode = ExitCode.Usage;
2333
+ return;
2334
+ }
2182
2335
  const parsed = target === "maxDisruption" || target === "bounceMaxItems" || target === "uiDriveBudgetMs"
2183
2336
  ? Number(value)
2184
2337
  : target === "auditEnabled" ||