From ac8c46f2d392923d2dcd98be26168c5ae5737c3d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 11 Jul 2026 16:11:51 +0100 Subject: [PATCH] docs: auto-pairing framing (Arc 2) + handoff snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-pairing framing at revision 3 (two pre-branch review rounds): pair chars leave both optimistic classifiers and round-trip through dispatch (adjacent daemon-peer undo units, real type-over, no transient skip paint); reaction hook pair.lua loads before lsp.lua so the synchronous sighelp didChange flush ships the closer; exact one-shot typed-edit provenance (pmacs.editor.take_typed_edit) gates pairing fail-closed against transformed, relocated, or context-switched source self-inserts and non-typed origins. Handoff: main @ 7e127ab (auto-indent #109 merged), auto-pairing in flight on this branch, Arc 2 deferrals consolidated into §6. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- docs/agent-handoff.md | 42 +-- docs/auto-pairing-framing.md | 490 +++++++++++++++++++++++++++++++++++ 2 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 docs/auto-pairing-framing.md diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 2bd5302..dd2421b 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,6 +1,6 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-10, on the laptop, by the auto-indent +**Last updated: 2026-07-11, on the laptop, by the auto-pairing session.** This file is the bridge between development machines. If you are an agent reading this on a fresh clone: this document plus the `docs/*-framing.md` files ARE your memory. Read this fully before @@ -8,28 +8,31 @@ taking on work, seed your persistent memory from it, and **update this file (and commit it) whenever project state changes materially** — the next machine reads it the way you just did. -## 1. Where the project stands (2026-07-10) +## 1. Where the project stands (2026-07-11) -- `main` @ `efa41cb`, protocol **v15** (`SUPPORTED=[6..15]`). -- **Auto-indent on newline (Arc 2) in flight on this branch** — - framing `docs/auto-indent-framing.md` is at revision 6 (five - pre-branch review rounds plus PR #109 round 1). RET now binds - `edit.newline-and-indent`; plain Enter is no longer GPU-optimistic - (round-trips like the TUI). Rode along: Q#AI8 search invalidation is - shared by dispatch, direct notification, undo, and redo (stale - step/summary fail closed; live origins translate through edits), and - Q#AI9 clears empty selections only after successful core inserts and - in the daemon's optimistic CRDT source arm for both frontends. The +- `main` @ `7e127ab` (auto-indent #109 merged), protocol **v15** + (`SUPPORTED=[6..15]`). +- **Auto-pairing (Arc 2, last item) in flight on this branch** — + framing `docs/auto-pairing-framing.md` at revision 3 (two + pre-branch review rounds). Shape: pair chars leave both frontends' + optimistic classifiers (dispatch-routed, daemon-peer undo units); + reaction hook in `builtin/runtime/pair.lua` loaded BEFORE lsp.lua; + exact one-shot typed-edit provenance via + `pmacs.editor.take_typed_edit()` (Q#AP9). When this merges, Arc 2 + closes. +- Auto-indent (#109) landed: RET binds `edit.newline-and-indent`; + plain Enter round-trips on both frontends. Rode along: shared + search invalidation across dispatch/notification/undo/redo (Q#AI8), + empty-selection clearing after successful inserts (Q#AI9). The TUI's missing nonempty-selection optimistic type-over gate and - generated-buffer search invalidation remain named deferrals. + generated-buffer search invalidation remain named deferrals (§6). - Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position: - **Arc 1 (LSP utility surface) COMPLETE** — completion popup (#92/#93), panels/references/outline/hover (#94–#96), plus hardening follow-ups (#102, #105, #106). - **Arc 2 (editing table stakes)** — query-replace (#97), kill ring - + `M-y` (#103/#105/#106), comment-toggle (#107), auto-indent (this - branch). **Remaining after this merges: auto-pairing**, as its own - small framing + PR. + + `M-y` (#103/#105/#106), comment-toggle (#107), auto-indent + (#109), auto-pairing (this branch — the last item). - **Arc 3 (persistence) COMPLETE** — saveplace/recentf (#98), desktop-save (#99), autosave/crash-recovery (#100), save-clobber fix (#101). @@ -172,7 +175,12 @@ comment padding. Substrate: buffer-aware edit epoch (after-edit currently compares the ACTIVE buffer only), wire provenance for CRDT self-insert classification, Lua intercept probe, completion.lua still on the old -cursor-delta heuristic (migrate to `this_command`). +cursor-delta heuristic (migrate to `this_command`), the TUI's +nonempty-selection optimistic type-over gate, generated-buffer search +invalidation, cross-peer chronological undo arbitration (mixed +source/daemon history; pinned by auto-pairing acceptance), +origin-pinned `buffer.after-edit` fan-out (a context-switching +intercept changes what later callbacks — LSP, completion — observe). LSP/persistence: hidden-buffer LSP attach, daemon desktop-restore, the *warning* half of external-change detection (verify-visited-file- modtime), config registry (no unified config surface yet). diff --git a/docs/auto-pairing-framing.md b/docs/auto-pairing-framing.md new file mode 100644 index 0000000..0479144 --- /dev/null +++ b/docs/auto-pairing-framing.md @@ -0,0 +1,490 @@ +# Auto-pairing — framing (Arc 2, editing table stakes) + +Typing `(` should give `()` with the cursor between; typing `)` when +the next char is already `)` should step over it instead of doubling +it. Language-aware pair sets, conservative insertion predicate. Last +Arc 2 item; after it merges, Arc 2 closes and the compile-mode vs +themes decision discussion is due. + +Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-pairing"). +Revision 2: R1 findings — pair chars now route through dispatch +(peer-bound undo falsified the pure-reaction undo story), pair.lua +loads before lsp.lua (the sighelp path flushes didChange +synchronously mid-hook), rejected/transformed intercept outcomes +separated (the effective edit has already landed), the type-over +claim is path-qualified with a region guard, and the CRDT acceptance +gains a second replica plus undo cases for both routing models. + +Revision 3: R2 findings — the dispatch route no longer claims global +chronological undo (an older source-peer edit still wins the TUI's +local undo arbitration), source self-inserts gain an exact ephemeral +typed-edit record so a relocated opener can never be inferred from +unrelated text, the context-switch/LSP limit is stated rather than +hidden by the cursor guard, non-typed acceptance now drives callbacks +that actually fire, and redo is exercised rather than merely named. + +## Ground truth (as of `7e127ab`) + +- **Dispatch is keymap-first for printables** — `Char('(')` resolves + through the keymap stack before the self-insert fallback + (`src/editor.rs:713-717`, fallback `:748-761`); printable keys are + bindable (the buffer list binds `n`/`p` buffer-locally, + `builtin/commands/default.lua:407-423`). +- **But a key binding cannot carry pairing.** The GPU applies plain + printables optimistically **even mid-line** — classifier + `pmacs-gpu/src/main.rs:1525-1533`, eligibility `:2054-2079` — so a + `(`-binding would never run for GPU typing. The TUI applies + printables optimistically **at end of line** (the F19 paint + constraint: mid-line inserts round-trip, EOL appends do not — + `src/optimistic.rs:269-271`, contract `:168-200`). And a binding + that inserted the pair atomically would break classification + (below) even where it did run. +- **The typed-char substrate exists and has a working consumer.** The + daemon classifies single-codepoint optimistic inserts as + `buffer.self-insert` (exact byte decode, + `src/daemon.rs:1985-2002`, `:2085-2087`), explicitly "for + typed-char consumers (signature help; …)". `buffer.after-edit` + fires on BOTH paths — dispatch (`src/editor.rs:772-776`) and the + optimistic CRDT arm (`src/daemon.rs:2156-2169`) — with core + borrows released, so a hook may edit the buffer. Signature-help + auto-trigger (`builtin/runtime/lsp.lua:761-791`) is the in-tree + template: `this_command() == "buffer.self-insert"` plus + `char_before`; a paste can never trigger it. +- **An atomic 2-byte `"()"` insert breaks that classification** + (`is_single_codepoint_insert` decodes the actual bytes, + `src/daemon.rs:2079-2084` names signature help as the reason), and + **intercepts cannot rewrite `(` into `()`** (M6.4: kind and payload + immutable, `src/lua_bindings/mod.rs:1061-1073`, `:1657-1705`). Any + pairing design must keep the opener a genuine single-codepoint + self-insert. +- **Hook mechanics**: callbacks run in registration order + (`src/hook.rs:240`, snapshot `:255-259`; fan-out `:291-297`); + `buffer.after-edit` is **all-must-succeed** + (`builtin/hooks/default.lua:41-45`) — one callback's error or + return value cannot suppress the others. Hook-created edits do + **not** re-fire `buffer.after-edit` (fired once per dispatch cycle + / per optimistic op). +- **The LSP hook flushes synchronously mid-fan-out.** The after-edit + callback in lsp.lua marks the buffer dirty, and — when the typed + char is a signature trigger — calls `signature_help_quiet`, which + calls `flush_did_change_for(rec)` **synchronously** + (`builtin/runtime/lsp.lua:739-743`: "The server must see the + character we just typed") before requesting. `flush_did_change` + reads `buffer_text` at flush time and clears the pending entry + (`:268-279`). Consequence: a closer inserted by a hook registered + **after** lsp.lua's would miss that flush AND stay unsynchronized + (no re-fire) until the next edit. Coalescing does not save this + path; registration order does. +- **Undo is peer-bound, per frontend.** Loro's `UndoManager` binds to + one peer at construction; each frontend's `BufferMirror` undoes + only its own peer's ops (`src/buffer_mirror.rs:541-555`), and the + daemon's `buffer.undo` covers only daemon-peer edits + (`src/buffer.rs:1360-1380`). The TUI's undo key tries the mirror + first and **round-trips to the daemon when the mirror has nothing + to undo** (`src/optimistic.rs:238-247`). Consequence: a pair whose + opener is a source-peer optimistic op and whose closer is a + daemon-peer Lua edit is **not undoable coherently by either + frontend** — the TUI removes the opener first (leaving `)`), the + daemon removes the closer then unrelated daemon edits. Edits that + route through dispatch are all daemon-peer. +- **The TUI's mirror-empty fallback is not chronological + arbitration.** If the mirror has *any* older source-peer edit, its + optimistic undo succeeds and the key never reaches the daemon + (`src/optimistic.rs:230-247`). Thus `a` (optimistic) then `()` + (daemon-routed) followed by the TUI's single-key undo removes `a`, + not the closer. `C-x u`, which always dispatches, removes the + daemon closer instead. Routing the pair's two edits to one peer + makes their daemon-local order coherent; it does not merge that + order with older source-peer history. Global chronological undo is + existing cross-peer substrate work, not something pairing can + truthfully claim to solve. +- **Mutator effects land before the caller sees them.** The effective + edit is applied and only then reported as the returned triple + (`src/lua_bindings/mod.rs:1184`, `:1246-1259`) — a transformed + (relocated/expanded) edit has already happened; it can be + *repaired around*, never *withheld*. Lua mutators move no cursors + and reconcile no window state (PR #109 ground truth); the + context-guarded right-gravity repair in + `builtin/runtime/indent.lua:60-64`, `:100-121` is the established + pattern. +- **The producing self-insert has the same intercept problem, and + `after-edit` currently carries no payload.** `insert_char` advances + the cursor from the requested position after `apply_active_edit` + (`src/editor_core.rs:1738-1749`), even if an insert intercept moved + the effective insertion. `this_command` proves only the input + class; `char_before(cursor)` does not prove which character was + typed or where it landed. Pairing therefore needs exact ephemeral + source-edit provenance, not the signature-help heuristic alone. +- **A hook-inserted closer lands correctly by construction**: insert + at the cursor, cursor stays before it (mutators move no cursors); + the daemon's `CursorByte` re-grounds both frontends; the GPU + applies incoming ops and rebases unconfirmed edits without moving + `own_cursor` (`pmacs-gpu/src/main.rs:2463-2564`). +- **Broadcast ordering quirk on the optimistic path**: the after-edit + hook fires (`src/daemon.rs:2167`) **before** the source opener op + is queued for broadcast (`:2178`), so a hook-queued DaemonKey + closer reaches **non-source replicas before the opener it depends + on**. Loro is expected to buffer and converge; nothing pins that + today. +- **Region-active typing**: CUA type-over consumes the region on the + dispatch path. The GPU round-trips when a selection decoration + exists; the TUI's optimistic gate consults no selection state (PR + #109 named deferral) — a nonempty TUI selection ending at EOL can + optimistically insert without consuming the region, and the daemon + arm deliberately preserves nonempty anchors. +- **A callback may switch context, and later callbacks observe the + switch.** The hook runner snapshots callbacks, not editor context; + lsp.lua reads `pmacs.window.buffer()` when its callback runs. If a + pair reaction's intercept switches from A to B, a local cursor guard + can avoid touching B, but it cannot make the later LSP callback see + A. Origin-pinned hook fan-out is named substrate work below. +- **Direct Lua mutation and plain `pmacs.command.invoke` do not fire + `buffer.after-edit`.** A non-typed regression that merely calls + either API is vacuous; the test must explicitly run the hook or use + a production path such as paste which fires it. +- **No pair knowledge exists anywhere**; `pmacs.comment.strings` + (`builtin/runtime/comment.lua:26-42`) is the per-language table + precedent. No node-at-byte API on `pmacs.parse` (manual descent + only; async/stale trees) — syntax-aware inhibit is not v1-viable. +- Rust uses `'` for lifetimes — pairing `'` per-language is a + correctness matter, not taste. + +## Decisions + +### Q#AP1 — Carrier: after-edit reaction, with pair chars routed through dispatch + +Two coupled decisions, each grounded in the constraints above: + +**The reaction carrier** (unchanged from R1): on +`this_command() == "buffer.self-insert"`, the pairing hook reads the +exact ephemeral typed-edit record from Q#AP9 and reacts with a second +edit. This keeps the opener a genuine single-codepoint self-insert — +the classification signature help depends on — without guessing its +identity or position from surrounding buffer text. + +**The routing change** (new, R1 finding): the built-in pair charset +`( ) [ ] { } " ' `` ` is **removed from both optimistic +classifiers** — the GPU's `optimistic_insert_text` and the TUI's +`classify_key` — so those chars always round-trip through dispatch. +Without this, the opener is a source-peer op and the closer a +daemon-peer op, and peer-bound undo makes the pair uncleanly +undoable on every frontend (ground truth). With it, both edits are +adjacent daemon-peer undo units. A daemon-routed undo removes the +closer and then the opener; the TUI's single-key optimistic undo does +the same **only when its older source-peer stack is empty**. This is +pair-local coherence, not global chronological arbitration (Q#AP5). +The exclusion also restores CUA type-over for pair chars on the TUI +(a round-tripped `(` consumes the region the optimistic path would +have skipped) and removes transient pair/skip paint from both +frontends. + +Costs, named: one daemon round-trip per built-in-charset keystroke — +on the TUI only EOL appends change (mid-line already round-trips); on +the GPU all nine chars do. `'` and `` ` `` round-trip even in languages +whose sets don't pair them — uniform routing beats per-language +classifier state the frontends don't have. **User-extended pair +chars beyond the built-in nine still arrive optimistically**: they +pair correctly (the reaction fires either way) but their undo is +cross-peer-degraded — documented limitation, pinned in acceptance, +full fix deferred with the pre-existing mixed-history problem +(cross-peer chronological undo arbitration). + +`builtin/runtime/pair.lua`; a `pmacs.pair.*` namespace. + +### Q#AP2 — Pair sets: per-language table, conservative default + +`pmacs.pair.sets` — the `pmacs.comment.strings` shape: language → +array of 2-byte pair strings, plus a `default` entry used when the +language is unknown or has no entry (pairing is useful in scratch +buffers): + +- `default = { "()", "[]", "{}", '""' }` — no `'` (prose + apostrophes), no backtick. +- `python`, `lua`, `javascript`/`typescript` (+react), `sh`/`bash` + add `''`; javascript/typescript/markdown add `` `` `` pairs. +- `rust`, `c`, `cpp`, `go`, `zig` = default (lifetimes, char + literals). Users opt in from init.lua: `pmacs.pair.sets.rust = …`. + +### Q#AP3 — Insert-pair semantics (open char typed) + +React when ALL hold: + +- `this_command() == "buffer.self-insert"` **and** Q#AP9 returns a + live typed-edit record for this callback (pastes, manual hook runs, + and programmatic inserts have no record and never pair); +- the record's buffer/window match the current context, its source + edit is clean (effective triple equals the requested insert or + replace), and the current cursor equals its recorded post-edit + cursor. A relocated/expanded or context-switching source + self-insert stands as the intercept produced it and gets no pair + reaction. A non-clean triple reports *"auto-pair skipped: source + self-insert transformed"*; a context/cursor mismatch reports + *"auto-pair skipped: source context changed"*. This is the + fail-closed answer to R2 finding 2; +- **no active region survives the edit** (`ed.region() == nil`) — + on the dispatch path type-over has already consumed and cleared + it; a surviving nonempty region means the edit arrived through the + TUI's selection-blind optimistic gate (custom chars only), where + reacting would pile a closer onto an unconsumed region; +- the record's exact typed codepoint is an opener in the buffer's pair + set (language via `pmacs.lsp.active_buffer_language()`, resolved + **at callback time**, nil-guarded — pair.lua loads before lsp.lua, + Q#AP7). `char_before` is not input provenance; +- **conservative predicate**: the char at the cursor is EOL, + whitespace, or a closing bracket from the pair set — `foo|bar` + + `(` gives `(bar`, never `()bar`; +- for symmetric pairs (quotes), the skip check (Q#AP4) runs first. + +Reaction: one pcall'd `buf:insert(cursor, closer)`. Outcomes, +separated (R1 finding): + +- **Rejected** (intercept threw): nothing landed; the opener stands + alone; status *"auto-pair closer rejected by buffer intercept"*. +- **Transformed** (effective triple deviates): the edit has already + landed wherever the intercept put it — the positional result + stands. Report, then **context-guarded cursor repair**: with the + window+buffer snapshot taken before the mutator, right-gravity- + translate the pre-edit cursor through the effective edit and + `goto_byte` (clamps); skip all repair if the intercept switched + window or buffer. (The clean path needs no cursor motion at all — + the asymmetry is deliberate: repair only on deviation, because the + clean at-cursor insert must leave the cursor *before* the closer, + which translation would not.) + +### Q#AP4 — Skip-over-close, reactive + +When Q#AP9's exact typed char is a closer in the pair set AND the char +at the recorded post-insert cursor equals it: one pcall'd +`buf:delete(cursor, cursor+len)`. Net text and cursor are exactly +Emacs's skip: `(|)` + `)` → `()|`; nested closers skip likewise; +`"` at `"|"` exits the string. With pair chars on the dispatch route +there is no transient duplicate to paint — the frontends never +locally applied the typed closer. + +Same outcome separation as Q#AP3: rejected delete → the duplicate +stays (`())`), status, no retry; transformed delete → already +landed, report, context-guarded translate-and-clamp repair. + +### Q#AP5 — Undo grain + +Built-in pairs are two adjacent daemon-peer edits. On the GPU, and on +the TUI when the mirror has no older source-peer unit (or when the +user invokes the always-dispatched `C-x u`), daemon undo removes the +closer and then the opener. Still two steps, not one; a skip's daemon +undo restores the swallowed duplicate. + +This does **not** make undo globally chronological. With optimistic +`a` already in the TUI mirror, then daemon-routed `()`, the TUI's +single-key optimistic undo removes `a` first because the mirror has a +local unit and never falls back. The GPU can undo the two daemon pair +units but cannot subsequently reach the older source-peer `a` through +daemon undo. Custom optimistic pair chars additionally split the pair +itself across peers. All three behaviors are pinned explicitly; the +general fix is a cross-peer chronological undo arbiter, deferred as +existing collaboration substrate rather than charged to pair.lua. + +### Q#AP6 — Type-over composes on the dispatch path; wrap is deferred + +For built-in pair chars (always dispatch-routed), typing an opener +over an active region type-overs the region, then the reaction runs +under the Q#AP3 predicate — Emacs-with-delete-selection semantics, +guaranteed for a clean, context-preserving source edit. A transformed +source edit fails closed under Q#AP9. For custom optimistic chars the +TUI selection gap persists; the Q#AP3 region guard suppresses the +reaction there rather than compounding the gap. **Wrapping** the region +in the pair is deferred (needs a command carrier plus the TUI +selection-gate fix). + +### Q#AP7 — Load order: pair.lua BEFORE lsp.lua + +Registration order is execution order (ground truth), and lsp.lua's +after-edit callback synchronously flushes didChange on the signature +trigger path — the closer must already be in the buffer when that +callback runs, or the server receives `(`-only text and the closer +stays unsynchronized until the next edit (hook edits don't re-fire). +So pair.lua's loader entry in `src/editor.rs` goes **before** +lsp.lua's (`:288`), with a comment naming this ordering contract; +all `pmacs.lsp.*` lookups inside the callback are lazy and +nil-guarded (lsp.lua defines them later in the load sequence). +Acceptance asserts the ordering by its observable: the **first** +didChange after an ordinary `(` carries `()`; when the closer is +position-transformed without switching context, that first didChange +carries the complete effective post-reaction text rather than an +opener-only intermediate. + +Scope: if the reaction intercept itself switches active window or +buffer, later callbacks observe that new context. The pair callback's +cursor guard cannot repair hook-wide context, so the first didChange +guarantee does not extend to that legal-but-pathological case; A may +remain pending while lsp.lua observes B. This is recorded under +Deferred as origin-pinned after-edit fan-out and is no longer hidden +behind a cursor-only acceptance claim. + +### Q#AP8 — Interactions, verified + +- **Signature help**: the opener still classifies as self-insert on + both routes; the closer insert moves no cursor, so `char_before` + still reads `(`; the synchronous flush ships `()` (Q#AP7). Pinned + with the `sighelp` fake-LSP mode. Same context-switch exception as + Q#AP7. +- **Completion popup**: `(` never belonged to the popup's key set; + `completion_popup_validate` runs after the hook fan-out + (`src/editor.rs:778-783`) and judges the post-pair buffer when + context is preserved. The Q#AP7 context-switch exception applies to + completion as well. +- **Kill ring / boundaries**: the closer is a plain Lua mutator — + stamps nothing, rotates nothing. +- **No recursion**: hook edits don't re-fire after-edit; the + autosave-driven manual `pmacs.hook.run("buffer.after-edit")` + (`builtin/runtime/autosave.lua:233`) is inert because Q#AP9 exposes + no typed-edit record outside the Rust-owned hook boundary, even if + `this_command` is stale. +- **Auto-indent**: RET inside `{|}` yields `{\n␣␣|}`; the electric + closer-on-own-line split stays deferred with language-aware + indent. + +### Q#AP9 — Exact typed-edit provenance, ephemeral, one-shot, fail-closed + +`this_command` remains the coarse input-origin signal used by existing +consumers, but pairing additionally requires a new +`pmacs.editor.take_typed_edit()` record. The record is per frontend, +is armed only while Rust is running the one `buffer.after-edit` +fan-out for that input, and can be consumed exactly once: + +```text +{ buffer, window_id, codepoint, requested_start, requested_end, + effective_start, effective_end, inserted_len, post_cursor, clean } +``` + +The dispatch path arms the record from the self-insert codepoint and +the requested Insert/Replace, then completes it from the effective +`Edit` returned by `insert_char_over_region`. The optimistic CRDT arm +already has both the decoded single codepoint and effective `Edit`, so +it builds the same record before firing the hook. A small EditorCore +outcome/report refactor is required because `apply_active_edit` +currently discards the effective range on its way back to +`insert_char`; payload immutability means the codepoint itself remains +authoritative. + +The pair callback takes the record; later callbacks and a nested +manual re-run of `buffer.after-edit` see nil. The dispatcher/daemon +also clears any untaken slot immediately after the hook returns, +including error paths and the no-revision-change path. Plain +`pmacs.hook.run`, paste, programmatic mutation, and a stale +`this_command == "buffer.self-insert"` therefore see nil. Pairing +requires `clean == true`, matching buffer/window, and +`cursor == post_cursor`; otherwise it reports and does nothing. This +is deliberately narrower than teaching every command to expose its +edit: one producer class, one consumer contract, and no persistent +history. + +## Bets + +1. **Nine round-tripped chars are imperceptible.** The TUI already + round-trips every mid-line char; the GPU pays one local-socket hop + on pair chars. The concrete return is real dispatch type-over, no + optimistic duplicate paint during skip, and adjacent daemon-local + pair units — not a false promise of global chronological undo. +2. **The conservative predicate kills the hate-mail cases** — no + pairing before words, no apostrophe pairing in the default set. +3. **Uniform routing beats language-aware classifiers** — `'` + round-trips in Rust for nothing, and nobody notices. +4. **`default`-set pairing in language-less buffers is wanted**, not + surprising. + +## Deferred (named) + +- Wrap-region on opener with active selection (command carrier + the + TUI selection-gate fix). +- Pair-aware backspace (delete both of a fresh empty pair). +- RET inside a pair → closer on its own line (with language-aware / + electric indent). +- In-string/in-comment inhibit — needs a node-at-byte `pmacs.parse` + binding and freshness guarantees. +- Undo amalgamation (pair = one undo step); cross-peer undo grouping + and chronological arbitration (would un-degrade custom optimistic + pair chars and mixed source/daemon history generally). +- Origin-pinned `buffer.after-edit` context. Today a legal intercept + that switches window/buffer changes what all later callbacks see; + pair.lua can guard its own cursor repair but cannot keep LSP and + completion on the producing context without a hook-wide substrate. +- Balance-aware quote handling (odd/even counting). +- A per-buffer toggle (config-registry-blocked). + +## Acceptance + +`tests/auto_pair_acceptance.rs` (dispatch-driven): + +- `(` at EOL → `()`, cursor between; mid-line before whitespace and + before `)`; before a word char → no pair. +- Skip: `(|)` + `)` → `()`, cursor after; nested; `"` at `"|"` exits. +- Quotes pair under the predicate. +- Per-language: `'` pairs in `.py`, not in `.rs`; scratch pairs the + default set. +- Non-typed provenance, with the callback actually exercised: + production `FrontendEvent::Paste("(")` after a prior self-insert + leaves a lone pasted opener; `buf:insert("(")` followed by explicit + `pmacs.hook.run("buffer.after-edit")` also leaves it lone even when + `this_command` was deliberately left as `buffer.self-insert`; + `pmacs.command.invoke`d self-insert plus the same explicit hook run + also has no record and no reaction. +- Type-over: region + `(` (dispatch route) → region consumed, then + the predicate decides; selection cleared. +- Daemon undo grain pinned in the non-replica harness: `(` then undo → + `(` alone; undo → empty; redo → `(`; redo → `()`; skip undo restores + the duplicate. +- Intercepts: rejected closer → opener stands, status; **relocated + closer** → landed at the intercept's position, reported, cursor + translated not teleported; rejected skip-delete → duplicate stays; + **expanded/relocated skip-delete** → landed, reported, + translate-and-clamp. The **source self-insert** gets separate cases: + relocated opener and expanded/relocated type-over produce exactly + the intercept's positional result, Q#AP9 reports/skips, and no + unrelated closer is inserted; a source context switch likewise + fails closed. +- Context-switching **reaction** intercept → pair cursor repair + skipped, new context's text/cursor untouched by pair.lua. A probe + callback registered after pair.lua observes the switched context, + explicitly pinning (not concealing) the origin-context deferral; do + not combine this case with the Q#AP7 first-didChange guarantee. +- Signature help: fake-LSP `sighelp` mode — auto-trigger still fires + with pairing active, and the **first didChange after `(` contains + `()`** when the reaction preserves context (the Q#AP7 ordering + observable). A preserving-context relocated closer instead asserts + that the first didChange contains the complete effective text. +- Hook fan-out: one fire per keystroke; the closer edit does not + re-fire. +- Typed-edit lifecycle: `take_typed_edit()` yields the exact + codepoint/effective triple once during both dispatch and optimistic + self-insert hooks; a second take (including a nested manual + after-edit run) is nil. It is also nil before/after the fan-out, for + paste, for standalone manual hook runs, and after a rejecting edit. + Two frontends cannot see or consume each other's slot. + +Classifier flips (in-crate): GPU `optimistic_insert_text` returns +`None` for the nine pair chars (test updated alongside Enter's); +TUI `classify_key`/orchestrator equivalents round-trip them (test +updated). Exercise both `Modifiers::NONE` and the `SHIFT` shapes real +keyboards use for `(){}"` so the test cannot pass only for synthetic +unshifted punctuation. + +CRDT (`--features crdt`), **two replicas** (source + observer): + +- Dispatch route: round-tripped `(` → both replicas converge to + `()`; daemon cursor between; `)` skip converges; **undo/redo**: + two daemon undos restore empty on both replicas, then two daemon + redos restore `(` and `()` in order on both. +- Mixed history, dispatch route: source optimistically inserts `a`, + then round-tripped `(` produces `a()`. On the TUI routing model the + single-key optimistic undo removes `a` first (leaving `()`). From a + fresh identical state, an always-dispatched `C-x u` removes the + closer first (leaving `a(`). On the GPU routing model two daemon + undos remove the pair but a further daemon undo cannot reach + source-peer `a`. These are assertions of the named substrate limit, + not frontend-equivalence claims. +- Optimistic route (custom pair char added to the set): source ships + the opener op; the hook's DaemonKey closer is queued **before** + the opener's broadcast — the observer receives the causally + dependent closer first and must still converge (pairing and skip + both). **Undo**: pinned degraded behavior — the source mirror's + undo removes the opener, leaving the closer.