things-api 0.18.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 (191) hide show
  1. package/README.md +16 -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 +13 -1
  20. package/dist/audit/schema.js.map +1 -1
  21. package/dist/capability.d.ts +160 -0
  22. package/dist/capability.js +416 -0
  23. package/dist/capability.js.map +1 -0
  24. package/dist/cli/commands/doctor.js +127 -1
  25. package/dist/cli/commands/doctor.js.map +1 -1
  26. package/dist/cli/commands/helpers.js +178 -51
  27. package/dist/cli/commands/helpers.js.map +1 -1
  28. package/dist/cli/commands/op-result.js +6 -0
  29. package/dist/cli/commands/op-result.js.map +1 -1
  30. package/dist/cli/commands/reads.js +47 -1
  31. package/dist/cli/commands/reads.js.map +1 -1
  32. package/dist/cli/commands/setup.d.ts +9 -0
  33. package/dist/cli/commands/setup.js +63 -83
  34. package/dist/cli/commands/setup.js.map +1 -1
  35. package/dist/cli/commands/writes.d.ts +1 -1
  36. package/dist/cli/commands/writes.js +241 -107
  37. package/dist/cli/commands/writes.js.map +1 -1
  38. package/dist/cli/dry-run.d.ts +1 -1
  39. package/dist/cli/dry-run.js +1 -1
  40. package/dist/cli/glyphs.d.ts +23 -4
  41. package/dist/cli/glyphs.js +46 -19
  42. package/dist/cli/glyphs.js.map +1 -1
  43. package/dist/cli/help.d.ts +1 -1
  44. package/dist/cli/help.js +49 -27
  45. package/dist/cli/help.js.map +1 -1
  46. package/dist/cli/period.d.ts +3 -1
  47. package/dist/cli/period.js +1 -1
  48. package/dist/cli/period.js.map +1 -1
  49. package/dist/cli/read-driver.js +26 -1
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/render.d.ts +31 -4
  52. package/dist/cli/render.js +127 -15
  53. package/dist/cli/render.js.map +1 -1
  54. package/dist/client.d.ts +22 -2
  55. package/dist/client.js +36 -13
  56. package/dist/client.js.map +1 -1
  57. package/dist/contracts.d.ts +6 -2
  58. package/dist/contracts.js +1 -1
  59. package/dist/contracts.js.map +1 -1
  60. package/dist/deputy/install.d.ts +219 -5
  61. package/dist/deputy/install.js +803 -31
  62. package/dist/deputy/install.js.map +1 -1
  63. package/dist/deputy/notices.js +2 -2
  64. package/dist/deputy/notices.js.map +1 -1
  65. package/dist/deputy/protocol.d.ts +59 -5
  66. package/dist/deputy/protocol.js +48 -11
  67. package/dist/deputy/protocol.js.map +1 -1
  68. package/dist/deputy/routing.d.ts +21 -0
  69. package/dist/deputy/routing.js +111 -27
  70. package/dist/deputy/routing.js.map +1 -1
  71. package/dist/diagnose.d.ts +14 -1
  72. package/dist/diagnose.js +46 -6
  73. package/dist/diagnose.js.map +1 -1
  74. package/dist/direct-setup.d.ts +78 -0
  75. package/dist/direct-setup.js +484 -0
  76. package/dist/direct-setup.js.map +1 -0
  77. package/dist/host-access.d.ts +63 -0
  78. package/dist/host-access.js +165 -0
  79. package/dist/host-access.js.map +1 -0
  80. package/dist/index.d.ts +20 -5
  81. package/dist/index.js +14 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/mcp/server.d.ts +27 -1
  84. package/dist/mcp/server.js +285 -100
  85. package/dist/mcp/server.js.map +1 -1
  86. package/dist/op-result.d.ts +9 -0
  87. package/dist/op-result.js +5 -0
  88. package/dist/op-result.js.map +1 -1
  89. package/dist/read/filter-contract.d.ts +1 -1
  90. package/dist/read/filter-contract.js +4 -0
  91. package/dist/read/filter-contract.js.map +1 -1
  92. package/dist/read/shape.js +4 -0
  93. package/dist/read/shape.js.map +1 -1
  94. package/dist/read/views.d.ts +29 -0
  95. package/dist/read/views.js +60 -0
  96. package/dist/read/views.js.map +1 -1
  97. package/dist/session-grant.d.ts +48 -0
  98. package/dist/session-grant.js +170 -0
  99. package/dist/session-grant.js.map +1 -0
  100. package/dist/surface-copy.d.ts +8 -0
  101. package/dist/surface-copy.js +8 -0
  102. package/dist/surface-copy.js.map +1 -1
  103. package/dist/wizard.d.ts +58 -0
  104. package/dist/wizard.js +167 -0
  105. package/dist/wizard.js.map +1 -0
  106. package/dist/write/availability.d.ts +1 -1
  107. package/dist/write/availability.js +3 -3
  108. package/dist/write/availability.js.map +1 -1
  109. package/dist/write/batch.d.ts +20 -0
  110. package/dist/write/batch.js +94 -15
  111. package/dist/write/batch.js.map +1 -1
  112. package/dist/write/capabilities.d.ts +9 -0
  113. package/dist/write/capabilities.js +2 -0
  114. package/dist/write/capabilities.js.map +1 -1
  115. package/dist/write/clear-reminder.js +1 -1
  116. package/dist/write/clear-reminder.js.map +1 -1
  117. package/dist/write/commands.d.ts +12 -1
  118. package/dist/write/commands.js +112 -224
  119. package/dist/write/commands.js.map +1 -1
  120. package/dist/write/failure-hints.d.ts +3 -1
  121. package/dist/write/failure-hints.js.map +1 -1
  122. package/dist/write/guards.js +19 -2
  123. package/dist/write/guards.js.map +1 -1
  124. package/dist/write/lock.d.ts +16 -13
  125. package/dist/write/lock.js +55 -0
  126. package/dist/write/lock.js.map +1 -1
  127. package/dist/write/make-repeating-project.js +9 -13
  128. package/dist/write/make-repeating-project.js.map +1 -1
  129. package/dist/write/operations.d.ts +36 -21
  130. package/dist/write/operations.js +7 -0
  131. package/dist/write/operations.js.map +1 -1
  132. package/dist/write/opid.d.ts +14 -0
  133. package/dist/write/opid.js +22 -0
  134. package/dist/write/opid.js.map +1 -1
  135. package/dist/write/param-schema.d.ts +124 -0
  136. package/dist/write/param-schema.js +747 -0
  137. package/dist/write/param-schema.js.map +1 -0
  138. package/dist/write/pipeline.d.ts +76 -2
  139. package/dist/write/pipeline.js +190 -2
  140. package/dist/write/pipeline.js.map +1 -1
  141. package/dist/write/pre-state.d.ts +8 -0
  142. package/dist/write/pre-state.js +29 -2
  143. package/dist/write/pre-state.js.map +1 -1
  144. package/dist/write/promote-clone.js +539 -439
  145. package/dist/write/promote-clone.js.map +1 -1
  146. package/dist/write/reorder.js +25 -8
  147. package/dist/write/reorder.js.map +1 -1
  148. package/dist/write/repeat-rule.d.ts +28 -1
  149. package/dist/write/repeat-rule.js +54 -4
  150. package/dist/write/repeat-rule.js.map +1 -1
  151. package/dist/write/resolution-timestamps.d.ts +11 -0
  152. package/dist/write/resolution-timestamps.js +83 -38
  153. package/dist/write/resolution-timestamps.js.map +1 -1
  154. package/dist/write/reversibility.js +4 -0
  155. package/dist/write/reversibility.js.map +1 -1
  156. package/dist/write/template-mutation.d.ts +137 -0
  157. package/dist/write/template-mutation.js +451 -0
  158. package/dist/write/template-mutation.js.map +1 -0
  159. package/dist/write/undo.js +10 -20
  160. package/dist/write/undo.js.map +1 -1
  161. package/dist/write/update-fields.d.ts +151 -0
  162. package/dist/write/update-fields.js +427 -0
  163. package/dist/write/update-fields.js.map +1 -0
  164. package/dist/write/vectors/applescript.js +3 -0
  165. package/dist/write/vectors/applescript.js.map +1 -1
  166. package/dist/write/vectors/registry.d.ts +1 -1
  167. package/dist/write/vectors/registry.js +10 -2
  168. package/dist/write/vectors/registry.js.map +1 -1
  169. package/dist/write/vectors/shortcuts.js +2 -2
  170. package/dist/write/vectors/shortcuts.js.map +1 -1
  171. package/dist/write/vectors/simulator.js +7 -2
  172. package/dist/write/vectors/simulator.js.map +1 -1
  173. package/dist/write/vectors/types.d.ts +57 -0
  174. package/dist/write/vectors/ui-recipes.d.ts +19 -0
  175. package/dist/write/vectors/ui-recipes.js +30 -0
  176. package/dist/write/vectors/ui-recipes.js.map +1 -1
  177. package/dist/write/vectors/ui.d.ts +13 -0
  178. package/dist/write/vectors/ui.js +62 -13
  179. package/dist/write/vectors/ui.js.map +1 -1
  180. package/dist/write/verify/delta.d.ts +21 -0
  181. package/dist/write/verify/delta.js.map +1 -1
  182. package/package.json +2 -1
  183. package/schema/envelope.schema.json +5 -3
  184. package/scripts/build-helpers.sh +5 -0
  185. package/skills/things-cli/SKILL.md +5 -3
  186. package/skills/things-cli/references/contracts.md +1 -0
  187. package/skills/things-cli/references/data-model.md +3 -0
  188. package/skills/things-cli/references/errors.md +1 -1
  189. package/skills/things-cli/references/gui.md +1 -0
  190. package/skills/things-cli/references/repeating.md +6 -0
  191. 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, 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, 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
  }
