things-api 0.9.0 → 0.11.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 (261) hide show
  1. package/README.md +57 -12
  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/clock.d.ts +8 -0
  8. package/dist/cli/clock.js +24 -0
  9. package/dist/cli/clock.js.map +1 -0
  10. package/dist/cli/commands/area.d.ts +26 -3
  11. package/dist/cli/commands/area.js +129 -75
  12. package/dist/cli/commands/area.js.map +1 -1
  13. package/dist/cli/commands/doctor.d.ts +1 -1
  14. package/dist/cli/commands/doctor.js +44 -5
  15. package/dist/cli/commands/doctor.js.map +1 -1
  16. package/dist/cli/commands/install-skill.d.ts +45 -0
  17. package/dist/cli/commands/install-skill.js +207 -0
  18. package/dist/cli/commands/install-skill.js.map +1 -0
  19. package/dist/cli/commands/mcp.js +23 -4
  20. package/dist/cli/commands/mcp.js.map +1 -1
  21. package/dist/cli/commands/project.d.ts +18 -1
  22. package/dist/cli/commands/project.js +74 -35
  23. package/dist/cli/commands/project.js.map +1 -1
  24. package/dist/cli/commands/reads.d.ts +15 -2
  25. package/dist/cli/commands/reads.js +459 -165
  26. package/dist/cli/commands/reads.js.map +1 -1
  27. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  28. package/dist/cli/commands/repeat-flags.js +76 -0
  29. package/dist/cli/commands/repeat-flags.js.map +1 -0
  30. package/dist/cli/commands/setup.js +8 -4
  31. package/dist/cli/commands/setup.js.map +1 -1
  32. package/dist/cli/commands/show.js +53 -30
  33. package/dist/cli/commands/show.js.map +1 -1
  34. package/dist/cli/commands/todo.d.ts +1 -1
  35. package/dist/cli/commands/todo.js +20 -13
  36. package/dist/cli/commands/todo.js.map +1 -1
  37. package/dist/cli/commands/writes.js +388 -161
  38. package/dist/cli/commands/writes.js.map +1 -1
  39. package/dist/cli/did-you-mean.d.ts +1 -1
  40. package/dist/cli/excess-args.d.ts +15 -0
  41. package/dist/cli/excess-args.js +51 -0
  42. package/dist/cli/excess-args.js.map +1 -0
  43. package/dist/cli/glyphs.d.ts +55 -2
  44. package/dist/cli/glyphs.js +114 -21
  45. package/dist/cli/glyphs.js.map +1 -1
  46. package/dist/cli/help.d.ts +54 -0
  47. package/dist/cli/help.js +405 -0
  48. package/dist/cli/help.js.map +1 -0
  49. package/dist/cli/main.js +64 -26
  50. package/dist/cli/main.js.map +1 -1
  51. package/dist/cli/move-hint.d.ts +45 -0
  52. package/dist/cli/move-hint.js +196 -0
  53. package/dist/cli/move-hint.js.map +1 -0
  54. package/dist/cli/period.d.ts +9 -8
  55. package/dist/cli/period.js +56 -30
  56. package/dist/cli/period.js.map +1 -1
  57. package/dist/cli/read-driver.d.ts +26 -19
  58. package/dist/cli/read-driver.js +77 -33
  59. package/dist/cli/read-driver.js.map +1 -1
  60. package/dist/cli/render.d.ts +92 -28
  61. package/dist/cli/render.js +333 -103
  62. package/dist/cli/render.js.map +1 -1
  63. package/dist/cli/resolve-invocation.d.ts +42 -4
  64. package/dist/cli/resolve-invocation.js +97 -14
  65. package/dist/cli/resolve-invocation.js.map +1 -1
  66. package/dist/cli/skill-check.d.ts +21 -0
  67. package/dist/cli/skill-check.js +82 -0
  68. package/dist/cli/skill-check.js.map +1 -0
  69. package/dist/cli/skill.d.ts +62 -0
  70. package/dist/cli/skill.js +142 -0
  71. package/dist/cli/skill.js.map +1 -0
  72. package/dist/cli/tag-filters.d.ts +63 -0
  73. package/dist/cli/tag-filters.js +59 -0
  74. package/dist/cli/tag-filters.js.map +1 -0
  75. package/dist/cli/verb-hint.d.ts +25 -0
  76. package/dist/cli/verb-hint.js +163 -0
  77. package/dist/cli/verb-hint.js.map +1 -0
  78. package/dist/cli/version.d.ts +4 -0
  79. package/dist/cli/version.js +18 -0
  80. package/dist/cli/version.js.map +1 -0
  81. package/dist/cli/width.d.ts +135 -0
  82. package/dist/cli/width.js +313 -0
  83. package/dist/cli/width.js.map +1 -0
  84. package/dist/client.d.ts +160 -24
  85. package/dist/client.js +132 -19
  86. package/dist/client.js.map +1 -1
  87. package/dist/config.d.ts +11 -0
  88. package/dist/config.js +3 -0
  89. package/dist/config.js.map +1 -1
  90. package/dist/contracts.d.ts +97 -17
  91. package/dist/contracts.js +44 -1
  92. package/dist/contracts.js.map +1 -1
  93. package/dist/db/fingerprint.d.ts +12 -0
  94. package/dist/db/fingerprint.js +15 -1
  95. package/dist/db/fingerprint.js.map +1 -1
  96. package/dist/db/locate.js +1 -1
  97. package/dist/db/locate.js.map +1 -1
  98. package/dist/diagnose.d.ts +63 -0
  99. package/dist/diagnose.js +38 -1
  100. package/dist/diagnose.js.map +1 -1
  101. package/dist/index.d.ts +36 -5
  102. package/dist/index.js +38 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp/server.d.ts +9 -1
  105. package/dist/mcp/server.js +1038 -436
  106. package/dist/mcp/server.js.map +1 -1
  107. package/dist/model/clock.d.ts +61 -0
  108. package/dist/model/clock.js +71 -0
  109. package/dist/model/clock.js.map +1 -0
  110. package/dist/model/dates.d.ts +26 -3
  111. package/dist/model/dates.js +84 -3
  112. package/dist/model/dates.js.map +1 -1
  113. package/dist/model/entities.d.ts +48 -10
  114. package/dist/model/entities.js.map +1 -1
  115. package/dist/model/mappers.d.ts +9 -2
  116. package/dist/model/mappers.js +32 -9
  117. package/dist/model/mappers.js.map +1 -1
  118. package/dist/model/serialize.d.ts +28 -0
  119. package/dist/model/serialize.js +80 -0
  120. package/dist/model/serialize.js.map +1 -0
  121. package/dist/model/when-sugar.d.ts +47 -0
  122. package/dist/model/when-sugar.js +45 -0
  123. package/dist/model/when-sugar.js.map +1 -0
  124. package/dist/read/area-view.d.ts +8 -1
  125. package/dist/read/area-view.js +55 -17
  126. package/dist/read/area-view.js.map +1 -1
  127. package/dist/read/detail.d.ts +1 -1
  128. package/dist/read/detail.js +11 -4
  129. package/dist/read/detail.js.map +1 -1
  130. package/dist/read/filter-contract.d.ts +117 -0
  131. package/dist/read/filter-contract.js +78 -0
  132. package/dist/read/filter-contract.js.map +1 -0
  133. package/dist/read/log-boundary.d.ts +1 -1
  134. package/dist/read/log-boundary.js +13 -3
  135. package/dist/read/log-boundary.js.map +1 -1
  136. package/dist/read/predicates.d.ts +30 -0
  137. package/dist/read/predicates.js +30 -0
  138. package/dist/read/predicates.js.map +1 -1
  139. package/dist/read/project-view.d.ts +12 -1
  140. package/dist/read/project-view.js +45 -12
  141. package/dist/read/project-view.js.map +1 -1
  142. package/dist/read/queries.d.ts +152 -7
  143. package/dist/read/queries.js +277 -36
  144. package/dist/read/queries.js.map +1 -1
  145. package/dist/read/sections.d.ts +51 -0
  146. package/dist/read/sections.js +37 -0
  147. package/dist/read/sections.js.map +1 -0
  148. package/dist/read/sidebar-order.js +2 -1
  149. package/dist/read/sidebar-order.js.map +1 -1
  150. package/dist/read/snapshot.d.ts +1 -1
  151. package/dist/read/snapshot.js +9 -2
  152. package/dist/read/snapshot.js.map +1 -1
  153. package/dist/read/tags.d.ts +27 -3
  154. package/dist/read/tags.js +83 -12
  155. package/dist/read/tags.js.map +1 -1
  156. package/dist/read/truncation.d.ts +71 -0
  157. package/dist/read/{pagination.js → truncation.js} +103 -94
  158. package/dist/read/truncation.js.map +1 -0
  159. package/dist/read/views.d.ts +147 -22
  160. package/dist/read/views.js +282 -80
  161. package/dist/read/views.js.map +1 -1
  162. package/dist/surface-copy.d.ts +19 -0
  163. package/dist/surface-copy.js +32 -0
  164. package/dist/surface-copy.js.map +1 -1
  165. package/dist/sync-health.d.ts +78 -0
  166. package/dist/sync-health.js +312 -0
  167. package/dist/sync-health.js.map +1 -0
  168. package/dist/write/accessibility-probe.d.ts +12 -0
  169. package/dist/write/accessibility-probe.js +71 -0
  170. package/dist/write/accessibility-probe.js.map +1 -0
  171. package/dist/write/automation-probe.d.ts +8 -1
  172. package/dist/write/automation-probe.js +16 -1
  173. package/dist/write/automation-probe.js.map +1 -1
  174. package/dist/write/batch.js +5 -2
  175. package/dist/write/batch.js.map +1 -1
  176. package/dist/write/capabilities.d.ts +8 -0
  177. package/dist/write/capabilities.js +13 -6
  178. package/dist/write/capabilities.js.map +1 -1
  179. package/dist/write/commands.d.ts +3 -1
  180. package/dist/write/commands.js +602 -49
  181. package/dist/write/commands.js.map +1 -1
  182. package/dist/write/edit-checklist.js +3 -2
  183. package/dist/write/edit-checklist.js.map +1 -1
  184. package/dist/write/guards.d.ts +2 -7
  185. package/dist/write/guards.js +87 -6
  186. package/dist/write/guards.js.map +1 -1
  187. package/dist/write/heading.js +2 -0
  188. package/dist/write/heading.js.map +1 -1
  189. package/dist/write/lock.d.ts +40 -2
  190. package/dist/write/lock.js +91 -14
  191. package/dist/write/lock.js.map +1 -1
  192. package/dist/write/make-repeating-project.d.ts +4 -0
  193. package/dist/write/make-repeating-project.js +253 -0
  194. package/dist/write/make-repeating-project.js.map +1 -0
  195. package/dist/write/operations.d.ts +145 -1
  196. package/dist/write/operations.js +48 -0
  197. package/dist/write/operations.js.map +1 -1
  198. package/dist/write/pipeline.d.ts +73 -2
  199. package/dist/write/pipeline.js +218 -43
  200. package/dist/write/pipeline.js.map +1 -1
  201. package/dist/write/planner.js +8 -1
  202. package/dist/write/planner.js.map +1 -1
  203. package/dist/write/pre-state.d.ts +105 -3
  204. package/dist/write/pre-state.js +144 -3
  205. package/dist/write/pre-state.js.map +1 -1
  206. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  207. package/dist/write/recurrence-rule-blob.js +150 -0
  208. package/dist/write/recurrence-rule-blob.js.map +1 -0
  209. package/dist/write/reopen.js +3 -2
  210. package/dist/write/reopen.js.map +1 -1
  211. package/dist/write/reorder.js +27 -17
  212. package/dist/write/reorder.js.map +1 -1
  213. package/dist/write/repeat-rule.d.ts +22 -0
  214. package/dist/write/repeat-rule.js +258 -0
  215. package/dist/write/repeat-rule.js.map +1 -0
  216. package/dist/write/reversibility.js +49 -0
  217. package/dist/write/reversibility.js.map +1 -1
  218. package/dist/write/tag-refs.d.ts +47 -0
  219. package/dist/write/tag-refs.js +126 -0
  220. package/dist/write/tag-refs.js.map +1 -0
  221. package/dist/write/undo.d.ts +74 -5
  222. package/dist/write/undo.js +493 -80
  223. package/dist/write/undo.js.map +1 -1
  224. package/dist/write/vectors/registry.d.ts +15 -1
  225. package/dist/write/vectors/registry.js +65 -2
  226. package/dist/write/vectors/registry.js.map +1 -1
  227. package/dist/write/vectors/simulator.d.ts +44 -0
  228. package/dist/write/vectors/simulator.js +1136 -0
  229. package/dist/write/vectors/simulator.js.map +1 -0
  230. package/dist/write/vectors/types.d.ts +154 -3
  231. package/dist/write/vectors/ui-certification.d.ts +54 -0
  232. package/dist/write/vectors/ui-certification.js +43 -0
  233. package/dist/write/vectors/ui-certification.js.map +1 -0
  234. package/dist/write/vectors/ui-drag.d.ts +188 -0
  235. package/dist/write/vectors/ui-drag.js +1095 -0
  236. package/dist/write/vectors/ui-drag.js.map +1 -0
  237. package/dist/write/vectors/ui-recipes.d.ts +83 -0
  238. package/dist/write/vectors/ui-recipes.js +610 -0
  239. package/dist/write/vectors/ui-recipes.js.map +1 -0
  240. package/dist/write/vectors/ui.d.ts +152 -0
  241. package/dist/write/vectors/ui.js +704 -0
  242. package/dist/write/vectors/ui.js.map +1 -0
  243. package/dist/write/verify/delta.d.ts +141 -4
  244. package/dist/write/verify/delta.js +199 -21
  245. package/dist/write/verify/delta.js.map +1 -1
  246. package/dist/write/verify/poller.d.ts +7 -1
  247. package/dist/write/verify/poller.js +21 -1
  248. package/dist/write/verify/poller.js.map +1 -1
  249. package/package.json +18 -3
  250. package/skills/things-cli/SKILL.md +53 -0
  251. package/skills/things-cli/references/contracts.md +41 -0
  252. package/skills/things-cli/references/gui.md +11 -0
  253. package/skills/things-cli/references/model.md +51 -0
  254. package/dist/cli/exit-codes.d.ts +0 -26
  255. package/dist/cli/exit-codes.js +0 -26
  256. package/dist/cli/exit-codes.js.map +0 -1
  257. package/dist/cli/output.d.ts +0 -42
  258. package/dist/cli/output.js +0 -16
  259. package/dist/cli/output.js.map +0 -1
  260. package/dist/read/pagination.d.ts +0 -104
  261. package/dist/read/pagination.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).
