mx 0.1.232

A Swiss army knife for Claude Code and multi-agent toolkits
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
# Changelog

All notable changes to `mx` are documented here.

The format is loosely based on [Keep a Changelog](https://keepachangelog.com).

## [Unreleased]

### Added — `mx doors` match grammar: `p + q`, affix wildcards, `--exclude` (doors P4, #490)

- **Upgrade before authoring the first exclusion.** An older `mx` doing a
  read-modify-write of kv drops fields it does not know, so it would silently
  strip `exclude` from every entry it rewrites.
- **`p + q` in a trigger** fires when every side matches somewhere in the
  same segment, in any order. Each side is a contiguous phrase, as before.
  ` + ` is an operator only as a standalone word, so `c++` and `c++ tips`
  stay plain literals and match as they did (broad, on `c`). Flat N-way AND
  (`a + b + c`) is allowed; there is no nesting, no parentheses and no OR
  (several `--trigger`s are already OR). A side that is empty (`p +`, `+ q`,
  `p + + q`, a bare `+`) exits `4`. A `+` glued to one word
  (`study +blarnia`) is still a phrase; it is stored, with a warning on stderr
  that shows the ` + ` spelling.
- **Affix wildcards**: a word written `prefix*` or `*suffix` matches exactly
  one message token that starts or ends with it (the bare literal included).
  It can sit anywhere in a phrase or a side. The literal is one run of letters
  and digits, at least 5 characters after normalization. `*`, `**foo`,
  `*foo*`, `fo*o`, `*foo-bar` and short literals exit `4`, and the message
  names the rule that was broken.
- **`--exclude PHRASE` on `mx kv push` and `mx kv update`** (repeatable): if
  any exclusion matches in a segment, the door is held for that segment. A
  clean match in another admitted segment still fires. Exclusions take the
  phrase/wildcard grammar; ` + ` in one exits `4`, and the Dead/Broad checks
  apply as they do to triggers. Stored normalized and deduplicated as a typed
  `exclude` field next to `triggers` and `scope`. On `update` it replaces the
  whole list; `--exclude ""` clears it, and `--exclude` on its own is a
  complete update. Exclusions on an entry with no triggers are stored with a
  warning, since they do nothing.
- **Dead and Broad per side.** A side with nothing to match on exits `4`. A
  side that is one short token warns, naming the side, since the other sides
  still narrow it.
- **The most specific trigger** for a grammar trigger counts the tokens of all
  its sides, then characters. Plain triggers measure exactly as before, and the
  fire row's `trigger` is the stored string.
- **`mx kv triggers`** shows a grammar trigger's matched form with its ` + `
  and `*` (`frim_bo + *flimbing` matches as `frim bo + *flimbing`), and an
  `exclude: [...]` line under an entry that has exclusions. `--json` carries
  `"exclude"` when one is set.
- **`mx doors check --explain`** ends the line of a door held by an exclusion
  with `excluded by "<phrase>"`, and `--json` gives that door an
  `excluded_by` field. A scope hold is shown as before.
- **Every plain trigger fires as before.** A trigger with no standalone `+`
  and no `*` goes through the same matcher call as 0.1.231, and plain output
  of `kv triggers` and `doors check` is unchanged.

### Fixed — the wake chunker never cuts a chunk of only rules and blank lines (#487)

- **A rule before a heading no longer becomes its own chunk.** The chunker
  takes the latest break of the best kind in each window, so after splitting
  at a `---` it could split again at the heading right below it and hand the
  ritual a `Part N/M` that was just `---`, with nothing to phrase. A break
  that would leave only blank and rule lines before it is now passed over for
  the next kind of break. The same fix stops a paragraph break followed by a
  long paragraph from producing a chunk that is a single newline.
- **A document that ends in a rule no longer gets a rule-only last chunk.** A
  break inside the trailing run of rules and blank lines is passed over for an
  earlier one, so the rule stays with the prose before it. After an oversized
  code block, a filler (or empty) remainder joins the oversized chunk instead
  of standing alone; that chunk was already flagged oversized.
- **Every other plan is unchanged.** A document that never produced such a
  chunk gets the same boundaries, chunk count and oversized flags, so wake-log
  positions still line up across wakes. A run of rules and blank lines about as
  long as the threshold or longer, or a document that is nothing else, can
  still be split into filler chunks.

### Added — `mx doors` scopes, modes and two budgets (doors P2a/P2b, #435)

- **Upgrade before authoring the first scoped door.** An older `mx` doing a
  read-modify-write of kv drops fields it does not know, so it would silently
  strip `scope` from every entry it rewrites.
- **`--scope` on `mx kv push` and `mx kv update`** (repeatable) says where a
  door may fire. Values: `house`, `pinned`, `book:<slug>` and
  `book:<slug>@<n>` (slug `[a-z0-9][a-z0-9-]*`, n a whole number of 1 or more
  with no leading zero). It is a typed entry field next to `triggers` and
  `fragment`, not part of `data`. On `update` it replaces the whole list;
  `--scope ""` clears it, and `--scope` on its own is a complete update.
  Duplicates are dropped, order kept. Anything else exits `4`, names the bad
  value, and writes nothing; `person:` and `agent:` are refused with a note
  that they arrive in a later phase (P3), because nothing routes them yet and
  such a door would never fire. Not on `kv set`.
- **No scope means `house`.** Nothing is migrated, and an entry without a
  scope serializes byte-for-byte as before.
- **`pinned` fires in every place and every mode**, a quarantined paste
  included. Setting it prints one warning on stderr and exits `0`. Keep it for
  safety and health facts.
- **`mx kv triggers` shows `[scope: …]`** on the line of a scoped entry;
  unscoped lines are unchanged. `--scope S` lists only entries whose scope
  holds S (no scope counts as `house`); `book:<slug>` with no position also
  takes `book:<slug>@<n>` at every n. A bad S exits `4` like `push`, and a
  good one that matches nothing says `no triggered entries match --scope S`.
  `--json` carries `"scope"` when one is set.
- **`mx doors mode`** prints the session's mode (`book:walden@3, since <ts>,
  set by <agent>`, or `house, never set`); `mx doors mode house | ours |
  book:<slug>[@n]` writes a mode row and prints the new mode. The session is
  `--session ID`, else `$CLAUDE_CODE_SESSION_ID`; with neither it exits `1`.
  Bad syntax exits `2`. Setting the current mode again still writes a row,
  which starts a fresh epoch (re-arms hints, resets the stale timer). The
  writer is `$MX_CURRENT_AGENT`, else `cli`.
- **Routing by mode.** Typed text places `{house}` in `house` and `ours` mode,
  and `{book:<slug>}` in `book:<slug>` mode: while reading, the user's own
  words about the book open book doors and hold house doors (pinned still
  fires). This departs from the original design, whose typed row kept
  `{house}` in book mode; book vocabulary in typed riffs was firing house
  doors. Channel bodies place `{house}` in every mode. A paste is routed by
  its verdict: in `house` mode a ForeignProse paste is quarantined (`∅`) and
  everything else is `{house}`; in `book:<slug>` mode an Ours paste goes to
  `{house}` and every other paste (ForeignProse, Unsure, too short to judge)
  goes to `{book:<slug>}`; in `ours` mode every paste is `{house}` and nothing
  is ever quarantined (the classifier still runs, so `--explain` shows its
  verdict). A door opens in a segment when any of its scope values names one
  of the segment's places; a pinned door opens anywhere.
- **The spoiler gate.** `book:walden@12` opens on a Walden paste only in mode
  `book:walden@n` with `12 <= n`. `book:walden` (no position) opens at any
  position. A mode with no position is page one: only position-less book
  doors open. Another slug never opens.
- **Two budget pools**, `house` and `book`, each of `--budget`. A door spends
  the pool of the segment its winning hit came from (a quarantined segment
  spends `house`). Within a pool pinned doors go first, then the existing
  order (fewest fires, oldest, key, id); overflow is deferred per pool. With no
  scopes and `house` mode the fired set, order and lines are what 0.1.228
  printed.
- **`MX_DOORS_BUDGET` (#435).** `mx doors hook` and `mx doors check` take the
  budget from `--budget`, else `MX_DOORS_BUDGET` when it parses as a whole
  number (`0` turns doors off), else 2. An unparseable value is ignored
  silently; the hook never fails on it. One value sets both pools.
- **Hints** (hook only), printed after the door lines as single
  `[doors] ` lines, never counted against a budget, each kind at most once per
  mode epoch: `foreign` (house mode, a paste reads as someone else's prose),
  `ours` (book mode, a paste reads as ours) and `stale` (book mode, and no
  mode or foreign-paste row for 6 hours). A hint is recorded before it
  prints, and one that cannot be recorded does not print. A prompt that opens
  no door still gets its hint. In book mode a prompt with a ForeignProse paste
  also writes one `foreign_paste` row, unless the session's latest mode or
  `foreign_paste` row is under an hour old (the row only feeds the stale
  timer). `--dry-run` prints and writes nothing.
  The hook still loads the store once and saves at most once per prompt.
- **`doors_session` is shared by all sessions and capped at its
  `max_entries`**, so a session's mode row can age out, and then that session
  reads as house.
- **Without `doors_session` in the schema nothing changes**: the hook reads
  `house`, writes no session rows, prints no hints and fires doors as before;
  `mx doors mode <x>` exits `1` and prints the schema to add.
- **Fire rows gain `place`** (`house`, `book:<slug>` or `pinned`) when the
  schema declares it as a string, the same guard as `origin`. Rows without it
  read fine everywhere.
- **`mx doors check --mode M`** evaluates under M instead of the session's
  mode and writes nothing to `doors_session`. `--explain` now starts with
  `mode <mode> (--mode | session | default)`, prints each segment's places
  (`{house}`, `{book:walden}`, `∅ (quarantined)`), each door's place and pool
  (`-` for a held door), and `hint: <kind>` for any hint the hook would print
  (never recorded).
  `--json` carries `mode`, `mode_source`, `places`, `place`, `pool` and
  `hints`. `check` without `--explain` never hints.
- **Schema to add** for modes, hints and `place` on fire rows:

  ```toml
  [keys.doors_session]
  type = "history"
  max_entries = 1000
  description = "Doors session state: mode changes, hints shown, and foreign pastes read in book mode."

  [keys.doors_session.data.session]
  type = "string"
  required = true

  [keys.doors_session.data.event]
  type = "string"
  required = true

  [keys.doors_session.data.mode]
  type = "string"
  required = false

  [keys.doors_session.data.by]
  type = "string"
  required = false

  [keys.doors_session.data.kind]
  type = "string"
  required = false

  [keys.doors_fired.data.place]
  type = "string"
  required = false
  ```

### Changed — `mx doors` drops harness notification preambles

- **A `[SYSTEM NOTIFICATION …]` header line and the prose under it no longer
  open doors.** The header is a whole line: trimmed, it starts with
  `[SYSTEM NOTIFICATION` followed by a space, `-` or `]`, ends with `]`, and
  holds no `<`. The line and the lines after it go, up to the first blank line
  or the first line holding a `<`, so a paste or block closer is never eaten.
  A mention mid-sentence is left alone, and a preamble never reaches past the
  next blank line. Harness blocks are then
  stripped as before.

### Added — `mx kv update --memory` (#480)

- **`mx kv update <key> --id <id> --memory kn-...` sets an entry's memory
  pointer.** It lands in the same write as the value, `--data`, `--trigger`
  and `--fragment`, so either all of them change or none do. An entry that
  already has a pointer gets the new one; `--memory ""` clears it. `--memory`
  on its own is a complete update.
- **Pointers are shape-checked** on `mx kv update --memory`,
  `mx kv set <key> --id <id> --memory` and `mx kv push <key> --memory`. A
  non-empty pointer must be `kn-` followed by one or more ASCII letters or
  digits (`kn-1a2b3c4d`). Anything else (`foo`, `kn-`, `kn-x/y`) exits `4`,
  names the bad value, and writes nothing, even when the same command also
  carries a valid new value. The check is shape only: it does not look the ID
  up in the graph, so kv still works without SurrealDB.
- **`set --id --memory` and `push --memory` used to store any string.**
  Key-level `mx kv set <key> --memory` (no `--id`) is unchanged.
- **`push --memory ""` now stores no pointer** instead of an empty one.

### Changed — `mx kv dec` only ever lowers a counter (#476, #457)

- **A decrement that would not lower the counter is an error.** This is a
  behavior change: `dec` on a counter sitting at its `min` used to clamp
  silently and exit `0`. It now exits `5`, prints the key, the current value
  and the value it would have become (and the `min` when that is why) to
  stderr, and writes nothing. The same goes for a stored value below a
  since-raised `min` and for saturation at `i64::MIN`. A fall that the `min`
  clamps (5 `--by 10` under min 0 -> 0) still succeeds.
- **`--by` must be positive.** `dec --by 0` and negative amounts exit `4`
  with a pointer to `mx kv inc`; nothing is written. `--by -3` (with a space)
  now reaches that check instead of failing in the argument parser with exit
  `2`.
- This is the `dec` half of #457; the `inc` half shipped in #453. There is no
  `--expect` on `dec`. `set` is still the way to force a counter to a value.
- A refused `dec` no longer materializes the key's default in memory.

### Changed — `mx doors` quarantines pasted prose that isn't ours (doors P1)

- **The prompt is matched in segments.** After harness blocks are removed,
  the prompt is cut into typed text, terminal pastes (Claude Code's
  `<pasted_content id="X">…</pasted_content id="X">` envelope; only a closer
  with the same id closes it, an opener nothing closes is text) and
  `<channel>` bodies (keyed by `user_id`, never the display name). Scanning
  left to right, whichever opener comes first defines the block and its body
  is opaque to the other kind: a pasted transcript stays one paste, and paste
  tags inside a channel body are plain text. Channel attributes are stripped
  everywhere and never become match text. Phrases no longer match across a
  segment boundary.
- **A pasted block of someone else's prose holds the doors in it.** Only
  collapsed (tagged) pastes are judged. Each one of at least 600 characters is
  classified ForeignProse / Ours / Unsure from its shape (dialogue share,
  paragraph and sentence length, citations, headings, epigraphs against
  lists, code, chat-log headers, chat register, house vocabulary and names).
  A ForeignProse paste is quarantined: no door matches in it. Ours, Unsure
  and pastes under 600 characters behave exactly as before. Known false
  positive: a long first-person essay that never names us reads as foreign.
- **Typed text and Matrix bodies are never classified.** However long, they
  fire exactly as before (in `book:<slug>` mode typed text routes to the
  book; see Routing by mode). A short paste that arrives untagged counts as
  typed text.
- **A door matching in several segments fires once**, with the most specific
  trigger across them (ties go to the earlier segment). Held doors are never
  fired, never recorded and never spend the budget.
- **`origin` on fire rows.** `doors_fired` rows and `check --json` doors
  gain `origin`: `typed`, `paste` or `channel`. It is written only when the
  store's schema declares `doors_fired.data.origin` as a string; a schema
  without it keeps recording rows exactly as before. Legacy rows without
  `origin` still dedup and count in `stats`.
- **`mx doors check` flags:** `--paste FILE` (repeatable) appends the file
  (less one trailing newline) in the paste envelope, id = first 4 hex of
  sha256(`--session`); `--from USER_ID` sends the message as a channel
  block; `--explain` prints each segment's origin, length, verdict, F/O
  scores, signals and places (typed and channel segments say
  `not classified`; in JSON their `verdict` is null with a `reason`), then
  every matched door as FIRED, DEFERRED or HELD with its segment. It implies
  `--dry-run`, and `--json` gives the same structure. The hook never
  computes held doors.
- **`MX_DOORS_NAMES`** (comma-separated) lists the words that mark text as
  ours, matched whole-word and case-insensitive. Unset, only a standalone
  `Q` counts.

### Fixed — `mx doors` ignores harness-injected blocks (#478)

- **Harness blocks no longer open doors.** `<task-notification>`,
  `<system-reminder>` and every `<local-command-*>` block (`stdout`, `stderr`,
  `caveat`, ...) are removed whole, body included, before triggers are
  matched. A trigger word in a subagent report, a reminder or a CLI echo used
  to open a door, spend its per-session dedup slot and eat the prompt's
  budget. Self-closing tags and stray closers go too, and each removed piece
  leaves one space, so the words on either side stay separate.
- **Channel bodies still match.** Harness blocks are removed first, then
  `<channel>` tags are stripped down to their body exactly as before, so a
  Matrix message still opens doors and its sender attributes still don't.
- **Tag mentions mostly stay text.** Only bare tags count (`<name>`,
  `<name/>`, `</name>`; the harness never writes attributes), so prose like
  `the <system-reminder thing -> please fix` is untouched. A backtick-quoted
  tag is literal text too: an opener or self-closing tag right after a
  backtick, or a closer right before one, takes no part in pairing, so a
  report saying `` `<task-notification>` `` is removed whole. A closer pairs
  with the latest unclosed opener of its name; an opener nothing pairs with
  and a stray closer each lose only the tag. Pairing runs in linear time, so
  hundreds of KB of unclosed openers cost no more than balanced input.
- Known edges: (1) a block whose body quotes a tag WITHOUT backticks can pair
  wrongly, so part of that block's text is still matched, and nested blocks
  of the same name leak the outer block's text; (2) when the next
  unbackticked tag after an unbackticked opener with the same name is a
  closer, the text between them is removed whoever wrote them (unless the
  opener already sits inside an earlier removed block), including when that
  closer is quoted inside a different block.

### Changed — `mx kv inc` only ever raises a counter (#453)

- **An increment that would not raise the counter is an error.** `inc` used
  to clamp silently, so a counter at its `max`, above a since-lowered `max`,
  or at `i64::MAX` reported success while staying put. It now exits `5`, prints
  the key, the current value and the value it would have become (and the
  `max` when that is why) to stderr, and writes nothing. A rise that the `max`
  clamps (51 `--by 100` under max 100 -> 100) still succeeds.
- **`--by` must be positive.** `inc --by 0` and negative amounts exit `4`
  with a pointer to `mx kv dec`; nothing is written.
- **`mx kv inc <key> --expect N`.** Refuses the increment (exit `5`, nothing
  written) unless the counter is currently `N`, so a caller that knows the
  true count catches drift when it happens. A never-written key is compared
  against its schema `default`.
- **New exit code `5`:** the command was well-formed but the store's current
  state refused the write.
- **Concurrent `inc` is pinned by a test:** 8 processes on one counter make 8
  increments and print exactly 1..=8. The kv write lock (#429) already made
  this hold; without it, the same test loses increments on every run.
- `set` is unchanged: it is the explicit way to force a counter, and keeps
  clamping silently. (`dec` got the mirror treatment in #476, above.)
- The strict check lives in `KvStore::inc_expecting` (`inc` delegates to it),
  and a refused `inc` no longer materializes the key's default in memory.

### Fixed — chunk phrases no longer leak on chunks 2..M (#469)

- **Authored phrases apply to chunk 1 only.** Every chunk below the
  authored-phrase count used to be matched against the whole authored list,
  which chunk 1's reveal had just shown. Chunk 1 is still matched against all
  of them, and a single-chunk entry behaves exactly as before. Each later chunk
  gets at most one phrase, from its own text: `derived` on an entry with
  authored phrases, `auto` on one without.
- **A later chunk's phrase is body text, not a heading.** Derived phrases used
  to take the chunk's first markdown heading, and chunks usually start at one.
  The phrase is now the first sentence of a paragraph or list item; headings
  (ATX and setext), rules, code, HTML, bold-only lines such as `**Status:**`
  or `**Status**:`, and sentences without a letter are skipped. A chunk with
  no body text at all still falls back to its heading.
- **A later chunk's phrase never repeats an earlier reveal.** A phrase that
  matches anything shown for an earlier chunk of the same entry, as cut to
  length or in full, is replaced by the chunk's next body sentence that
  doesn't.
- A later chunk is prompted with no phrase (`phrase_source: unphrased`, always
  revealed) when it has no sentence, heading or line with a letter to phrase
  from (a body of only `...` or an emoji is still body text, so it doesn't fall
  back to the heading), or when every candidate repeats an earlier reveal.
- **`wake-log` flags rows logged before the fix.** An `authored` row past the
  first chunk was matched against phrases its entry had just revealed. Read
  commands mark it `leaked` (a `leaked` field in JSON, `leaked` after the
  bucket in text); `report` counts such rows in a new `leaked` column instead of
  `authored`, and leaves them out of Axis A `sim_phrase`. Nothing stored
  changes and `score` is unchanged.

### Added — `mx memory wake-log`: scoring and terminal-only reads (#449)

- **`wake-log score`** scores every pending guess row of the calling agent,
  including rows logged before this release: the guess embedding, Axis A
  (`sim_phrase`, `sim_content`, `sim_title`) and Axis B (`sim_prior`,
  `sim_prior_null`, `prior_n`, plus the same three split by substrate into
  `*_same` / `*_cross`). It loads the embedding model once, and not at all when
  nothing is pending. Its stdout is only `{"status","rows","skipped"}`. A row
  that fails stays pending and is retried; a model-load failure exits non-zero
  with nothing on stdout and every row pending. Authored-phrase vectors are
  cached in a new `wake_phrase_embedding` table (keyed by a SHA-256 of model
  and normalized phrase; no phrase text stored), so a repeat ritual embeds only
  each row's guess and title.
- **`wake-log report [--wake N]`, `wake-log bloom ID [--limit N]` and
  `wake-log wake N`** read the scored log, each opening with the Goodhart text
  (also in `report --help`).
- **The terminal-only rule.** The three read commands print only when stdout is
  a terminal. Otherwise they refuse before reading anything, naming the rule,
  unless `--out FILE` is given; then they write the file (JSON for `.json`,
  text otherwise) and print only its path. An `--out` path that exists must be
  a regular file and not the process's own stdout or stderr (`/dev/stdout` is
  refused, whether stdout is a pipe or a file), and the text layout escapes
  control and bidi-control characters in stored strings. This stops accidental and casual
  leakage of scores and guesses into a model's context; it does not stop a
  process that fakes a terminal or reads the `--out` file.
- **Schema:** six additive optional fields on `wake_guess`
  (`sim_prior_same`, `sim_prior_null_same`, `prior_n_same`, `sim_prior_cross`,
  `sim_prior_null_cross`, `prior_n_cross`). No backfill. `--begin`, `--respond`
  and their replays project and print no scoring field.

### Fixed — a guess row records the title that was shown (#467)

- **`title_shown` is the prompted title.** The session records each prompt as
  it is handed out (at `--begin` and with every `next`): its title, including
  any `(Part N/M)` suffix, its `phrase_source` and its chunk total. The guess
  row's `title_shown`, `phrase_source` and `chunk_total` come from that record,
  not from the entry as it is when the guess arrives, so renaming or
  re-chunking an entry mid-ritual no longer files a guess under a title nobody
  saw. The guess is still matched against the entry's current phrases, and the
  ritual still navigates by its current chunks.
- **The session summary counts the prompted phrase source**, the same one the
  row records and a replay of it counts.
- **A resumed or replayed call hands back the recorded `next`**, so a retry
  after a lost response sees the title its guess will be logged under.
- **A guess about an entry no prompt was issued for is refused.** When the
  entry the caller was handed has been deleted, naming a later one returns
  `invalid_bloom_id` with the deleted entry as `expected_id`; responding about
  it (`bloom_missing`) hands out the next prompt. Previously the later entry
  was judged and logged under a title the caller was never shown.
- **Only the prompted entry is answered as deleted.** Naming any other entry
  while the prompted one is gone is refused with the prompted entry as
  `expected_id`, even when that entry was deleted too or nothing is left after
  it; previously such a call skipped the prompted entry, or was refused with no
  `expected_id` to recover from.
- **A response's `bloom` reveal takes `title` and `phrase_source` from the
  prompt**, as the row does, so the original response, the row and any replay
  of it agree. Its `phrases` and `content` are still the ones the guess was
  judged against.
- **A session awaiting a response with no prompt record is refused** with the
  usual "run `mx memory wake --begin`" error. Sessions opened by an earlier
  binary have none; nothing is backfilled. Completed sessions are unaffected.

### Fixed — wake ritual failure paths (Wake 464 review)

- **A half-written step no longer wedges a ritual.** The guess row and the
  session advance are written in one transaction; the session write is a
  compare-and-swap on `step`, and rows are keyed `wake_guess:[session_id,
  position]`. A step already logged is answered from the log instead of
  judged again — `replayed: true`, with the LOGGED `guess`, `bucket` and
  `match`, the current token and `next`. The first guess counts. This covers
  a retry after a lost response, the loser of a double submit (previously a raw
  index error), and rows the previous binary wrote without advancing — even
  when the entry that row judged has since been deleted.
- **A lost response can be resumed.** The session records the step its last
  write started from (`prev_step`) and that write's status (`last_status`).
  Retrying with that token returns the same answer with `replayed: true` and
  the token the caller never received, however far the call moved `step`; a
  lost `bloom_missing` / `chunk_truncated` echoes its status (without `bloom`).
  **Token out of sync** now means the token really is stale, and says to retry
  with the token from the last successful response.
- **`match.kind` is monotonic.** `exact` means identical after trimming;
  anything that needed normalizing or fuzzing is `close`. A guess wrapped in
  quotes was previously logged `exact` while the bare guess was `close`. Rows
  logged before this fix can be re-derived from their snapshotted guess and
  phrases.
- **`--wake`** is echoed as `wake` (with `model`) on the begin response and
  every respond payload. It must be positive. `--begin` refuses a wake number
  another session already logged guesses under unless `--force-wake`, and
  warns (`warnings`) when it is below the agent's highest logged wake or more
  than one above it. A
  refused begin no longer bumps activation counts.
- **Completed sessions are kept** with `completed_at` set, not deleted.
- **`--respond` accepts a guess starting with `-`.**
- **`invalid_bloom_id` exits 1** with its JSON on stderr, like every other
  error.

### Changed — BREAKING: `mx memory wake` ritual JSON (#448)

The wake ritual is now **one guess per chunk, made from the title alone, after
which the entry is shown**. There is no hint ladder, no second attempt, and no
status that claims the responder knew anything. Every consumer of the ritual's
JSON must be updated; the shapes below are the whole contract.

- **Statuses.** `--respond` returns `shown` or, when the entry shrank past the
  session's chunk cursor mid-ritual, `chunk_truncated`. The old `remembered`,
  `incorrect` and `revealed` statuses are gone, as are `attempt`, `hint`,
  `match_type` and `derived_phrase_mismatch`.
- **Buckets.** Each judged chunk lands in `unhinted` (the guess string-matched
  a phrase, and the tool had given no hint) or `revealed` (it did not). These
  are the only two values. They name what the tool did, not what the responder
  knew: entries shown earlier in a ritual leak into later guesses, and the tool
  cannot see that — which is why every logged row records its position.
- **Respond payload** gains `bucket`, `guess` and
  `match: {kind: exact|close|none, phrase_index}`, and carries `bloom` with
  `{id, title, phrases, phrase_source, content, chunk?}`. `progress` gains
  `buckets: {unhinted, revealed}`. Two statuses judge no guess and so omit
  `bucket`, `guess` and `match`: `chunk_truncated`, where the entry shrank past
  the session's chunk cursor, and `bloom_missing`, where it was deleted
  mid-ritual — the latter omits `bloom` too, there being nothing left to show.
  Both still advance the step, so the token the caller spent stops verifying,
  and both are counted in `summary.unjudged`. An entry deleted between one call
  and the next is stepped over at the start of the following one, so a caller
  that names the entry it was just handed is answered about that entry rather
  than told it used the wrong id.
- **Final summary** is `{chunks, blooms, buckets: {unhinted: {authored,
  derived, auto}, revealed: {...}}, unjudged}` and nothing else. No total, no ratio, no
  per-entry roll-up string; `summary.blooms_complete` and the `BloomRollup`
  type behind it are both removed. `chunks` counts every step the ritual
  walked, and `unjudged` counts the ones where no guess was judged, so
  `chunks` always equals the bucket totals plus `unjudged` and the difference
  never has to be derived — deriving it is how an "N out of M" score gets
  reinvented.
- **Prompt payload** is `{id, title, phrase_source, chunk?}`. `resonance`,
  `resonance_type` and `wake_phrase_count` are dropped — the last existed only
  to tell a consumer whether `--skip` was legal.
- **`--skip` is removed** and clap rejects it as an unknown flag (#452). It had
  been unreachable since every chunk gained a phrase: it always returned
  `skip_requires_phraseless_bloom`.
- **A session created by an older binary** does not load: such a row has no
  `agent` field, and the loader refuses it and asks for a fresh `--begin`
  rather than walking it and filing every guess under an empty agent. Sessions
  live for one ritual, so at most one is affected per deployment.
- **A `--respond` must come from the agent that began the ritual**, and is
  refused otherwise, writing no row. The session token authorises the session,
  not whoever holds it. A caller naming no agent is refused too: the blooms are
  fetched with the caller's context, and an entry that context cannot see is
  indistinguishable from one that was deleted, so a caller with less visibility
  than the owner would otherwise walk the ritual while silently stepping over
  every entry it could not read.
- **A guess is refused, rather than logged, when it is not usable data.** Over
  2000 characters (counted in characters, not bytes, and never truncated), or
  carrying no alphanumeric content at all — an empty, whitespace-only or
  punctuation-only guess. A refusal writes no row and does not advance the
  session, so the caller can simply guess again.
- **Wake-set flags are rejected on a `--respond` call** instead of being
  silently ignored: `--limit`, `--min-resonance`, `--days`, `--no-activate` and
  `--include-excluded` now conflict with `--respond`, as `--wake` and `--model`
  already did. `--limit` and `--days` became optional arguments (defaults
  unchanged, 20 and 7) so an explicit value can be told from an absent one.
- **An empty wake set caused entirely by the tag exclusion now says so**,
  naming the per-tag counts and `--include-excluded`, instead of reporting a
  bare "No blooms to wake" while the entries sit in the graph.
- Help text: `--respond` is "Submit your one guess for this bloom (2000
  characters maximum)", and `--wake-phrase` is "a cue the title is meant to
  evoke".

### Added
- **`wake_guess` table and a row per guess (#448).** Every `--respond` that
  judges a guess writes exactly one row before the session advances, carrying
  the agent, wake number, model, entry and chunk, both positions in the
  sequence, the title as shown, the guess, the phrase snapshot it was matched
  against, the match kind and index, the bucket, and the SHA-256 of the chunk
  text. **A failed row write fails the respond call** and leaves the session
  where it was: the guess is the data the ritual exists to collect, so it is
  not best-effort. A response that judges no guess writes no row. One row per
  session step is enforced by a unique index on (`session_id`, `position`), so
  a client that retries a step after a failed session update cannot log the
  same guess twice. The similarity columns are defined but left null — a row
  with a null `scored_at` is pending for the scoring pass that lands with
  #449. The table is not reachable from `mx memory export`, which reads the
  knowledge table only.
- **`mx memory wake --wake N` and `--model ID`** (with `--begin`) record the
  wake number and the answering model on every guess row. Both are optional
  because agents other than the one that counts wakes also run the ritual, and
  mx has no way to discover either; rows written without them stay reachable by
  entry and by date. mx does not read a wake counter of its own.
- **Default tag exclusion, and `--include-excluded` to turn it off (#448).**
  Entries tagged `archive` or `wake-exclude` are kept out of every layer of the
  wake cascade — core, recent, bridges and the `--min-resonance` path — and
  `--begin` reports per-tag counts in `excluded`, a key that is always present
  and empty when nothing was dropped. **The core layer never lets an exclusion
  cost a kept entry its slot**: it widens its query until it holds a full set
  of entries that survive the exclusion. The recent and bridge layers fetch
  double their quota, which absorbs the ordinary case but is not a guarantee —
  enough excluded entries in one layer can still leave the wake set short.
  Batch-archiving is how you would meet that, since a freshly tagged entry
  counts as recent for seven days. Every layer counts only as far as its quota
  is filled, so an entry ranked below the wake set is never counted. The count
  is an **upper bound** on the entries the exclusion kept out, not an exact
  figure — an excluded entry displaces everything after it, so one reached only
  *because* an earlier exclusion pushed the window down is counted too, though
  it would not have made the cut untagged. Tightening it means comparing
  against the untagged ordering, which for the core layer is already in hand.
  The two tags are separate because a live entry merely kept out of the wake
  set is not an archived copy.
  The match is **exact**: a tag that starts with `archive`, such as
  `archive/2026`, is not excluded. Applying the policy in mx rather than in
  caller-side text keeps it from being dropped in a rewrite.
- `wake_session` rows record `agent`, `wake`, `model_id`, `unhinted_count` and
  `revealed_count`. The superseded counter fields stay defined in the schema so
  existing rows keep validating, and are no longer read or written.

### Fixed
- **Match tolerance is measured in characters on both sides.** `fuzzy_match`
  counted edit distance in characters and divided by a byte length, so for
  3-byte text the denominator was three times too large and the 0.8 tolerance
  widened until a guess with half its characters wrong came back as a close
  match. The same edit ratio now gets the same verdict whatever the text is
  written in. Pre-existing; it starts to matter here because every authored
  phrase is compared and the verdict is written to a log meant to be read back.
- **Nothing matches nothing.** `fuzzy_match` strips every non-alphanumeric
  character before comparing, so a blank guess and a punctuation-only phrase
  both collapsed to the empty string and compared *exactly equal* — logged as
  an exact match in the `unhinted` bucket. An empty normalization on either
  side is now no match.
- **A deleted entry no longer bricks an open ritual.** Every `--respond`
  re-fetches all the session's entries and used to fail on the first one
  missing, so deleting an entry the ritual had already walked past left a
  session that could be neither finished nor cleared. Entries that are gone are
  stepped over; if it is the entry on the table, the response says
  `bloom_missing` and the ritual continues.
- **A `chunk_truncated` response no longer hands back the token it just
  consumed.** The truncation path advanced the entry cursor without ticking the
  step, so the spent token still verified and `summary.chunks` undercounted by
  one per truncation.
- **The final response no longer reports a position past the end.** `progress`
  read "chunk 3 of 2" on the last response of every ritual, in the payload the
  model reads, next to a summary saying otherwise.
- **The core cascade query is bounded again.** It had dropped its SQL `LIMIT`
  and truncated in Rust; since the projection carries `embedding`, that dragged
  a 768-float vector for every high-resonance entry in the graph on every wake.
- **A `wake_order` of zero is no longer read as unset (#456).** Every read path
  selected the field with a truthiness test, and SurrealQL treats `0` as falsy,
  so a stored order of `0` came back as null. The cascade queries derive
  `has_wake_order` from that value, so the entry an author had put *first*
  sorted behind every other ordered one. The field is now read with a presence
  test. The other numeric fields in the same projection fall back to their own
  zero and were never affected. (Unchanged and known: the `?? 999999` sentinel
  that sorts unset orders last would collide with a stored `wake_order` of
  999999.)
- **`mx memory wake --respond` no longer re-runs the cascade (#451).** The
  cascade query and `increment_activation_count` ran before the command
  branched, so every guess incremented the activation count of all ~20 cascade
  entries — a 95-call ritual added about 95 to each. Only `--begin` and the
  plain listing touch activation counts now. Existing inflated values are not
  repaired by this change.
- **Every authored phrase is now matched, not just the first (#450).** Phrases
  were indexed by chunk — chunk *i* was compared against `wake_phrases[i]` only
  — so a single-chunk entry with three phrases could only ever match phrase 0.
  A guess is now compared against every authored phrase, `exact` beating
  `close`, and `match.phrase_index` records which one matched. Authored phrases
  also use the tolerant comparison (case, quotes, whitespace, trailing
  punctuation) that derived phrases already used; the strict rule existed to
  make a game harder, and the match is a recorded fact now, not a gate.
- **The `--min-resonance` wake query has a stable order (#448).** It sorted by
  `resonance DESC` alone, leaving entries of equal resonance in whatever order
  the database returned. It now shares the core query's ordering
  (`has_wake_order DESC, effective_wake_order ASC, resonance DESC`), and all
  four cascade queries gained a final `id ASC` tiebreak. Two rituals over
  unchanged data now produce the same sequence, and `wake_order` decides which
  entry opens it. The core layer's limit is applied after tag exclusion, so a
  dropped entry no longer consumes a slot.
- `mx doors` — trigger-based ambient memory, intended as a Claude Code
  `UserPromptSubmit` hook. Any history/list entry carrying `triggers` becomes a
  *door*: when one of its phrases appears in a prompt, `mx doors hook` prints one
  line with a fragment and a `<key>/kv-<id>` pointer. Subcommands: `hook`,
  `check`, `stats`, `reset`. The hook reads the kv file only — no SurrealDB, no
  network — and **always exits 0**, because on `UserPromptSubmit` an exit of 2
  blocks the turn and erases the typed prompt.
- Entries in `history` and `list` keys gained two optional fields, `triggers` and
  `fragment`, settable with `mx kv push --trigger/--fragment` and
  `mx kv update --trigger/--fragment`. Both are omitted from the serialized entry
  when unset, so existing data files load and round-trip unchanged. On `update`,
  `--trigger` replaces the whole list and `--trigger ""` clears it;
  `--fragment ""` clears the override.
- `mx kv triggers [KEY] [--json]` lists every entry carrying triggers, with
  all-time fire counts, and shows what each trigger actually MATCHES on when
  that differs from the stored text (`ayo-` matches as `ayo`).
- `mx kv push`/`update` now vet authored triggers. A trigger with no letters or
  digits (`🦊`, `!!!`) is **rejected** with exit 4 — it could never fire, and
  would otherwise sit in the audit view at `fires=0` looking merely unused. A
  trigger that collapses to a single one- or two-character token (`c++` and `c#`
  both match as `c`) is **warned** about, naming what it became. Warnings and
  notes go to stderr; stdout stays machine-readable. `--trigger ""` remains the
  clear gesture and is never treated as a dead trigger.
