things-api 0.8.0 → 0.10.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 (238) hide show
  1. package/README.md +31 -11
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/commands/area.d.ts +34 -2
  8. package/dist/cli/commands/area.js +140 -28
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +44 -5
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +23 -4
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +23 -1
  16. package/dist/cli/commands/project.js +82 -15
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.d.ts +6 -125
  19. package/dist/cli/commands/reads.js +649 -657
  20. package/dist/cli/commands/reads.js.map +1 -1
  21. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  22. package/dist/cli/commands/repeat-flags.js +76 -0
  23. package/dist/cli/commands/repeat-flags.js.map +1 -0
  24. package/dist/cli/commands/setup.js +2 -3
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.d.ts +1 -1
  27. package/dist/cli/commands/show.js +169 -18
  28. package/dist/cli/commands/show.js.map +1 -1
  29. package/dist/cli/commands/snapshot.js +1 -1
  30. package/dist/cli/commands/snapshot.js.map +1 -1
  31. package/dist/cli/commands/todo.d.ts +1 -1
  32. package/dist/cli/commands/todo.js +21 -9
  33. package/dist/cli/commands/todo.js.map +1 -1
  34. package/dist/cli/commands/writes.js +378 -148
  35. package/dist/cli/commands/writes.js.map +1 -1
  36. package/dist/cli/did-you-mean.d.ts +29 -0
  37. package/dist/cli/did-you-mean.js +53 -0
  38. package/dist/cli/did-you-mean.js.map +1 -0
  39. package/dist/cli/excess-args.d.ts +15 -0
  40. package/dist/cli/excess-args.js +51 -0
  41. package/dist/cli/excess-args.js.map +1 -0
  42. package/dist/cli/glyphs.d.ts +67 -7
  43. package/dist/cli/glyphs.js +202 -19
  44. package/dist/cli/glyphs.js.map +1 -1
  45. package/dist/cli/help.d.ts +54 -0
  46. package/dist/cli/help.js +355 -0
  47. package/dist/cli/help.js.map +1 -0
  48. package/dist/cli/main.js +36 -21
  49. package/dist/cli/main.js.map +1 -1
  50. package/dist/cli/period.d.ts +39 -0
  51. package/dist/cli/period.js +141 -0
  52. package/dist/cli/period.js.map +1 -0
  53. package/dist/cli/read-driver.d.ts +83 -0
  54. package/dist/cli/read-driver.js +215 -0
  55. package/dist/cli/read-driver.js.map +1 -0
  56. package/dist/cli/render.d.ts +246 -0
  57. package/dist/cli/render.js +806 -0
  58. package/dist/cli/render.js.map +1 -0
  59. package/dist/cli/resolve-invocation.d.ts +116 -0
  60. package/dist/cli/resolve-invocation.js +261 -0
  61. package/dist/cli/resolve-invocation.js.map +1 -0
  62. package/dist/cli/shell-quote.d.ts +9 -0
  63. package/dist/cli/shell-quote.js +12 -0
  64. package/dist/cli/shell-quote.js.map +1 -0
  65. package/dist/cli/tag-filters.d.ts +59 -0
  66. package/dist/cli/tag-filters.js +57 -0
  67. package/dist/cli/tag-filters.js.map +1 -0
  68. package/dist/cli/verb-hint.d.ts +25 -0
  69. package/dist/cli/verb-hint.js +138 -0
  70. package/dist/cli/verb-hint.js.map +1 -0
  71. package/dist/cli/width.d.ts +135 -0
  72. package/dist/cli/width.js +313 -0
  73. package/dist/cli/width.js.map +1 -0
  74. package/dist/client.d.ts +146 -24
  75. package/dist/client.js +106 -16
  76. package/dist/client.js.map +1 -1
  77. package/dist/config.d.ts +11 -0
  78. package/dist/config.js +3 -0
  79. package/dist/config.js.map +1 -1
  80. package/dist/contracts.d.ts +136 -1
  81. package/dist/contracts.js +44 -1
  82. package/dist/contracts.js.map +1 -1
  83. package/dist/db/fingerprint.d.ts +12 -0
  84. package/dist/db/fingerprint.js +15 -1
  85. package/dist/db/fingerprint.js.map +1 -1
  86. package/dist/db/locate.js +1 -1
  87. package/dist/db/locate.js.map +1 -1
  88. package/dist/diagnose.d.ts +63 -0
  89. package/dist/diagnose.js +38 -1
  90. package/dist/diagnose.js.map +1 -1
  91. package/dist/index.d.ts +30 -4
  92. package/dist/index.js +35 -2
  93. package/dist/index.js.map +1 -1
  94. package/dist/mcp/server.d.ts +9 -1
  95. package/dist/mcp/server.js +850 -128
  96. package/dist/mcp/server.js.map +1 -1
  97. package/dist/model/entities.d.ts +40 -7
  98. package/dist/model/entities.js.map +1 -1
  99. package/dist/model/mappers.d.ts +7 -0
  100. package/dist/model/mappers.js +20 -3
  101. package/dist/model/mappers.js.map +1 -1
  102. package/dist/model/occurrences.d.ts +6 -1
  103. package/dist/model/occurrences.js +9 -5
  104. package/dist/model/occurrences.js.map +1 -1
  105. package/dist/model/recurrence.d.ts +17 -8
  106. package/dist/model/recurrence.js.map +1 -1
  107. package/dist/model/serialize.d.ts +28 -0
  108. package/dist/model/serialize.js +80 -0
  109. package/dist/model/serialize.js.map +1 -0
  110. package/dist/model/when-sugar.d.ts +47 -0
  111. package/dist/model/when-sugar.js +45 -0
  112. package/dist/model/when-sugar.js.map +1 -0
  113. package/dist/read/area-view.d.ts +8 -1
  114. package/dist/read/area-view.js +49 -13
  115. package/dist/read/area-view.js.map +1 -1
  116. package/dist/read/caps.d.ts +14 -0
  117. package/dist/read/caps.js +21 -0
  118. package/dist/read/caps.js.map +1 -0
  119. package/dist/read/filter-contract.d.ts +117 -0
  120. package/dist/read/filter-contract.js +78 -0
  121. package/dist/read/filter-contract.js.map +1 -0
  122. package/dist/read/log-boundary.d.ts +9 -4
  123. package/dist/read/log-boundary.js.map +1 -1
  124. package/dist/read/predicates.d.ts +61 -0
  125. package/dist/read/predicates.js +78 -0
  126. package/dist/read/predicates.js.map +1 -0
  127. package/dist/read/project-view.d.ts +12 -1
  128. package/dist/read/project-view.js +39 -8
  129. package/dist/read/project-view.js.map +1 -1
  130. package/dist/read/queries.d.ts +156 -8
  131. package/dist/read/queries.js +261 -38
  132. package/dist/read/queries.js.map +1 -1
  133. package/dist/read/search-rank.d.ts +36 -0
  134. package/dist/read/search-rank.js +31 -0
  135. package/dist/read/search-rank.js.map +1 -0
  136. package/dist/read/sections.d.ts +51 -0
  137. package/dist/read/sections.js +37 -0
  138. package/dist/read/sections.js.map +1 -0
  139. package/dist/read/show-target.d.ts +13 -4
  140. package/dist/read/show-target.js +11 -2
  141. package/dist/read/show-target.js.map +1 -1
  142. package/dist/read/sidebar-order.d.ts +19 -0
  143. package/dist/read/sidebar-order.js +93 -0
  144. package/dist/read/sidebar-order.js.map +1 -0
  145. package/dist/read/tags.d.ts +27 -3
  146. package/dist/read/tags.js +83 -12
  147. package/dist/read/tags.js.map +1 -1
  148. package/dist/read/truncation.d.ts +71 -0
  149. package/dist/read/truncation.js +217 -0
  150. package/dist/read/truncation.js.map +1 -0
  151. package/dist/read/views.d.ts +177 -19
  152. package/dist/read/views.js +390 -213
  153. package/dist/read/views.js.map +1 -1
  154. package/dist/surface-copy.d.ts +42 -0
  155. package/dist/surface-copy.js +55 -0
  156. package/dist/surface-copy.js.map +1 -1
  157. package/dist/sync-health.d.ts +78 -0
  158. package/dist/sync-health.js +312 -0
  159. package/dist/sync-health.js.map +1 -0
  160. package/dist/write/accessibility-probe.d.ts +12 -0
  161. package/dist/write/accessibility-probe.js +63 -0
  162. package/dist/write/accessibility-probe.js.map +1 -0
  163. package/dist/write/automation-probe.d.ts +7 -0
  164. package/dist/write/automation-probe.js +8 -1
  165. package/dist/write/automation-probe.js.map +1 -1
  166. package/dist/write/batch.js +4 -2
  167. package/dist/write/batch.js.map +1 -1
  168. package/dist/write/capabilities.d.ts +8 -0
  169. package/dist/write/capabilities.js +13 -5
  170. package/dist/write/capabilities.js.map +1 -1
  171. package/dist/write/commands.d.ts +2 -0
  172. package/dist/write/commands.js +562 -48
  173. package/dist/write/commands.js.map +1 -1
  174. package/dist/write/edit-checklist.js +3 -2
  175. package/dist/write/edit-checklist.js.map +1 -1
  176. package/dist/write/guards.d.ts +2 -7
  177. package/dist/write/guards.js +64 -6
  178. package/dist/write/guards.js.map +1 -1
  179. package/dist/write/heading.js +2 -0
  180. package/dist/write/heading.js.map +1 -1
  181. package/dist/write/lock.d.ts +40 -2
  182. package/dist/write/lock.js +91 -14
  183. package/dist/write/lock.js.map +1 -1
  184. package/dist/write/make-repeating-project.d.ts +4 -0
  185. package/dist/write/make-repeating-project.js +253 -0
  186. package/dist/write/make-repeating-project.js.map +1 -0
  187. package/dist/write/operations.d.ts +145 -1
  188. package/dist/write/operations.js +48 -0
  189. package/dist/write/operations.js.map +1 -1
  190. package/dist/write/pipeline.d.ts +19 -0
  191. package/dist/write/pipeline.js +109 -26
  192. package/dist/write/pipeline.js.map +1 -1
  193. package/dist/write/pre-state.d.ts +68 -3
  194. package/dist/write/pre-state.js +102 -3
  195. package/dist/write/pre-state.js.map +1 -1
  196. package/dist/write/reopen.js +3 -2
  197. package/dist/write/reopen.js.map +1 -1
  198. package/dist/write/reorder.js +27 -17
  199. package/dist/write/reorder.js.map +1 -1
  200. package/dist/write/repeat-rule.d.ts +22 -0
  201. package/dist/write/repeat-rule.js +258 -0
  202. package/dist/write/repeat-rule.js.map +1 -0
  203. package/dist/write/reversibility.js +49 -0
  204. package/dist/write/reversibility.js.map +1 -1
  205. package/dist/write/tag-refs.d.ts +47 -0
  206. package/dist/write/tag-refs.js +126 -0
  207. package/dist/write/tag-refs.js.map +1 -0
  208. package/dist/write/undo.d.ts +74 -5
  209. package/dist/write/undo.js +494 -80
  210. package/dist/write/undo.js.map +1 -1
  211. package/dist/write/vectors/registry.d.ts +17 -1
  212. package/dist/write/vectors/registry.js +18 -2
  213. package/dist/write/vectors/registry.js.map +1 -1
  214. package/dist/write/vectors/types.d.ts +119 -3
  215. package/dist/write/vectors/ui-certification.d.ts +48 -0
  216. package/dist/write/vectors/ui-certification.js +46 -0
  217. package/dist/write/vectors/ui-certification.js.map +1 -0
  218. package/dist/write/vectors/ui-drag.d.ts +188 -0
  219. package/dist/write/vectors/ui-drag.js +1095 -0
  220. package/dist/write/vectors/ui-drag.js.map +1 -0
  221. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  222. package/dist/write/vectors/ui-recipes.js +558 -0
  223. package/dist/write/vectors/ui-recipes.js.map +1 -0
  224. package/dist/write/vectors/ui.d.ts +137 -0
  225. package/dist/write/vectors/ui.js +656 -0
  226. package/dist/write/vectors/ui.js.map +1 -0
  227. package/dist/write/verify/delta.d.ts +31 -3
  228. package/dist/write/verify/delta.js +22 -6
  229. package/dist/write/verify/delta.js.map +1 -1
  230. package/dist/write/verify/poller.js +1 -0
  231. package/dist/write/verify/poller.js.map +1 -1
  232. package/package.json +1 -1
  233. package/dist/cli/exit-codes.d.ts +0 -26
  234. package/dist/cli/exit-codes.js +0 -26
  235. package/dist/cli/exit-codes.js.map +0 -1
  236. package/dist/cli/output.d.ts +0 -42
  237. package/dist/cli/output.js +0 -16
  238. package/dist/cli/output.js.map +0 -1