@@ -334,7 +444,13 @@ function wholesaleChecklistConflict(record, current) {
334
444
  * it was cleared from), so it must read the live schedule.
335
445
  */
336
446
  export function planUndo(record, now, allRecords = [], current) {
337
- 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
+ };
338
454
  const todayIso = localToday(now);
339
455
  const notes = [];
340
456
  const irreversible = (reason) => ({
@@ -412,7 +528,7 @@ export function planUndo(record, now, allRecords = [], current) {
412
528
  return {
413
529
  target,
414
530
  kind: "invertible",
415
- steps: [{ op: "todo.reopen", params: { uuid } }],
531
+ steps: [{ op: "todo.reopen", params: { uuid }, options: { guardFields: ["status"] } }],
416
532
  notes,
417
533
  };
418
534
  }
@@ -426,7 +542,13 @@ export function planUndo(record, now, allRecords = [], current) {
426
542
  return {
427
543
  target,
428
544
  kind: "invertible",
429
- 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
+ ],
430
552
  notes,
431
553
  };
432
554
  }
@@ -438,7 +560,7 @@ export function planUndo(record, now, allRecords = [], current) {
438
560
  return {
439
561
  target,
440
562
  kind: "invertible",
441
- steps: [{ op: "todo.restore", params: { uuid } }],
563
+ steps: [{ op: "todo.restore", params: { uuid }, options: { guardFields: ["trashed"] } }],
442
564
  notes,
443
565
  };
444
566
  }
@@ -448,7 +570,7 @@ export function planUndo(record, now, allRecords = [], current) {
448
570
  return {
449
571
  target,
450
572
  kind: "invertible",
451
- steps: [{ op: "todo.delete", params: { uuid } }],
573
+ steps: [{ op: "todo.delete", params: { uuid }, options: { guardFields: ["trashed"] } }],
452
574
  notes,
453
575
  };
454
576
  }
@@ -459,7 +581,7 @@ export function planUndo(record, now, allRecords = [], current) {
459
581
  return {
460
582
  target,
461
583
  kind: "invertible",
462
- steps: [{ op: "project.restore", params: { uuid } }],
584
+ steps: [{ op: "project.restore", params: { uuid }, options: { guardFields: ["trashed"] } }],
463
585
  notes,
464
586
  };
465
587
  }
@@ -470,7 +592,9 @@ export function planUndo(record, now, allRecords = [], current) {
470
592
  case "project.cancel": {
471
593
  if (uuid === null)
472
594
  return irreversible("no target uuid recorded");
473
- const steps = [{ op: "project.reopen", params: { uuid } }];
595
+ const steps = [
596
+ { op: "project.reopen", params: { uuid }, options: { guardFields: ["status"] } },
597
+ ];
474
598
  const pre = record.pre ?? {};
475
599
  if (!("status" in pre)) {
476
600
  for (const [childUuid, fields] of Object.entries(pre)) {
@@ -502,8 +626,16 @@ export function planUndo(record, now, allRecords = [], current) {
502
626
  kind: "invertible",
503
627
  steps: [
504
628
  was === "completed"
505
- ? { op: "project.complete", params: { uuid, children: "require-resolved" } }
506
- : { 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
+ },
507
639
  ],
508
640
  notes,
509
641
  };
@@ -514,7 +646,7 @@ export function planUndo(record, now, allRecords = [], current) {
514
646
  return {
515
647
  target,
516
648
  kind: "invertible",
517
- steps: [{ op: "project.delete", params: { uuid } }],
649
+ steps: [{ op: "project.delete", params: { uuid }, options: { guardFields: ["trashed"] } }],
518
650
  notes,
519
651
  };
520
652
  }
@@ -533,19 +665,30 @@ export function planUndo(record, now, allRecords = [], current) {
533
665
  if (deadline !== undefined)
534
666
  params["deadline"] = deadline;
535
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;
536
672
  if (requestedWhen) {
537
673
  const schedule = scheduleSteps(uuid, record, todayIso);
538
674
  notes.push(...schedule.notes);
539
675
  const scheduleStep = schedule.steps[0];
540
676
  if (scheduleStep !== undefined && scheduleStep.op === "todo.update") {
541
677
  Object.assign(params, scheduleStep.params);
678
+ scheduleMerged = true;
542
679
  }
543
680
  else if (scheduleStep !== undefined) {
544
- 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 } });
545
683
  }
546
684
  }
547
- if (Object.keys(params).length > 1)
548
- 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
+ }
549
692
  if (steps.length === 0) {
550
693
  return irreversible("no pre-values were captured for the changed fields");
551
694
  }
@@ -567,12 +710,15 @@ export function planUndo(record, now, allRecords = [], current) {
567
710
  // when/reminder restore reuses the schedule reconstructor (emitting a
568
711
  // project.update); projects never live in the Inbox so that branch is
569
712
  // unreachable here.
713
+ let projectScheduleMerged = false;
570
714
  if ((record.requested["when"] ?? record.requested["reminder"]) !== undefined) {
571
715
  const schedule = scheduleSteps(uuid, record, todayIso, "project.update");
572
716
  notes.push(...schedule.notes);
573
717
  const scheduleStep = schedule.steps[0];
574
- if (scheduleStep !== undefined)
718
+ if (scheduleStep !== undefined) {
575
719
  Object.assign(params, scheduleStep.params);
720
+ projectScheduleMerged = true;
721
+ }
576
722
  }
577
723
  if (Object.keys(params).length === 1) {
578
724
  return irreversible("no pre-values were captured for the changed fields");
@@ -580,7 +726,15 @@ export function planUndo(record, now, allRecords = [], current) {
580
726
  return {
581
727
  target,
582
728
  kind: "invertible",
583
- 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
+ ],
584
738
  notes,
585
739
  };
586
740
  }
@@ -613,11 +767,19 @@ export function planUndo(record, now, allRecords = [], current) {
613
767
  if (preField(record, "heading") !== null && preField(record, "heading") !== undefined) {
614
768
  notes.push("heading placement cannot be restored — the to-do returns to the project root");
615
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.
616
772
  if (typeof oldProj === "string") {
617
773
  return {
618
774
  target,
619
775
  kind: "invertible",
620
- 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
+ ],
621
783
  notes,
622
784
  };
623
785
  }
@@ -625,7 +787,13 @@ export function planUndo(record, now, allRecords = [], current) {
625
787
  return {
626
788
  target,
627
789
  kind: "invertible",
628
- 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
+ ],
629
797
  notes,
630
798
  };
631
799
  }
@@ -639,6 +807,11 @@ export function planUndo(record, now, allRecords = [], current) {
639
807
  if (schedule.steps.length === 0) {
640
808
  return irreversible("pre-op scheduling state was not captured — cannot leave the Inbox");
641
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
+ }
642
815
  return { target, kind: "invertible", steps: schedule.steps, notes };
643
816
  }
644
817
  // The audit captured the OLD value of whatever destination-kind fields
@@ -658,7 +831,13 @@ export function planUndo(record, now, allRecords = [], current) {
658
831
  return {
659
832
  target,
660
833
  kind: "invertible",
661
- 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
+ ],
662
841
  notes,
663
842
  };
664
843
  }
@@ -666,7 +845,13 @@ export function planUndo(record, now, allRecords = [], current) {
666
845
  return {
667
846
  target,
668
847
  kind: "invertible",
669
- 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
+ ],
670
855
  notes,
671
856
  };
672
857
  }
@@ -684,10 +869,17 @@ export function planUndo(record, now, allRecords = [], current) {
684
869
  ? areaRef.uuid
685
870
  : undefined;
686
871
  if (typeof oldArea === "string") {
872
+ // The op detached (area now null); guard it is still area-less.
687
873
  return {
688
874
  target,
689
875
  kind: "invertible",
690
- 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
+ ],
691
883
  notes,
692
884
  };
693
885
  }
@@ -698,7 +890,13 @@ export function planUndo(record, now, allRecords = [], current) {
698
890
  return {
699
891
  target,
700
892
  kind: "invertible",
701
- 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
+ ],
702
900
  notes,
703
901
  };
704
902
  }