- `mx memory add` and `mx memory add-batch` now run a write-boundary
  duplicate check (W447) before every new-entry write. Dedup identity is the
  4-tuple **(session_id, owner, category, normalized title+body hash)** —
  `category` is part of the key (fixing PR #402 finding 1 where an
  identical title+body filed under a different category was wrongly treated
  as the same fact); `tags` are deliberately excluded, so identical content
  re-filed with different tags still dedups as the same fact re-tagged. When
  a candidate in the same `(session, owner, category)` group already
  matches, the write is skipped: the command **exits 0 without writing**.
  On the standard `mx memory add` write path, plain-mode output prints
  `Already saved as <id> (identical entry this session —
  nothing to do).` and `--json` mode returns `{"id": <existing-id>,
  "skipped": true, "duplicate_of": <existing-id>, "status":
  "already_persisted", ...}` instead of the normal write payload;
  `add-batch` has no `--json` flag, and `add`'s `--type` fact-routing path
  ignores `--json`; both print `Already saved: <id> (<title>) — identical
  this session, no action` instead (batch prefixes each line with its
  1-based line index). This is a change to `add`/`add-batch`'s existing
  success semantics, not a new error path. A new `--allow-duplicate` flag
  on `mx memory add` (and an `allow_duplicate` JSONL field on
  `add-batch`) bypasses the gate entirely for an intentional re-add.
  **Limits, stated plainly:** this is an
  in-process, best-effort check, not a database guarantee — read-then-write
  within a single invocation, no DB-level UNIQUE constraint behind it, so two
  concurrent `mx` invocations can still both write the same normalized
  content (a TOCTOU race, accepted by design). Dedup is also bypassed
  entirely for session-less writes (no `session_id` given). A failure to
  look up existing candidates **fails open**: it prints a stderr warning and
  the write still lands, rather than aborting an otherwise-good write over a
  transient read error.