@@ -432,6 +497,7 @@ async function runMoveCmd(opts, fn) {
432
497
  return;
433
498
  }
434
499
  const started = Date.now();
500
+ const endInvocation = beginWriteInvocation(opts.json === true, started);
435
501
  let client = null;
436
502
  const meta = () => {
437
503
  let dbVersion = null;
@@ -469,6 +535,7 @@ async function runMoveCmd(opts, fn) {
469
535
  }
470
536
  finally {
471
537
  client?.close();
538
+ endInvocation();
472
539
  }
473
540
  }
474
541
  function emitMoveResult(result, opts, meta) {
@@ -690,6 +757,7 @@ function addResultLine(r) {
690
757
  * in creation order, and nothing else. Exit code is the worst leg's failure.
691
758
  */
692
759
  async function runBulkAdd(opts, ops, idOnly) {
760
+ const endInvocation = beginWriteInvocation(opts.json === true, Date.now());
693
761
  let client = null;
694
762
  try {
695
763
  client = openThings(opts.db ? { dbPath: opts.db } : {});
@@ -735,6 +803,7 @@ async function runBulkAdd(opts, ops, idOnly) {
735
803
  }
736
804
  finally {
737
805
  client?.close();
806
+ endInvocation();
738
807
  }
739
808
  }
740
809
  /** Read newline-delimited titles from stdin; blank lines (whitespace-only) skipped. */
@@ -748,6 +817,45 @@ async function readStdinTitles() {
748
817
  .map((l) => l.replace(/\r$/, ""))
749
818
  .filter((l) => l.trim() !== "");
750
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
+ }
751
859
  function group(program, name, description) {
752
860
  const existing = program.commands.find((c) => c.name() === name);
753
861
  if (existing !== undefined)
@@ -772,7 +880,7 @@ export function registerWriteCommands(program) {
772
880
  "--create-tags. Adding into a completed/canceled project reopens that project — " +
773
881
  "requires --acknowledge-project-reopen. When several to-dos are created, one undo " +
774
882
  "token removes the whole skeleton at once.")
775
- .option("--notes <text>", "notes body")
883
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
776
884
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
777
885
  .option("--reminder <HH:mm>", "time-of-day reminder (24h); requires --when today|evening|YYYY-MM-DD")
778
886
  .option("--deadline <date>", "YYYY-MM-DD")
@@ -792,6 +900,8 @@ export function registerWriteCommands(program) {
792
900
  usageError(opts, "--id-only and --json are mutually exclusive");
793
901
  return;
794
902
  }
903
+ if (!(await resolveNotesStdin(opts, true)))
904
+ return;
795
905
  if (useStdin && titles.length > 0) {
796
906
  usageError(opts, "--stdin is mutually exclusive with title arguments");
797
907
  return;
@@ -884,15 +994,17 @@ export function registerWriteCommands(program) {
884
994
  });
885
995
  addWriteFlags(todo
886
996
  .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 " +
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 " +
889
1001
  "today|evening|YYYY-MM-DD; when re-scheduling WITHOUT --reminder an existing " +
890
1002
  "reminder is auto-preserved. --clear-reminder works while the to-do is scheduled " +
891
1003
  "for today|evening — a DATED reminder can only be changed, not cleared " +
892
1004
  "(re-schedule to today first). --append-notes/--prepend-notes join with a " +
893
1005
  "newline (exclusive with --notes).")
894
1006
  .option("--title <text>", "new title")
895
- .option("--notes <text>", "replace notes")
1007
+ .option("--notes <text>", `replace the notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
896
1008
  .option("--append-notes <text>", "append to existing notes (newline-joined)")
897
1009
  .option("--prepend-notes <text>", "prepend to existing notes (newline-joined)")
898
1010
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
@@ -901,33 +1013,35 @@ export function registerWriteCommands(program) {
901
1013
  .option("--deadline <date>", "YYYY-MM-DD")
902
1014
  .option("--clear-deadline", "remove the deadline")
903
1015
  .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");
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)))
908
1024
  return;
909
- }
910
- if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
911
- 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);
912
1030
  return;
913
1031
  }
914
- 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)));
915
1042
  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)));
1043
+ }
1044
+ await runWrite(opts, (c) => c.write.updateTodo(uuid, built.patch, writeOptionsFrom(opts)));
931
1045
  });
932
1046
  for (const verb of ["complete", "cancel"]) {
933
1047
  const method = verb === "complete" ? "completeTodo" : "cancelTodo";
@@ -936,7 +1050,13 @@ export function registerWriteCommands(program) {
936
1050
  : "keeping it canceled via the certified flip legs";
937
1051
  addWriteFlags(todo
938
1052
  .command(`${verb} <uuid>`)
939
- .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. " +
940
1060
  '--completed-at sets the completion timestamp (also the "Completed on" stamp for a canceled ' +
941
1061
  `item), ${stampNote} — a multi-leg sequence, disclosed in the result and --dry-run.`)
942
1062
  .option("--completed-at <iso>", "completion timestamp (ISO date or datetime; a date is noon in the effective zone)")).action(async (uuid, opts) => {
@@ -949,7 +1069,7 @@ export function registerWriteCommands(program) {
949
1069
  .description("Reopen a to-do. Not available for repeating to-dos.")).action(async (uuid, opts) => {
950
1070
  await runWrite(opts, (c) => c.write.reopenTodo(uuid, writeOptionsFrom(opts)));
951
1071
  });
952
- addPositionFlags(addWriteFlags(todo
1072
+ addPositionFlags(addCompoundWriteFlags(todo
953
1073
  .command("move <refs...>")
954
1074
  .description("Move one or more to-dos as an ordered block (the argument order is the order they " +
955
1075
  "land — name them backwards to reverse). MOVE changes WHAT a to-do belongs to; to " +
@@ -1109,8 +1229,12 @@ export function registerWriteCommands(program) {
1109
1229
  });
1110
1230
  addWriteFlags(todo
1111
1231
  .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) => {
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) => {
1114
1238
  await runWrite(opts, (c) => c.write.deleteTodo(uuid, writeOptionsFrom(opts)));
1115
1239
  });
1116
1240
  addWriteFlags(todo
@@ -1123,7 +1247,7 @@ export function registerWriteCommands(program) {
1123
1247
  addWriteFlags(todo
1124
1248
  .command("clear-reminder <uuid>")
1125
1249
  .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 " +
1250
+ "shortcuts installed (`things setup`) this is in place, and is the only " +
1127
1251
  "way for a repeating to-do; otherwise a date-scheduled to-do is cleared by a brief " +
1128
1252
  "re-schedule through Today. Reversible with `things undo`.")).action(async (uuid, opts) => {
1129
1253
  if (opIdCompoundRefused(opts, "clear-reminder"))
@@ -1201,11 +1325,11 @@ export function registerWriteCommands(program) {
1201
1325
  // silently re-targets a different heading after any reorder). An empty-string
1202
1326
  // title selects a titleless heading; duplicates fail closed with uuid
1203
1327
  // candidates.
1204
- addPlacementFlags(addWriteFlags(project
1328
+ addPlacementFlags(addCompoundWriteFlags(project
1205
1329
  .command("add-heading <project> <title>")
1206
1330
  .description("Create a heading inside an existing project; its uuid is printed on success. The " +
1207
1331
  "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 " +
1332
+ "shortcuts — run `things setup` once first. By default the heading is " +
1209
1333
  "appended; a placement flag positions it among the project's headings (that leg " +
1210
1334
  "needs `things config set allow-experimental true`)."))).action(async (projectRef, title, opts) => {
1211
1335
  if (countPlacementFlags(opts) > 1) {
@@ -1230,7 +1354,7 @@ export function registerWriteCommands(program) {
1230
1354
  return c.write.renameHeading(h.uuid, opts["to"], writeOptionsFrom(opts));
1231
1355
  });
1232
1356
  });
1233
- addWriteFlags(project
1357
+ addCompoundWriteFlags(project
1234
1358
  .command("archive-heading <project> <heading>")
1235
1359
  .description("Archive a heading — it leaves the active project view (reversible with " +
1236
1360
  "`things project unarchive-heading`). This is the preferred way to retire a heading: " +
@@ -1253,7 +1377,7 @@ export function registerWriteCommands(program) {
1253
1377
  return outcome.heading;
1254
1378
  });
1255
1379
  });
1256
- addWriteFlags(project
1380
+ addCompoundWriteFlags(project
1257
1381
  .command("unarchive-heading <project> <heading>")
1258
1382
  .description("Un-archive a heading. --restore-children also reopens the children the archive " +
1259
1383
  "cascade resolved with it (identified by matching resolution timestamps; a " +
@@ -1368,7 +1492,7 @@ export function registerWriteCommands(program) {
1368
1492
  await runWrite(opts, (c) => c.write.run(op, { uuid }, writeOptionsFrom(opts)));
1369
1493
  });
1370
1494
  }
1371
- addDriveGuiFlag(addRepeatRuleFlags(addWriteFlags(project
1495
+ addDriveGuiFlag(addRepeatRuleFlags(addCompoundWriteFlags(project
1372
1496
  .command("make-repeating <ref>")
1373
1497
  .description("Turn a project into a repeating one. A disposable copy is promoted and the ORIGINAL is " +
1374
1498
  "moved to the Trash — so `things undo` reverses it (it removes the new series and " +
@@ -1386,17 +1510,19 @@ export function registerWriteCommands(program) {
1386
1510
  ...repeatRuleFlagsFromOpts(opts, frequency),
1387
1511
  }, writeOptionsFrom(opts)));
1388
1512
  });
1389
- addDriveGuiFlag(addRepeatCalendarFlags(addWriteFlags(project
1513
+ addDriveGuiFlag(addRepeatCalendarFlags(addCompoundWriteFlags(project
1390
1514
  .command("add-repeating <title>")
1391
1515
  .description("Create a project and turn it into a repeating series in ONE call. Two legs: the project " +
1392
1516
  "is created (notes/area/when/deadline/child to-dos) and PERSISTS even if the promote " +
1393
1517
  "refuses; then it is promoted (which drives the GUI). Give --area to place it, or omit " +
1394
1518
  "it to create in Someday. The new repeating project's uuid is printed; `things undo` " +
1395
1519
  "removes the created series (trash-both).")
1396
- .option("--notes <text>", "notes body")
1520
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1397
1521
  .option("--area <ref>", "destination area (uuid or unique name)")
1398
1522
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
1399
- .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)")
1400
1526
  .option("--todo <title>", "initial child to-do (repeatable)", collect, [])
1401
1527
  .requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
1402
1528
  .requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (title, opts) => {
@@ -1405,6 +1531,8 @@ export function registerWriteCommands(program) {
1405
1531
  const frequency = opts["frequency"];
1406
1532
  if (opIdCompoundRefused(opts, "project add-repeating"))
1407
1533
  return;
1534
+ if (!(await resolveNotesStdin(opts)))
1535
+ return;
1408
1536
  await runWrite(opts, (c) => c.write.addRepeatingProject({
1409
1537
  title,
1410
1538
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
@@ -1415,13 +1543,13 @@ export function registerWriteCommands(program) {
1415
1543
  ...addRepeatingRuleFieldsFromOpts(opts, frequency, Number(opts["interval"])),
1416
1544
  }, writeOptionsFrom(opts)));
1417
1545
  });
1418
- addDriveGuiFlag(addRepeatCalendarFlags(addWriteFlags(todo
1546
+ addDriveGuiFlag(addRepeatCalendarFlags(addCompoundWriteFlags(todo
1419
1547
  .command("add-repeating <title>")
1420
1548
  .description("Create a to-do and turn it into a repeating series in ONE call. Two legs: the to-do is " +
1421
1549
  "created (notes/tags/when/deadline/reminder/checklist/--created-at) and PERSISTS even " +
1422
1550
  "if the promote refuses; then it is promoted (which drives the GUI). The new repeating " +
1423
1551
  "template's uuid is printed; `things undo` removes the created series (trash-both).")
1424
- .option("--notes <text>", "notes body")
1552
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1425
1553
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
1426
1554
  .option("--reminder <time>", "HH:mm reminder (needs a schedulable --when)")
1427
1555
  .option("--deadline <date>", "YYYY-MM-DD — deadlines EVERY occurrence: each starts (deadline − --when) days " +
@@ -1445,6 +1573,8 @@ export function registerWriteCommands(program) {
1445
1573
  const frequency = opts["frequency"];
1446
1574
  if (opIdCompoundRefused(opts, "todo add-repeating"))
1447
1575
  return;
1576
+ if (!(await resolveNotesStdin(opts)))
1577
+ return;
1448
1578
  await runWrite(opts, (c) => c.write.addRepeatingTodo({
1449
1579
  title,
1450
1580
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
@@ -1468,7 +1598,7 @@ export function registerWriteCommands(program) {
1468
1598
  .description("Create a project; its uuid is printed on success. Give --todo (repeatable) to " +
1469
1599
  "seed it with child to-dos in the same call — the quick way to stand up a new " +
1470
1600
  "project skeleton.")
1471
- .option("--notes <text>", "notes body")
1601
+ .option("--notes <text>", `notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1472
1602
  .option("--area <ref>", "destination area (uuid or unique name)")
1473
1603
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
1474
1604
  .option("--deadline <date>", "YYYY-MM-DD")
@@ -1477,6 +1607,8 @@ export function registerWriteCommands(program) {
1477
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) => {
1478
1608
  const todos = opts["todo"];
1479
1609
  const area = containerRef(opts["area"]);
1610
+ if (!(await resolveNotesStdin(opts)))
1611
+ return;
1480
1612
  await runWrite(opts, (c) => c.write.addProject({
1481
1613
  title,
1482
1614
  ...(opts["notes"] !== undefined && { notes: opts["notes"] }),
@@ -1496,7 +1628,7 @@ export function registerWriteCommands(program) {
1496
1628
  "--notes). --reminder needs --when today|evening|YYYY-MM-DD; when re-scheduling " +
1497
1629
  "WITHOUT --reminder an existing reminder is auto-preserved."))
1498
1630
  .option("--title <text>", "new title")
1499
- .option("--notes <text>", "replace notes")
1631
+ .option("--notes <text>", `replace the notes body — ${NOTES_FORMAT} (${NOTES_INPUT})`)
1500
1632
  .option("--append-notes <text>", "append to existing notes (newline-joined)")
1501
1633
  .option("--prepend-notes <text>", "prepend to existing notes (newline-joined)")
1502
1634
  .option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
@@ -1507,36 +1639,14 @@ export function registerWriteCommands(program) {
1507
1639
  .option("--created-at <iso>", "rewrite the creation timestamp (ISO date or datetime; a date is noon in the effective zone); status-safe")
1508
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")
1509
1641
  .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");
1642
+ if (!(await resolveNotesStdin(opts)))
1513
1643
  return;
1514
- }
1515
- if (opts["reminder"] !== undefined && opts["clearReminder"] === true) {
1516
- 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);
1517
1647
  return;
1518
1648
  }
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)));
1649
+ await runWrite(opts, (c) => c.write.updateProject(uuid, built.patch, writeOptionsFrom(opts)));
1540
1650
  });
1541
1651
  addCreateTagsFlag(addWriteFlags(project
1542
1652
  .command("tags <ref>")
@@ -1556,7 +1666,7 @@ export function registerWriteCommands(program) {
1556
1666
  ? c.write.setProjectTags(uuid, set, writeOptionsFrom(opts, createTagsExtra(opts)))
1557
1667
  : c.write.addProjectTags(uuid, add ?? [], writeOptionsFrom(opts, createTagsExtra(opts))));
1558
1668
  });
1559
- addPositionFlags(addWriteFlags(project
1669
+ addPositionFlags(addCompoundWriteFlags(project
1560
1670
  .command("move <refs...>")
1561
1671
  .description("Move one or more projects as an ordered block (argument order = resulting order). " +
1562
1672
  "Pass one destination: --to-area (uuid or unique name) or --no-area (leave the " +
@@ -1602,7 +1712,7 @@ export function registerWriteCommands(program) {
1602
1712
  }),
1603
1713
  }, writeOptionsFrom(opts)));
1604
1714
  });