@@ -707,7 +905,13 @@ export function planUndo(record, now, allRecords = [], current) {
707
905
  return {
708
906
  target,
709
907
  kind: "invertible",
710
- 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
+ ],
711
915
  notes,
712
916
  };
713
917
  }
@@ -871,6 +1075,35 @@ export function planUndo(record, now, allRecords = [], current) {
871
1075
  "their current positions");
872
1076
  return { target, kind: "invertible", steps: [{ op: "reorder", params }], notes };
873
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
+ }
874
1107
  case "heading.rename": {
875
1108
  if (uuid === null)
876
1109
  return irreversible("no target uuid recorded");
@@ -887,7 +1120,9 @@ export function planUndo(record, now, allRecords = [], current) {
887
1120
  case "heading.archive": {
888
1121
  if (uuid === null)
889
1122
  return irreversible("no target uuid recorded");
890
- const steps = [{ op: "heading.unarchive", params: { uuid } }];
1123
+ const steps = [
1124
+ { op: "heading.unarchive", params: { uuid }, options: { guardFields: ["status"] } },
1125
+ ];
891
1126
  // Reopen exactly the children the cascade resolved (nested pre map —
892
1127
  // the project.complete pattern). Reparented children live in leg
893
1128
  // records; replay their inverses too when this summary heads a txn.
@@ -942,7 +1177,13 @@ export function planUndo(record, now, allRecords = [], current) {
942
1177
  return {
943
1178
  target,
944
1179
  kind: "invertible",
945
- 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
+ ],
946
1187
  notes,
947
1188
  };
948
1189
  }
@@ -983,6 +1224,78 @@ export function planUndo(record, now, allRecords = [], current) {
983
1224
  notes,
984
1225
  };
985
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
+ }
986
1299
  default:
987
1300
  return irreversible(`no inverse is defined for operation "${record.op}"`);
988
1301
  }