@@ -22,16 +22,30 @@
22
22
  * - Inverse mutations are audited under an `undo:`-prefixed actor and are
23
23
  * themselves EXCLUDED from later undo target selection (no undo-the-undo).
24
24
  * - PRECONDITION guard: before executing each inverse step, runUndo confirms
25
- * the fields the step would OVERWRITE still hold their recorded after-state
26
- * (`observed`). A field an out-of-band edit already moved is NOT clobbered —
27
- * the step is refused (blocked) and unwinding stops. This is in addition to
28
- * the pipeline's own verified read-after-write.
25
+ * the state the step would OVERWRITE still holds its recorded after-state
26
+ * (`observed`). Two axes are checked, both against the SAME recorded
27
+ * `observed` after-values: CONTENT fields (title/notes/deadline/reminder/
28
+ * tags, keyed 1:1 to the inverse step's params) and the STRUCTURAL axes each
29
+ * step names in its `guardFields` — status, container (project/area/
30
+ * heading), schedule (start/startDate/todaySection), and trashed state. A
31
+ * field an out-of-band edit already moved is NOT clobbered — the step is
32
+ * refused (blocked, naming what moved) and unwinding stops.
33
+ * `--acknowledge-out-of-band-changes` (MCP `acknowledge_out_of_band_changes`)
34
+ * bypasses this whole class uniformly (content + structural) — a single
35
+ * "the world moved, overwrite anyway". Axes with no captured after-state are
36
+ * left unguarded (see the NOT-GUARDED note on `checkStepPrecondition`); the
37
+ * checklist item-level refusal is a SEPARATE plan-time mechanism (it cannot
38
+ * compute a faithful forced inverse), so the flag does not bypass it. This is
39
+ * in addition to the pipeline's own verified read-after-write.
29
40
  */
30
41
  import { readFileSync, readdirSync } from "node:fs";
31
42
  import { join } from "node:path";
43
+ import { undoToken } from "../audit/schema.js";
32
44
  import { localToday } from "../model/dates.js";
33
45
  import { getField } from "./verify/delta.js";
34
46
  import { isRepeatingTemplate, loadTarget } from "./pre-state.js";
47
+ import { isUiDriveOp } from "./operations.js";
48
+ import { ruleToInverseParams } from "./repeat-rule.js";
35
49
  import { runMutation } from "./pipeline.js";
36
50
  import { runReorder } from "./reorder.js";
37
51
  // -------------------------------------------------------------- audit reads
@@ -47,6 +61,7 @@ export function readAuditRecords(dir) {
47
61
  return [];
48
62
  }
49
63
  const records = [];
64
+ let torn = 0;
50
65
  for (const file of files) {
51
66
  let raw;
52
67
  try {
@@ -64,24 +79,85 @@ export function readAuditRecords(dir) {
64
79
  records.push(parsed);
65
80
  }
66
81
  catch {
67
- // tolerate a torn/corrupt line — audit files are append-only
82
+ // tolerate a torn/corrupt line — append-only files can hold a partial
83
+ // trailing write — but make it VISIBLE rather than silently dropping it.
84
+ torn += 1;
68
85
  }
69
86
  }
70
87
  }
88
+ // One note per read (M5): silent line-dropping could hide a lost record.
89
+ if (torn > 0) {
90
+ process.stderr.write(`things: skipped ${torn} unreadable line(s) in the local change history (${dir}) — ` +
91
+ "a change record may be incomplete; recent history is otherwise intact\n");
92
+ }
71
93
  return records.toSorted((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
72
94
  }
73
95
  /**
74
- * The last N undoable targets, NEWEST FIRST (undo unwinds a stack). Only
75
- * successful mutations qualify; inverse mutations (actor `undo:…`) never do.
96
+ * Which audit records are undoable at all: only SUCCESSFUL mutations, never an
97
+ * inverse mutation (actor `undo:…` — no undo-the-undo), and never a compound
98
+ * leg (its summary record is the single undoable unit for the whole sequence).
99
+ *
100
+ * `result === "ok"` also excludes INTENT records (result `"intent"`, the
101
+ * pre-execute marker — M3): an intent is not a completed mutation, so it is
102
+ * never an undo target. This is the single choke point every selector flows
103
+ * through (selectUndoTargets, its `--txn` and `by` branches), so intent records
104
+ * are skipped there uniformly. The two OTHER audit readers that scan by result
105
+ * — runUndo's already-undone check and planUndo's compound-leg lookup — also
106
+ * filter `result === "ok"`, so they skip intent records too.
76
107
  */
77
- export function selectUndoTargets(records, last) {
78
- // Compound operations undo as ONE unit: legs are excluded here; their
79
- // summary record replays every inverse (or, for reorders, issues a single
80
- // inverse reorder from the recorded pre-ranks).
81
- return records
82
- .filter((r) => r.result === "ok" && !r.actor.startsWith("undo:") && r.txn?.role !== "leg")
83
- .slice(-Math.max(1, last))
84
- .toReversed();
108
+ function undoableRecords(records) {
109
+ return records.filter((r) => r.result === "ok" && !r.actor.startsWith("undo:") && r.txn?.role !== "leg");
110
+ }
111
+ /** Pairing key for an intent and its final record (both derive from startedAt). */
112
+ function intentKey(r) {
113
+ // uuid is DELIBERATELY excluded: a create discovers its uuid only in the
114
+ // final record, so the intent (uuid null) and final (uuid set) must still pair.
115
+ return JSON.stringify([r.ts, r.op, r.actor, r.host]);
116
+ }
117
+ /**
118
+ * Detect crashed writes: INTENT records left without a matching final record.
119
+ * A mutation writes an intent immediately before touching the app and a final
120
+ * record after read-after-write; the two share ts+op+actor+host. An intent
121
+ * with no non-intent sibling on that key means the process died mid-write — the
122
+ * app may have applied the change, but no result was recorded, so the change is
123
+ * invisible to undo. Surfaced by `things doctor` so the user can reconcile.
124
+ */
125
+ export function scanAuditIntegrity(records) {
126
+ const finalKeys = new Set();
127
+ for (const r of records) {
128
+ if (r.result !== "intent")
129
+ finalKeys.add(intentKey(r));
130
+ }
131
+ let orphanedIntents = 0;
132
+ let newestOrphanIntent = null;
133
+ for (const r of records) {
134
+ if (r.result !== "intent" || finalKeys.has(intentKey(r)))
135
+ continue;
136
+ orphanedIntents += 1;
137
+ if (newestOrphanIntent === null || r.ts > newestOrphanIntent)
138
+ newestOrphanIntent = r.ts;
139
+ }
140
+ return { orphanedIntents, newestOrphanIntent };
141
+ }
142
+ /**
143
+ * The undoable targets to unwind, NEWEST FIRST (undo unwinds a stack).
144
+ *
145
+ * - `txn` wins when present: the ONE record whose undo token matches (or an
146
+ * empty array — the caller distinguishes not-found from already-undone).
147
+ * - otherwise the last `last` records, optionally narrowed to actor `by`
148
+ * (exact match; "*"/undefined = every actor). `by` NEVER matches an
149
+ * `undo:<actor>` record — those are excluded from undoability entirely.
150
+ */
151
+ export function selectUndoTargets(records, selector = {}) {
152
+ const undoable = undoableRecords(records);
153
+ if (selector.txn !== undefined) {
154
+ const match = undoable.find((r) => undoToken(r) === selector.txn);
155
+ return match === undefined ? [] : [match];
156
+ }
157
+ const byActor = selector.by === undefined || selector.by === "*"
158
+ ? undoable
159
+ : undoable.filter((r) => r.actor === selector.by);
160
+ return byActor.slice(-Math.max(1, selector.last ?? 1)).toReversed();
85
161
  }
86
162
  // -------------------------------------------------------------- plan builder
87
163
  /**
@@ -96,6 +172,22 @@ export const IRREVERSIBLE = {
96
172
  "trash.empty": "emptying the Trash hard-deletes every row — nothing to restore (A27)",
97
173
  "heading.create": "a created heading can only be removed by deleting it, which has no headless surface " +
98
174
  "(heading delete is interactive-only) — archive it in the app instead",
175
+ "todo.make-repeating": "making a to-do repeat is an identity replacement (UI2-a): the original uuid is destroyed " +
176
+ "and a new template row is born — there is no un-repeat that restores the original",
177
+ "todo.convert-to-project": "converting a to-do to a project is an identity replacement (UI2-d): the to-do uuid is " +
178
+ "destroyed and a new project is born — the app offers no convert-back",
179
+ "project.make-repeating": "making a project repeat is an identity replacement (UIC4-b): the original project uuid is " +
180
+ "destroyed and a new template project is born — there is no un-repeat that restores the original",
181
+ "project.create-repeating": "the composite creates a project then promotes it (identity replacement, UIC4-b): the " +
182
+ "created uuid is destroyed by the promote and a new repeating template is born — delete the " +
183
+ "resulting repeating project in the app",
184
+ "heading.convert-to-project": "converting a heading to a project is an identity replacement (UI2-d): the heading uuid is " +
185
+ "destroyed and a new project is born — no convert-back",
186
+ // NB: todo.reschedule-repeat / project.reschedule-repeat are NOT here — with
187
+ // the full rule vocabulary they are CONDITIONAL (planUndo re-drives reschedule
188
+ // with the CAPTURED prior rule when it is decodable and expressible; only a
189
+ // rule the Repeat dialog itself cannot produce falls to the irreversible
190
+ // branch). See the reschedule cases below and reversibility.ts.
99
191
  // NB: todo.clear-dated-reminder is NOT here — it IS reversible. The URL
100
192
  // scheme re-SETS a dated reminder (update?id=X&when=<date>@<time>, R17/R18),
101
193
  // so its inverse re-attaches the captured reminder to the item's current
@@ -106,6 +198,24 @@ export const IRREVERSIBLE = {
106
198
  function preField(record, field) {
107
199
  return record.pre === null ? undefined : record.pre[field];
108
200
  }
201
+ const RULE_UNITS = new Set(["daily", "weekly", "monthly", "yearly"]);
202
+ /**
203
+ * The decoded PRIOR recurrence rule captured by a reschedule pre-read
204
+ * (`repeating.rule`), or null when it was not captured / not a decodable rule.
205
+ * The value survived JSON in the audit trail, so it is shape-checked rather than
206
+ * re-decoded.
207
+ */
208
+ function decodedRuleOf(record) {
209
+ const raw = preField(record, "repeating.rule");
210
+ if (raw === null || typeof raw !== "object")
211
+ return null;
212
+ const r = raw;
213
+ if (typeof r["unit"] !== "string" || !RULE_UNITS.has(r["unit"]))
214
+ return null;
215
+ if (typeof r["interval"] !== "number" || !Array.isArray(r["offsets"]))
216
+ return null;
217
+ return raw;
218
+ }
109
219
  /**
110
220
  * Reconstruct the scheduling step that restores a to-do's pre-op placement
111
221
  * from the captured pre-values (start / startDate / todaySection / reminder).
@@ -291,6 +401,7 @@ function planChecklistItemInverse(record, current) {
291
401
  return conflict(`the moved item "${title}" is no longer in the checklist`);
292
402
  if (i === -2)
293
403
  return conflict(`"${title}" is now a duplicate title — the moved item is ambiguous`);
404
+ // oxlint-disable-next-line no-map-spread -- cloning specs before splice, not mutating in place
294
405
  const next = items.map((c) => ({ ...c }));
295
406
  const [moved] = next.splice(i, 1);
296
407
  next.splice(Math.max(0, Math.min(next.length, oldPos - 1)), 0, moved);
@@ -333,7 +444,13 @@ function wholesaleChecklistConflict(record, current) {
333
444
  * it was cleared from), so it must read the live schedule.
334
445
  */
335
446
  export function planUndo(record, now, allRecords = [], current) {
336
- const target = { ts: record.ts, op: record.op, uuid: record.uuid, actor: record.actor };
447
+ const target = {
448
+ ts: record.ts,
449
+ op: record.op,
450
+ uuid: record.uuid,
451
+ actor: record.actor,
452
+ token: undoToken(record),
453
+ };
337
454
  const todayIso = localToday(now);
338
455
  const notes = [];
339
456
  const irreversible = (reason) => ({
@@ -411,7 +528,7 @@ export function planUndo(record, now, allRecords = [], current) {
411
528
  return {
412
529
  target,
413
530
  kind: "invertible",
414
- steps: [{ op: "todo.reopen", params: { uuid } }],
531
+ steps: [{ op: "todo.reopen", params: { uuid }, options: { guardFields: ["status"] } }],
415
532
  notes,
416
533
  };
417
534
  }
@@ -425,7 +542,13 @@ export function planUndo(record, now, allRecords = [], current) {
425
542
  return {
426
543
  target,
427
544
  kind: "invertible",
428
- steps: [{ op: was === "completed" ? "todo.complete" : "todo.cancel", params: { uuid } }],
545
+ steps: [
546
+ {
547
+ op: was === "completed" ? "todo.complete" : "todo.cancel",
548
+ params: { uuid },
549
+ options: { guardFields: ["status"] },
550
+ },
551
+ ],
429
552
  notes,
430
553
  };
431
554
  }
@@ -437,7 +560,7 @@ export function planUndo(record, now, allRecords = [], current) {
437
560
  return {
438
561
  target,
439
562
  kind: "invertible",
440
- steps: [{ op: "todo.restore", params: { uuid } }],
563
+ steps: [{ op: "todo.restore", params: { uuid }, options: { guardFields: ["trashed"] } }],
441
564
  notes,
442
565
  };
443
566
  }
@@ -447,7 +570,7 @@ export function planUndo(record, now, allRecords = [], current) {
447
570
  return {
448
571
  target,
449
572
  kind: "invertible",
450
- steps: [{ op: "todo.delete", params: { uuid } }],
573
+ steps: [{ op: "todo.delete", params: { uuid }, options: { guardFields: ["trashed"] } }],
451
574
  notes,
452
575
  };
453
576
  }
@@ -458,7 +581,7 @@ export function planUndo(record, now, allRecords = [], current) {
458
581
  return {
459
582
  target,
460
583
  kind: "invertible",
461
- steps: [{ op: "project.restore", params: { uuid } }],
584
+ steps: [{ op: "project.restore", params: { uuid }, options: { guardFields: ["trashed"] } }],
462
585
  notes,
463
586
  };
464
587
  }
@@ -469,7 +592,9 @@ export function planUndo(record, now, allRecords = [], current) {
469
592
  case "project.cancel": {
470
593
  if (uuid === null)
471
594
  return irreversible("no target uuid recorded");
472
- const steps = [{ op: "project.reopen", params: { uuid } }];
595
+ const steps = [
596
+ { op: "project.reopen", params: { uuid }, options: { guardFields: ["status"] } },
597
+ ];
473
598
  const pre = record.pre ?? {};
474
599
  if (!("status" in pre)) {
475
600
  for (const [childUuid, fields] of Object.entries(pre)) {
@@ -501,8 +626,16 @@ export function planUndo(record, now, allRecords = [], current) {
501
626
  kind: "invertible",
502
627
  steps: [
503
628
  was === "completed"
504
- ? { op: "project.complete", params: { uuid, children: "require-resolved" } }
505
- : { op: "project.cancel", params: { uuid, children: "require-resolved" } },
629
+ ? {
630
+ op: "project.complete",
631
+ params: { uuid, children: "require-resolved" },
632
+ options: { guardFields: ["status"] },
633
+ }
634
+ : {
635
+ op: "project.cancel",
636
+ params: { uuid, children: "require-resolved" },
637
+ options: { guardFields: ["status"] },
638
+ },
506
639
  ],
507
640
  notes,
508
641
  };
@@ -513,7 +646,7 @@ export function planUndo(record, now, allRecords = [], current) {
513
646
  return {
514
647
  target,
515
648
  kind: "invertible",
516
- steps: [{ op: "project.delete", params: { uuid } }],
649
+ steps: [{ op: "project.delete", params: { uuid }, options: { guardFields: ["trashed"] } }],
517
650
  notes,
518
651
  };
519
652
  }
@@ -532,19 +665,30 @@ export function planUndo(record, now, allRecords = [], current) {
532
665
  if (deadline !== undefined)
533
666
  params["deadline"] = deadline;
534
667
  const requestedWhen = (record.requested["when"] ?? record.requested["reminder"]) !== undefined;
668
+ // Schedule axes the restore overwrites — guarded against the recorded
669
+ // after-state so an out-of-band re-schedule blocks rather than clobbers.
670
+ const scheduleGuard = ["start", "startDate", "todaySection"];
671
+ let scheduleMerged = false;
535
672
  if (requestedWhen) {
536
673
  const schedule = scheduleSteps(uuid, record, todayIso);
537
674
  notes.push(...schedule.notes);
538
675
  const scheduleStep = schedule.steps[0];
539
676
  if (scheduleStep !== undefined && scheduleStep.op === "todo.update") {
540
677
  Object.assign(params, scheduleStep.params);
678
+ scheduleMerged = true;
541
679
  }
542
680
  else if (scheduleStep !== undefined) {
543
- steps.push(scheduleStep); // inbox restore is a separate move op
681
+ // inbox restore is a separate move op — carry the schedule guard on it
682
+ steps.push({ ...scheduleStep, options: { guardFields: scheduleGuard } });
544
683
  }
545
684
  }
546
- if (Object.keys(params).length > 1)
547
- steps.unshift({ op: "todo.update", params });
685
+ if (Object.keys(params).length > 1) {
686
+ steps.unshift({
687
+ op: "todo.update",
688
+ params,
689
+ ...(scheduleMerged && { options: { guardFields: scheduleGuard } }),
690
+ });
691
+ }
548
692
  if (steps.length === 0) {
549
693
  return irreversible("no pre-values were captured for the changed fields");
550
694
  }
@@ -566,12 +710,15 @@ export function planUndo(record, now, allRecords = [], current) {
566
710
  // when/reminder restore reuses the schedule reconstructor (emitting a
567
711
  // project.update); projects never live in the Inbox so that branch is
568
712
  // unreachable here.
713
+ let projectScheduleMerged = false;
569
714
  if ((record.requested["when"] ?? record.requested["reminder"]) !== undefined) {
570
715
  const schedule = scheduleSteps(uuid, record, todayIso, "project.update");
571
716
  notes.push(...schedule.notes);
572
717
  const scheduleStep = schedule.steps[0];
573
- if (scheduleStep !== undefined)
718
+ if (scheduleStep !== undefined) {
574
719
  Object.assign(params, scheduleStep.params);
720
+ projectScheduleMerged = true;
721
+ }
575
722
  }
576
723
  if (Object.keys(params).length === 1) {
577
724
  return irreversible("no pre-values were captured for the changed fields");
@@ -579,7 +726,15 @@ export function planUndo(record, now, allRecords = [], current) {
579
726
  return {
580
727
  target,
581
728
  kind: "invertible",
582
- steps: [{ op: "project.update", params }],
729
+ steps: [
730
+ {
731
+ op: "project.update",
732
+ params,
733
+ ...(projectScheduleMerged && {
734
+ options: { guardFields: ["start", "startDate", "todaySection"] },
735
+ }),
736
+ },
737
+ ],
583
738
  notes,
584
739
  };
585
740
  }
@@ -612,11 +767,19 @@ export function planUndo(record, now, allRecords = [], current) {
612
767
  if (preField(record, "heading") !== null && preField(record, "heading") !== undefined) {
613
768
  notes.push("heading placement cannot be restored — the to-do returns to the project root");
614
769
  }
770
+ // The detach removed the container; guard that it is STILL detached
771
+ // (current project/area both null) so a re-attach out of band blocks.
615
772
  if (typeof oldProj === "string") {
616
773
  return {
617
774
  target,
618
775
  kind: "invertible",
619
- steps: [{ op: "todo.move", params: { uuid, project: { uuid: oldProj } } }],
776
+ steps: [
777
+ {
778
+ op: "todo.move",
779
+ params: { uuid, project: { uuid: oldProj } },
780
+ options: { guardFields: ["project", "area"] },
781
+ },
782
+ ],
620
783
  notes,
621
784
  };
622
785
  }
@@ -624,7 +787,13 @@ export function planUndo(record, now, allRecords = [], current) {
624
787
  return {
625
788
  target,
626
789
  kind: "invertible",
627
- steps: [{ op: "todo.move", params: { uuid, area: { uuid: oldArea } } }],
790
+ steps: [
791
+ {
792
+ op: "todo.move",
793
+ params: { uuid, area: { uuid: oldArea } },
794
+ options: { guardFields: ["project", "area"] },
795
+ },
796
+ ],
628
797
  notes,
629
798
  };
630
799
  }
@@ -638,6 +807,11 @@ export function planUndo(record, now, allRecords = [], current) {
638
807
  if (schedule.steps.length === 0) {
639
808
  return irreversible("pre-op scheduling state was not captured — cannot leave the Inbox");
640
809
  }
810
+ // The op left the item in the Inbox (start="inbox"); guard that placement
811
+ // so an out-of-band re-schedule blocks the leave-Inbox restore.
812
+ for (const s of schedule.steps) {
813
+ s.options = { ...s.options, guardFields: ["start", "startDate"] };
814
+ }
641
815
  return { target, kind: "invertible", steps: schedule.steps, notes };
642
816
  }
643
817
  // The audit captured the OLD value of whatever destination-kind fields
@@ -657,7 +831,13 @@ export function planUndo(record, now, allRecords = [], current) {
657
831
  return {
658
832
  target,
659
833
  kind: "invertible",
660
- steps: [{ op: "todo.move", params: { uuid, project: { uuid: oldProject } } }],
834
+ steps: [
835
+ {
836
+ op: "todo.move",
837
+ params: { uuid, project: { uuid: oldProject } },
838
+ options: { guardFields: ["project.uuid", "heading.uuid"] },
839
+ },
840
+ ],
661
841
  notes,
662
842
  };
663
843
  }
@@ -665,7 +845,13 @@ export function planUndo(record, now, allRecords = [], current) {
665
845
  return {
666
846
  target,
667
847
  kind: "invertible",
668
- steps: [{ op: "todo.move", params: { uuid, area: { uuid: oldArea } } }],
848
+ steps: [
849
+ {
850
+ op: "todo.move",
851
+ params: { uuid, area: { uuid: oldArea } },
852
+ options: { guardFields: ["area.uuid"] },
853
+ },
854
+ ],
669
855
  notes,
670
856
  };
671
857
  }
@@ -683,10 +869,17 @@ export function planUndo(record, now, allRecords = [], current) {
683
869
  ? areaRef.uuid
684
870
  : undefined;
685
871
  if (typeof oldArea === "string") {
872
+ // The op detached (area now null); guard it is still area-less.
686
873
  return {
687
874
  target,
688
875
  kind: "invertible",
689
- steps: [{ op: "project.move", params: { uuid, area: { uuid: oldArea } } }],
876
+ steps: [
877
+ {
878
+ op: "project.move",
879
+ params: { uuid, area: { uuid: oldArea } },
880
+ options: { guardFields: ["area"] },
881
+ },
882
+ ],
690
883
  notes,
691
884
  };
692
885
  }
@@ -697,7 +890,13 @@ export function planUndo(record, now, allRecords = [], current) {
697
890
  return {
698
891
  target,
699
892
  kind: "invertible",
700
- steps: [{ op: "project.move", params: { uuid, area: { uuid: areaPre } } }],
893
+ steps: [
894
+ {
895
+ op: "project.move",
896
+ params: { uuid, area: { uuid: areaPre } },
897
+ options: { guardFields: ["area.uuid"] },
898
+ },
899
+ ],
701
900
  notes,
702
901
  };
703
902
  }
@@ -706,7 +905,13 @@ export function planUndo(record, now, allRecords = [], current) {
706
905
  return {
707
906
  target,
708
907
  kind: "invertible",
709
- steps: [{ op: "project.move", params: { uuid, detach: true } }],
908
+ steps: [
909
+ {
910
+ op: "project.move",
911
+ params: { uuid, detach: true },
912
+ options: { guardFields: ["area.uuid"] },
913
+ },
914
+ ],
710
915
  notes,
711
916
  };
712
917
  }
@@ -870,6 +1075,35 @@ export function planUndo(record, now, allRecords = [], current) {
870
1075
  "their current positions");
871
1076
  return { target, kind: "invertible", steps: [{ op: "reorder", params }], notes };
872
1077
  }
1078
+ case "area.reorder": {
1079
+ if (uuid === null)
1080
+ return irreversible("no target area uuid recorded");
1081
+ const pre = record.pre;
1082
+ if (pre === null)
1083
+ return irreversible("the pre-move area order was not captured");
1084
+ const ranked = Object.entries(pre).filter(([, rank]) => typeof rank === "number");
1085
+ if (ranked.length === 0)
1086
+ return irreversible("the pre-move area order was not captured");
1087
+ const ranks = ranked.map(([, r]) => r);
1088
+ if (new Set(ranks).size !== ranks.length) {
1089
+ return irreversible("the area order before this move was not fully determined (areas were still " +
1090
+ "unranked), so the previous position cannot be reconstructed");
1091
+ }
1092
+ const ordered = ranked.toSorted((a, b) => a[1] - b[1]).map(([id]) => id);
1093
+ const idx = ordered.indexOf(uuid);
1094
+ if (idx < 0)
1095
+ return irreversible("the moved area is not in the captured order");
1096
+ const pred = ordered[idx - 1];
1097
+ const params = pred === undefined ? { target: uuid, position: "first" } : { target: uuid, after: pred };
1098
+ notes.push("the area returns to its previous position by driving the app again " +
1099
+ "(relative order restores; the app may assign fresh rank numbers)");
1100
+ return {
1101
+ target,
1102
+ kind: "invertible",
1103
+ steps: [{ op: "area.reorder", params }],
1104
+ notes,
1105
+ };
1106
+ }
873
1107
  case "heading.rename": {
874
1108
  if (uuid === null)
875
1109
  return irreversible("no target uuid recorded");
@@ -886,7 +1120,9 @@ export function planUndo(record, now, allRecords = [], current) {
886
1120
  case "heading.archive": {
887
1121
  if (uuid === null)
888
1122
  return irreversible("no target uuid recorded");
889
- const steps = [{ op: "heading.unarchive", params: { uuid } }];
1123
+ const steps = [
1124
+ { op: "heading.unarchive", params: { uuid }, options: { guardFields: ["status"] } },
1125
+ ];
890
1126
  // Reopen exactly the children the cascade resolved (nested pre map —
891
1127
  // the project.complete pattern). Reparented children live in leg
892
1128
  // records; replay their inverses too when this summary heads a txn.
@@ -941,7 +1177,13 @@ export function planUndo(record, now, allRecords = [], current) {
941
1177
  return {
942
1178
  target,
943
1179
  kind: "invertible",
944
- steps: [{ op: "heading.archive", params: { uuid, children: "complete" } }],
1180
+ steps: [
1181
+ {
1182
+ op: "heading.archive",
1183
+ params: { uuid, children: "complete" },
1184
+ options: { guardFields: ["status"] },
1185
+ },
1186
+ ],
945
1187
  notes,
946
1188
  };
947
1189
  }
@@ -982,6 +1224,78 @@ export function planUndo(record, now, allRecords = [], current) {
982
1224
  notes,
983
1225
  };
984
1226
  }
1227
+ // The ui-vector reversible pair: pause ↔ resume. Both drive the GUI, so
1228
+ // the inverse carries the drive acknowledgement (added in runUndo).
1229
+ case "todo.pause-repeat": {
1230
+ if (uuid === null)
1231
+ return irreversible("no target uuid recorded");
1232
+ return {
1233
+ target,
1234
+ kind: "invertible",
1235
+ steps: [{ op: "todo.resume-repeat", params: { uuid } }],
1236
+ notes,
1237
+ };
1238
+ }
1239
+ case "todo.resume-repeat": {
1240
+ if (uuid === null)
1241
+ return irreversible("no target uuid recorded");
1242
+ return {
1243
+ target,
1244
+ kind: "invertible",
1245
+ steps: [{ op: "todo.pause-repeat", params: { uuid } }],
1246
+ notes,
1247
+ };
1248
+ }
1249
+ case "project.pause-repeat": {
1250
+ if (uuid === null)
1251
+ return irreversible("no target uuid recorded");
1252
+ return {
1253
+ target,
1254
+ kind: "invertible",
1255
+ steps: [{ op: "project.resume-repeat", params: { uuid } }],
1256
+ notes,
1257
+ };
1258
+ }
1259
+ case "project.resume-repeat": {
1260
+ if (uuid === null)
1261
+ return irreversible("no target uuid recorded");
1262
+ return {
1263
+ target,
1264
+ kind: "invertible",
1265
+ steps: [{ op: "project.pause-repeat", params: { uuid } }],
1266
+ notes,
1267
+ };
1268
+ }
1269
+ // Reschedule: identity preserved (UI2-b / UIC2-a), the rule mutated in
1270
+ // place. The captured-rule inverse re-drives reschedule with the PRIOR rule
1271
+ // (recorded pre-op as the decoded rule + deadline flag). Reversible when the
1272
+ // prior rule was captured, decodable, AND expressible in the Repeat dialog's
1273
+ // vocabulary; irreversible for a rule the dialog itself cannot produce.
1274
+ case "todo.reschedule-repeat":
1275
+ case "project.reschedule-repeat": {
1276
+ if (uuid === null)
1277
+ return irreversible("no target uuid recorded");
1278
+ const priorRule = decodedRuleOf(record);
1279
+ if (priorRule === null) {
1280
+ return irreversible("the prior recurrence rule was not captured (or could not be decoded) — reschedule again by hand");
1281
+ }
1282
+ const inverse = ruleToInverseParams(priorRule, preField(record, "repeating.deadlined") === true);
1283
+ if (inverse === null) {
1284
+ return irreversible("the prior rule is outside the Repeat dialog's vocabulary (a rule with two end bounds, or a " +
1285
+ "multi-anchor month/year rule) — reschedule again by hand");
1286
+ }
1287
+ if (inverse.reminder === undefined) {
1288
+ // The reminder time is not part of the recurrence rule, so a reminder
1289
+ // the reschedule may have set/changed is not restored by the inverse.
1290
+ notes.push("the recurrence structure is restored; a reminder time on the spawned instances is not part of the captured rule and is not restored");
1291
+ }
1292
+ return {
1293
+ target,
1294
+ kind: "invertible",
1295
+ steps: [{ op: record.op, params: { uuid, ...inverse } }],
1296
+ notes,
1297
+ };
1298
+ }
985
1299
  default:
986
1300
  return irreversible(`no inverse is defined for operation "${record.op}"`);
987
1301
  }
@@ -989,6 +1303,44 @@ export function planUndo(record, now, allRecords = [], current) {
989
1303
  // ------------------------------------------------------ precondition guard
990
1304
  /** Content fields an inverse can silently CLOBBER; keyed to observed 1:1. */
991
1305
  const CLOBBER_FIELDS = ["title", "notes", "deadline", "reminder", "tags"];
1306
+ /**
1307
+ * Human labels for the guarded axes, used in the block detail. Both the raw
1308
+ * content fields and the structural getField paths a step may name in
1309
+ * `guardFields` are mapped here; anything unmapped falls back to the raw path.
1310
+ */
1311
+ const AXIS_LABEL = {
1312
+ title: "title",
1313
+ notes: "notes",
1314
+ deadline: "deadline",
1315
+ reminder: "reminder",
1316
+ tags: "tags",
1317
+ status: "status",
1318
+ trashed: "trashed state",
1319
+ project: "project",
1320
+ "project.uuid": "project",
1321
+ area: "area",
1322
+ "area.uuid": "area",
1323
+ "heading.uuid": "heading",
1324
+ start: "schedule",
1325
+ startDate: "schedule",
1326
+ todaySection: "schedule",
1327
+ };
1328
+ /**
1329
+ * NOT GUARDED (no captured after-state to compare against, so left unguarded —
1330
+ * these axes rely on the inverse's own verified read-after-write instead):
1331
+ * - REPEAT rule / paused state (reschedule-repeat, pause/resume-repeat): the
1332
+ * repeat axis is out of this guard's scope.
1333
+ * - todo.backdate timestamps (creation/completion date).
1334
+ * - reorder / area.reorder RANKS: the inverse is a 3-way restore that already
1335
+ * leaves non-targeted members in place; a rank precondition is not modeled.
1336
+ * - area.update / tag.update fields: those steps address by `target`, not
1337
+ * `uuid`, so this guard returns early for them (entity ops are unguarded, as
1338
+ * before this change).
1339
+ * - CASCADE CHILDREN of a project/heading resolve (the extra todo.reopen steps
1340
+ * carry no guardFields): the recorded `observed` after-values are the PARENT
1341
+ * target's, not each child's, so a per-child compare cannot be made from
1342
+ * them; the parent's status axis IS guarded.
1343
+ */
992
1344
  function fieldsEqual(a, b) {
993
1345
  if (Array.isArray(a) && Array.isArray(b))
994
1346
  return JSON.stringify(a) === JSON.stringify(b);
@@ -997,14 +1349,31 @@ function fieldsEqual(a, b) {
997
1349
  function formatValue(value) {
998
1350
  return value === null || value === undefined ? "none" : JSON.stringify(value);
999
1351
  }
1352
+ /** The blocked result for an axis an out-of-band change already moved. */
1353
+ function clobberBlock(op, field, after, cur) {
1354
+ const label = AXIS_LABEL[field] ?? field;
1355
+ return {
1356
+ kind: "blocked",
1357
+ op,
1358
+ reason: "environment",
1359
+ detail: `${label} changed since the recorded mutation (expected ${formatValue(after)}, found ` +
1360
+ `${formatValue(cur)}) — refusing to overwrite a change made outside things-api`,
1361
+ remediation: "review the item's current state and redo the change by hand if it's still wanted; or " +
1362
+ "re-run undo with --acknowledge-out-of-band-changes to overwrite the change made since",
1363
+ };
1364
+ }
1000
1365
  /**
1001
- * Refuse an inverse step that would overwrite a field an out-of-band edit has
1002
- * already moved. For every CLOBBER_FIELD the step writes AND that the audit
1003
- * `observed` (after-state) recorded, the target's CURRENT value must still
1004
- * equal that after-value; a divergence means the world moved underneath us, so
1005
- * we block rather than clobber. Fields absent from `observed` are skipped (we
1006
- * cannot confirm them but must not break ops whose observed legitimately omits
1007
- * them). Returns a blocked result on divergence, else null.
1366
+ * Refuse an inverse step that would overwrite state an out-of-band change has
1367
+ * already moved. Two axes are checked against the SAME recorded `observed`
1368
+ * after-state:
1369
+ * - CONTENT (CLOBBER_FIELDS): every content field the step WRITES (present in
1370
+ * its params) whose after-value `observed` recorded.
1371
+ * - STRUCTURAL (`step.options.guardFields`): the container / status / schedule
1372
+ * / trashed getField paths the step overwrites, set by planUndo on the
1373
+ * primary-target step. Paths absent from `observed` are skipped.
1374
+ * For each, the target's CURRENT value must still equal the recorded after-value;
1375
+ * a divergence means the world moved underneath us, so we block rather than
1376
+ * clobber. Returns a blocked result on the first divergence, else null.
1008
1377
  */
1009
1378
  function checkStepPrecondition(deps, step, observed) {
1010
1379
  if (observed === null)
@@ -1019,25 +1388,62 @@ function checkStepPrecondition(deps, step, observed) {
1019
1388
  if (!(field in step.params) || !(field in observed))
1020
1389
  continue;
1021
1390
  const cur = getField(current, field) ?? null;
1022
- const after = observed[field];
1023
- if (!fieldsEqual(cur, after)) {
1024
- return {
1025
- kind: "blocked",
1026
- op: step.op,
1027
- reason: "environment",
1028
- detail: `${field} changed since the recorded mutation (expected ${formatValue(after)}, found ` +
1029
- `${formatValue(cur)}) — refusing to avoid clobbering an out-of-band edit`,
1030
- remediation: "review the item's current state; redo the change by hand if it's still wanted, or " +
1031
- "re-run undo once the field is back to its post-change value",
1032
- };
1033
- }
1391
+ if (!fieldsEqual(cur, observed[field]))
1392
+ return clobberBlock(step.op, field, observed[field], cur);
1393
+ }
1394
+ for (const field of step.options?.guardFields ?? []) {
1395
+ if (!(field in observed))
1396
+ continue; // no captured after-state for this axis
1397
+ const cur = getField(current, field) ?? null;
1398
+ if (!fieldsEqual(cur, observed[field]))
1399
+ return clobberBlock(step.op, field, observed[field], cur);
1034
1400
  }
1035
1401
  return null;
1036
1402
  }
1403
+ /**
1404
+ * The block that would refuse a step before execution, or null. Two sources:
1405
+ * the plan-time checklist refusal (item-level, never bypassed — there is no
1406
+ * faithful forced inverse) and the runtime precondition guard (content +
1407
+ * structural), which `acknowledgeOutOfBandChanges` suppresses uniformly. Shared
1408
+ * by the executor and the dry-run preview so both report the same outcome.
1409
+ */
1410
+ function stepWouldBlock(deps, step, observed, acknowledgeOutOfBandChanges) {
1411
+ const planBlock = step.options?.blocked;
1412
+ if (planBlock !== undefined) {
1413
+ return {
1414
+ kind: "blocked",
1415
+ op: step.op,
1416
+ reason: "environment",
1417
+ detail: planBlock.detail,
1418
+ remediation: planBlock.remediation,
1419
+ };
1420
+ }
1421
+ if (acknowledgeOutOfBandChanges)
1422
+ return null;
1423
+ return checkStepPrecondition(deps, step, observed);
1424
+ }
1037
1425
  // ----------------------------------------------------------------- executor
1038
1426
  export async function runUndo(deps, auditDirPath, options = {}, onItem) {
1039
1427
  const records = readAuditRecords(auditDirPath);
1040
- const targets = selectUndoTargets(records, options.last ?? 1);
1428
+ // Exact-token selection is loud and specific: distinguish a token that was
1429
+ // already undone (an inverse for it is on the trail) from one that never
1430
+ // named an undoable mutation. Both are usage errors (RangeError → exit 2).
1431
+ if (options.txn !== undefined) {
1432
+ const alreadyUndone = records.some((r) => r.undoOf === options.txn && r.result === "ok" && r.actor.startsWith("undo:"));
1433
+ if (alreadyUndone) {
1434
+ throw new RangeError(`mutation "${options.txn}" has already been undone (an inverse for it is in the audit ` +
1435
+ "trail); there is nothing left to undo");
1436
+ }
1437
+ if (selectUndoTargets(records, { txn: options.txn }).length === 0) {
1438
+ throw new RangeError(`no undoable mutation has undo token "${options.txn}" — check the token from the ` +
1439
+ "mutation result, or run `things undo --dry-run` to list recent targets");
1440
+ }
1441
+ }
1442
+ const targets = selectUndoTargets(records, {
1443
+ ...(options.last !== undefined && { last: options.last }),
1444
+ ...(options.by !== undefined && { by: options.by }),
1445
+ ...(options.txn !== undefined && { txn: options.txn }),
1446
+ });
1041
1447
  const now = deps.now?.() ?? new Date();
1042
1448
  const items = [];
1043
1449
  for (const record of targets) {
@@ -1050,30 +1456,29 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
1050
1456
  item = { plan, results: [], outcome: "irreversible" };
1051
1457
  }
1052
1458
  else if (options.dryRun === true) {
1053
- item = { plan, results: [], outcome: "dry-run" };
1459
+ // Preview the would-block outcome so --dry-run surfaces a refusal (an
1460
+ // out-of-band move/status/schedule change, or the checklist item-level
1461
+ // conflict) before any change is attempted — respecting the ack flag.
1462
+ const preview = [];
1463
+ for (const step of plan.steps) {
1464
+ const block = stepWouldBlock(deps, step, record.observed, options.acknowledgeOutOfBandChanges === true);
1465
+ if (block !== null) {
1466
+ preview.push(block);
1467
+ break;
1468
+ }
1469
+ }
1470
+ item = { plan, results: preview, outcome: "dry-run" };
1054
1471
  }
1055
1472
  else {
1056
1473
  const results = [];
1057
1474
  let failed = false;
1058
1475
  for (const step of plan.steps) {
1059
- // Plan-time precondition (checklist undos): the inverse was resolved
1060
- // against the current list and found a conflict — refuse, don't clobber.
1061
- const planBlock = step.options?.blocked;
1062
- if (planBlock !== undefined) {
1063
- results.push({
1064
- kind: "blocked",
1065
- op: step.op,
1066
- reason: "environment",
1067
- detail: planBlock.detail,
1068
- remediation: planBlock.remediation,
1069
- });
1070
- failed = true;
1071
- break;
1072
- }
1073
- // Precondition guard: never clobber a field an out-of-band edit moved.
1074
- const precondition = checkStepPrecondition(deps, step, record.observed);
1075
- if (precondition !== null) {
1076
- results.push(precondition);
1476
+ // Refuse rather than clobber: the plan-time checklist conflict, or the
1477
+ // runtime precondition guard (content + structural) unless the caller
1478
+ // acknowledged out-of-band changes.
1479
+ const block = stepWouldBlock(deps, step, record.observed, options.acknowledgeOutOfBandChanges === true);
1480
+ if (block !== null) {
1481
+ results.push(block);
1077
1482
  failed = true;
1078
1483
  break;
1079
1484
  }
@@ -1092,6 +1497,9 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
1092
1497
  }
1093
1498
  const writeOptions = {
1094
1499
  actor: `undo:${options.actor ?? deps.config.actor}`,
1500
+ // Back-reference the mutation being reversed so a later
1501
+ // `undo --txn <token>` can report it as already undone.
1502
+ undoOf: plan.target.token,
1095
1503
  ...(options.verifyTimeoutMs !== undefined && {
1096
1504
  verifyTimeoutMs: options.verifyTimeoutMs,
1097
1505
  }),
@@ -1099,10 +1507,16 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
1099
1507
  acknowledgeChecklistReset: true,
1100
1508
  }),
1101
1509
  ...(needsPermanent && { dangerouslyPermanent: true }),
1510
+ // A GUI-driven inverse (pause ↔ resume) replays a change the user
1511
+ // already acknowledged when they made it — carry the drive ack so it
1512
+ // is not re-gated by H-UI-DRIVE.
1513
+ ...(isUiDriveOp(step.op) && { dangerouslyDriveGui: true }),
1102
1514
  };
1103
1515
  const result = step.op === "reorder"
1104
- ? await runReorder(deps, step.params, writeOptions)
1105
- : await runMutation(deps, step.op, step.params, writeOptions);
1516
+ ? // undo steps must be inverted in order: each step's precondition check depends on the DB state left by the prior step
1517
+ await runReorder(deps, step.params, writeOptions)
1518
+ : // same sequencing requirement as the reorder branch above
1519
+ await runMutation(deps, step.op, step.params, writeOptions);
1106
1520
  results.push(result);
1107
1521
  if (result.kind !== "ok") {
1108
1522
  failed = true;