things-api 0.15.0 → 0.17.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 (145) hide show
  1. package/README.md +4 -4
  2. package/dist/audit/schema.d.ts +8 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/commands/install-skill.js +9 -2
  5. package/dist/cli/commands/install-skill.js.map +1 -1
  6. package/dist/cli/commands/repeat-flags.d.ts +14 -4
  7. package/dist/cli/commands/repeat-flags.js +113 -33
  8. package/dist/cli/commands/repeat-flags.js.map +1 -1
  9. package/dist/cli/commands/todo.js +49 -2
  10. package/dist/cli/commands/todo.js.map +1 -1
  11. package/dist/cli/commands/writes.js +241 -66
  12. package/dist/cli/commands/writes.js.map +1 -1
  13. package/dist/cli/help.js +23 -13
  14. package/dist/cli/help.js.map +1 -1
  15. package/dist/cli/interrupt.d.ts +42 -0
  16. package/dist/cli/interrupt.js +105 -0
  17. package/dist/cli/interrupt.js.map +1 -0
  18. package/dist/cli/main.js +5 -0
  19. package/dist/cli/main.js.map +1 -1
  20. package/dist/cli/read-driver.d.ts +2 -2
  21. package/dist/cli/skill.d.ts +24 -0
  22. package/dist/cli/skill.js +40 -0
  23. package/dist/cli/skill.js.map +1 -1
  24. package/dist/client.d.ts +53 -12
  25. package/dist/client.js +9 -1
  26. package/dist/client.js.map +1 -1
  27. package/dist/config.d.ts +39 -0
  28. package/dist/config.js +16 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/contracts.d.ts +9 -2
  31. package/dist/contracts.js +1 -1
  32. package/dist/contracts.js.map +1 -1
  33. package/dist/index.d.ts +5 -2
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/mcp/server.js +132 -31
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/model/entities.d.ts +35 -0
  39. package/dist/model/entities.js.map +1 -1
  40. package/dist/model/occurrences.d.ts +14 -1
  41. package/dist/model/occurrences.js +20 -4
  42. package/dist/model/occurrences.js.map +1 -1
  43. package/dist/model/recurrence.d.ts +60 -9
  44. package/dist/model/recurrence.js +76 -19
  45. package/dist/model/recurrence.js.map +1 -1
  46. package/dist/paths.d.ts +8 -0
  47. package/dist/paths.js +10 -0
  48. package/dist/paths.js.map +1 -1
  49. package/dist/read/detail.js +49 -1
  50. package/dist/read/detail.js.map +1 -1
  51. package/dist/read/queries.d.ts +12 -0
  52. package/dist/read/queries.js +14 -0
  53. package/dist/read/queries.js.map +1 -1
  54. package/dist/read/shape.d.ts +25 -22
  55. package/dist/read/shape.js +81 -55
  56. package/dist/read/shape.js.map +1 -1
  57. package/dist/read/stage.d.ts +5 -5
  58. package/dist/read/stage.js +2 -2
  59. package/dist/read/views.d.ts +1 -1
  60. package/dist/read/views.js +4 -4
  61. package/dist/surface-copy.d.ts +2 -2
  62. package/dist/surface-copy.js +2 -2
  63. package/dist/sync-health.js +2 -1
  64. package/dist/sync-health.js.map +1 -1
  65. package/dist/trace/tracer.d.ts +87 -0
  66. package/dist/trace/tracer.js +167 -0
  67. package/dist/trace/tracer.js.map +1 -0
  68. package/dist/write/batch.d.ts +28 -3
  69. package/dist/write/batch.js +162 -46
  70. package/dist/write/batch.js.map +1 -1
  71. package/dist/write/clear-reminder.js +24 -21
  72. package/dist/write/clear-reminder.js.map +1 -1
  73. package/dist/write/clone.d.ts +4 -0
  74. package/dist/write/clone.js +531 -0
  75. package/dist/write/clone.js.map +1 -0
  76. package/dist/write/commands.js +140 -49
  77. package/dist/write/commands.js.map +1 -1
  78. package/dist/write/completion-context.d.ts +57 -0
  79. package/dist/write/completion-context.js +49 -0
  80. package/dist/write/completion-context.js.map +1 -0
  81. package/dist/write/failure-hints.d.ts +10 -1
  82. package/dist/write/failure-hints.js +8 -0
  83. package/dist/write/failure-hints.js.map +1 -1
  84. package/dist/write/guards.d.ts +1 -1
  85. package/dist/write/guards.js +43 -5
  86. package/dist/write/guards.js.map +1 -1
  87. package/dist/write/make-repeating-project.d.ts +2 -3
  88. package/dist/write/make-repeating-project.js +2 -73
  89. package/dist/write/make-repeating-project.js.map +1 -1
  90. package/dist/write/move.js +8 -1
  91. package/dist/write/move.js.map +1 -1
  92. package/dist/write/operations.d.ts +140 -9
  93. package/dist/write/operations.js +4 -1
  94. package/dist/write/operations.js.map +1 -1
  95. package/dist/write/pipeline.d.ts +77 -0
  96. package/dist/write/pipeline.js +298 -20
  97. package/dist/write/pipeline.js.map +1 -1
  98. package/dist/write/pre-state.d.ts +1 -1
  99. package/dist/write/pre-state.js +9 -3
  100. package/dist/write/pre-state.js.map +1 -1
  101. package/dist/write/preserve-modified.d.ts +55 -0
  102. package/dist/write/preserve-modified.js +106 -0
  103. package/dist/write/preserve-modified.js.map +1 -0
  104. package/dist/write/promote-clone.d.ts +32 -0
  105. package/dist/write/promote-clone.js +867 -0
  106. package/dist/write/promote-clone.js.map +1 -0
  107. package/dist/write/repeat-anchor.d.ts +110 -0
  108. package/dist/write/repeat-anchor.js +238 -0
  109. package/dist/write/repeat-anchor.js.map +1 -0
  110. package/dist/write/repeat-asserts.d.ts +28 -0
  111. package/dist/write/repeat-asserts.js +169 -0
  112. package/dist/write/repeat-asserts.js.map +1 -0
  113. package/dist/write/repeat-rule.js +27 -12
  114. package/dist/write/repeat-rule.js.map +1 -1
  115. package/dist/write/resolution-timestamps.js +22 -0
  116. package/dist/write/resolution-timestamps.js.map +1 -1
  117. package/dist/write/reversibility.js +27 -14
  118. package/dist/write/reversibility.js.map +1 -1
  119. package/dist/write/undo.d.ts +11 -1
  120. package/dist/write/undo.js +126 -10
  121. package/dist/write/undo.js.map +1 -1
  122. package/dist/write/vectors/session-reachability.d.ts +78 -0
  123. package/dist/write/vectors/session-reachability.js +103 -0
  124. package/dist/write/vectors/session-reachability.js.map +1 -0
  125. package/dist/write/vectors/simulator.js +100 -30
  126. package/dist/write/vectors/simulator.js.map +1 -1
  127. package/dist/write/vectors/types.d.ts +117 -10
  128. package/dist/write/vectors/ui-certification.d.ts +1 -1
  129. package/dist/write/vectors/ui-certification.js +10 -3
  130. package/dist/write/vectors/ui-certification.js.map +1 -1
  131. package/dist/write/vectors/ui-recipes.d.ts +8 -0
  132. package/dist/write/vectors/ui-recipes.js +120 -22
  133. package/dist/write/vectors/ui-recipes.js.map +1 -1
  134. package/dist/write/vectors/ui.d.ts +79 -13
  135. package/dist/write/vectors/ui.js +415 -59
  136. package/dist/write/vectors/ui.js.map +1 -1
  137. package/dist/write/verify/delta.d.ts +12 -14
  138. package/dist/write/verify/delta.js +35 -34
  139. package/dist/write/verify/delta.js.map +1 -1
  140. package/package.json +1 -1
  141. package/schema/envelope.schema.json +22 -0
  142. package/skills/things-cli/SKILL.md +7 -3
  143. package/skills/things-cli/references/banner.md +1 -1
  144. package/skills/things-cli/references/contracts.md +17 -9
  145. package/skills/things-cli/references/data-model.md +2 -1