@@ -990,6 +1303,44 @@ export function planUndo(record, now, allRecords = [], current) {
990
1303
  // ------------------------------------------------------ precondition guard
991
1304
  /** Content fields an inverse can silently CLOBBER; keyed to observed 1:1. */
992
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
+ */
993
1344
  function fieldsEqual(a, b) {
994
1345
  if (Array.isArray(a) && Array.isArray(b))
995
1346
  return JSON.stringify(a) === JSON.stringify(b);
@@ -998,14 +1349,31 @@ function fieldsEqual(a, b) {
998
1349
  function formatValue(value) {
999
1350
  return value === null || value === undefined ? "none" : JSON.stringify(value);
1000
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
+ }
1001
1365
  /**
1002
- * Refuse an inverse step that would overwrite a field an out-of-band edit has
1003
- * already moved. For every CLOBBER_FIELD the step writes AND that the audit
1004
- * `observed` (after-state) recorded, the target's CURRENT value must still
1005
- * equal that after-value; a divergence means the world moved underneath us, so
1006
- * we block rather than clobber. Fields absent from `observed` are skipped (we
1007
- * cannot confirm them but must not break ops whose observed legitimately omits
1008
- * 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.
1009
1377
  */
1010
1378
  function checkStepPrecondition(deps, step, observed) {
1011
1379
  if (observed === null)
@@ -1020,25 +1388,62 @@ function checkStepPrecondition(deps, step, observed) {
1020
1388
  if (!(field in step.params) || !(field in observed))
1021
1389
  continue;
1022
1390
  const cur = getField(current, field) ?? null;
1023
- const after = observed[field];
1024
- if (!fieldsEqual(cur, after)) {
1025
- return {
1026
- kind: "blocked",
1027
- op: step.op,
1028
- reason: "environment",
1029
- detail: `${field} changed since the recorded mutation (expected ${formatValue(after)}, found ` +
1030
- `${formatValue(cur)}) — refusing to avoid clobbering an out-of-band edit`,
1031
- remediation: "review the item's current state; redo the change by hand if it's still wanted, or " +
1032
- "re-run undo once the field is back to its post-change value",
1033
- };
1034
- }
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);
1035
1400
  }
1036
1401
  return null;
1037
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
+ }
1038
1425
  // ----------------------------------------------------------------- executor
1039
1426
  export async function runUndo(deps, auditDirPath, options = {}, onItem) {
1040
1427
  const records = readAuditRecords(auditDirPath);
1041
- 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
+ });
1042
1447
  const now = deps.now?.() ?? new Date();
1043
1448
  const items = [];
1044
1449
  for (const record of targets) {
@@ -1051,30 +1456,29 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
1051
1456
  item = { plan, results: [], outcome: "irreversible" };
1052
1457
  }
1053
1458
  else if (options.dryRun === true) {
1054
- 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" };
1055
1471
  }
1056
1472
  else {
1057
1473
  const results = [];
1058
1474
  let failed = false;
1059
1475
  for (const step of plan.steps) {
1060
- // Plan-time precondition (checklist undos): the inverse was resolved
1061
- // against the current list and found a conflict — refuse, don't clobber.
1062
- const planBlock = step.options?.blocked;
1063
- if (planBlock !== undefined) {
1064
- results.push({
1065
- kind: "blocked",
1066
- op: step.op,
1067
- reason: "environment",
1068
- detail: planBlock.detail,
1069
- remediation: planBlock.remediation,
1070
- });
1071
- failed = true;
1072
- break;
1073
- }
1074
- // Precondition guard: never clobber a field an out-of-band edit moved.
1075
- const precondition = checkStepPrecondition(deps, step, record.observed);
1076
- if (precondition !== null) {
1077
- 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);
1078
1482
  failed = true;
1079
1483
  break;
1080
1484
  }
@@ -1093,6 +1497,9 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
1093
1497
  }
1094
1498
  const writeOptions = {
1095
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,
1096
1503
  ...(options.verifyTimeoutMs !== undefined && {
1097
1504
  verifyTimeoutMs: options.verifyTimeoutMs,
1098
1505
  }),
@@ -1100,10 +1507,16 @@ export async function runUndo(deps, auditDirPath, options = {}, onItem) {
1100
1507
  acknowledgeChecklistReset: true,
1101
1508
  }),
1102
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 }),
1103
1514
  };
1104
1515
  const result = step.op === "reorder"
1105
- ? await runReorder(deps, step.params, writeOptions)
1106
- : 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);
1107
1520
  results.push(result);
1108
1521
  if (result.kind !== "ok") {
1109
1522
  failed = true;