- `mx memory list` and `mx memory search` now emit a best-effort **stderr**
  hint when the caller's own private entries match the query but are hidden by
  the public-only default (Issue #400). The hint reads
  `note: N private entr(y|ies) of yours matched but ... hidden; use
  --include-private to see them`. It fires only when `MX_CURRENT_AGENT` is set
  and neither `--include-private` nor `--mine` was given. **No change** to
  stdout, `--json` output, or exit codes — the hint is STDERR only and any
  error computing it is swallowed silently. The hint is **suppressed under
  `search --semantic`**: its count uses the BM25 `@@` text predicate, which does
  not agree with vector similarity, so counting there would both under- and
  over-report relative to what `--include-private --semantic` actually shows.

### Fixed
- `mx kv` write commands now hold an exclusive advisory lock (`flock`) across
  the whole load-mutate-save cycle, on a sidecar `<data>.lock` file beside the
  data file. Every `mx kv` invocation reads the entire JSON store, mutates it in
  memory, and rewrites the whole file; with no lock, two overlapping writers
  each saved a snapshot taken before the other's change and one write was
  silently lost. Reproduced at 8 concurrent `kv push` calls, where 4 of 9
  expected entries survived. The lock lives on a sidecar rather than on the data
  file because `save` publishes by rename, so a lock held on the pre-rename
  inode guards a file the next writer never opens. Read-only commands (`get`,
  `last`, `since`, `dump`, `search`, `random`, `count`) release the lock
  immediately after loading and take none for the rest of the command: the
  atomic rename already gives them a whole-file snapshot, and holding it would
  stall writers for the length of a read, which under `--memory` includes a
  SurrealDB round trip. The wait for the lock is **bounded at 2 s** and then
  fails with a message naming the lock file and how to find the holder; a
  blocking `flock` would turn one wedged `mx kv` (a suspended shell job, a
  process killed with the descriptor still open) into a silent freeze of every
  `mx kv` call on the machine. A command that fails before it can touch the data
  file no longer creates the data directory or an empty lock file. **No change**
  to any command's stdout, `--json` output, or exit codes on success; concurrent
  writers now queue instead of racing, and a wedged lock exits 1 with a
  diagnostic on stderr instead of hanging.
- `mx doors stats --since` no longer reports a door as "never fired" merely
  because its only fires fall outside the window. Never-fired is computed over
  the whole log; `--since` narrows the counts only. That list is the signal used
  to prune bad doors, so scoping it made it lie.
- `mx doors hook` accepts a `session_id` that arrives as a JSON number rather
  than failing the whole payload and going silent for that prompt.
- `mx kv` id errors now name the expected form: `invalid ID '4UW1oq' -- use a
  numeric index, or the stable id with its prefix: kv-4UW1oq`.
- `mx memory search --semantic` and `mx memory list` now return tags and
  applicability on chunked entries, and `--exclude-tags` now filters them.
  Batch hydration filtered with `WHERE in IN $knowledge`, which plans as a
  `union` lookup over the `in` prefix of the composite UNIQUE index on
  `(in, out)` and matches nothing. The bind itself is correct and `in = $one`
  on that same prefix works, so this was not a keyword collision — it is the
  `union` operator over a composite prefix. Both call sites then swallowed the
  empty result with `take(0).unwrap_or_default()` and reported it as "this
  entry has no tags", so chunked entries came back with tags and applicability
  stripped, and `keep_after_exclude(&[], prefixes)` was unconditionally true —
  `--exclude-tags` silently dropped nothing on the chunked path. Unchunked
  entries were unaffected; they filter in SQL and never read hydrated tags.
  Both queries now traverse from the record ids
  (`SELECT meta::id(id) AS entry_id, ->tagged_with->tag.name AS tags FROM
  $knowledge`), which resolves each entry by key and never reaches that index
  lookup, and both `take(0)` calls propagate instead of defaulting. Measured at
  40k edges / 1000 bound ids: 92ms for tags, 132ms for applicability, flat in
  table size. The narrower alternative `WHERE $knowledge CONTAINS in` returns
  correct rows but plans as `Iterate Table` — 18.9–24.4s at that size, growing
  as O(table rows × bound array length).
- `mx commit` now verifies that the encoded body decodes back to the original
  message before committing, and re-rolls the codec pair when it does not.
  `validate_encoded_output` only ever checked that the output was *safe* (no
  NUL, no control characters), never that it was *readable* — so dictionaries
  whose base-d codec does not round-trip passed validation and committed
  permanently unreadable messages. Measured at ~6% of encodes (29/500), which
  matches the ~20% of unreadable commits observed in `~/.crewu` history. The
  check is deliberately generic rather than a blacklist of known-bad
  dictionaries, so a newly-broken codec is caught without first being
  identified by hand. Costs one decode per commit. Failures report as
  `roundtrip ...` on the existing retry line, alongside the NUL/control
  reasons.
- `mx commit` no longer draws dictionaries whose alphabet contains a
  whitespace character (`base45`, `uuencode`). Such an encoding cannot survive
  a commit message: `git commit -m` runs `--cleanup=whitespace` and strips
  trailing whitespace from every line, and mx trims independently on both the
  encode and decode paths, so a symbol that IS a space is deleted in transit
  and the payload becomes unrecoverable — a write-time loss the round-trip
  check above can detect but never repair. Implemented as a categorical
  property test on the alphabet, not a list of names, so a whitespace-bearing
  dictionary added later is excluded automatically. Rejection happens at the
  draw rather than in the validation loop, so it does not consume one of the
  bounded encode attempts.
- Embedded schema application now retries transient SurrealDB
  "read or write conflict … can be retried" errors with jittered backoff
  (bounded, `IF NOT EXISTS`-idempotent). Fixes flaky failures when several `mx`
  processes initialize a fresh store concurrently (surfaced by the integration
  test suite under parallel load). No change on the happy path — retries only
  run on a contended init.
- Single-id knowledge lookups now resolve their target as a direct
  `type::thing('knowledge', $id)` record reference in the `FROM`/`UPDATE`
  position, instead of a table scan filtered by `WHERE meta::id(id) = $id`
  (record lookup, backing `mx memory show`) or a one-element `WHERE id IN
  $ids` (the activation-count bump that accompanies it). Contracts are
  unchanged: an empty id still resolves to `Ok(None)`/`Ok(())` rather than
  erroring, and an id with no matching row is still a no-op. Part of #415 --
  this is the record-lookup half only. `list`'s separate per-row hydration
  cost (two additional queries per hydrated row, scaling with table size
  regardless of `--limit`) is a known, still-open cost this PR does not fix.
- Durable memory writes no longer exit non-zero when a post-write embed or
  anchor step fails. `add`, `update`, `edit`, `append`, `prepend` and `restore`
  commit the entry, then run `auto_embed`/`auto_anchor` as best-effort side
  effects; those were chained with `?`, so a transient failure propagated to
  `main()` and produced a non-zero exit **after the write had already landed** —
  callers read "failure", retried, and duplicated the entry. The side-effect
  failure is now captured instead of propagated: the process exits 0 and the
  entry stays durable. **A genuine write failure — the write itself never
  landing — still exits non-zero; that path is unchanged.** The failure is not
  silent either. `--json` mode on all six write paths now carries
  `embed_deferred` / `anchor_deferred`: string fields present **only** when that
  step actually failed, absent on success and absent on a deliberate
  `--no-embed` / `--no-auto-anchor` skip, which keep their existing `(skipped)`
  notices. Plain-mode callers get the same signal on stderr, and all thirteen
  post-write warnings now name the entry id — `... (entry durable, id=kn-…):
  <error>` — so a deferred embed can be reconciled later without grepping for
  which row it was. **Limits, stated plainly:** `add-batch` hoists its embed
  pass and defers anchoring to the nightly run, so its per-entry side effects
  are always empty, and it has no `--json` mode at all — stderr is its only
  signal surface. `add`'s `--type` fact-routing path ignores `--json`
  (pre-existing), so it surfaces the warning on stderr and nothing in JSON.

### Changed
- **Removed `mx memory trigger-check` and `mx memory trigger-reset`** (Issue
  #246). Their matching engine survives in `src/triggers.rs` and is what `mx
  doors` runs on; only the graph-backed storage and CLI are gone. The
  session fired-state file (`MX_TRIGGER_FIRED_PATH`, default
  `/tmp/wonka-triggered-fired.json`) is no longer read or written. The
  `triggers` field on knowledge entries is unaffected: `mx memory add/update/show`
  still author and display it.
- `triggers::stem_tokens(raw)` is now `triggers::tokens(raw, stem: bool)`, and
  `match_triggers`/`match_entries` take the same flag. Doors match with stemming
  **off** — the English Snowball stemmer folds Tagalog "ayos" onto "ayo", which
  would open an `ayo-` door on unrelated text.
- Library-internal: `KvStore::push`/`push_with_ts` take an `EntryAttrs` struct and
  `KvStore::update_entry` an `EntryPatch`, instead of positional
  `data`/`memory` arguments. No CLI behaviour changes from this.

- `mx log` and `mx show` no longer silently print the raw encoded blob when a
  commit body fails to decode. Every decode error — unknown dictionary, decode
  failure, failed decompression, bad UTF-8 — previously collapsed into
  `Err(_) => passthrough` with no reason and no marker, which is why the codec
  round-trip bug above went unnoticed for months. The reason is now captured
  and rendered as a `[decode failed: <reason>]` marker on the affected line.
  Passthrough behavior is otherwise unchanged: the raw text is still shown and
  `mx log` remains usable across ranges that contain broken commits. Exit codes
  are unchanged. The marker is surfaced by **all three** renderers —
  `mx log` (oneline/compact), **`mx log --full`**, and `mx show`. `--full` is
  the case that matters most and was the easiest to miss: what it prints for an
  undecodable commit is the one-way *title hash*, not a message, so without the
  marker a permanently unreadable commit renders as an entirely normal one.
  Reasons are de-duplicated and length-capped so they annotate the line rather
  than consume it.
- Documented the `--min-resonance` basis divergence (Issue #404): `wake`
  filters on **raw** stored resonance, while `list`/`search` filter on
  time-decayed **effective** resonance. Behavior is unchanged; the flag help
  text now states which basis each command uses until an explicit
  `--resonance-basis raw|decayed` flag lands in #404.