@@ -9,6 +9,8 @@ import { type EnvironmentTracker } from "./environment.ts";
9
9
  import { type LikelyCause } from "./failure-hints.ts";
10
10
  import { type HazardId } from "./guards.ts";
11
11
  import { type Acknowledgements, type OperationKind, type OperationParamsMap } from "./operations.ts";
12
+ import { type CompletionContext } from "./completion-context.ts";
13
+ import { type PreserveModifiedFailure } from "./preserve-modified.ts";
12
14
  import type { VectorId, WriteVector } from "./vectors/types.ts";
13
15
  import { type DeltaSpec, type RepeatingDiscovery } from "./verify/delta.ts";
14
16
  import { type PollerDeps } from "./verify/poller.ts";
@@ -20,6 +22,18 @@ export interface WriteOptions extends Acknowledgements {
20
22
  verifyTimeoutMs?: number;
21
23
  /** Return the plan without executing (nothing is audited). */
22
24
  dryRun?: boolean;
25
+ /**
26
+ * Keep this change off the `userModificationDate` (`umd`) timeline. When set,
27
+ * the pipeline captures every pre-existing TARGET row's `umd` before the write
28
+ * and — after the change verifies — restores it through the AppleScript `set
29
+ * modification date` leg (preserve-modified.ts), so a `changes`/watch query
30
+ * keyed on `umd` does not surface the edit. Rows the op CREATES are untouched
31
+ * (their `umd` is legitimately new); a create-only op (add) is a silent no-op.
32
+ * BEST-EFFORT: a failed restore never fails the (already-verified) mutation —
33
+ * it is disclosed per row on the result. Restore lands on `floor(umd0)` (the
34
+ * AppleScript 1-second floor) and is proven only on an UNSYNCED store (SYNC2).
35
+ */
36
+ preserveModified?: boolean;
23
37
  /**
24
38
  * Skip the post-execute state VERIFY poll for this write, treating a clean
25
39
  * transport (exit 0) as ok. Fail-loud on the transport itself is preserved (a
@@ -73,6 +87,17 @@ export interface WriteOptions extends Acknowledgements {
73
87
  * stay on the host clock.
74
88
  */
75
89
  normalizeWhen?: boolean;
90
+ /**
91
+ * TEMPLATE-DIRECT clone (template-clone via re-promote): tells the clone
92
+ * orchestrator to copy a repeating TEMPLATE source's content as a PLAIN item
93
+ * (recurrence stripped — no rule, no schedule/reminder), instead of refusing it
94
+ * (H-CLONE-SOURCE). NEVER set by a consumer entry point (the CLI/MCP/batch/client
95
+ * do not thread it) — only `cloneTemplateViaRepromote` sets it on the embedded
96
+ * clone leg, which it then native-promotes with the source's decoded rule. A bare
97
+ * `todo/project clone` of a template still routes through the compound (which sets
98
+ * this flag internally). See promote-clone.ts cloneTemplateViaRepromote.
99
+ */
100
+ cloneTemplateAsPlain?: boolean;
76
101
  }