1605
- addWriteFlags(project
1715
+ addCompoundWriteFlags(project
1606
1716
  .command("reopen <ref>")
1607
1717
  .description("Reopen a completed/canceled project (target by uuid or unique name). Its children " +
1608
1718
  "stay completed/canceled unless " +
@@ -1613,6 +1723,7 @@ export function registerWriteCommands(program) {
1613
1723
  if (opIdCompoundRefused(opts, "project reopen"))
1614
1724
  return;
1615
1725
  const started = Date.now();
1726
+ const endInvocation = beginWriteInvocation(opts.json === true, started);
1616
1727
  let client = null;
1617
1728
  try {
1618
1729
  client = openThings(opts.db ? { dbPath: opts.db } : {});
@@ -1649,6 +1760,7 @@ export function registerWriteCommands(program) {
1649
1760
  }
1650
1761
  finally {
1651
1762
  client?.close();
1763
+ endInvocation();
1652
1764
  }
1653
1765
  });
1654
1766
  addWriteFlags(project
@@ -1697,7 +1809,10 @@ export function registerWriteCommands(program) {
1697
1809
  addWriteFlags(project
1698
1810
  .command("delete <ref>")
1699
1811
  .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) => {
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) => {
1701
1816
  await runWrite(opts, (c) => c.write.deleteProject(uuid, writeOptionsFrom(opts)));
1702
1817
  });
1703
1818
  const area = group(program, "area", "Area-scoped operations");
@@ -1867,9 +1982,11 @@ export function registerWriteCommands(program) {
1867
1982
  .description("Run MANY mutations from JSONL (file, or stdin when omitted/'-'): one op per line, " +
1868
1983
  '{"op": "<kind>", "params": {...}, "options": {...}} — see `things capabilities` for ' +
1869
1984
  "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 " +
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 " +
1873
1990
  "results stream as JSONL. Per-op " +
1874
1991
  "options carry the confirmation flags (acknowledgeChecklistReset, " +
1875
1992
  "acknowledgeProjectReopen, dangerouslyPermanent, acknowledgeTagSubtree). " +
@@ -1882,12 +1999,18 @@ export function registerWriteCommands(program) {
1882
1999
  "re-created (put an opId on EVERY line so a stopped batch can be resubmitted verbatim " +
1883
2000
  "to resume). The trailing summary line adds `tempIdMapping` (handle → uuid) and " +
1884
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). " +
1885
2006
  "By DEFAULT a runtime failure STOPS the batch (later lines reported not-run, with resume " +
1886
2007
  "guidance in the summary); --continue-on-error runs past failures. --dry-run plans " +
1887
2008
  "everything without executing. Exit (worst failure wins): 0 all ok · 3 any " +
1888
2009
  "verify-failed/invalid · 4 any blocked · 5 any drift-blocked · 6 any unsupported.")
1889
2010
  .option("--dry-run", "plan every op; execute nothing")
1890
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.')
1891
2014
  .option("--json", "JSONL results + summary on stdout (also the default)")
1892
2015
  .option("--db <path>", "explicit database path")
1893
2016
  .option("--actor <name>", "author name recorded for the whole batch")
@@ -1923,12 +2046,18 @@ export function registerWriteCommands(program) {
1923
2046
  });
1924
2047
  }
1925
2048
  }
2049
+ // batch streams JSONL regardless of --json, so the interrupt guard is
2050
+ // armed machine-readable.
2051
+ const endInvocation = beginWriteInvocation(true, Date.now());
1926
2052
  let client = null;
1927
2053
  try {
1928
2054
  client = openThings(opts.db ? { dbPath: opts.db } : {});
1929
2055
  const batchResult = await client.write.batch(ops, {
1930
2056
  ...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
1931
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 }),
1932
2061
  ...(opts.actor !== undefined && { actor: opts.actor }),
1933
2062
  }, (r) => {
1934
2063
  const pre = preInvalid.find((p) => p.index === r.index);
@@ -1960,6 +2089,7 @@ export function registerWriteCommands(program) {
1960
2089
  }
1961
2090
  finally {
1962
2091
  client?.close();
2092
+ endInvocation();
1963
2093
  }
1964
2094
  });
1965
2095
  program
@@ -2007,6 +2137,9 @@ export function registerWriteCommands(program) {
2007
2137
  usageError(opts, "--txn cannot be combined with --last or --by");
2008
2138
  return;
2009
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());
2010
2143
  let client = null;
2011
2144
  try {
2012
2145
  client = openThings(opts.db ? { dbPath: opts.db } : {});
@@ -2063,9 +2196,10 @@ export function registerWriteCommands(program) {
2063
2196
  }
2064
2197
  finally {
2065
2198
  client?.close();
2199
+ endInvocation();
2066
2200
  }
2067
2201
  });
2068
- addReorderPositionFlags(addDriveGuiFlag(addWriteFlags(program
2202
+ addReorderPositionFlags(addDriveGuiFlag(addCompoundWriteFlags(program
2069
2203
  .command("reorder <refs...>")
2070
2204
  .description("The ONE reorder verb — rearrange to-dos, projects, headings, OR sidebar areas IN " +
2071
2205
  "PLACE (REARRANGES, never changes membership; to change what an item belongs to use " +