77
102
  export interface MutationPlan {
78
103
  op: OperationKind;
@@ -130,6 +155,34 @@ export type MutationResult = {
130
155
  * the ORIGINAL mutation's identity. Absent on a normal (executed) result.
131
156
  */
132
157
  alreadyApplied?: true;
158
+ /**
159
+ * `--preserve-modified` disclosure (ADDITIVE, presence-keyed): the count of
160
+ * pre-existing target rows whose `userModificationDate` was restored to its
161
+ * pre-write value (to the floored second) so the change stays off the
162
+ * `changes`/watch timeline. Present only when the flag did real work —
163
+ * absent on a create-only or already-silent op, where restore is a no-op.
164
+ */
165
+ preservedModified?: number;
166
+ /**
167
+ * `--preserve-modified` per-row failures (ADDITIVE): rows the restore leg
168
+ * could not neutralize. Best-effort — the mutation itself stands; these are
169
+ * disclosed, never fatal. Absent when every restore leg succeeded.
170
+ */
171
+ preserveFailures?: PreserveModifiedFailure[];
172
+ /**
173
+ * HINTS1 completion-context (ADDITIVE, presence-keyed): on a successful
174
+ * `todo.complete` / `todo.cancel` only, the remaining OPEN work in the
175
+ * to-do's container(s), computed from a cheap post-verify re-read so an
176
+ * agent can notice an emptied project/Today without a second read.
177
+ * `project` is present when the to-do was in a project (directly or under a
178
+ * heading) and carries its `uuid`, `title`, and the OPEN, untrashed,
179
+ * non-template to-dos remaining in it (heading children included; `0` = the
180
+ * project's open work is now empty). `today` is present when the to-do was
181
+ * a Today member at mutation time and carries the OPEN Today members
182
+ * remaining. The hint INFORMS, never auto-acts; absent when neither applies
183
+ * (and on every other op). See {@link CompletionContext}.
184
+ */
185
+ context?: CompletionContext;
133
186
  } | {
134
187
  kind: "verify-failed";
135
188
  op: OperationKind;
@@ -140,6 +193,16 @@ export type MutationResult = {
140
193
  /** Advisory attribution when the failure signals point somewhere. */
141
194
  likelyCause?: LikelyCause;
142
195
  hint?: string;
196
+ /**
197
+ * The outcome is genuinely UNCERTAIN, not confirmed-failed (TRACE1, #487):
198
+ * a UI drive was aborted by the watchdog (or the process was interrupted)
199
+ * while the app might have been mid-commit, so the caller must re-check
200
+ * with `things show <uuid>` rather than assume nothing changed. Present
201
+ * only on the ui-drive watchdog timeout.
202
+ */
203
+ uncertain?: true;
204
+ /** The local trace file reconstructing this drive's timeline (TRACE1). */
205
+ tracePath?: string;
143
206
  } | {
144
207
  kind: "blocked";
145
208
  op: OperationKind;
@@ -170,6 +233,20 @@ export interface WriteDeps {
170
233
  /** Injectable for tests/lab: returns true when Things is up (launching if needed). */
171
234
  ensureRunning?: (alreadyRunning: boolean) => Promise<boolean>;
172
235
  isAppRunning?: () => boolean;
236
+ /**
237
+ * Seam for the default launch: the consent-free launch-readiness probe polled
238
+ * after a background launch, before dispatch (past the startup URL-drop
239
+ * window, #486). Only consulted by the default {@link ensureRunning}; ignored
240
+ * when a test injects its own `ensureRunning`.
241
+ */
242
+ appReady?: () => boolean;
243
+ /**
244
+ * The resolved on-disk database path (client-wired from the located DB). The
245
+ * default launch uses `${dbPath}-wal` as its consent-free readiness signal
246
+ * (the app has written since we launched it, #486). Absent = fall back to the
247
+ * LaunchServices launch-completion label.
248
+ */
249
+ dbPath?: string;
173
250
  /** Canary seam: does the installed sdef still declare the private command? */
174
251
  sdefProbe?: () => boolean;
175
252
  /** Consent-churn tripwire: tuple recorded per verified mutation (client wires the default). */
@@ -7,12 +7,14 @@
7
7
  * after verify.
8
8
  */
9
9
  import { execFile, execFileSync } from "node:child_process";
10
+ import { statSync } from "node:fs";
10
11
  import { undoToken } from "../audit/schema.js";
11
12
  import { blockedCode, verifyFailedCode } from "../contracts.js";
12
13
  import { localToday } from "../model/dates.js";
13
- import { resolveProjectWriteTarget, resolveTaskUuidPrefix } from "../read/queries.js";
14
+ import { liveSeriesInstances, resolveProjectWriteTarget, resolveTaskUuidPrefix, } from "../read/queries.js";
14
15
  import { namedProjectClause, taskMembershipClause } from "../read/scope.js";
15
16
  import { evaluateScope } from "./scope-guard.js";
17
+ import { isThingsRunning } from "./automation-probe.js";
16
18
  import { readShortcutProxies, readUrlSchemeEnabled } from "./availability.js";
17
19
  import { COMMANDS } from "./commands.js";
18
20
  import { describeEnvironmentChanges, diffEnvironment, } from "./environment.js";
@@ -21,11 +23,15 @@ import { classifyTransportFailure, classifyVerifyFailure, } from "./failure-hint
21
23
  import { evaluateGuards } from "./guards.js";
22
24
  import { acquireMutationLock, MutationLockError } from "./lock.js";
23
25
  import { isHeadingTargetOp, } from "./operations.js";
26
+ import { computeCompletionContext } from "./completion-context.js";
24
27
  import { planVector } from "./planner.js";
28
+ import { isRepeatingTemplate } from "./pre-state.js";
29
+ import { restoreModDates, } from "./preserve-modified.js";
25
30
  import { REVERSIBILITY } from "./reversibility.js";
26
31
  import { certificationOf } from "./vectors/ui-certification.js";
27
32
  import { createDbReader, evaluateDelta, getField, } from "./verify/delta.js";
28
33
  import { pollUntilVerified } from "./verify/poller.js";
34
+ import { setInflight, trace } from "../trace/tracer.js";
29
35
  /**
30
36
  * Bounded backoff for the post-transport-failure re-verify (0½ defect (a)): a
31
37
  * GUI drive can abort part-way yet still have LANDED the change, so a nonzero
@@ -41,33 +47,108 @@ export function readAuthToken(db) {
41
47
  return null;
42
48
  }
43
49
  }
44
- function defaultIsAppRunning() {
50
+ /**
51
+ * The ONE process-presence signal (shared with `doctor`/sync-health via
52
+ * automation-probe.ts) — a single stable `pgrep -x Things3` shape so macOS
53
+ * never sees a second command form.
54
+ */
55
+ const defaultIsAppRunning = isThingsRunning;
56
+ /** Poll interval for the launch waits. */
57
+ const LAUNCH_POLL_INTERVAL_MS = 200;
58
+ /** Cap on how long we wait for the launched PROCESS to appear. */
59
+ const LAUNCH_PROCESS_TIMEOUT_MS = 20_000;
60
+ /**
61
+ * Cap on how long we wait — after the process appears — for the app to become
62
+ * READY to land a write. A freshly launched Things registers its URL handler
63
+ * before it can actually apply a command: a command dispatched into that
64
+ * startup window is accepted and silently dropped (issue #486). We poll a cheap
65
+ * consent-free readiness signal past that window before dispatching.
66
+ */
67
+ const LAUNCH_READY_TIMEOUT_MS = 12_000;
68
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
69
+ /** The write-ahead-log's mtime in ms, or null when it cannot be read. */
70
+ function walMtimeMs(walPath) {
45
71
  try {
46
- execFileSync("pgrep", ["-x", "Things3"], { stdio: "ignore" });
72
+ return statSync(walPath).mtimeMs;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /**
79
+ * Consent-free launch-completion signal via LaunchServices — the fallback used
80
+ * only when the database path is unknown. NOT an Apple Event, so it never drags
81
+ * Automation consent onto the consent-free URL path. `lsappinfo` reports a
82
+ * process still coming up as `"Not Finished Launching"`; anything else is
83
+ * treated as launched. False on any error (treated as "not ready yet").
84
+ */
85
+ function lsappinfoLaunched() {
86
+ try {
87
+ const out = execFileSync("lsappinfo", ["info", "-only", "StatusLabel", "-app", "com.culturedcode.ThingsMac"], { encoding: "utf8", timeout: 3000 });
88
+ if (out.trim() === "" || /Not Finished Launching/i.test(out))
89
+ return false;
47
90
  return true;
48
91
  }
49
92
  catch {
50
93
  return false;
51
94
  }
52
95
  }
53
- /** Background-launch Things and wait for the process (tier 1, by policy). */
54
- async function defaultEnsureRunning(alreadyRunning) {
96
+ /**
97
+ * Consent-free launch-readiness signal — a DB-write-capability probe. A freshly
98
+ * launched Things recomputes its Today/repeat state and writes to its database
99
+ * shortly after launch; a URL command applied BEFORE the app is truly up is at
100
+ * risk of being dropped in the startup window (issue #486). Reading a file mtime
101
+ * needs no Apple Event, so this never triggers a consent prompt. "Ready" = the
102
+ * write-ahead log has advanced past the pre-launch baseline (the app has written
103
+ * since we launched it). APPRUN1 (golden-v3, Things 3.22.14): the WAL advanced
104
+ * ~0.9s after `open -g` — a real post-launch signal — while the process appeared
105
+ * at ~0.03s; the drop window itself did not reproduce in a clean airgapped clone
106
+ * (first URL landed immediately), so this wait is a defensive floor, and any
107
+ * residual drop is caught by verify and attributed `app-not-running`. Falls back
108
+ * to the LaunchServices label when the DB path is unknown. See
109
+ * docs/lab/apprun1-launch-readiness.md.
110
+ */
111
+ function walAdvancedSince(walPath, baseline) {
112
+ if (walPath === undefined)
113
+ return lsappinfoLaunched();
114
+ const m = walMtimeMs(walPath);
115
+ return m !== null && (baseline === null || m > baseline);
116
+ }
117
+ /**
118
+ * Background-launch Things (tier 1, by policy — `open -g` keeps it off the
119
+ * foreground) and wait CLOSED-LOOP for it to become ready to land a write:
120
+ * first for the process to appear, then for the readiness signal to trip (past
121
+ * the startup URL-drop window, #486). Returns false only if the process never
122
+ * appears within {@link LAUNCH_PROCESS_TIMEOUT_MS}; once it is up we proceed
123
+ * after readiness trips OR the readiness cap elapses (best-effort — a residual
124
+ * drop is then caught by verify + attributed "app was not running").
125
+ */
126
+ async function defaultEnsureRunning(alreadyRunning, opts = {}) {
55
127
  if (alreadyRunning)
56
128
  return true;
129
+ // Capture the readiness baseline BEFORE launching, so "the app has written
130
+ // since we launched it" is judged against the pre-launch WAL state.
131
+ const walPath = opts.dbPath !== undefined ? `${opts.dbPath}-wal` : undefined;
132
+ const walBaseline = walPath !== undefined ? walMtimeMs(walPath) : null;
133
+ const appReady = opts.appReady ?? (() => walAdvancedSince(walPath, walBaseline));
57
134
  await new Promise((resolve) => {
58
135
  execFile("open", ["-g", "-a", "Things3"], () => resolve());
59
136
  });
60
- const deadline = Date.now() + 20_000;
61
- while (Date.now() < deadline) {
62
- if (defaultIsAppRunning()) {
63
- // the post-launch settle wait must happen once, right after the process is first detected, before returning
64
- await new Promise((r) => setTimeout(r, 2000)); // post-launch settle
65
- return true;
66
- }
67
- // launch-detection retries are inherently sequential polling of the same process state
68
- await new Promise((r) => setTimeout(r, 250));
137
+ // Phase 1: wait for the process to exist.
138
+ const processDeadline = Date.now() + LAUNCH_PROCESS_TIMEOUT_MS;
139
+ while (!defaultIsAppRunning()) {
140
+ if (Date.now() >= processDeadline)
141
+ return false;
142
+ await sleep(LAUNCH_POLL_INTERVAL_MS);
143
+ }
144
+ // Phase 2: wait for launch-readiness (past the URL-drop window).
145
+ const readyDeadline = Date.now() + LAUNCH_READY_TIMEOUT_MS;
146
+ while (!appReady()) {
147
+ if (Date.now() >= readyDeadline)
148
+ break; // best-effort: proceed and let verify judge
149
+ await sleep(LAUNCH_POLL_INTERVAL_MS);
69
150
  }
70
- return false;
151
+ return true;
71
152
  }
72
153
  /** Capture pre-values of asserted fields + movement tripwires for the spec. */
73
154
  function capturePre(spec, deps, pre) {
@@ -514,14 +595,35 @@ export async function runMutation(deps, op, params, options = {}) {
514
595
  // plain opens and AppleEvents to a closed Things steal focus (A40/A41).
515
596
  // A simulating vector applies SQL to a fixture DB and never touches the
516
597
  // real app, so it neither needs nor may trigger the background launch.
517
- if (vector.simulates !== true) {
518
- const running = await (deps.ensureRunning ?? defaultEnsureRunning)(appRunning);
598
+ // A write can only land through a real transport when Things is up (a
599
+ // command dispatched into a closed app is silently dropped, #486), so a
600
+ // closed app is either launched-and-readied here or — when auto-launch is
601
+ // disabled — refused BEFORE dispatch with a plain environment error.
602
+ if (vector.simulates !== true && !appRunning) {
603
+ if (!config.autoLaunch) {
604
+ audit({ result: blockedCode({ reason: "environment" }) });
605
+ return {
606
+ kind: "blocked",
607
+ op,
608
+ reason: "environment",
609
+ likelyCause: "app-not-running",
610
+ detail: "Things is not running, and auto-launch is turned off",
611
+ remediation: "open Things (or run `things config set auto-launch true` to let writes launch it), " +
612
+ "then retry",
613
+ };
614
+ }
615
+ const running = await (deps.ensureRunning ??
616
+ ((ar) => defaultEnsureRunning(ar, {
617
+ ...(deps.dbPath !== undefined && { dbPath: deps.dbPath }),
618
+ ...(deps.appReady !== undefined && { appReady: deps.appReady }),
619
+ })))(appRunning);
519
620
  if (!running) {
520
621
  audit({ result: blockedCode({ reason: "environment" }) });
521
622
  return {
522
623
  kind: "blocked",
523
624
  op,
524
625
  reason: "environment",
626
+ likelyCause: "app-not-running",
525
627
  detail: "Things did not become available after a background launch attempt",
526
628
  remediation: "launch Things manually and retry",
527
629
  };
@@ -554,7 +656,54 @@ export async function runMutation(deps, op, params, options = {}) {
554
656
  pre: flattenPreFields(preCapture.fields),
555
657
  uuid: intentUuid,
556
658
  });
557
- const executeResult = await vector.execute(invocation);
659
+ // Mark the write as touching the app (read by the CLI's signal handler so a
660
+ // SIGTERM/SIGINT can name the exact op — and last UI step — it interrupted,
661
+ // TRACE1 #487) and open the execute stage in the trace. Cleared the moment
662
+ // execute returns, whatever the outcome.
663
+ setInflight({
664
+ op,
665
+ uuid: intentUuid,
666
+ vector: vector.id,
667
+ uiDrive: vector.id === "ui",
668
+ startedAt: (deps.now?.() ?? new Date()).getTime(),
669
+ });
670
+ trace(() => ({
671
+ phase: "stage",
672
+ stage: "execute-start",
673
+ op,
674
+ vector: vector.id,
675
+ tier: effectiveTier,
676
+ }));
677
+ let executeResult;
678
+ try {
679
+ executeResult = await vector.execute(invocation);
680
+ }
681
+ finally {
682
+ setInflight(null);
683
+ }
684
+ trace(() => ({
685
+ phase: "stage",
686
+ stage: "execute-done",
687
+ op,
688
+ exitCode: executeResult.exitCode,
689
+ timedOut: executeResult.timedOut === true,
690
+ ...(executeResult.watchdog !== undefined && { watchdog: executeResult.watchdog }),
691
+ }));
692
+ // A vector that REFUSED at runtime before touching the app (the ui vector's
693
+ // session-reachability gate: a locked / full-screen session leaves no
694
+ // AX-reachable Things window for the dialog, SESSGATE #480). Nothing was
695
+ // mutated → a `blocked` outcome (exit 4), never a transport failure.
696
+ if (executeResult.blocked !== undefined) {
697
+ const { hazard, detail, remediation } = executeResult.blocked;
698
+ audit({
699
+ result: blockedCode({ hazard, reason: "hazard" }),
700
+ vector: vector.id,
701
+ disruption: effectiveTier,
702
+ invocation: invocation.redactedPayload,
703
+ pre: flattenPreFields(preCapture.fields),
704
+ });
705
+ return { kind: "blocked", op, reason: "hazard", hazard, detail, remediation };
706
+ }
558
707
  // Verify under the injected clock (deps.now/deps.zone), never the wall
559
708
  // clock: an `evening`/`today` write dated pinned-today must read back IN
560
709
  // Today at verify time, or its today/evening marker assertion fails under a
@@ -576,6 +725,46 @@ export async function runMutation(deps, op, params, options = {}) {
576
725
  // with bounded backoff and treat a landed target state as SUCCESS.
577
726
  const recovery = await pollUntilVerified(() => evaluateDelta(delta, reader, preCapture), RECOVERY_VERIFY_TIMEOUT_MS, deps.poller ?? {});
578
727
  if (recovery.kind !== "ok") {
728
+ // The ui-drive WATCHDOG timeout is its own honest outcome (TRACE1 #487):
729
+ // the CLI gave up first and cleared the dialog, but a drive aborted while
730
+ // the OK was possibly mid-commit cannot promise the app is untouched — so
731
+ // it is reported UNCERTAIN with the trace path and a "re-check first"
732
+ // remediation, distinct from a plain transport failure with no landed change.
733
+ const wd = executeResult.watchdog;
734
+ if (wd !== undefined) {
735
+ audit({
736
+ result: verifyFailedCode({ reason: "timeout" }),
737
+ vector: vector.id,
738
+ disruption: effectiveTier,
739
+ invocation: invocation.redactedPayload,
740
+ pre: flattenPreFields(preCapture.fields),
741
+ observed: recovery.observed,
742
+ });
743
+ const budgetS = Math.round(wd.budgetMs / 1000);
744
+ const elapsedS = Math.round(wd.elapsedMs / 1000);
745
+ const cleared = wd.clear === "dismissed"
746
+ ? "the open dialog was dismissed"
747
+ : wd.clear === "cleared-blind"
748
+ ? "the Things window was closed and reopened to clear the open dialog"
749
+ : "a dialog may still be open in Things";
750
+ const traceNote = wd.tracePath != null && wd.tracePath !== ""
751
+ ? ` The step timeline is at ${wd.tracePath}.`
752
+ : "";
753
+ return {
754
+ kind: "verify-failed",
755
+ op,
756
+ reason: "timeout",
757
+ expected: delta,
758
+ observed: recovery.observed,
759
+ detail: `the GUI drive ran past its ${budgetS}s budget at step "${wd.lastStep}" (after ` +
760
+ `~${elapsedS}s) and the CLI stopped it before the change could be confirmed; ` +
761
+ `${cleared}. The outcome is UNCERTAIN — a rule committed at the last moment could ` +
762
+ `still appear — so re-check with \`things show ${intentUuid ?? "<uuid>"}\` before ` +
763
+ `retrying (retrying could create a duplicate series).${traceNote}`,
764
+ uncertain: true,
765
+ ...(wd.tracePath != null && wd.tracePath !== "" && { tracePath: wd.tracePath }),
766
+ };
767
+ }
579
768
  audit({
580
769
  result: verifyFailedCode({ reason: "silent-noop" }),
581
770
  vector: vector.id,
@@ -612,6 +801,15 @@ export async function runMutation(deps, op, params, options = {}) {
612
801
  else {
613
802
  outcome = await pollUntilVerified(() => evaluateDelta(delta, reader, preCapture), timeoutMs, deps.poller ?? {});
614
803
  }
804
+ trace(() => ({
805
+ phase: "stage",
806
+ stage: "verify",
807
+ op,
808
+ kind: outcome.kind,
809
+ attempts: outcome.attempts,
810
+ elapsedMs: outcome.elapsedMs,
811
+ recovered: transportRecovered,
812
+ }));
615
813
  const auditCommon = {
616
814
  vector: vector.id,
617
815
  disruption: effectiveTier,
@@ -627,15 +825,53 @@ export async function runMutation(deps, op, params, options = {}) {
627
825
  : delta.mode === "ordering"
628
826
  ? (delta.subject ?? null)
629
827
  : null);
630
- audit({ ...auditCommon, result: "ok", uuid });
828
+ // --preserve-modified: after the change verifies, restore the pre-write
829
+ // `userModificationDate` of every pre-existing TARGET row the op BUMPED, so
830
+ // the edit stays off the umd-keyed `changes`/watch timeline (TAGMOD T5).
831
+ // The pre-read already captured each asserted/cascade row's umd
832
+ // (preCapture.modDates); a null capture is a row the op CREATED (skip it),
833
+ // and a row whose umd did not rise needs no restore (a silent op — no-op).
834
+ // Best-effort: a failed restore is disclosed per row, never fatal (the
835
+ // mutation already stands). The captured pre-values ride the audit record
836
+ // when the flag is active (enables a future symmetric undo).
837
+ let preserve = null;
838
+ let preModDatesAudit;
839
+ if (options.preserveModified === true) {
840
+ const postReader = createDbReader(deps.db, deps.now?.() ?? new Date(), deps.zone);
841
+ const captured = {};
842
+ const targets = [];
843
+ for (const [tUuid, preUmd] of Object.entries(preCapture.modDates)) {
844
+ captured[tUuid] = preUmd;
845
+ if (preUmd === null)
846
+ continue; // a row the op created — legitimately new umd
847
+ const post = postReader.modDateOf(tUuid);
848
+ if (post !== null && post > preUmd)
849
+ targets.push({ uuid: tUuid, preUmd });
850
+ }
851
+ if (Object.keys(captured).length > 0)
852
+ preModDatesAudit = captured;
853
+ preserve = await restoreModDates(deps.db, deps.vectors, targets);
854
+ }
855
+ audit({
856
+ ...auditCommon,
857
+ result: "ok",
858
+ uuid,
859
+ ...(preModDatesAudit !== undefined && { preModDates: preModDatesAudit }),
860
+ });
631
861
  if (deps.environment !== undefined) {
632
862
  deps.environment.record(deps.environment.capture());
633
863
  }
864
+ // Deleting a repeating TEMPLATE is allowed (byte-identical to the GUI's own
865
+ // Edit ▸ Delete, SERDEL S1) but is IRREVERSIBLE headlessly — the app forbids
866
+ // restoring a template out to a list (AS 301), so its only revival is Trash ▸
867
+ // Put Back. Treat it like an irreversible op (no undo token) and disclose the
868
+ // consequences below. Trashing an INSTANCE (no rule of its own) is unaffected.
869
+ const templateDelete = (op === "todo.delete" || op === "project.delete") && isRepeatingTemplate(pre.target);
634
870
  // The undo token identifies THIS record on the trail (see undoToken); a
635
871
  // leg's token would be its shared txn id, but legs are never undone
636
872
  // directly, so we only surface it for non-leg writes. Irreversible ops get
637
873
  // NO token: `undo --txn` can only refuse it, so emitting one is misleading.
638
- const resultToken = options.txn?.role === "leg" || REVERSIBILITY[op].class === "irreversible"
874
+ const resultToken = options.txn?.role === "leg" || REVERSIBILITY[op].class === "irreversible" || templateDelete
639
875
  ? undefined
640
876
  : undoToken({
641
877
  ts: startedAt.toISOString(),
@@ -646,6 +882,28 @@ export async function runMutation(deps, op, params, options = {}) {
646
882
  ...(options.txn !== undefined && { txn: options.txn }),
647
883
  });
648
884
  const warnings = [];
885
+ // Auto-launch disclosure (#486): the app was not running when this write
886
+ // started, so it was background-launched for the write. Never silent — a
887
+ // side effect the caller should see (a simulating vector never launches).
888
+ if (!appRunning && vector.simulates !== true) {
889
+ warnings.push("Things was not running, so it was launched in the background for this write");
890
+ }
891
+ // Template-delete disclosure (public deletes only — internal trash-both legs
892
+ // run under a txn and aggregate their own result). The series stops, its live
893
+ // instances are left in place (count + name the current occurrence), and the
894
+ // only revival is the app's Trash ▸ Put Back.
895
+ if (templateDelete && options.txn?.role !== "leg" && pre.target !== null) {
896
+ const kindNoun = op === "project.delete" ? "project" : "to-do";
897
+ const series = liveSeriesInstances(deps.db, pre.target.uuid);
898
+ warnings.push("this repeating series will no longer generate new occurrences");
899
+ if (series.count > 0) {
900
+ warnings.push(`its ${series.count} existing occurrence${series.count === 1 ? " was" : "s were"} ` +
901
+ "left in place (not moved to the Trash)" +
902
+ (series.currentUuid !== null ? ` — the current one is ${series.currentUuid}` : ""));
903
+ }
904
+ warnings.push(`this cannot be undone here — to bring the series back, use the Things app's Trash ` +
905
+ `(Put Back on the ${kindNoun})`);
906
+ }
649
907
  if (transportRecovered) {
650
908
  warnings.push("the GUI drive reported a transport error, but a follow-up re-read confirmed the " +
651
909
  "requested change DID land — no retry is needed (retrying could overwrite it)");
@@ -678,6 +936,15 @@ export async function runMutation(deps, op, params, options = {}) {
678
936
  `${describeEnvironmentChanges(envChanges)} — the first use of another ` +
679
937
  `capability may show a macOS consent prompt`);
680
938
  }
939
+ // HINTS1 completion-context: on a verified complete/cancel of a to-do,
940
+ // attach the remaining OPEN work in its project and/or Today. Applicability
941
+ // is read off the captured pre-state, so a to-do in neither costs no extra
942
+ // reads; the counts are a cheap post-verify re-read via the library's own
943
+ // read paths.
944
+ const completionContext = op === "todo.complete" || op === "todo.cancel"
945
+ ? computeCompletionContext(deps.db, pre.target, deps.now?.() ?? new Date(), deps.zone)
946
+ : undefined;
947
+ trace(() => ({ phase: "result", op, kind: "ok", uuid, vector: vector.id }));
681
948
  return {
682
949
  kind: "ok",
683
950
  op,
@@ -692,6 +959,16 @@ export async function runMutation(deps, op, params, options = {}) {
692
959
  tier: effectiveTier,
693
960
  ...(resultToken !== undefined && { undoToken: resultToken }),
694
961
  ...(outcome.repeating !== undefined && { repeating: outcome.repeating }),
962
+ // Disclose --preserve-modified only when it did real work (restored a
963
+ // bump or hit a restore failure) — a create-only / already-silent op
964
+ // stays clean (no `preservedModified: 0` noise).
965
+ ...(preserve !== null &&
966
+ (preserve.restored > 0 || preserve.failures.length > 0) && {
967
+ preservedModified: preserve.restored,
968
+ }),
969
+ ...(preserve !== null &&
970
+ preserve.failures.length > 0 && { preserveFailures: preserve.failures }),
971
+ ...(completionContext !== undefined && { context: completionContext }),
695
972
  ...(warnings.length > 0 && { warnings }),
696
973
  };
697
974
  }
@@ -712,6 +989,7 @@ export async function runMutation(deps, op, params, options = {}) {
712
989
  reason: outcome.kind,
713
990
  vector: vector.id,
714
991
  urlSchemeEnabled: (deps.urlSchemeEnabled ?? (() => readUrlSchemeEnabled().enabled))(),
992
+ appWasRunning: appRunning,
715
993
  environmentChanges: envChanges,
716
994
  }));
717
995
  }